@slashfi/agents-sdk 0.71.4 → 0.73.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.
Files changed (80) hide show
  1. package/dist/adk-tools.d.ts.map +1 -1
  2. package/dist/adk-tools.js +2 -4
  3. package/dist/adk-tools.js.map +1 -1
  4. package/dist/adk.d.ts +1 -1
  5. package/dist/adk.js +7 -27
  6. package/dist/adk.js.map +1 -1
  7. package/dist/agent-definitions/remote-registry.d.ts +15 -0
  8. package/dist/agent-definitions/remote-registry.d.ts.map +1 -1
  9. package/dist/agent-definitions/remote-registry.js +42 -17
  10. package/dist/agent-definitions/remote-registry.js.map +1 -1
  11. package/dist/cjs/adk-tools.js +2 -4
  12. package/dist/cjs/adk-tools.js.map +1 -1
  13. package/dist/cjs/agent-definitions/remote-registry.js +42 -17
  14. package/dist/cjs/agent-definitions/remote-registry.js.map +1 -1
  15. package/dist/cjs/config-store.js +125 -8
  16. package/dist/cjs/config-store.js.map +1 -1
  17. package/dist/cjs/events.js +11 -3
  18. package/dist/cjs/events.js.map +1 -1
  19. package/dist/cjs/fetch-types.js +3 -0
  20. package/dist/cjs/fetch-types.js.map +1 -0
  21. package/dist/cjs/index.js +8 -2
  22. package/dist/cjs/index.js.map +1 -1
  23. package/dist/cjs/key-manager.js +7 -1
  24. package/dist/cjs/key-manager.js.map +1 -1
  25. package/dist/cjs/logger.js +115 -0
  26. package/dist/cjs/logger.js.map +1 -0
  27. package/dist/cjs/registry-consumer.js.map +1 -1
  28. package/dist/cjs/registry.js +1 -1
  29. package/dist/cjs/registry.js.map +1 -1
  30. package/dist/cjs/server.js +70 -13
  31. package/dist/cjs/server.js.map +1 -1
  32. package/dist/config-store.d.ts +19 -0
  33. package/dist/config-store.d.ts.map +1 -1
  34. package/dist/config-store.js +125 -8
  35. package/dist/config-store.js.map +1 -1
  36. package/dist/events.d.ts +6 -1
  37. package/dist/events.d.ts.map +1 -1
  38. package/dist/events.js +11 -3
  39. package/dist/events.js.map +1 -1
  40. package/dist/fetch-types.d.ts +11 -0
  41. package/dist/fetch-types.d.ts.map +1 -0
  42. package/dist/fetch-types.js +2 -0
  43. package/dist/fetch-types.js.map +1 -0
  44. package/dist/index.d.ts +4 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +2 -0
  47. package/dist/index.js.map +1 -1
  48. package/dist/key-manager.d.ts +6 -0
  49. package/dist/key-manager.d.ts.map +1 -1
  50. package/dist/key-manager.js +7 -1
  51. package/dist/key-manager.js.map +1 -1
  52. package/dist/logger.d.ts +42 -0
  53. package/dist/logger.d.ts.map +1 -0
  54. package/dist/logger.js +109 -0
  55. package/dist/logger.js.map +1 -0
  56. package/dist/registry-consumer.d.ts +8 -2
  57. package/dist/registry-consumer.d.ts.map +1 -1
  58. package/dist/registry-consumer.js.map +1 -1
  59. package/dist/registry.d.ts +6 -0
  60. package/dist/registry.d.ts.map +1 -1
  61. package/dist/registry.js +1 -1
  62. package/dist/registry.js.map +1 -1
  63. package/dist/server.d.ts +7 -0
  64. package/dist/server.d.ts.map +1 -1
  65. package/dist/server.js +70 -13
  66. package/dist/server.js.map +1 -1
  67. package/package.json +1 -1
  68. package/src/adk-tools.ts +2 -4
  69. package/src/adk.ts +7 -22
  70. package/src/agent-definitions/remote-registry.ts +56 -28
  71. package/src/config-store.ts +168 -9
  72. package/src/events.ts +16 -6
  73. package/src/fetch-types.ts +13 -0
  74. package/src/index.ts +13 -0
  75. package/src/key-manager.ts +12 -1
  76. package/src/logger.test.ts +206 -0
  77. package/src/logger.ts +123 -0
  78. package/src/registry-consumer.ts +13 -7
  79. package/src/registry.ts +7 -2
  80. package/src/server.ts +76 -42
@@ -27,6 +27,8 @@ import type {
27
27
  ResolvedRegistry,
28
28
  } from "./define-config.js";
29
29
  import { normalizeRef } from "./define-config.js";
30
+ import type { FetchFn } from "./fetch-types.js";
31
+ import type { Logger } from "./logger.js";
30
32
  import { createRegistryConsumer } from "./registry-consumer.js";
31
33
  import type {
32
34
  AgentListing,
@@ -94,6 +96,23 @@ export interface AdkOptions {
94
96
  * (e.g. client_id/client_secret) before the user auth flow runs.
95
97
  */
96
98
  resolveCredentials?: ResolveCredentials;
99
+ /**
100
+ * Custom fetch implementation. Forwarded to every `createRegistryConsumer`
101
+ * call spun up internally by the adk (consumer(), available(), registry.test()).
102
+ *
103
+ * Hosts running inside a long-lived server (e.g. atlas) should pass a
104
+ * hardened fetch — one backed by a connection pool with short timeouts,
105
+ * TCP keepalive, and one-shot retry on connection errors — to avoid
106
+ * dead-socket hangs when upstream pods roll. Defaults to `globalThis.fetch`.
107
+ */
108
+ fetch?: FetchFn;
109
+ /**
110
+ * Structured logger. Currently reserved for future use; the adk itself
111
+ * does not emit logs today but may in future versions. Threading this in
112
+ * now lets hosts standardize on a single logger across all sdk surfaces
113
+ * (`createAgentRegistry`, `createAgentServer`, `createAdk`, etc.).
114
+ */
115
+ logger?: Logger;
97
116
  }
98
117
 
99
118
  export interface RegistryTestResult {
@@ -353,6 +372,74 @@ function isUnauthorized(result: unknown): boolean {
353
372
  return false;
354
373
  }
355
374
 
375
+ // ============================================
376
+ // Local auth form HTML
377
+ // ============================================
378
+
379
+ const esc = (s: string) =>
380
+ s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
381
+
382
+ function renderCredentialForm(name: string, fields: AuthChallengeField[], error?: string): string {
383
+ const fieldHtml = fields.map((f) => `
384
+ <div class="field">
385
+ <label for="${esc(f.name)}">${esc(f.label)}</label>
386
+ ${f.description ? `<p class="desc">${esc(f.description)}</p>` : ""}
387
+ <input id="${esc(f.name)}" name="${esc(f.name)}" type="${f.secret ? "password" : "text"}" required autocomplete="off" spellcheck="false" />
388
+ </div>`).join("");
389
+
390
+ const errorHtml = error
391
+ ? `<div class="error">${esc(error)}</div>`
392
+ : "";
393
+
394
+ return `<!DOCTYPE html>
395
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
396
+ <title>Authenticate \u2014 ${esc(name)}</title>
397
+ <style>
398
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
399
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0a0a0a;color:#e5e5e5;display:flex;justify-content:center;align-items:center;min-height:100vh}
400
+ .card{background:#141414;border:1px solid #262626;border-radius:12px;padding:32px;width:100%;max-width:420px}
401
+ h1{font-size:20px;font-weight:600;color:#fafafa;margin-bottom:4px}
402
+ .sub{font-size:14px;color:#a3a3a3;margin-bottom:24px}
403
+ .field{margin-bottom:16px}
404
+ label{display:block;font-size:13px;font-weight:500;color:#d4d4d4;margin-bottom:6px}
405
+ .desc{font-size:12px;color:#737373;margin-bottom:6px}
406
+ input{width:100%;padding:10px 12px;background:#0a0a0a;border:1px solid #333;border-radius:8px;color:#fafafa;font-size:14px;font-family:'SF Mono',Menlo,Consolas,monospace;outline:none;transition:border-color .15s}
407
+ input:focus{border-color:#3b82f6}
408
+ button{width:100%;padding:10px;background:#f5f5f5;color:#0a0a0a;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;margin-top:8px;transition:background .15s}
409
+ button:hover{background:#e5e5e5}
410
+ .error{font-size:13px;color:#f87171;margin-bottom:16px;padding:10px 12px;background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.2);border-radius:8px}
411
+ </style></head><body>
412
+ <div class="card">
413
+ <h1>Authenticate</h1>
414
+ <p class="sub">${esc(name)}</p>
415
+ ${errorHtml}
416
+ <form method="POST">${fieldHtml}
417
+ <button type="submit">Authenticate</button>
418
+ </form>
419
+ </div>
420
+ </body></html>`;
421
+ }
422
+
423
+ function renderAuthSuccess(name: string): string {
424
+ return `<!DOCTYPE html>
425
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
426
+ <title>Authenticated \u2014 ${esc(name)}</title>
427
+ <style>
428
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
429
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0a0a0a;color:#e5e5e5;display:flex;justify-content:center;align-items:center;min-height:100vh}
430
+ .card{background:#141414;border:1px solid #262626;border-radius:12px;padding:32px 48px;width:100%;max-width:420px;text-align:center}
431
+ .icon{font-size:48px;margin-bottom:16px;color:#22c55e}
432
+ h1{font-size:20px;font-weight:600;color:#fafafa;margin-bottom:4px}
433
+ p{font-size:14px;color:#a3a3a3}
434
+ </style></head><body>
435
+ <div class="card">
436
+ <div class="icon">&#10003;</div>
437
+ <h1>Authenticated</h1>
438
+ <p>${esc(name)} is ready to use. You can close this tab.</p>
439
+ </div>
440
+ </body></html>`;
441
+ }
442
+
356
443
  export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
357
444
 
358
445
  async function readConfig(): Promise<ConsumerConfig> {
@@ -579,7 +666,7 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
579
666
 
580
667
  return createRegistryConsumer(
581
668
  { registries: resolved, refs: config.refs ?? [] },
582
- { token: options.token },
669
+ { token: options.token, fetch: options.fetch },
583
670
  );
584
671
  }
585
672
 
@@ -617,7 +704,7 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
617
704
 
618
705
  return createRegistryConsumer(
619
706
  { registries: resolved, refs: config.refs ?? [] },
620
- { token: options.token },
707
+ { token: options.token, fetch: options.fetch },
621
708
  );
622
709
  }
623
710
 
@@ -725,7 +812,10 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
725
812
  const url = registryUrl(r);
726
813
  const rName = registryDisplayName(r);
727
814
  try {
728
- const consumer = await createRegistryConsumer({ registries: [r] }, { token: options.token });
815
+ const consumer = await createRegistryConsumer(
816
+ { registries: [r] },
817
+ { token: options.token, fetch: options.fetch },
818
+ );
729
819
  const disc = await consumer.discover(url);
730
820
  return { name: rName, url, status: "active", issuer: disc.issuer };
731
821
  } catch (err: unknown) {
@@ -1392,19 +1482,88 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
1392
1482
  const result = await ref.auth(name);
1393
1483
 
1394
1484
  if (result.complete) return { complete: true };
1485
+
1486
+ const port = options.oauthCallbackPort ?? 8919;
1487
+ const timeout = opts?.timeoutMs ?? 300_000;
1488
+ const { createServer } = await import("node:http");
1489
+
1490
+ // API key / HTTP auth — serve a local credential form
1491
+ if (result.fields && result.fields.length > 0 && result.type !== "oauth2") {
1492
+ return new Promise<{ complete: boolean }>((resolve, reject) => {
1493
+ const server = createServer(async (req, res) => {
1494
+ const reqUrl = new URL(req.url ?? "/", `http://localhost:${port}`);
1495
+
1496
+ if (req.method === "GET" && reqUrl.pathname === "/auth") {
1497
+ res.writeHead(200, { "Content-Type": "text/html" });
1498
+ res.end(renderCredentialForm(name, result.fields!));
1499
+ return;
1500
+ }
1501
+
1502
+ if (req.method === "POST" && reqUrl.pathname === "/auth") {
1503
+ const chunks: Buffer[] = [];
1504
+ for await (const chunk of req) chunks.push(chunk as Buffer);
1505
+ const body = Buffer.concat(chunks).toString();
1506
+ const params = new URLSearchParams(body);
1507
+
1508
+ const credentials: Record<string, string> = {};
1509
+ for (const field of result.fields!) {
1510
+ const val = params.get(field.name);
1511
+ if (val) credentials[field.name] = val;
1512
+ }
1513
+
1514
+ try {
1515
+ const authResult = await ref.auth(name, { credentials });
1516
+ if (authResult.complete) {
1517
+ res.writeHead(200, { "Content-Type": "text/html" });
1518
+ res.end(renderAuthSuccess(name));
1519
+ server.close();
1520
+ resolve({ complete: true });
1521
+ } else {
1522
+ res.writeHead(200, { "Content-Type": "text/html" });
1523
+ res.end(renderCredentialForm(
1524
+ name,
1525
+ authResult.fields ?? result.fields!,
1526
+ "Some credentials were missing or invalid.",
1527
+ ));
1528
+ }
1529
+ } catch (err) {
1530
+ res.writeHead(500, { "Content-Type": "text/html" });
1531
+ res.end(renderCredentialForm(
1532
+ name,
1533
+ result.fields!,
1534
+ err instanceof Error ? err.message : String(err),
1535
+ ));
1536
+ }
1537
+ return;
1538
+ }
1539
+
1540
+ res.writeHead(404);
1541
+ res.end();
1542
+ });
1543
+
1544
+ server.listen(port, () => {
1545
+ if (opts?.onAuthorizeUrl) {
1546
+ opts.onAuthorizeUrl(`http://localhost:${port}/auth`);
1547
+ }
1548
+ });
1549
+
1550
+ const timer = setTimeout(() => {
1551
+ server.close();
1552
+ reject(new Error("Auth timed out"));
1553
+ }, timeout);
1554
+ server.on("close", () => clearTimeout(timer));
1555
+ });
1556
+ }
1557
+
1558
+ // OAuth2 — open authorize URL and wait for callback
1395
1559
  if (result.type !== "oauth2" || !result.authorizeUrl) {
1396
- throw new Error(`authLocal only handles OAuth2. Auth type: ${result.type}`);
1560
+ throw new Error(`authLocal cannot handle auth type: ${result.type}`);
1397
1561
  }
1398
1562
 
1399
1563
  if (opts?.onAuthorizeUrl) {
1400
1564
  opts.onAuthorizeUrl(result.authorizeUrl);
1401
1565
  }
1402
1566
 
1403
- // Spin up local callback server
1404
- const port = options.oauthCallbackPort ?? 8919;
1405
- const timeout = opts?.timeoutMs ?? 300_000;
1406
-
1407
- const { createServer } = await import("node:http");
1408
1567
  return new Promise<{ complete: boolean }>((resolve, reject) => {
1409
1568
  const server = createServer(async (req, res) => {
1410
1569
  const reqUrl = new URL(req.url ?? "/", `http://localhost:${port}`);
package/src/events.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  * Filtering happens in the callback, not the API.
11
11
  */
12
12
 
13
+ import { getDefaultLogger, type Logger } from "./logger.js";
13
14
  import type { AgentDefinition, CallAgentRequest, CallAgentResponse } from "./types.js";
14
15
  // =============================================================================
15
16
  // Event Types
@@ -293,11 +294,17 @@ export interface EventBus {
293
294
  ): void;
294
295
  }
295
296
 
297
+ export interface EventBusOptions {
298
+ /** Logger for listener errors (default: module-level default logger) */
299
+ logger?: Logger;
300
+ }
301
+
296
302
  /**
297
303
  * Create an event bus.
298
304
  */
299
- export function createEventBus(): EventBus {
305
+ export function createEventBus(options: EventBusOptions = {}): EventBus {
300
306
  const listeners: ListenerEntry[] = [];
307
+ const logger = options.logger ?? getDefaultLogger();
301
308
 
302
309
  function on<T extends EventType>(
303
310
  eventType: T,
@@ -346,11 +353,14 @@ export function createEventBus(): EventBus {
346
353
  try {
347
354
  await listener.callback(event as never);
348
355
  } catch (err) {
349
- // Never propagate listener errors — log and continue
350
- console.error(
351
- `[agents-sdk] Event listener error for ${event.type}:`,
352
- err,
353
- );
356
+ // Never propagate listener errors — log and continue.
357
+ // Structured fields so Datadog keeps the whole record as one event.
358
+ logger.error("event_listener_error", {
359
+ component: "agents-sdk.events",
360
+ event_type: event.type,
361
+ agent_path: event.agentPath,
362
+ error: err,
363
+ });
354
364
  }
355
365
  }
356
366
  }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Structural `fetch` type that's compatible across Node (undici-backed) and
3
+ * Bun (which extends the global fetch with extras like `preconnect`).
4
+ *
5
+ * Using `typeof globalThis.fetch` in options types causes type-errors when a
6
+ * Node-typed fetch implementation (e.g. an undici.Agent-backed wrapper) is
7
+ * passed in a codebase whose @types/bun is loaded. This structural subset is
8
+ * the minimum surface the SDK actually uses and both runtimes satisfy it.
9
+ */
10
+ export type FetchFn = (
11
+ input: string | URL | Request,
12
+ init?: RequestInit,
13
+ ) => Promise<Response>;
package/src/index.ts CHANGED
@@ -126,6 +126,7 @@ export type {
126
126
  export { createEventBus } from "./events.js";
127
127
  export type {
128
128
  EventBus,
129
+ EventBusOptions,
129
130
  EventType,
130
131
  SystemEventType,
131
132
  CustomEventMap,
@@ -145,6 +146,18 @@ export type {
145
146
  ListenerEntry,
146
147
  } from "./events.js";
147
148
 
149
+ // Logger
150
+ export {
151
+ createConsoleJsonLogger,
152
+ createNoopLogger,
153
+ getDefaultLogger,
154
+ setDefaultLogger,
155
+ } from "./logger.js";
156
+ export type { LogFields, LogLevel, Logger } from "./logger.js";
157
+
158
+ // Structural fetch type
159
+ export type { FetchFn } from "./fetch-types.js";
160
+
148
161
  // Server
149
162
  export {
150
163
  createAgentServer,
@@ -21,6 +21,7 @@ import {
21
21
  generateKeyPair,
22
22
  importJWK,
23
23
  } from "jose";
24
+ import { getDefaultLogger, type Logger } from "./logger.js";
24
25
 
25
26
  // ── Types ──
26
27
 
@@ -88,6 +89,11 @@ export interface KeyManagerOptions {
88
89
  tokenTtlSeconds?: number;
89
90
  /** Enable background key rotation (default: true). Set to false on read-only replicas. */
90
91
  enableRotation?: boolean;
92
+ /**
93
+ * Structured logger for rotation errors. Defaults to the module-level
94
+ * default logger (single-line JSON).
95
+ */
96
+ logger?: Logger;
91
97
  }
92
98
 
93
99
  // ── Constants ──
@@ -148,6 +154,7 @@ export async function createKeyManager(
148
154
  tokenTtlSeconds = 300,
149
155
  enableRotation = true,
150
156
  } = opts;
157
+ const logger = opts.logger ?? getDefaultLogger();
151
158
 
152
159
  let keys: CachedKey[] = [];
153
160
 
@@ -221,7 +228,11 @@ export async function createKeyManager(
221
228
  try {
222
229
  await checkAndRotate();
223
230
  } catch (err) {
224
- console.error("[key-manager] Check/rotation failed:", err);
231
+ logger.error("key_rotation_failed", {
232
+ component: "agents-sdk.key-manager",
233
+ issuer,
234
+ error: err,
235
+ });
225
236
  }
226
237
  }, checkIntervalMs)
227
238
  : null;
@@ -0,0 +1,206 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createEventBus } from "./events.js";
3
+ import {
4
+ type Logger,
5
+ createConsoleJsonLogger,
6
+ createNoopLogger,
7
+ getDefaultLogger,
8
+ setDefaultLogger,
9
+ } from "./logger.js";
10
+
11
+ function captureLogger(): { logger: Logger; records: unknown[] } {
12
+ const records: unknown[] = [];
13
+ const logger: Logger = {
14
+ debug: (msg, fields) => records.push({ level: "debug", msg, fields }),
15
+ info: (msg, fields) => records.push({ level: "info", msg, fields }),
16
+ warn: (msg, fields) => records.push({ level: "warn", msg, fields }),
17
+ error: (msg, fields) => records.push({ level: "error", msg, fields }),
18
+ with: () => logger,
19
+ };
20
+ return { logger, records };
21
+ }
22
+
23
+ describe("createConsoleJsonLogger", () => {
24
+ test("emits single-line JSON for each record", () => {
25
+ const lines: string[] = [];
26
+ const originalError = console.error;
27
+ const originalLog = console.log;
28
+ console.error = (line: string) => lines.push(line);
29
+ console.log = (line: string) => lines.push(line);
30
+ try {
31
+ const logger = createConsoleJsonLogger();
32
+ logger.info("hello", { foo: "bar" });
33
+ logger.error("oops", { err: new Error("boom") });
34
+ } finally {
35
+ console.error = originalError;
36
+ console.log = originalLog;
37
+ }
38
+
39
+ expect(lines.length).toBe(2);
40
+ for (const line of lines) {
41
+ // Single line
42
+ expect(line.includes("\n")).toBe(false);
43
+ // Valid JSON
44
+ expect(() => JSON.parse(line)).not.toThrow();
45
+ }
46
+
47
+ const info = JSON.parse(lines[0]!);
48
+ expect(info.level).toBe("info");
49
+ expect(info.message).toBe("hello");
50
+ expect(info.foo).toBe("bar");
51
+ expect(typeof info.timestamp).toBe("string");
52
+
53
+ const err = JSON.parse(lines[1]!);
54
+ expect(err.level).toBe("error");
55
+ expect(err.message).toBe("oops");
56
+ expect(err.err.name).toBe("Error");
57
+ expect(err.err.message).toBe("boom");
58
+ expect(typeof err.err.stack).toBe("string");
59
+ });
60
+
61
+ test("with() merges base fields into child records", () => {
62
+ const lines: string[] = [];
63
+ const originalLog = console.log;
64
+ console.log = (line: string) => lines.push(line);
65
+ try {
66
+ const root = createConsoleJsonLogger({ service: "atlas-api" });
67
+ const child = root.with({ request_id: "abc123" });
68
+ child.info("req", { method: "GET" });
69
+ } finally {
70
+ console.log = originalLog;
71
+ }
72
+ expect(lines.length).toBe(1);
73
+ const record = JSON.parse(lines[0]!);
74
+ expect(record.service).toBe("atlas-api");
75
+ expect(record.request_id).toBe("abc123");
76
+ expect(record.method).toBe("GET");
77
+ expect(record.message).toBe("req");
78
+ });
79
+
80
+ test("handles non-serializable values without throwing", () => {
81
+ const lines: string[] = [];
82
+ const originalLog = console.log;
83
+ console.log = (line: string) => lines.push(line);
84
+ try {
85
+ const logger = createConsoleJsonLogger();
86
+ const circular: Record<string, unknown> = {};
87
+ circular.self = circular;
88
+ logger.info("cycle", { circular });
89
+ } finally {
90
+ console.log = originalLog;
91
+ }
92
+ expect(lines.length).toBe(1);
93
+ const record = JSON.parse(lines[0]!);
94
+ expect(record.serializer_error).toBe(true);
95
+ expect(record.message).toBe("cycle");
96
+ });
97
+
98
+ test("serializes bigint and drops functions", () => {
99
+ const lines: string[] = [];
100
+ const originalLog = console.log;
101
+ console.log = (line: string) => lines.push(line);
102
+ try {
103
+ const logger = createConsoleJsonLogger();
104
+ logger.info("values", { big: 42n, fn: () => "ignored" });
105
+ } finally {
106
+ console.log = originalLog;
107
+ }
108
+ const record = JSON.parse(lines[0]!);
109
+ expect(record.big).toBe("42");
110
+ expect(record.fn).toBeUndefined();
111
+ });
112
+ });
113
+
114
+ describe("createNoopLogger", () => {
115
+ test("drops every record and with() returns self", () => {
116
+ const noop = createNoopLogger();
117
+ expect(() => {
118
+ noop.debug("d");
119
+ noop.info("i");
120
+ noop.warn("w");
121
+ noop.error("e", { err: new Error("ignored") });
122
+ }).not.toThrow();
123
+ const child = noop.with({ ctx: "x" });
124
+ expect(typeof child.info).toBe("function");
125
+ });
126
+ });
127
+
128
+ describe("default logger", () => {
129
+ test("setDefaultLogger / getDefaultLogger round-trip", () => {
130
+ const original = getDefaultLogger();
131
+ const { logger, records } = captureLogger();
132
+ try {
133
+ setDefaultLogger(logger);
134
+ getDefaultLogger().info("routed");
135
+ expect(records.length).toBe(1);
136
+ } finally {
137
+ setDefaultLogger(original);
138
+ }
139
+ });
140
+ });
141
+
142
+ describe("createEventBus uses injected logger", () => {
143
+ test("listener error is logged as a single structured record", async () => {
144
+ const { logger, records } = captureLogger();
145
+ const bus = createEventBus({ logger });
146
+ bus.on("tool/call", () => {
147
+ throw new Error("listener boom");
148
+ });
149
+ await bus.emit({
150
+ type: "tool/call",
151
+ agentPath: "@test",
152
+ timestamp: Date.now(),
153
+ tool: "demo",
154
+ params: {},
155
+ });
156
+ expect(records.length).toBe(1);
157
+ const rec = records[0] as {
158
+ level: string;
159
+ msg: string;
160
+ fields: Record<string, unknown>;
161
+ };
162
+ expect(rec.level).toBe("error");
163
+ expect(rec.msg).toBe("event_listener_error");
164
+ expect(rec.fields.component).toBe("agents-sdk.events");
165
+ expect(rec.fields.event_type).toBe("tool/call");
166
+ expect(rec.fields.agent_path).toBe("@test");
167
+ expect(rec.fields.error).toBeInstanceOf(Error);
168
+ });
169
+
170
+ test("listener errors never propagate", async () => {
171
+ const bus = createEventBus({ logger: createNoopLogger() });
172
+ bus.on("invoke", () => {
173
+ throw new Error("nope");
174
+ });
175
+ await expect(
176
+ bus.emit({
177
+ type: "invoke",
178
+ agentPath: "@test",
179
+ timestamp: Date.now(),
180
+ prompt: "hi",
181
+ }),
182
+ ).resolves.toBeUndefined();
183
+ });
184
+
185
+ test("falls back to module default logger when no option passed", async () => {
186
+ const original = getDefaultLogger();
187
+ const { logger, records } = captureLogger();
188
+ try {
189
+ setDefaultLogger(logger);
190
+ const bus = createEventBus();
191
+ bus.on("tool/call", () => {
192
+ throw new Error("captured");
193
+ });
194
+ await bus.emit({
195
+ type: "tool/call",
196
+ agentPath: "@test",
197
+ timestamp: Date.now(),
198
+ tool: "demo",
199
+ params: {},
200
+ });
201
+ expect(records.length).toBe(1);
202
+ } finally {
203
+ setDefaultLogger(original);
204
+ }
205
+ });
206
+ });
package/src/logger.ts ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Logger — pluggable structured logger for the agents-sdk.
3
+ *
4
+ * The SDK emits errors and traces via this interface so consumers (e.g. atlas)
5
+ * can route logs into their own observability stack. The default logger writes
6
+ * single-line JSON to stdout/stderr, which keeps Datadog from splitting
7
+ * multi-line stack traces into separate events.
8
+ *
9
+ * @example Inject your own logger
10
+ * ```ts
11
+ * const registry = createAgentRegistry({ logger: myStructuredLogger });
12
+ * ```
13
+ *
14
+ * @example Disable all SDK logging
15
+ * ```ts
16
+ * const registry = createAgentRegistry({ logger: createNoopLogger() });
17
+ * ```
18
+ */
19
+
20
+ export type LogFields = Record<string, unknown>;
21
+
22
+ export type LogLevel = "debug" | "info" | "warn" | "error";
23
+
24
+ export interface Logger {
25
+ debug(message: string, fields?: LogFields): void;
26
+ info(message: string, fields?: LogFields): void;
27
+ warn(message: string, fields?: LogFields): void;
28
+ error(message: string, fields?: LogFields): void;
29
+ /** Return a child logger that merges the given fields into every record. */
30
+ with(fields: LogFields): Logger;
31
+ }
32
+
33
+ /**
34
+ * Replacer that expands Error objects into plain JSON-serializable fields
35
+ * (name/message/stack/cause), and falls back to String() for other
36
+ * non-serializable values so the logger never throws or drops the message.
37
+ */
38
+ function errorReplacer(_key: string, value: unknown): unknown {
39
+ if (value instanceof Error) {
40
+ return {
41
+ name: value.name,
42
+ message: value.message,
43
+ stack: value.stack,
44
+ ...(value.cause !== undefined ? { cause: value.cause } : {}),
45
+ };
46
+ }
47
+ if (typeof value === "bigint") return value.toString();
48
+ if (typeof value === "function") return undefined;
49
+ return value;
50
+ }
51
+
52
+ /**
53
+ * Default logger: emits a single JSON line per record.
54
+ * - debug/info → stdout
55
+ * - warn/error → stderr
56
+ *
57
+ * Kept intentionally minimal. Hosts should replace this with their own
58
+ * transport in production.
59
+ */
60
+ export function createConsoleJsonLogger(base: LogFields = {}): Logger {
61
+ function emit(level: LogLevel, message: string, fields?: LogFields): void {
62
+ const record = {
63
+ level,
64
+ timestamp: new Date().toISOString(),
65
+ message,
66
+ ...base,
67
+ ...fields,
68
+ };
69
+ let line: string;
70
+ try {
71
+ line = JSON.stringify(record, errorReplacer);
72
+ } catch {
73
+ // Last-ditch fallback: if the payload can't be serialized, at least
74
+ // emit the message so callers aren't flying blind.
75
+ line = JSON.stringify({
76
+ level,
77
+ timestamp: new Date().toISOString(),
78
+ message,
79
+ serializer_error: true,
80
+ });
81
+ }
82
+ if (level === "error" || level === "warn") {
83
+ console.error(line);
84
+ } else {
85
+ console.log(line);
86
+ }
87
+ }
88
+ return {
89
+ debug: (message, fields) => emit("debug", message, fields),
90
+ info: (message, fields) => emit("info", message, fields),
91
+ warn: (message, fields) => emit("warn", message, fields),
92
+ error: (message, fields) => emit("error", message, fields),
93
+ with: (fields) => createConsoleJsonLogger({ ...base, ...fields }),
94
+ };
95
+ }
96
+
97
+ /** Logger that drops every record. Useful for silencing SDK output in tests. */
98
+ export function createNoopLogger(): Logger {
99
+ const noop = (): void => {};
100
+ const self: Logger = {
101
+ debug: noop,
102
+ info: noop,
103
+ warn: noop,
104
+ error: noop,
105
+ with: () => self,
106
+ };
107
+ return self;
108
+ }
109
+
110
+ /**
111
+ * Module-level default logger. Hosts that want JSON output everywhere can
112
+ * set this once at startup instead of threading a logger through every
113
+ * factory call.
114
+ */
115
+ let defaultLogger: Logger = createConsoleJsonLogger();
116
+
117
+ export function setDefaultLogger(logger: Logger): void {
118
+ defaultLogger = logger;
119
+ }
120
+
121
+ export function getDefaultLogger(): Logger {
122
+ return defaultLogger;
123
+ }