@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.
package/dist/index.js CHANGED
@@ -114,6 +114,86 @@ var REDACTED = "***REDACTED***";
114
114
  var SENSITIVE_QUERY_PARAMS = /* @__PURE__ */ new Set(["key", "api_key", "access_token", "token"]);
115
115
  var SENSITIVE_HEADERS = /* @__PURE__ */ new Set(["authorization", "x-goog-api-key", "x-api-key", "api-key"]);
116
116
  var MAX_ERROR_RAW_CHARS = 512;
117
+ var OTLP_SPAN_KIND = { internal: 1, client: 3 };
118
+ var OTLP_KIND_BY_SPAN = {
119
+ llm: OTLP_SPAN_KIND.client,
120
+ http: OTLP_SPAN_KIND.client,
121
+ mcp: OTLP_SPAN_KIND.client,
122
+ media: OTLP_SPAN_KIND.client,
123
+ agent: OTLP_SPAN_KIND.internal,
124
+ tool: OTLP_SPAN_KIND.internal,
125
+ other: OTLP_SPAN_KIND.internal
126
+ };
127
+ function fnv1a32(input, seed) {
128
+ let h = seed >>> 0;
129
+ for (let i = 0; i < input.length; i++) {
130
+ h ^= input.charCodeAt(i);
131
+ h = Math.imul(h, 16777619) >>> 0;
132
+ }
133
+ return h >>> 0;
134
+ }
135
+ var isHex = (value, chars) => value.length === chars && /^[0-9a-f]+$/.test(value);
136
+ function toOtlpId(input, bytes) {
137
+ let out = "";
138
+ for (let i = 0; i < bytes / 4; i++) {
139
+ out += fnv1a32(input, 2166136261 + i * 2654435769 >>> 0).toString(16).padStart(8, "0");
140
+ }
141
+ return /^0+$/.test(out) ? `${out.slice(0, -1)}1` : out;
142
+ }
143
+ function toOtlpValue(value) {
144
+ if (typeof value === "boolean") return { boolValue: value };
145
+ if (typeof value === "number" && Number.isFinite(value)) {
146
+ return Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value };
147
+ }
148
+ if (typeof value === "string") return { stringValue: value };
149
+ if (value === null || value === void 0) return { stringValue: "" };
150
+ return { stringValue: typeof value === "object" ? JSON.stringify(value) : String(value) };
151
+ }
152
+ var SPAN_NAME_SUBJECT = {
153
+ chat: "gen_ai.request.model",
154
+ invoke_agent: "gen_ai.agent.name",
155
+ execute_tool: "gen_ai.tool.name"
156
+ };
157
+ function otlpSpanName(span) {
158
+ const op = span.attributes["gen_ai.operation.name"];
159
+ if (typeof op !== "string") return span.name;
160
+ const subject = SPAN_NAME_SUBJECT[op] ? span.attributes[SPAN_NAME_SUBJECT[op]] : void 0;
161
+ return typeof subject === "string" && subject ? `${op} ${subject}` : op;
162
+ }
163
+ function toMessageList(payload, defaultRole) {
164
+ if (payload == null) return [];
165
+ if (typeof payload === "string") {
166
+ return payload ? [{ role: defaultRole, content: payload }] : [];
167
+ }
168
+ if (Array.isArray(payload)) {
169
+ const parts2 = payload;
170
+ if (parts2.length > 0 && parts2[0] && "role" in parts2[0]) {
171
+ return parts2.map((m) => ({ role: String(m.role ?? defaultRole), content: contentToText(m.content) })).filter((m) => m.content);
172
+ }
173
+ const text2 = contentToText(parts2);
174
+ return text2 ? [{ role: defaultRole, content: text2 }] : [];
175
+ }
176
+ const text = contentToText(payload);
177
+ return text ? [{ role: defaultRole, content: text }] : [];
178
+ }
179
+ function contentToText(content) {
180
+ if (typeof content === "string") return content;
181
+ if (!Array.isArray(content)) return "";
182
+ return content.map((part) => {
183
+ const p = part;
184
+ return typeof p?.text === "string" ? p.text : "";
185
+ }).filter(Boolean).join("");
186
+ }
187
+ var EVENT_TYPE_BY_KIND = {
188
+ agent: "agent",
189
+ tool: "tool",
190
+ llm: "llm",
191
+ http: "http",
192
+ mcp: "mcp",
193
+ media: "media",
194
+ other: "other"
195
+ };
196
+ var SAMPLE_SEED = 2654435769;
117
197
  var CATEGORY = {
118
198
  // Network
119
199
  onEnqueue: "network",
@@ -182,10 +262,29 @@ function traceIdsOf(ctx) {
182
262
  const t = c?.trace ?? c?.ctx ?? c;
183
263
  return {
184
264
  sessionId: t?.sessionId,
185
- requestId: t?.requestId
265
+ requestId: t?.requestId,
266
+ /** W3C parent context, when the app is already inside a trace of its own. */
267
+ traceparent: t?.traceparent,
268
+ // `gen_ai.conversation.id` in the semantic conventions — the thread a turn
269
+ // belongs to, which is what lets a backend group turns into one conversation.
270
+ // AgentLoop sets it from the history id; a bare client call has none.
271
+ conversationId: t?.conversationId
186
272
  };
187
273
  }
188
- var traceKey = (ids) => ids.requestId ? `${ids.sessionId ?? "?"}:${ids.requestId}` : void 0;
274
+ function parseTraceparent(value) {
275
+ if (!value) return null;
276
+ const m = /^[0-9a-f]{2}-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/.exec(value.trim().toLowerCase());
277
+ if (!m) return null;
278
+ const [, traceId, spanId] = m;
279
+ if (/^0+$/.test(traceId) || /^0+$/.test(spanId)) return null;
280
+ return { traceId, spanId };
281
+ }
282
+ var CONTAINER_SPANS = /* @__PURE__ */ new Set(["agent.run", "tool.call"]);
283
+ var traceKey = (ids) => {
284
+ const parent = parseTraceparent(ids.traceparent);
285
+ if (parent) return parent.traceId;
286
+ return ids.requestId ? `${ids.sessionId ?? "?"}:${ids.requestId}` : void 0;
287
+ };
189
288
  var TelemetryAdapter = class {
190
289
  events = [];
191
290
  spans = [];
@@ -206,17 +305,138 @@ var TelemetryAdapter = class {
206
305
  /** Service identity stamped on exported telemetry. */
207
306
  resource;
208
307
  seq = 0;
308
+ /** Discriminator for POINT spans (media, mcp connect/tool), whose natural keys are
309
+ * not unique — the same server reconnects, a run emits two images, two tool calls
310
+ * land in one millisecond. A duplicate span id inside a trace is invalid OTLP and
311
+ * the backend silently keeps only one. */
312
+ spanSeq = 0;
313
+ /** Per trace: the app's span from a `traceparent`, and the CONTAINER spans currently
314
+ * open on it. Together they decide what a new span hangs under — see `parentFor`.
315
+ * Both are cleared once a trace has nothing open, so a long-lived process does not
316
+ * accumulate an entry per conversation forever.
317
+ *
318
+ * A list, not a single slot: an agent nested in a tool call (C2 inside C1's tool) is a
319
+ * second run on the SAME trace, and with one slot it overwrote its own parent and then
320
+ * deleted it on close — leaving the rest of the outer run parentless. */
321
+ appParent = /* @__PURE__ */ new Map();
322
+ containers = /* @__PURE__ */ new Map();
209
323
  latSum = 0;
210
324
  open = /* @__PURE__ */ new Map();
211
325
  maxEvents;
212
326
  includeSensitiveData;
213
327
  unsub;
328
+ /** Subscribers, each with its own filter. Re-parenting is computed PER SINK: two
329
+ * consumers asking for different types each get a tree that is correct for them. */
330
+ sinks = [];
331
+ content;
332
+ sampleRate;
333
+ /** spanId → its parent and type, for EVERY span including filtered ones — walking up
334
+ * past a dropped ancestor is the whole point, so the dropped ones must still be here.
335
+ * Bounded, because a long-lived process would otherwise remember every span it ever
336
+ * saw. */
337
+ lineage = /* @__PURE__ */ new Map();
338
+ maxLineage;
339
+ msgSeq = 0;
214
340
  constructor(hooks, opts = {}) {
215
341
  this.maxEvents = opts.maxEvents ?? 2e3;
216
342
  this.includeSensitiveData = opts.includeSensitiveData ?? true;
217
343
  this.resource = opts.resource ?? { serviceName: "unknown_service" };
344
+ this.content = opts.content ?? "none";
345
+ this.sampleRate = opts.sample ?? 1;
346
+ this.maxLineage = this.maxEvents * 2;
347
+ if (opts.onTrace) this.onTrace({ types: opts.types }, opts.onTrace);
218
348
  this.unsub = hooks.onAny((name, ctx) => this.handle(name, ctx));
219
349
  }
350
+ onTrace(filterOrHandler, maybeHandler) {
351
+ const handler = typeof filterOrHandler === "function" ? filterOrHandler : maybeHandler;
352
+ if (!handler) throw new Error("onTrace requires a handler");
353
+ const filter = typeof filterOrHandler === "function" ? {} : filterOrHandler;
354
+ const sink = { types: filter.types ? new Set(filter.types) : void 0, handler };
355
+ this.sinks.push(sink);
356
+ return () => {
357
+ const at = this.sinks.indexOf(sink);
358
+ if (at !== -1) this.sinks.splice(at, 1);
359
+ };
360
+ }
361
+ /** Record a finished span and hand it to the subscribers. Every span reaches the store
362
+ * through here, so there is one place where an event can be missed rather than five. */
363
+ recordSpan(span) {
364
+ this.spans.push(span);
365
+ const type = EVENT_TYPE_BY_KIND[span.kind];
366
+ if (!this.lineage.has(span.spanId)) this.remember(span.spanId, span.parentSpanId, type);
367
+ this.dispatch({
368
+ type,
369
+ traceId: span.traceId,
370
+ spanId: span.spanId,
371
+ parentSpanId: span.parentSpanId,
372
+ name: otlpSpanName(span),
373
+ startTime: span.startTime,
374
+ endTime: span.endTime,
375
+ durationMs: span.durationMs,
376
+ status: span.status,
377
+ attributes: span.attributes
378
+ });
379
+ }
380
+ remember(spanId, parentSpanId, type) {
381
+ this.lineage.set(spanId, { parentSpanId, type });
382
+ if (this.lineage.size > this.maxLineage) {
383
+ const oldest = this.lineage.keys().next().value;
384
+ if (oldest !== void 0) this.lineage.delete(oldest);
385
+ }
386
+ }
387
+ dispatch(event) {
388
+ if (this.sinks.length === 0) return;
389
+ if (!this.isSampled(event.traceId)) return;
390
+ for (const sink of this.sinks) {
391
+ if (sink.types && !sink.types.has(event.type)) continue;
392
+ const parentSpanId = sink.types ? this.survivingParent(event.parentSpanId, sink.types) : event.parentSpanId;
393
+ sink.handler(parentSpanId === event.parentSpanId ? event : { ...event, parentSpanId });
394
+ }
395
+ }
396
+ /** The nearest ancestor this subscriber actually receives. Without this, filtering out
397
+ * `http` would leave its children pointing at a span that never arrives, and a backend
398
+ * renders a dangling parent as a separate root. */
399
+ survivingParent(parentSpanId, types) {
400
+ let id = parentSpanId;
401
+ while (id) {
402
+ const node = this.lineage.get(id);
403
+ if (!node) return void 0;
404
+ if (types.has(node.type)) return id;
405
+ id = node.parentSpanId;
406
+ }
407
+ return void 0;
408
+ }
409
+ /** Hashed rather than random, so the same trace samples the same way in every process
410
+ * and a trace shared by two services is kept or dropped by both. */
411
+ isSampled(traceId) {
412
+ if (this.sampleRate >= 1) return true;
413
+ if (this.sampleRate <= 0) return false;
414
+ return fnv1a32(traceId, SAMPLE_SEED) / 4294967296 < this.sampleRate;
415
+ }
416
+ /** Conversation content, as its own event so it can be routed somewhere different from
417
+ * the spans — a debug store, not the metrics backend. */
418
+ emitMessage(span, direction, payload) {
419
+ if (this.sinks.length === 0) return;
420
+ const messages = toMessageList(payload, direction === "input" ? "user" : "assistant");
421
+ if (messages.length === 0) return;
422
+ const chars = messages.reduce((n, m) => n + m.content.length, 0);
423
+ this.dispatch({
424
+ type: "message",
425
+ traceId: span.traceId,
426
+ spanId: `${span.spanId}:msg${this.msgSeq++}`,
427
+ parentSpanId: span.spanId,
428
+ name: `message.${direction}`,
429
+ startTime: Date.now(),
430
+ status: "unset",
431
+ attributes: clean({
432
+ "message.direction": direction,
433
+ "message.count": messages.length,
434
+ "message.chars": chars,
435
+ // Opt-In in the spec, and off by default here for the same reason.
436
+ [`gen_ai.${direction}.messages`]: this.content === "full" ? messages : void 0
437
+ })
438
+ });
439
+ }
220
440
  /** Stop tapping the bus. */
221
441
  destroy() {
222
442
  this.unsub();
@@ -224,6 +444,8 @@ var TelemetryAdapter = class {
224
444
  handle(name, ctx) {
225
445
  const ids = traceIdsOf(ctx);
226
446
  const traceId = traceKey(ids);
447
+ const parent = parseTraceparent(ids.traceparent);
448
+ if (parent && traceId) this.appParent.set(traceId, parent.spanId);
227
449
  this.events.push({
228
450
  seq: this.seq++,
229
451
  time: Date.now(),
@@ -246,20 +468,27 @@ var TelemetryAdapter = class {
246
468
  this.metrics.outputTokens += usage.outputTokens ?? 0;
247
469
  }
248
470
  if (traceId) {
471
+ const responseModel = c.response?.model;
249
472
  const attrs = {
250
- "gen_ai.provider": c.provider,
251
- "gen_ai.model": c.model,
473
+ "gen_ai.provider.name": c.provider,
474
+ "gen_ai.operation.name": "chat",
475
+ "gen_ai.request.model": c.model,
476
+ // The model that actually answered, which can differ from the one asked
477
+ // for (an alias resolving to a dated snapshot, a router picking a peer).
478
+ "gen_ai.response.model": responseModel,
479
+ "gen_ai.conversation.id": ids.conversationId,
252
480
  "gen_ai.usage.input_tokens": usage?.inputTokens,
253
481
  "gen_ai.usage.output_tokens": usage?.outputTokens
254
482
  };
255
483
  const key = `llm:${traceId}`;
484
+ let llmSpan;
256
485
  if (this.open.has(key)) {
257
- this.closeSpan(key, "ok", attrs);
486
+ llmSpan = this.closeSpan(key, "ok", attrs);
258
487
  } else {
259
488
  const http = [...this.spans].reverse().find((s) => s.traceId === traceId && s.kind === "http");
260
489
  const start = http?.startTime ?? Date.now();
261
490
  const end = Date.now();
262
- this.spans.push({
491
+ llmSpan = {
263
492
  traceId,
264
493
  spanId: key,
265
494
  name: "llm.request",
@@ -269,7 +498,12 @@ var TelemetryAdapter = class {
269
498
  durationMs: end - start,
270
499
  status: "ok",
271
500
  attributes: clean(attrs)
272
- });
501
+ };
502
+ this.recordSpan(llmSpan);
503
+ }
504
+ if (llmSpan) {
505
+ const response = c.response;
506
+ this.emitMessage(llmSpan, "output", response?.content ?? response?.text);
273
507
  }
274
508
  }
275
509
  break;
@@ -315,9 +549,11 @@ var TelemetryAdapter = class {
315
549
  this.metrics.mediaGenerated += c.count ?? 1;
316
550
  if (traceId) {
317
551
  const now = Date.now();
318
- this.spans.push({
552
+ this.recordSpan({
319
553
  traceId,
320
- spanId: `media:${traceId}`,
554
+ // One run can generate several images; `media:${traceId}` would give them
555
+ // all the same span id, which is invalid within a trace.
556
+ spanId: `media:${traceId}:${this.spanSeq++}`,
321
557
  name: "media.generate",
322
558
  kind: "media",
323
559
  startTime: now,
@@ -332,10 +568,21 @@ var TelemetryAdapter = class {
332
568
  case "onRunStart": {
333
569
  const runId = c.runId;
334
570
  if (runId) {
335
- this.openSpan(`agent:${runId}`, runId, "agent.run", "agent", {
336
- "agent.id": c.agentId,
337
- "agent.model": c.model
571
+ const runSpan = this.openSpan(`agent:${runId}`, traceId ?? runId, "agent.run", "agent", {
572
+ // The host's own attributes go FIRST so ours win on a key collision: a stray
573
+ // `gen_ai.*` key in a caller's bag must not be able to rewrite the identity
574
+ // of the span.
575
+ ...c.attributes,
576
+ "gen_ai.operation.name": "invoke_agent",
577
+ // Named when the agent was given a label; the exported span is then
578
+ // `invoke_agent {label}` rather than the bare operation.
579
+ "gen_ai.agent.name": c.label,
580
+ "gen_ai.agent.id": c.agentId,
581
+ "gen_ai.request.model": c.model,
582
+ // Ours, not a convention attribute — the GenAI spec has no term for it.
583
+ "agent.source": c.source
338
584
  });
585
+ this.emitMessage(runSpan, "input", c.userMessage);
339
586
  }
340
587
  break;
341
588
  }
@@ -362,9 +609,11 @@ var TelemetryAdapter = class {
362
609
  case "onToolCallStart": {
363
610
  const callId = c.callId;
364
611
  if (callId) {
365
- this.openSpan(`tool:${callId}`, callId, "tool.call", "tool", {
366
- "tool.name": c.toolName,
367
- "agent.id": c.agentId
612
+ this.openSpan(`tool:${callId}`, traceId ?? callId, "tool.call", "tool", {
613
+ "gen_ai.operation.name": "execute_tool",
614
+ "gen_ai.tool.name": c.toolName,
615
+ "gen_ai.tool.call.id": callId,
616
+ "gen_ai.agent.id": c.agentId
368
617
  });
369
618
  }
370
619
  break;
@@ -373,7 +622,7 @@ var TelemetryAdapter = class {
373
622
  const callId = c.callId;
374
623
  if (callId) {
375
624
  this.closeSpan(`tool:${callId}`, "ok", {
376
- "tool.name": c.toolName,
625
+ "gen_ai.tool.name": c.toolName,
377
626
  "tool.latency_ms": c.latencyMs
378
627
  });
379
628
  }
@@ -383,7 +632,7 @@ var TelemetryAdapter = class {
383
632
  const callId = c.callId;
384
633
  if (callId) {
385
634
  this.closeSpan(`tool:${callId}`, "error", {
386
- "tool.name": c.toolName,
635
+ "gen_ai.tool.name": c.toolName,
387
636
  "tool.error": c.error?.message
388
637
  });
389
638
  }
@@ -394,9 +643,17 @@ var TelemetryAdapter = class {
394
643
  const server = c.server;
395
644
  if (server) {
396
645
  const now = Date.now();
397
- this.spans.push({
398
- traceId: server,
399
- spanId: `mcp:connect:${server}`,
646
+ this.recordSpan({
647
+ // A connect usually happens at startup, outside any run, so there is often
648
+ // no trace to join — but keying the trace by server name merged every
649
+ // reconnect over the process lifetime into one trace. Falls back to a span
650
+ // of its own instead.
651
+ traceId: traceId ?? `mcp:connect:${server}:${this.spanSeq}`,
652
+ // `${server}` alone repeats on every reconnect, and a duplicate span id
653
+ // within a trace is invalid OTLP — the backend keeps one and drops the
654
+ // rest. The counter is monotonic where a timestamp is not: two connects
655
+ // inside the same millisecond would still collide.
656
+ spanId: `mcp:connect:${server}:${this.spanSeq++}`,
400
657
  name: "mcp.connect",
401
658
  kind: "mcp",
402
659
  startTime: now,
@@ -418,9 +675,14 @@ var TelemetryAdapter = class {
418
675
  if (server && tool) {
419
676
  const now = Date.now();
420
677
  const lat = c.latencyMs;
421
- this.spans.push({
422
- traceId: server,
423
- spanId: `mcp:tool:${server}:${tool}:${now}`,
678
+ this.recordSpan({
679
+ // An MCP tool call happens INSIDE a run, so it belongs to that run's trace.
680
+ // Keying it by server put every call to one server in a single eternal
681
+ // trace, and none of them with the agent that made the call.
682
+ traceId: traceId ?? `mcp:${server}`,
683
+ // A timestamp is not a unique key: two tool calls in the same millisecond
684
+ // share it. The counter is.
685
+ spanId: `mcp:tool:${server}:${tool}:${this.spanSeq++}`,
424
686
  name: "mcp.tool_call",
425
687
  kind: "mcp",
426
688
  startTime: now - (lat ?? 0),
@@ -438,10 +700,33 @@ var TelemetryAdapter = class {
438
700
  }
439
701
  }
440
702
  }
703
+ /** What a new span on this trace hangs under: the innermost container still open on
704
+ * it, else the app's span, else nothing (we are the root).
705
+ *
706
+ * A container wins over the app's span because an LLM call made during a run belongs
707
+ * to that run — attaching it straight to the app would flatten the very nesting the
708
+ * tree exists to show. A span joins the stack only after it is built, so nothing can
709
+ * become its own parent, and a run nested in a tool call lands under that tool call —
710
+ * exactly where it happened.
711
+ *
712
+ * Limit worth naming: with tools running in parallel two `tool.call` spans are open at
713
+ * once and "innermost" is merely the more recent one. Attributing a nested run to the
714
+ * right sibling needs real async context propagation, which this adapter does not
715
+ * have; sequential tools, the common case, are exact. */
716
+ parentFor(traceId) {
717
+ const stack = this.containers.get(traceId);
718
+ return stack?.[stack.length - 1] ?? this.appParent.get(traceId);
719
+ }
441
720
  openSpan(key, traceId, spanName, kind, attributes) {
721
+ const parentSpanId = this.parentFor(traceId);
442
722
  const span = {
443
723
  traceId,
444
- spanId: key,
724
+ ...parentSpanId ? { parentSpanId } : {},
725
+ // The KEY pairs open with close (`llm:${traceId}`); the SPAN ID must be unique.
726
+ // Those were the same string until a run stopped fragmenting into one trace per
727
+ // call — at which point every LLM call in a run produced the identical key, and
728
+ // the collision merged them into one span at the collector.
729
+ spanId: `${key}#${this.spanSeq++}`,
445
730
  name: spanName,
446
731
  kind,
447
732
  startTime: Date.now(),
@@ -449,17 +734,33 @@ var TelemetryAdapter = class {
449
734
  attributes
450
735
  };
451
736
  this.open.set(key, span);
737
+ this.remember(span.spanId, parentSpanId, EVENT_TYPE_BY_KIND[kind]);
738
+ if (CONTAINER_SPANS.has(spanName)) {
739
+ const stack = this.containers.get(traceId);
740
+ if (stack) stack.push(span.spanId);
741
+ else this.containers.set(traceId, [span.spanId]);
742
+ }
452
743
  return span;
453
744
  }
454
745
  closeSpan(key, status, attributes) {
455
746
  const span = this.open.get(key);
456
- if (!span) return;
747
+ if (!span) return void 0;
748
+ const stack = this.containers.get(span.traceId);
749
+ if (stack) {
750
+ const at = stack.lastIndexOf(span.spanId);
751
+ if (at !== -1) stack.splice(at, 1);
752
+ if (stack.length === 0) {
753
+ this.containers.delete(span.traceId);
754
+ this.appParent.delete(span.traceId);
755
+ }
756
+ }
457
757
  span.endTime = Date.now();
458
758
  span.durationMs = span.endTime - span.startTime;
459
759
  span.status = status;
460
760
  Object.assign(span.attributes, clean(attributes));
461
761
  this.open.delete(key);
462
- this.spans.push(span);
762
+ this.recordSpan(span);
763
+ return span;
463
764
  }
464
765
  recordLatency(ms) {
465
766
  if (typeof ms !== "number") return;
@@ -509,14 +810,27 @@ var TelemetryAdapter = class {
509
810
  {
510
811
  scope: { name: "combycode.telemetry" },
511
812
  spans: this.spans.map((s) => ({
512
- traceId: s.traceId,
513
- spanId: s.spanId,
514
- name: s.name,
813
+ // An app-supplied trace id is ALREADY a real 32-hex id — hashing it
814
+ // would produce a different trace and defeat the whole point of
815
+ // accepting a parent.
816
+ traceId: isHex(s.traceId, 32) ? s.traceId : toOtlpId(s.traceId, 16),
817
+ // Scoped by trace: two conversations can each hold a span keyed
818
+ // `llm:…`, and colliding their ids would merge unrelated traces.
819
+ spanId: toOtlpId(`${s.traceId}|${s.spanId}`, 8),
820
+ // The app's own span id arrives as hex and passes through; one of ours
821
+ // is hashed exactly as it was when we emitted it, so the link matches.
822
+ ...s.parentSpanId ? {
823
+ parentSpanId: isHex(s.parentSpanId, 16) ? s.parentSpanId : toOtlpId(`${s.traceId}|${s.parentSpanId}`, 8)
824
+ } : {},
825
+ name: otlpSpanName(s),
515
826
  startTimeUnixNano: Math.round(s.startTime * 1e6),
516
827
  endTimeUnixNano: Math.round((s.endTime ?? s.startTime) * 1e6),
517
- kind: s.kind,
828
+ kind: OTLP_KIND_BY_SPAN[s.kind] ?? OTLP_SPAN_KIND.internal,
518
829
  status: { code: s.status === "error" ? 2 : s.status === "ok" ? 1 : 0 },
519
- attributes: Object.entries(s.attributes).map(([key, value]) => ({ key, value: { stringValue: String(value) } }))
830
+ attributes: Object.entries(s.attributes).map(([key, value]) => ({
831
+ key,
832
+ value: toOtlpValue(value)
833
+ }))
520
834
  }))
521
835
  }
522
836
  ]
@@ -25147,7 +25461,7 @@ function extractSystem(messages) {
25147
25461
  const rest = [];
25148
25462
  for (const m of messages) {
25149
25463
  if (m.role === "system") {
25150
- const text = typeof m.content === "string" ? m.content : contentToText(m.content);
25464
+ const text = typeof m.content === "string" ? m.content : contentToText2(m.content);
25151
25465
  if (text) systemTexts.push(text);
25152
25466
  } else {
25153
25467
  rest.push(m);
@@ -25158,7 +25472,7 @@ function extractSystem(messages) {
25158
25472
  messages: rest
25159
25473
  };
25160
25474
  }
25161
- function contentToText(content) {
25475
+ function contentToText2(content) {
25162
25476
  return content.filter((p) => p.type === "text").map((p) => p.text).join("\n");
25163
25477
  }
25164
25478
  function parseStructured(text) {
@@ -25440,7 +25754,15 @@ var LLMClient = class {
25440
25754
  signal: options.signal,
25441
25755
  provider: this.provider,
25442
25756
  model: this.model,
25443
- trace: { sessionId: ctx.sessionId, requestId: ctx.requestId, callId: ctx.callId }
25757
+ // Every trace field, not a hand-picked three: `traceparent` rides with the ids,
25758
+ // and picking fields here is what left the HTTP spans rooting a trace of their
25759
+ // own while the LLM span they belong to had joined the caller's.
25760
+ trace: {
25761
+ sessionId: ctx.sessionId,
25762
+ requestId: ctx.requestId,
25763
+ callId: ctx.callId,
25764
+ traceparent: ctx.traceparent
25765
+ }
25444
25766
  };
25445
25767
  response = await this.fetchFn(httpReq, {
25446
25768
  queueName: this.queueName,
@@ -25568,7 +25890,15 @@ var LLMClient = class {
25568
25890
  stream: true,
25569
25891
  provider: this.provider,
25570
25892
  model: this.model,
25571
- trace: { sessionId: ctx.sessionId, requestId: ctx.requestId, callId: ctx.callId }
25893
+ // Every trace field, not a hand-picked three: `traceparent` rides with the ids,
25894
+ // and picking fields here is what left the HTTP spans rooting a trace of their
25895
+ // own while the LLM span they belong to had joined the caller's.
25896
+ trace: {
25897
+ sessionId: ctx.sessionId,
25898
+ requestId: ctx.requestId,
25899
+ callId: ctx.callId,
25900
+ traceparent: ctx.traceparent
25901
+ }
25572
25902
  };
25573
25903
  const start = performance.now();
25574
25904
  let text = "";
@@ -25693,6 +26023,19 @@ var ANTHROPIC_THINKING_BUDGETS = {
25693
26023
  max: 16384
25694
26024
  };
25695
26025
  var DEFAULT_ANTHROPIC_THINKING_BUDGET = 2048;
26026
+ var ANTHROPIC_ADAPTIVE_THINKING_MIN = { major: 4, minor: 6 };
26027
+ function anthropicThinkingShape(model) {
26028
+ const id = model.toLowerCase().replace(/^anthropic\//, "");
26029
+ const modern = /^claude-[a-z]+-(\d+)(?:[-.](\d+))?/.exec(id);
26030
+ if (modern) {
26031
+ const major = Number(modern[1]);
26032
+ const minor = modern[2] === void 0 ? 0 : Number(modern[2]);
26033
+ const { major: minMajor, minor: minMinor } = ANTHROPIC_ADAPTIVE_THINKING_MIN;
26034
+ return major > minMajor || major === minMajor && minor >= minMinor ? "adaptive" : "budgeted";
26035
+ }
26036
+ if (/^claude-\d/.test(id)) return "budgeted";
26037
+ return "adaptive";
26038
+ }
25696
26039
  var ANTHROPIC_TOP_K_MODELS = /^claude-(opus-4-(1|5|6)|sonnet-4-(5|6)|haiku-4-5)(\b|-)/;
25697
26040
  function anthropicAcceptsTopK(model) {
25698
26041
  return ANTHROPIC_TOP_K_MODELS.test(model);
@@ -26074,6 +26417,16 @@ var AnthropicAdapter = class {
26074
26417
  }
26075
26418
  if (req.thinking) {
26076
26419
  if (req.thinking.mode === "off") {
26420
+ } else if (anthropicThinkingShape(req.model) === "adaptive") {
26421
+ const thinking = { type: "adaptive" };
26422
+ if (req.thinking.visibility === "hidden") thinking.display = "omitted";
26423
+ body.thinking = thinking;
26424
+ if (req.thinking.effort) {
26425
+ body.output_config = {
26426
+ ...body.output_config ?? {},
26427
+ effort: req.thinking.effort
26428
+ };
26429
+ }
26077
26430
  } else {
26078
26431
  const budget = req.thinking.effort ? ANTHROPIC_THINKING_BUDGETS[req.thinking.effort] ?? DEFAULT_ANTHROPIC_THINKING_BUDGET : DEFAULT_ANTHROPIC_THINKING_BUDGET;
26079
26432
  const thinking = { type: "enabled", budget_tokens: budget };
@@ -31475,6 +31828,12 @@ async function handleToolError(e, tc, hooks, runId, agentId, step, metrics, repo
31475
31828
  // src/agent/loop.ts
31476
31829
  var AgentLoop = class _AgentLoop {
31477
31830
  id;
31831
+ /** Human name, surfaced as `gen_ai.agent.name` — see AgentLoopConfig.label. */
31832
+ label;
31833
+ /** Which part of the host system this agent belongs to. */
31834
+ source;
31835
+ /** Extra attributes stamped on this agent's spans. */
31836
+ attributes;
31478
31837
  client;
31479
31838
  hooks;
31480
31839
  _system;
@@ -31548,6 +31907,9 @@ var AgentLoop = class _AgentLoop {
31548
31907
  this._history = new ConversationHistory();
31549
31908
  }
31550
31909
  this.id = this._history.id;
31910
+ this.label = config.label;
31911
+ this.source = config.source;
31912
+ this.attributes = config.attributes;
31551
31913
  writeAgentLoopSystem(this._history.registry, this._system, "agent-loop");
31552
31914
  writeAgentLoopContext(this._history.registry, this._context, "agent-loop");
31553
31915
  this.syncLazyProtocol();
@@ -31652,7 +32014,7 @@ var AgentLoop = class _AgentLoop {
31652
32014
  }
31653
32015
  // ─── complete (non-streaming) ───────────────────────────────────────────
31654
32016
  async complete(input, options = {}) {
31655
- const { runId, startedAt, startPerf, userMessageText, runTrace } = await this.beginRun(input);
32017
+ const { runId, startedAt, startPerf, userMessageText, runTrace } = await this.beginRun(input, options.ctx);
31656
32018
  const steps = [];
31657
32019
  const totalUsage = emptyUsage();
31658
32020
  let totalLlmTimeMs = 0;
@@ -31704,7 +32066,23 @@ var AgentLoop = class _AgentLoop {
31704
32066
  thinking: options.thinking ?? this._thinking,
31705
32067
  cache: options.cache ?? this._cache,
31706
32068
  tools: this.toolDefinitions(options),
31707
- ctx: { ...options.ctx, conversationId: this._history.id },
32069
+ ctx: {
32070
+ // The RUN's trace, handed down to every LLM call it makes.
32071
+ //
32072
+ // Without this the agent kept `runTrace` to itself: its own spans used it
32073
+ // while each `client.complete()` fell through to mint-if-absent and
32074
+ // invented a fresh `requestId`. Since the trace id is `sessionId:requestId`,
32075
+ // one conversation arrived at the backend as SEVERAL unrelated traces —
32076
+ // measured against a real collector: a single turn with one tool call
32077
+ // produced six. Correlation is the whole point of a trace id, so this is
32078
+ // the one thing it must not get wrong.
32079
+ ...runTrace,
32080
+ conversationId: this._history.id,
32081
+ // A caller's explicit ctx wins over all of the above: an app that already
32082
+ // owns a request id or a conversation id has better information than we do,
32083
+ // and silently overwriting it is how its telemetry stops joining up.
32084
+ ...options.ctx
32085
+ },
31708
32086
  signal: options.signal ?? this._abortController?.signal
31709
32087
  });
31710
32088
  const stepLatency = performance.now() - stepStart;
@@ -31872,7 +32250,7 @@ var AgentLoop = class _AgentLoop {
31872
32250
  }
31873
32251
  // ─── stream ─────────────────────────────────────────────────────────────
31874
32252
  async *stream(input, options = {}) {
31875
- const { runId, startedAt, startPerf, userMessageText, runTrace } = await this.beginRun(input);
32253
+ const { runId, startedAt, startPerf, userMessageText, runTrace } = await this.beginRun(input, options.ctx);
31876
32254
  const steps = [];
31877
32255
  const totalUsage = emptyUsage();
31878
32256
  let totalLlmTimeMs = 0;
@@ -31928,7 +32306,23 @@ var AgentLoop = class _AgentLoop {
31928
32306
  thinking: options.thinking ?? this._thinking,
31929
32307
  cache: options.cache ?? this._cache,
31930
32308
  tools: this.toolDefinitions(options),
31931
- ctx: { ...options.ctx, conversationId: this._history.id },
32309
+ ctx: {
32310
+ // The RUN's trace, handed down to every LLM call it makes.
32311
+ //
32312
+ // Without this the agent kept `runTrace` to itself: its own spans used it
32313
+ // while each `client.complete()` fell through to mint-if-absent and
32314
+ // invented a fresh `requestId`. Since the trace id is `sessionId:requestId`,
32315
+ // one conversation arrived at the backend as SEVERAL unrelated traces —
32316
+ // measured against a real collector: a single turn with one tool call
32317
+ // produced six. Correlation is the whole point of a trace id, so this is
32318
+ // the one thing it must not get wrong.
32319
+ ...runTrace,
32320
+ conversationId: this._history.id,
32321
+ // A caller's explicit ctx wins over all of the above: an app that already
32322
+ // owns a request id or a conversation id has better information than we do,
32323
+ // and silently overwriting it is how its telemetry stops joining up.
32324
+ ...options.ctx
32325
+ },
31932
32326
  signal: options.signal ?? this._abortController?.signal
31933
32327
  })) {
31934
32328
  const toYield = accumulateStreamEvent(event, state);
@@ -32140,7 +32534,7 @@ var AgentLoop = class _AgentLoop {
32140
32534
  arguments: tc.arguments,
32141
32535
  callId: tc.id,
32142
32536
  step,
32143
- trace: { sessionId: runTrace.sessionId, requestId: runTrace.requestId, callId: tc.id }
32537
+ trace: { ...runTrace, callId: tc.id }
32144
32538
  });
32145
32539
  if (!decision.pass) {
32146
32540
  return this.buildDeniedResult(tc, decision.reason, reports);
@@ -32160,7 +32554,7 @@ var AgentLoop = class _AgentLoop {
32160
32554
  }
32161
32555
  }
32162
32556
  try {
32163
- const baseCtx = { step, callId: tc.id, metrics, trace: { sessionId: runTrace.sessionId, requestId: runTrace.requestId, callId: tc.id } };
32557
+ const baseCtx = { step, callId: tc.id, metrics, trace: { ...runTrace, callId: tc.id } };
32164
32558
  const result = await executeWithTimeout(lookup.tool, tc, baseCtx, this._toolTimeout);
32165
32559
  return await this.buildSuccessResult(tc, result, runId, step, metrics, reports, toolStart, runTrace, lookup.tool, baseCtx);
32166
32560
  } catch (e) {
@@ -32218,7 +32612,7 @@ var AgentLoop = class _AgentLoop {
32218
32612
  arguments: tc.arguments,
32219
32613
  reason,
32220
32614
  step,
32221
- trace: { sessionId: runTrace.sessionId, requestId: runTrace.requestId, callId: tc.id }
32615
+ trace: { ...runTrace, callId: tc.id }
32222
32616
  };
32223
32617
  const pending = {
32224
32618
  callId: tc.id,
@@ -32251,7 +32645,7 @@ var AgentLoop = class _AgentLoop {
32251
32645
  return this.buildOverriddenResult(tc, decision.overrideResult, reports);
32252
32646
  }
32253
32647
  try {
32254
- const baseCtx = { step, callId: tc.id, metrics, trace: { sessionId: runTrace.sessionId, requestId: runTrace.requestId, callId: tc.id } };
32648
+ const baseCtx = { step, callId: tc.id, metrics, trace: { ...runTrace, callId: tc.id } };
32255
32649
  const result = await executeWithTimeout(tool, tc, baseCtx, this._toolTimeout);
32256
32650
  return await this.buildSuccessResult(tc, result, runId, step, metrics, reports, toolStart, runTrace, tool, baseCtx);
32257
32651
  } catch (e) {
@@ -32347,7 +32741,7 @@ var AgentLoop = class _AgentLoop {
32347
32741
  return own.length > 0 ? own : void 0;
32348
32742
  }
32349
32743
  // ─── Run helpers ────────────────────────────────────────────────────────
32350
- async beginRun(input) {
32744
+ async beginRun(input, callerCtx) {
32351
32745
  if (this._running) throw new Error("AgentLoop is already running");
32352
32746
  this._running = true;
32353
32747
  this._stopRequested = false;
@@ -32364,10 +32758,23 @@ var AgentLoop = class _AgentLoop {
32364
32758
  const startedAt = Date.now();
32365
32759
  const startPerf = performance.now();
32366
32760
  const userMessageText = typeof input === "string" ? input : Array.isArray(input) && input.length > 0 && "role" in input[0] ? contentText(input[input.length - 1].content) : contentText(input);
32367
- const runTrace = { sessionId: this.id, requestId: runId };
32761
+ const runTrace = {
32762
+ sessionId: callerCtx?.sessionId ?? this.id,
32763
+ requestId: callerCtx?.requestId ?? runId,
32764
+ // The caller's span travels WITH the ids, for the same reason they do: `agent.run`,
32765
+ // every `tool.call`, and any agent nested inside a tool reach the telemetry through
32766
+ // `runTrace` and nothing else. While this field was missing from it only the LLM
32767
+ // calls joined the app's trace — they are built from the caller's ctx directly —
32768
+ // and the run that made them sat in a second, unrelated one. Measured against a
32769
+ // live backend; the unit tests fed the hooks directly and never saw it.
32770
+ ...callerCtx?.traceparent ? { traceparent: callerCtx.traceparent } : {}
32771
+ };
32368
32772
  await this.hooks.emit("onRunStart", {
32369
32773
  runId,
32370
32774
  agentId: this.id,
32775
+ label: this.label,
32776
+ source: this.source,
32777
+ attributes: this.attributes,
32371
32778
  userMessage: input,
32372
32779
  model: this.client.model,
32373
32780
  system: this._history.system,
@@ -32422,7 +32829,7 @@ var AgentLoop = class _AgentLoop {
32422
32829
  if (g.kind !== "input") continue;
32423
32830
  const decision = await g.check({
32424
32831
  kind: "input",
32425
- trace: { sessionId: runTrace.sessionId, requestId: runTrace.requestId },
32832
+ trace: { ...runTrace },
32426
32833
  step,
32427
32834
  messages,
32428
32835
  system
@@ -32450,7 +32857,7 @@ var AgentLoop = class _AgentLoop {
32450
32857
  if (g.kind !== "output") continue;
32451
32858
  const decision = await g.check({
32452
32859
  kind: "output",
32453
- trace: { sessionId: runTrace.sessionId, requestId: runTrace.requestId },
32860
+ trace: { ...runTrace },
32454
32861
  step,
32455
32862
  response
32456
32863
  });
@@ -34615,6 +35022,7 @@ function createEngine(config = {}) {
34615
35022
  const fetchStreamBound = (req, options) => network.fetchStream(req, options);
34616
35023
  const connectBound = (req) => network.connect(req);
34617
35024
  const cost = new CostCollector({ hooks, catalog });
35025
+ const telemetry = config.telemetry ? new TelemetryAdapter(hooks, config.telemetry) : null;
34618
35026
  const handle = {
34619
35027
  sessionId,
34620
35028
  hooks,
@@ -34627,9 +35035,11 @@ function createEngine(config = {}) {
34627
35035
  connect: connectBound,
34628
35036
  catalog,
34629
35037
  cost,
35038
+ telemetry,
34630
35039
  apiKeys: config.apiKeys ?? {},
34631
35040
  destroy() {
34632
35041
  cost.destroy();
35042
+ telemetry?.destroy();
34633
35043
  network.destroy();
34634
35044
  }
34635
35045
  };
@@ -38399,7 +38809,8 @@ async function complete(opts) {
38399
38809
  serviceTier,
38400
38810
  cache: opts.cache,
38401
38811
  topK: opts.topK,
38402
- seed: opts.seed
38812
+ seed: opts.seed,
38813
+ thinking: opts.thinking
38403
38814
  });
38404
38815
  } else {
38405
38816
  res = await llm.complete(input, {
@@ -38413,7 +38824,8 @@ async function complete(opts) {
38413
38824
  serviceTier,
38414
38825
  cache: opts.cache,
38415
38826
  topK: opts.topK,
38416
- seed: opts.seed
38827
+ seed: opts.seed,
38828
+ thinking: opts.thinking
38417
38829
  });
38418
38830
  }
38419
38831
  const result = {