@combycode/llm-sdk 2.1.0 → 2.2.1

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.
@@ -165,6 +165,86 @@ var REDACTED = "***REDACTED***";
165
165
  var SENSITIVE_QUERY_PARAMS = /* @__PURE__ */ new Set(["key", "api_key", "access_token", "token"]);
166
166
  var SENSITIVE_HEADERS = /* @__PURE__ */ new Set(["authorization", "x-goog-api-key", "x-api-key", "api-key"]);
167
167
  var MAX_ERROR_RAW_CHARS = 512;
168
+ var OTLP_SPAN_KIND = { internal: 1, client: 3 };
169
+ var OTLP_KIND_BY_SPAN = {
170
+ llm: OTLP_SPAN_KIND.client,
171
+ http: OTLP_SPAN_KIND.client,
172
+ mcp: OTLP_SPAN_KIND.client,
173
+ media: OTLP_SPAN_KIND.client,
174
+ agent: OTLP_SPAN_KIND.internal,
175
+ tool: OTLP_SPAN_KIND.internal,
176
+ other: OTLP_SPAN_KIND.internal
177
+ };
178
+ function fnv1a32(input, seed) {
179
+ let h = seed >>> 0;
180
+ for (let i = 0; i < input.length; i++) {
181
+ h ^= input.charCodeAt(i);
182
+ h = Math.imul(h, 16777619) >>> 0;
183
+ }
184
+ return h >>> 0;
185
+ }
186
+ var isHex = (value, chars) => value.length === chars && /^[0-9a-f]+$/.test(value);
187
+ function toOtlpId(input, bytes) {
188
+ let out = "";
189
+ for (let i = 0; i < bytes / 4; i++) {
190
+ out += fnv1a32(input, 2166136261 + i * 2654435769 >>> 0).toString(16).padStart(8, "0");
191
+ }
192
+ return /^0+$/.test(out) ? `${out.slice(0, -1)}1` : out;
193
+ }
194
+ function toOtlpValue(value) {
195
+ if (typeof value === "boolean") return { boolValue: value };
196
+ if (typeof value === "number" && Number.isFinite(value)) {
197
+ return Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value };
198
+ }
199
+ if (typeof value === "string") return { stringValue: value };
200
+ if (value === null || value === void 0) return { stringValue: "" };
201
+ return { stringValue: typeof value === "object" ? JSON.stringify(value) : String(value) };
202
+ }
203
+ var SPAN_NAME_SUBJECT = {
204
+ chat: "gen_ai.request.model",
205
+ invoke_agent: "gen_ai.agent.name",
206
+ execute_tool: "gen_ai.tool.name"
207
+ };
208
+ function otlpSpanName(span) {
209
+ const op = span.attributes["gen_ai.operation.name"];
210
+ if (typeof op !== "string") return span.name;
211
+ const subject = SPAN_NAME_SUBJECT[op] ? span.attributes[SPAN_NAME_SUBJECT[op]] : void 0;
212
+ return typeof subject === "string" && subject ? `${op} ${subject}` : op;
213
+ }
214
+ function toMessageList(payload, defaultRole) {
215
+ if (payload == null) return [];
216
+ if (typeof payload === "string") {
217
+ return payload ? [{ role: defaultRole, content: payload }] : [];
218
+ }
219
+ if (Array.isArray(payload)) {
220
+ const parts2 = payload;
221
+ if (parts2.length > 0 && parts2[0] && "role" in parts2[0]) {
222
+ return parts2.map((m) => ({ role: String(m.role ?? defaultRole), content: contentToText(m.content) })).filter((m) => m.content);
223
+ }
224
+ const text2 = contentToText(parts2);
225
+ return text2 ? [{ role: defaultRole, content: text2 }] : [];
226
+ }
227
+ const text = contentToText(payload);
228
+ return text ? [{ role: defaultRole, content: text }] : [];
229
+ }
230
+ function contentToText(content) {
231
+ if (typeof content === "string") return content;
232
+ if (!Array.isArray(content)) return "";
233
+ return content.map((part) => {
234
+ const p = part;
235
+ return typeof p?.text === "string" ? p.text : "";
236
+ }).filter(Boolean).join("");
237
+ }
238
+ var EVENT_TYPE_BY_KIND = {
239
+ agent: "agent",
240
+ tool: "tool",
241
+ llm: "llm",
242
+ http: "http",
243
+ mcp: "mcp",
244
+ media: "media",
245
+ other: "other"
246
+ };
247
+ var SAMPLE_SEED = 2654435769;
168
248
  var CATEGORY = {
169
249
  // Network
170
250
  onEnqueue: "network",
@@ -233,10 +313,29 @@ function traceIdsOf(ctx) {
233
313
  const t = c?.trace ?? c?.ctx ?? c;
234
314
  return {
235
315
  sessionId: t?.sessionId,
236
- requestId: t?.requestId
316
+ requestId: t?.requestId,
317
+ /** W3C parent context, when the app is already inside a trace of its own. */
318
+ traceparent: t?.traceparent,
319
+ // `gen_ai.conversation.id` in the semantic conventions — the thread a turn
320
+ // belongs to, which is what lets a backend group turns into one conversation.
321
+ // AgentLoop sets it from the history id; a bare client call has none.
322
+ conversationId: t?.conversationId
237
323
  };
238
324
  }
239
- var traceKey = (ids) => ids.requestId ? `${ids.sessionId ?? "?"}:${ids.requestId}` : void 0;
325
+ function parseTraceparent(value) {
326
+ if (!value) return null;
327
+ const m = /^[0-9a-f]{2}-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/.exec(value.trim().toLowerCase());
328
+ if (!m) return null;
329
+ const [, traceId, spanId] = m;
330
+ if (/^0+$/.test(traceId) || /^0+$/.test(spanId)) return null;
331
+ return { traceId, spanId };
332
+ }
333
+ var CONTAINER_SPANS = /* @__PURE__ */ new Set(["agent.run", "tool.call"]);
334
+ var traceKey = (ids) => {
335
+ const parent = parseTraceparent(ids.traceparent);
336
+ if (parent) return parent.traceId;
337
+ return ids.requestId ? `${ids.sessionId ?? "?"}:${ids.requestId}` : void 0;
338
+ };
240
339
  var TelemetryAdapter = class {
241
340
  events = [];
242
341
  spans = [];
@@ -257,17 +356,138 @@ var TelemetryAdapter = class {
257
356
  /** Service identity stamped on exported telemetry. */
258
357
  resource;
259
358
  seq = 0;
359
+ /** Discriminator for POINT spans (media, mcp connect/tool), whose natural keys are
360
+ * not unique — the same server reconnects, a run emits two images, two tool calls
361
+ * land in one millisecond. A duplicate span id inside a trace is invalid OTLP and
362
+ * the backend silently keeps only one. */
363
+ spanSeq = 0;
364
+ /** Per trace: the app's span from a `traceparent`, and the CONTAINER spans currently
365
+ * open on it. Together they decide what a new span hangs under — see `parentFor`.
366
+ * Both are cleared once a trace has nothing open, so a long-lived process does not
367
+ * accumulate an entry per conversation forever.
368
+ *
369
+ * A list, not a single slot: an agent nested in a tool call (C2 inside C1's tool) is a
370
+ * second run on the SAME trace, and with one slot it overwrote its own parent and then
371
+ * deleted it on close — leaving the rest of the outer run parentless. */
372
+ appParent = /* @__PURE__ */ new Map();
373
+ containers = /* @__PURE__ */ new Map();
260
374
  latSum = 0;
261
375
  open = /* @__PURE__ */ new Map();
262
376
  maxEvents;
263
377
  includeSensitiveData;
264
378
  unsub;
379
+ /** Subscribers, each with its own filter. Re-parenting is computed PER SINK: two
380
+ * consumers asking for different types each get a tree that is correct for them. */
381
+ sinks = [];
382
+ content;
383
+ sampleRate;
384
+ /** spanId → its parent and type, for EVERY span including filtered ones — walking up
385
+ * past a dropped ancestor is the whole point, so the dropped ones must still be here.
386
+ * Bounded, because a long-lived process would otherwise remember every span it ever
387
+ * saw. */
388
+ lineage = /* @__PURE__ */ new Map();
389
+ maxLineage;
390
+ msgSeq = 0;
265
391
  constructor(hooks, opts = {}) {
266
392
  this.maxEvents = opts.maxEvents ?? 2e3;
267
393
  this.includeSensitiveData = opts.includeSensitiveData ?? true;
268
394
  this.resource = opts.resource ?? { serviceName: "unknown_service" };
395
+ this.content = opts.content ?? "none";
396
+ this.sampleRate = opts.sample ?? 1;
397
+ this.maxLineage = this.maxEvents * 2;
398
+ if (opts.onTrace) this.onTrace({ types: opts.types }, opts.onTrace);
269
399
  this.unsub = hooks.onAny((name, ctx) => this.handle(name, ctx));
270
400
  }
401
+ onTrace(filterOrHandler, maybeHandler) {
402
+ const handler = typeof filterOrHandler === "function" ? filterOrHandler : maybeHandler;
403
+ if (!handler) throw new Error("onTrace requires a handler");
404
+ const filter = typeof filterOrHandler === "function" ? {} : filterOrHandler;
405
+ const sink = { types: filter.types ? new Set(filter.types) : void 0, handler };
406
+ this.sinks.push(sink);
407
+ return () => {
408
+ const at = this.sinks.indexOf(sink);
409
+ if (at !== -1) this.sinks.splice(at, 1);
410
+ };
411
+ }
412
+ /** Record a finished span and hand it to the subscribers. Every span reaches the store
413
+ * through here, so there is one place where an event can be missed rather than five. */
414
+ recordSpan(span) {
415
+ this.spans.push(span);
416
+ const type = EVENT_TYPE_BY_KIND[span.kind];
417
+ if (!this.lineage.has(span.spanId)) this.remember(span.spanId, span.parentSpanId, type);
418
+ this.dispatch({
419
+ type,
420
+ traceId: span.traceId,
421
+ spanId: span.spanId,
422
+ parentSpanId: span.parentSpanId,
423
+ name: otlpSpanName(span),
424
+ startTime: span.startTime,
425
+ endTime: span.endTime,
426
+ durationMs: span.durationMs,
427
+ status: span.status,
428
+ attributes: span.attributes
429
+ });
430
+ }
431
+ remember(spanId, parentSpanId, type) {
432
+ this.lineage.set(spanId, { parentSpanId, type });
433
+ if (this.lineage.size > this.maxLineage) {
434
+ const oldest = this.lineage.keys().next().value;
435
+ if (oldest !== void 0) this.lineage.delete(oldest);
436
+ }
437
+ }
438
+ dispatch(event) {
439
+ if (this.sinks.length === 0) return;
440
+ if (!this.isSampled(event.traceId)) return;
441
+ for (const sink of this.sinks) {
442
+ if (sink.types && !sink.types.has(event.type)) continue;
443
+ const parentSpanId = sink.types ? this.survivingParent(event.parentSpanId, sink.types) : event.parentSpanId;
444
+ sink.handler(parentSpanId === event.parentSpanId ? event : { ...event, parentSpanId });
445
+ }
446
+ }
447
+ /** The nearest ancestor this subscriber actually receives. Without this, filtering out
448
+ * `http` would leave its children pointing at a span that never arrives, and a backend
449
+ * renders a dangling parent as a separate root. */
450
+ survivingParent(parentSpanId, types) {
451
+ let id = parentSpanId;
452
+ while (id) {
453
+ const node = this.lineage.get(id);
454
+ if (!node) return void 0;
455
+ if (types.has(node.type)) return id;
456
+ id = node.parentSpanId;
457
+ }
458
+ return void 0;
459
+ }
460
+ /** Hashed rather than random, so the same trace samples the same way in every process
461
+ * and a trace shared by two services is kept or dropped by both. */
462
+ isSampled(traceId) {
463
+ if (this.sampleRate >= 1) return true;
464
+ if (this.sampleRate <= 0) return false;
465
+ return fnv1a32(traceId, SAMPLE_SEED) / 4294967296 < this.sampleRate;
466
+ }
467
+ /** Conversation content, as its own event so it can be routed somewhere different from
468
+ * the spans — a debug store, not the metrics backend. */
469
+ emitMessage(span, direction, payload) {
470
+ if (this.sinks.length === 0) return;
471
+ const messages = toMessageList(payload, direction === "input" ? "user" : "assistant");
472
+ if (messages.length === 0) return;
473
+ const chars = messages.reduce((n, m) => n + m.content.length, 0);
474
+ this.dispatch({
475
+ type: "message",
476
+ traceId: span.traceId,
477
+ spanId: `${span.spanId}:msg${this.msgSeq++}`,
478
+ parentSpanId: span.spanId,
479
+ name: `message.${direction}`,
480
+ startTime: Date.now(),
481
+ status: "unset",
482
+ attributes: clean({
483
+ "message.direction": direction,
484
+ "message.count": messages.length,
485
+ "message.chars": chars,
486
+ // Opt-In in the spec, and off by default here for the same reason.
487
+ [`gen_ai.${direction}.messages`]: this.content === "full" ? messages : void 0
488
+ })
489
+ });
490
+ }
271
491
  /** Stop tapping the bus. */
272
492
  destroy() {
273
493
  this.unsub();
@@ -275,6 +495,8 @@ var TelemetryAdapter = class {
275
495
  handle(name, ctx) {
276
496
  const ids = traceIdsOf(ctx);
277
497
  const traceId = traceKey(ids);
498
+ const parent = parseTraceparent(ids.traceparent);
499
+ if (parent && traceId) this.appParent.set(traceId, parent.spanId);
278
500
  this.events.push({
279
501
  seq: this.seq++,
280
502
  time: Date.now(),
@@ -297,20 +519,27 @@ var TelemetryAdapter = class {
297
519
  this.metrics.outputTokens += usage.outputTokens ?? 0;
298
520
  }
299
521
  if (traceId) {
522
+ const responseModel = c.response?.model;
300
523
  const attrs = {
301
- "gen_ai.provider": c.provider,
302
- "gen_ai.model": c.model,
524
+ "gen_ai.provider.name": c.provider,
525
+ "gen_ai.operation.name": "chat",
526
+ "gen_ai.request.model": c.model,
527
+ // The model that actually answered, which can differ from the one asked
528
+ // for (an alias resolving to a dated snapshot, a router picking a peer).
529
+ "gen_ai.response.model": responseModel,
530
+ "gen_ai.conversation.id": ids.conversationId,
303
531
  "gen_ai.usage.input_tokens": usage?.inputTokens,
304
532
  "gen_ai.usage.output_tokens": usage?.outputTokens
305
533
  };
306
534
  const key = `llm:${traceId}`;
535
+ let llmSpan;
307
536
  if (this.open.has(key)) {
308
- this.closeSpan(key, "ok", attrs);
537
+ llmSpan = this.closeSpan(key, "ok", attrs);
309
538
  } else {
310
539
  const http = [...this.spans].reverse().find((s) => s.traceId === traceId && s.kind === "http");
311
540
  const start = http?.startTime ?? Date.now();
312
541
  const end = Date.now();
313
- this.spans.push({
542
+ llmSpan = {
314
543
  traceId,
315
544
  spanId: key,
316
545
  name: "llm.request",
@@ -320,7 +549,12 @@ var TelemetryAdapter = class {
320
549
  durationMs: end - start,
321
550
  status: "ok",
322
551
  attributes: clean(attrs)
323
- });
552
+ };
553
+ this.recordSpan(llmSpan);
554
+ }
555
+ if (llmSpan) {
556
+ const response = c.response;
557
+ this.emitMessage(llmSpan, "output", response?.content ?? response?.text);
324
558
  }
325
559
  }
326
560
  break;
@@ -366,9 +600,11 @@ var TelemetryAdapter = class {
366
600
  this.metrics.mediaGenerated += c.count ?? 1;
367
601
  if (traceId) {
368
602
  const now = Date.now();
369
- this.spans.push({
603
+ this.recordSpan({
370
604
  traceId,
371
- spanId: `media:${traceId}`,
605
+ // One run can generate several images; `media:${traceId}` would give them
606
+ // all the same span id, which is invalid within a trace.
607
+ spanId: `media:${traceId}:${this.spanSeq++}`,
372
608
  name: "media.generate",
373
609
  kind: "media",
374
610
  startTime: now,
@@ -383,10 +619,21 @@ var TelemetryAdapter = class {
383
619
  case "onRunStart": {
384
620
  const runId = c.runId;
385
621
  if (runId) {
386
- this.openSpan(`agent:${runId}`, runId, "agent.run", "agent", {
387
- "agent.id": c.agentId,
388
- "agent.model": c.model
622
+ const runSpan = this.openSpan(`agent:${runId}`, traceId ?? runId, "agent.run", "agent", {
623
+ // The host's own attributes go FIRST so ours win on a key collision: a stray
624
+ // `gen_ai.*` key in a caller's bag must not be able to rewrite the identity
625
+ // of the span.
626
+ ...c.attributes,
627
+ "gen_ai.operation.name": "invoke_agent",
628
+ // Named when the agent was given a label; the exported span is then
629
+ // `invoke_agent {label}` rather than the bare operation.
630
+ "gen_ai.agent.name": c.label,
631
+ "gen_ai.agent.id": c.agentId,
632
+ "gen_ai.request.model": c.model,
633
+ // Ours, not a convention attribute — the GenAI spec has no term for it.
634
+ "agent.source": c.source
389
635
  });
636
+ this.emitMessage(runSpan, "input", c.userMessage);
390
637
  }
391
638
  break;
392
639
  }
@@ -413,9 +660,11 @@ var TelemetryAdapter = class {
413
660
  case "onToolCallStart": {
414
661
  const callId = c.callId;
415
662
  if (callId) {
416
- this.openSpan(`tool:${callId}`, callId, "tool.call", "tool", {
417
- "tool.name": c.toolName,
418
- "agent.id": c.agentId
663
+ this.openSpan(`tool:${callId}`, traceId ?? callId, "tool.call", "tool", {
664
+ "gen_ai.operation.name": "execute_tool",
665
+ "gen_ai.tool.name": c.toolName,
666
+ "gen_ai.tool.call.id": callId,
667
+ "gen_ai.agent.id": c.agentId
419
668
  });
420
669
  }
421
670
  break;
@@ -424,7 +673,7 @@ var TelemetryAdapter = class {
424
673
  const callId = c.callId;
425
674
  if (callId) {
426
675
  this.closeSpan(`tool:${callId}`, "ok", {
427
- "tool.name": c.toolName,
676
+ "gen_ai.tool.name": c.toolName,
428
677
  "tool.latency_ms": c.latencyMs
429
678
  });
430
679
  }
@@ -434,7 +683,7 @@ var TelemetryAdapter = class {
434
683
  const callId = c.callId;
435
684
  if (callId) {
436
685
  this.closeSpan(`tool:${callId}`, "error", {
437
- "tool.name": c.toolName,
686
+ "gen_ai.tool.name": c.toolName,
438
687
  "tool.error": c.error?.message
439
688
  });
440
689
  }
@@ -445,9 +694,17 @@ var TelemetryAdapter = class {
445
694
  const server = c.server;
446
695
  if (server) {
447
696
  const now = Date.now();
448
- this.spans.push({
449
- traceId: server,
450
- spanId: `mcp:connect:${server}`,
697
+ this.recordSpan({
698
+ // A connect usually happens at startup, outside any run, so there is often
699
+ // no trace to join — but keying the trace by server name merged every
700
+ // reconnect over the process lifetime into one trace. Falls back to a span
701
+ // of its own instead.
702
+ traceId: traceId ?? `mcp:connect:${server}:${this.spanSeq}`,
703
+ // `${server}` alone repeats on every reconnect, and a duplicate span id
704
+ // within a trace is invalid OTLP — the backend keeps one and drops the
705
+ // rest. The counter is monotonic where a timestamp is not: two connects
706
+ // inside the same millisecond would still collide.
707
+ spanId: `mcp:connect:${server}:${this.spanSeq++}`,
451
708
  name: "mcp.connect",
452
709
  kind: "mcp",
453
710
  startTime: now,
@@ -469,9 +726,14 @@ var TelemetryAdapter = class {
469
726
  if (server && tool) {
470
727
  const now = Date.now();
471
728
  const lat = c.latencyMs;
472
- this.spans.push({
473
- traceId: server,
474
- spanId: `mcp:tool:${server}:${tool}:${now}`,
729
+ this.recordSpan({
730
+ // An MCP tool call happens INSIDE a run, so it belongs to that run's trace.
731
+ // Keying it by server put every call to one server in a single eternal
732
+ // trace, and none of them with the agent that made the call.
733
+ traceId: traceId ?? `mcp:${server}`,
734
+ // A timestamp is not a unique key: two tool calls in the same millisecond
735
+ // share it. The counter is.
736
+ spanId: `mcp:tool:${server}:${tool}:${this.spanSeq++}`,
475
737
  name: "mcp.tool_call",
476
738
  kind: "mcp",
477
739
  startTime: now - (lat ?? 0),
@@ -489,10 +751,33 @@ var TelemetryAdapter = class {
489
751
  }
490
752
  }
491
753
  }
754
+ /** What a new span on this trace hangs under: the innermost container still open on
755
+ * it, else the app's span, else nothing (we are the root).
756
+ *
757
+ * A container wins over the app's span because an LLM call made during a run belongs
758
+ * to that run — attaching it straight to the app would flatten the very nesting the
759
+ * tree exists to show. A span joins the stack only after it is built, so nothing can
760
+ * become its own parent, and a run nested in a tool call lands under that tool call —
761
+ * exactly where it happened.
762
+ *
763
+ * Limit worth naming: with tools running in parallel two `tool.call` spans are open at
764
+ * once and "innermost" is merely the more recent one. Attributing a nested run to the
765
+ * right sibling needs real async context propagation, which this adapter does not
766
+ * have; sequential tools, the common case, are exact. */
767
+ parentFor(traceId) {
768
+ const stack = this.containers.get(traceId);
769
+ return stack?.[stack.length - 1] ?? this.appParent.get(traceId);
770
+ }
492
771
  openSpan(key, traceId, spanName, kind, attributes) {
772
+ const parentSpanId = this.parentFor(traceId);
493
773
  const span = {
494
774
  traceId,
495
- spanId: key,
775
+ ...parentSpanId ? { parentSpanId } : {},
776
+ // The KEY pairs open with close (`llm:${traceId}`); the SPAN ID must be unique.
777
+ // Those were the same string until a run stopped fragmenting into one trace per
778
+ // call — at which point every LLM call in a run produced the identical key, and
779
+ // the collision merged them into one span at the collector.
780
+ spanId: `${key}#${this.spanSeq++}`,
496
781
  name: spanName,
497
782
  kind,
498
783
  startTime: Date.now(),
@@ -500,17 +785,33 @@ var TelemetryAdapter = class {
500
785
  attributes
501
786
  };
502
787
  this.open.set(key, span);
788
+ this.remember(span.spanId, parentSpanId, EVENT_TYPE_BY_KIND[kind]);
789
+ if (CONTAINER_SPANS.has(spanName)) {
790
+ const stack = this.containers.get(traceId);
791
+ if (stack) stack.push(span.spanId);
792
+ else this.containers.set(traceId, [span.spanId]);
793
+ }
503
794
  return span;
504
795
  }
505
796
  closeSpan(key, status, attributes) {
506
797
  const span = this.open.get(key);
507
- if (!span) return;
798
+ if (!span) return void 0;
799
+ const stack = this.containers.get(span.traceId);
800
+ if (stack) {
801
+ const at = stack.lastIndexOf(span.spanId);
802
+ if (at !== -1) stack.splice(at, 1);
803
+ if (stack.length === 0) {
804
+ this.containers.delete(span.traceId);
805
+ this.appParent.delete(span.traceId);
806
+ }
807
+ }
508
808
  span.endTime = Date.now();
509
809
  span.durationMs = span.endTime - span.startTime;
510
810
  span.status = status;
511
811
  Object.assign(span.attributes, clean(attributes));
512
812
  this.open.delete(key);
513
- this.spans.push(span);
813
+ this.recordSpan(span);
814
+ return span;
514
815
  }
515
816
  recordLatency(ms) {
516
817
  if (typeof ms !== "number") return;
@@ -560,14 +861,27 @@ var TelemetryAdapter = class {
560
861
  {
561
862
  scope: { name: "combycode.telemetry" },
562
863
  spans: this.spans.map((s) => ({
563
- traceId: s.traceId,
564
- spanId: s.spanId,
565
- name: s.name,
864
+ // An app-supplied trace id is ALREADY a real 32-hex id — hashing it
865
+ // would produce a different trace and defeat the whole point of
866
+ // accepting a parent.
867
+ traceId: isHex(s.traceId, 32) ? s.traceId : toOtlpId(s.traceId, 16),
868
+ // Scoped by trace: two conversations can each hold a span keyed
869
+ // `llm:…`, and colliding their ids would merge unrelated traces.
870
+ spanId: toOtlpId(`${s.traceId}|${s.spanId}`, 8),
871
+ // The app's own span id arrives as hex and passes through; one of ours
872
+ // is hashed exactly as it was when we emitted it, so the link matches.
873
+ ...s.parentSpanId ? {
874
+ parentSpanId: isHex(s.parentSpanId, 16) ? s.parentSpanId : toOtlpId(`${s.traceId}|${s.parentSpanId}`, 8)
875
+ } : {},
876
+ name: otlpSpanName(s),
566
877
  startTimeUnixNano: Math.round(s.startTime * 1e6),
567
878
  endTimeUnixNano: Math.round((s.endTime ?? s.startTime) * 1e6),
568
- kind: s.kind,
879
+ kind: OTLP_KIND_BY_SPAN[s.kind] ?? OTLP_SPAN_KIND.internal,
569
880
  status: { code: s.status === "error" ? 2 : s.status === "ok" ? 1 : 0 },
570
- attributes: Object.entries(s.attributes).map(([key, value]) => ({ key, value: { stringValue: String(value) } }))
881
+ attributes: Object.entries(s.attributes).map(([key, value]) => ({
882
+ key,
883
+ value: toOtlpValue(value)
884
+ }))
571
885
  }))
572
886
  }
573
887
  ]
@@ -25220,7 +25534,7 @@ function extractSystem(messages) {
25220
25534
  const rest = [];
25221
25535
  for (const m of messages) {
25222
25536
  if (m.role === "system") {
25223
- const text = typeof m.content === "string" ? m.content : contentToText(m.content);
25537
+ const text = typeof m.content === "string" ? m.content : contentToText2(m.content);
25224
25538
  if (text) systemTexts.push(text);
25225
25539
  } else {
25226
25540
  rest.push(m);
@@ -25231,7 +25545,7 @@ function extractSystem(messages) {
25231
25545
  messages: rest
25232
25546
  };
25233
25547
  }
25234
- function contentToText(content) {
25548
+ function contentToText2(content) {
25235
25549
  return content.filter((p) => p.type === "text").map((p) => p.text).join("\n");
25236
25550
  }
25237
25551
  function parseStructured(text) {
@@ -25513,7 +25827,15 @@ var LLMClient = class {
25513
25827
  signal: options.signal,
25514
25828
  provider: this.provider,
25515
25829
  model: this.model,
25516
- trace: { sessionId: ctx.sessionId, requestId: ctx.requestId, callId: ctx.callId }
25830
+ // Every trace field, not a hand-picked three: `traceparent` rides with the ids,
25831
+ // and picking fields here is what left the HTTP spans rooting a trace of their
25832
+ // own while the LLM span they belong to had joined the caller's.
25833
+ trace: {
25834
+ sessionId: ctx.sessionId,
25835
+ requestId: ctx.requestId,
25836
+ callId: ctx.callId,
25837
+ traceparent: ctx.traceparent
25838
+ }
25517
25839
  };
25518
25840
  response = await this.fetchFn(httpReq, {
25519
25841
  queueName: this.queueName,
@@ -25641,7 +25963,15 @@ var LLMClient = class {
25641
25963
  stream: true,
25642
25964
  provider: this.provider,
25643
25965
  model: this.model,
25644
- trace: { sessionId: ctx.sessionId, requestId: ctx.requestId, callId: ctx.callId }
25966
+ // Every trace field, not a hand-picked three: `traceparent` rides with the ids,
25967
+ // and picking fields here is what left the HTTP spans rooting a trace of their
25968
+ // own while the LLM span they belong to had joined the caller's.
25969
+ trace: {
25970
+ sessionId: ctx.sessionId,
25971
+ requestId: ctx.requestId,
25972
+ callId: ctx.callId,
25973
+ traceparent: ctx.traceparent
25974
+ }
25645
25975
  };
25646
25976
  const start = performance.now();
25647
25977
  let text = "";
@@ -25766,6 +26096,19 @@ var ANTHROPIC_THINKING_BUDGETS = {
25766
26096
  max: 16384
25767
26097
  };
25768
26098
  var DEFAULT_ANTHROPIC_THINKING_BUDGET = 2048;
26099
+ var ANTHROPIC_ADAPTIVE_THINKING_MIN = { major: 4, minor: 6 };
26100
+ function anthropicThinkingShape(model) {
26101
+ const id = model.toLowerCase().replace(/^anthropic\//, "");
26102
+ const modern = /^claude-[a-z]+-(\d+)(?:[-.](\d+))?/.exec(id);
26103
+ if (modern) {
26104
+ const major = Number(modern[1]);
26105
+ const minor = modern[2] === void 0 ? 0 : Number(modern[2]);
26106
+ const { major: minMajor, minor: minMinor } = ANTHROPIC_ADAPTIVE_THINKING_MIN;
26107
+ return major > minMajor || major === minMajor && minor >= minMinor ? "adaptive" : "budgeted";
26108
+ }
26109
+ if (/^claude-\d/.test(id)) return "budgeted";
26110
+ return "adaptive";
26111
+ }
25769
26112
  var ANTHROPIC_TOP_K_MODELS = /^claude-(opus-4-(1|5|6)|sonnet-4-(5|6)|haiku-4-5)(\b|-)/;
25770
26113
  function anthropicAcceptsTopK(model) {
25771
26114
  return ANTHROPIC_TOP_K_MODELS.test(model);
@@ -26147,6 +26490,16 @@ var AnthropicAdapter = class {
26147
26490
  }
26148
26491
  if (req.thinking) {
26149
26492
  if (req.thinking.mode === "off") {
26493
+ } else if (anthropicThinkingShape(req.model) === "adaptive") {
26494
+ const thinking = { type: "adaptive" };
26495
+ if (req.thinking.visibility === "hidden") thinking.display = "omitted";
26496
+ body.thinking = thinking;
26497
+ if (req.thinking.effort) {
26498
+ body.output_config = {
26499
+ ...body.output_config ?? {},
26500
+ effort: req.thinking.effort
26501
+ };
26502
+ }
26150
26503
  } else {
26151
26504
  const budget = req.thinking.effort ? ANTHROPIC_THINKING_BUDGETS[req.thinking.effort] ?? DEFAULT_ANTHROPIC_THINKING_BUDGET : DEFAULT_ANTHROPIC_THINKING_BUDGET;
26152
26505
  const thinking = { type: "enabled", budget_tokens: budget };
@@ -31548,6 +31901,12 @@ async function handleToolError(e, tc, hooks, runId, agentId, step, metrics, repo
31548
31901
  // src/agent/loop.ts
31549
31902
  var AgentLoop = class _AgentLoop {
31550
31903
  id;
31904
+ /** Human name, surfaced as `gen_ai.agent.name` — see AgentLoopConfig.label. */
31905
+ label;
31906
+ /** Which part of the host system this agent belongs to. */
31907
+ source;
31908
+ /** Extra attributes stamped on this agent's spans. */
31909
+ attributes;
31551
31910
  client;
31552
31911
  hooks;
31553
31912
  _system;
@@ -31621,6 +31980,9 @@ var AgentLoop = class _AgentLoop {
31621
31980
  this._history = new ConversationHistory();
31622
31981
  }
31623
31982
  this.id = this._history.id;
31983
+ this.label = config.label;
31984
+ this.source = config.source;
31985
+ this.attributes = config.attributes;
31624
31986
  writeAgentLoopSystem(this._history.registry, this._system, "agent-loop");
31625
31987
  writeAgentLoopContext(this._history.registry, this._context, "agent-loop");
31626
31988
  this.syncLazyProtocol();
@@ -31725,7 +32087,7 @@ var AgentLoop = class _AgentLoop {
31725
32087
  }
31726
32088
  // ─── complete (non-streaming) ───────────────────────────────────────────
31727
32089
  async complete(input, options = {}) {
31728
- const { runId, startedAt, startPerf, userMessageText, runTrace } = await this.beginRun(input);
32090
+ const { runId, startedAt, startPerf, userMessageText, runTrace } = await this.beginRun(input, options.ctx);
31729
32091
  const steps = [];
31730
32092
  const totalUsage = emptyUsage();
31731
32093
  let totalLlmTimeMs = 0;
@@ -31777,7 +32139,23 @@ var AgentLoop = class _AgentLoop {
31777
32139
  thinking: options.thinking ?? this._thinking,
31778
32140
  cache: options.cache ?? this._cache,
31779
32141
  tools: this.toolDefinitions(options),
31780
- ctx: { ...options.ctx, conversationId: this._history.id },
32142
+ ctx: {
32143
+ // The RUN's trace, handed down to every LLM call it makes.
32144
+ //
32145
+ // Without this the agent kept `runTrace` to itself: its own spans used it
32146
+ // while each `client.complete()` fell through to mint-if-absent and
32147
+ // invented a fresh `requestId`. Since the trace id is `sessionId:requestId`,
32148
+ // one conversation arrived at the backend as SEVERAL unrelated traces —
32149
+ // measured against a real collector: a single turn with one tool call
32150
+ // produced six. Correlation is the whole point of a trace id, so this is
32151
+ // the one thing it must not get wrong.
32152
+ ...runTrace,
32153
+ conversationId: this._history.id,
32154
+ // A caller's explicit ctx wins over all of the above: an app that already
32155
+ // owns a request id or a conversation id has better information than we do,
32156
+ // and silently overwriting it is how its telemetry stops joining up.
32157
+ ...options.ctx
32158
+ },
31781
32159
  signal: options.signal ?? this._abortController?.signal
31782
32160
  });
31783
32161
  const stepLatency = performance.now() - stepStart;
@@ -31945,7 +32323,7 @@ var AgentLoop = class _AgentLoop {
31945
32323
  }
31946
32324
  // ─── stream ─────────────────────────────────────────────────────────────
31947
32325
  async *stream(input, options = {}) {
31948
- const { runId, startedAt, startPerf, userMessageText, runTrace } = await this.beginRun(input);
32326
+ const { runId, startedAt, startPerf, userMessageText, runTrace } = await this.beginRun(input, options.ctx);
31949
32327
  const steps = [];
31950
32328
  const totalUsage = emptyUsage();
31951
32329
  let totalLlmTimeMs = 0;
@@ -32001,7 +32379,23 @@ var AgentLoop = class _AgentLoop {
32001
32379
  thinking: options.thinking ?? this._thinking,
32002
32380
  cache: options.cache ?? this._cache,
32003
32381
  tools: this.toolDefinitions(options),
32004
- ctx: { ...options.ctx, conversationId: this._history.id },
32382
+ ctx: {
32383
+ // The RUN's trace, handed down to every LLM call it makes.
32384
+ //
32385
+ // Without this the agent kept `runTrace` to itself: its own spans used it
32386
+ // while each `client.complete()` fell through to mint-if-absent and
32387
+ // invented a fresh `requestId`. Since the trace id is `sessionId:requestId`,
32388
+ // one conversation arrived at the backend as SEVERAL unrelated traces —
32389
+ // measured against a real collector: a single turn with one tool call
32390
+ // produced six. Correlation is the whole point of a trace id, so this is
32391
+ // the one thing it must not get wrong.
32392
+ ...runTrace,
32393
+ conversationId: this._history.id,
32394
+ // A caller's explicit ctx wins over all of the above: an app that already
32395
+ // owns a request id or a conversation id has better information than we do,
32396
+ // and silently overwriting it is how its telemetry stops joining up.
32397
+ ...options.ctx
32398
+ },
32005
32399
  signal: options.signal ?? this._abortController?.signal
32006
32400
  })) {
32007
32401
  const toYield = accumulateStreamEvent(event, state);
@@ -32213,7 +32607,7 @@ var AgentLoop = class _AgentLoop {
32213
32607
  arguments: tc.arguments,
32214
32608
  callId: tc.id,
32215
32609
  step,
32216
- trace: { sessionId: runTrace.sessionId, requestId: runTrace.requestId, callId: tc.id }
32610
+ trace: { ...runTrace, callId: tc.id }
32217
32611
  });
32218
32612
  if (!decision.pass) {
32219
32613
  return this.buildDeniedResult(tc, decision.reason, reports);
@@ -32233,7 +32627,7 @@ var AgentLoop = class _AgentLoop {
32233
32627
  }
32234
32628
  }
32235
32629
  try {
32236
- const baseCtx = { step, callId: tc.id, metrics, trace: { sessionId: runTrace.sessionId, requestId: runTrace.requestId, callId: tc.id } };
32630
+ const baseCtx = { step, callId: tc.id, metrics, trace: { ...runTrace, callId: tc.id } };
32237
32631
  const result = await executeWithTimeout(lookup.tool, tc, baseCtx, this._toolTimeout);
32238
32632
  return await this.buildSuccessResult(tc, result, runId, step, metrics, reports, toolStart, runTrace, lookup.tool, baseCtx);
32239
32633
  } catch (e) {
@@ -32291,7 +32685,7 @@ var AgentLoop = class _AgentLoop {
32291
32685
  arguments: tc.arguments,
32292
32686
  reason,
32293
32687
  step,
32294
- trace: { sessionId: runTrace.sessionId, requestId: runTrace.requestId, callId: tc.id }
32688
+ trace: { ...runTrace, callId: tc.id }
32295
32689
  };
32296
32690
  const pending = {
32297
32691
  callId: tc.id,
@@ -32324,7 +32718,7 @@ var AgentLoop = class _AgentLoop {
32324
32718
  return this.buildOverriddenResult(tc, decision.overrideResult, reports);
32325
32719
  }
32326
32720
  try {
32327
- const baseCtx = { step, callId: tc.id, metrics, trace: { sessionId: runTrace.sessionId, requestId: runTrace.requestId, callId: tc.id } };
32721
+ const baseCtx = { step, callId: tc.id, metrics, trace: { ...runTrace, callId: tc.id } };
32328
32722
  const result = await executeWithTimeout(tool, tc, baseCtx, this._toolTimeout);
32329
32723
  return await this.buildSuccessResult(tc, result, runId, step, metrics, reports, toolStart, runTrace, tool, baseCtx);
32330
32724
  } catch (e) {
@@ -32420,7 +32814,7 @@ var AgentLoop = class _AgentLoop {
32420
32814
  return own.length > 0 ? own : void 0;
32421
32815
  }
32422
32816
  // ─── Run helpers ────────────────────────────────────────────────────────
32423
- async beginRun(input) {
32817
+ async beginRun(input, callerCtx) {
32424
32818
  if (this._running) throw new Error("AgentLoop is already running");
32425
32819
  this._running = true;
32426
32820
  this._stopRequested = false;
@@ -32437,10 +32831,23 @@ var AgentLoop = class _AgentLoop {
32437
32831
  const startedAt = Date.now();
32438
32832
  const startPerf = performance.now();
32439
32833
  const userMessageText = typeof input === "string" ? input : Array.isArray(input) && input.length > 0 && "role" in input[0] ? contentText(input[input.length - 1].content) : contentText(input);
32440
- const runTrace = { sessionId: this.id, requestId: runId };
32834
+ const runTrace = {
32835
+ sessionId: callerCtx?.sessionId ?? this.id,
32836
+ requestId: callerCtx?.requestId ?? runId,
32837
+ // The caller's span travels WITH the ids, for the same reason they do: `agent.run`,
32838
+ // every `tool.call`, and any agent nested inside a tool reach the telemetry through
32839
+ // `runTrace` and nothing else. While this field was missing from it only the LLM
32840
+ // calls joined the app's trace — they are built from the caller's ctx directly —
32841
+ // and the run that made them sat in a second, unrelated one. Measured against a
32842
+ // live backend; the unit tests fed the hooks directly and never saw it.
32843
+ ...callerCtx?.traceparent ? { traceparent: callerCtx.traceparent } : {}
32844
+ };
32441
32845
  await this.hooks.emit("onRunStart", {
32442
32846
  runId,
32443
32847
  agentId: this.id,
32848
+ label: this.label,
32849
+ source: this.source,
32850
+ attributes: this.attributes,
32444
32851
  userMessage: input,
32445
32852
  model: this.client.model,
32446
32853
  system: this._history.system,
@@ -32495,7 +32902,7 @@ var AgentLoop = class _AgentLoop {
32495
32902
  if (g.kind !== "input") continue;
32496
32903
  const decision = await g.check({
32497
32904
  kind: "input",
32498
- trace: { sessionId: runTrace.sessionId, requestId: runTrace.requestId },
32905
+ trace: { ...runTrace },
32499
32906
  step,
32500
32907
  messages,
32501
32908
  system
@@ -32523,7 +32930,7 @@ var AgentLoop = class _AgentLoop {
32523
32930
  if (g.kind !== "output") continue;
32524
32931
  const decision = await g.check({
32525
32932
  kind: "output",
32526
- trace: { sessionId: runTrace.sessionId, requestId: runTrace.requestId },
32933
+ trace: { ...runTrace },
32527
32934
  step,
32528
32935
  response
32529
32936
  });
@@ -34688,6 +35095,7 @@ function createEngine(config = {}) {
34688
35095
  const fetchStreamBound = (req, options) => network.fetchStream(req, options);
34689
35096
  const connectBound = (req) => network.connect(req);
34690
35097
  const cost = new CostCollector({ hooks, catalog });
35098
+ const telemetry = config.telemetry ? new TelemetryAdapter(hooks, config.telemetry) : null;
34691
35099
  const handle = {
34692
35100
  sessionId,
34693
35101
  hooks,
@@ -34700,9 +35108,11 @@ function createEngine(config = {}) {
34700
35108
  connect: connectBound,
34701
35109
  catalog,
34702
35110
  cost,
35111
+ telemetry,
34703
35112
  apiKeys: config.apiKeys ?? {},
34704
35113
  destroy() {
34705
35114
  cost.destroy();
35115
+ telemetry?.destroy();
34706
35116
  network.destroy();
34707
35117
  }
34708
35118
  };
@@ -38472,7 +38882,8 @@ async function complete(opts) {
38472
38882
  serviceTier,
38473
38883
  cache: opts.cache,
38474
38884
  topK: opts.topK,
38475
- seed: opts.seed
38885
+ seed: opts.seed,
38886
+ thinking: opts.thinking
38476
38887
  });
38477
38888
  } else {
38478
38889
  res = await llm.complete(input, {
@@ -38486,7 +38897,8 @@ async function complete(opts) {
38486
38897
  serviceTier,
38487
38898
  cache: opts.cache,
38488
38899
  topK: opts.topK,
38489
- seed: opts.seed
38900
+ seed: opts.seed,
38901
+ thinking: opts.thinking
38490
38902
  });
38491
38903
  }
38492
38904
  const result = {