@struct-ai/sdk 0.3.0 → 0.3.17

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/esm/core.js CHANGED
@@ -1,5 +1,4 @@
1
- import { randomUUID } from "node:crypto";
2
- import { context as otelContext, SpanKind, SpanStatusCode, trace, } from "@opentelemetry/api";
1
+ import { context as otelContext, ROOT_CONTEXT, SpanKind, SpanStatusCode, trace, } from "@opentelemetry/api";
3
2
  import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
4
3
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
5
4
  import { resourceFromAttributes } from "@opentelemetry/resources";
@@ -10,6 +9,7 @@ import { getAgentSpan, getSessionId, popPendingToolCallId, runWithContext, } fro
10
9
  import { autoInstrument } from "./integrations/index.js";
11
10
  import { ERROR_TYPE, GEN_AI, STRUCT, } from "./semconv.js";
12
11
  import { safeJsonStringify } from "./truncation.js";
12
+ import { SDK_VERSION } from "./version.js";
13
13
  export const DEFAULT_ENDPOINT = "https://ingest.struct.ai";
14
14
  const LOG_ENABLED = process.env.STRUCT_SDK_LOG === "1";
15
15
  const defaultInternalLogger = {
@@ -228,13 +228,13 @@ export class StructSDK {
228
228
  if (!this._tracerProvider) {
229
229
  throw new Error("Call struct.init() before using the SDK");
230
230
  }
231
- return this._tracerProvider.getTracer(name);
231
+ return this._tracerProvider.getTracer(name, SDK_VERSION);
232
232
  }
233
233
  getLogger(name = "struct-sdk") {
234
234
  if (!this._loggerProvider) {
235
235
  throw new Error("Call struct.init() before using the SDK");
236
236
  }
237
- return this._loggerProvider.getLogger(name);
237
+ return this._loggerProvider.getLogger(name, SDK_VERSION);
238
238
  }
239
239
  getInternalLogger() {
240
240
  return this._internalLogger;
@@ -302,8 +302,7 @@ export class StructSDK {
302
302
  return await fn();
303
303
  }
304
304
  const agentName = options.name;
305
- const sessionId = options.sessionId ?? randomUUID();
306
- const parentSessionId = getSessionId();
305
+ const explicitSessionId = options.sessionId;
307
306
  const tracer = this.getTracer("struct-sdk");
308
307
  // Parent the new agent span on the nearest enclosing agent span (if
309
308
  // any) so nested `struct.agent()` / `struct.tool()` calls build a
@@ -312,16 +311,68 @@ export class StructSDK {
312
311
  // nothing to hang off. We do NOT rely on the global OTel context
313
312
  // manager being installed — the SDK manages its own parent chain via
314
313
  // StructContext ALS to stay neutral in multi-tenant OTel setups.
315
- const parentSpan = getAgentSpan();
316
- const parentCtx = parentSpan
317
- ? trace.setSpan(otelContext.active(), parentSpan)
318
- : otelContext.active();
314
+ //
315
+ // Captured BEFORE we resolve/overwrite anything below — mirrors
316
+ // python's `enclosing_session_id` / `enclosing_agent_span` capture at
317
+ // core.py:625-626. Used to (1) resolve this agent's own session id via
318
+ // ambient inheritance, (2) detect break-out, and (3) decide whether to
319
+ // stamp `struct.agent.parent_session_id`.
320
+ const enclosingSessionId = getSessionId();
321
+ const enclosingAgentSpan = getAgentSpan();
322
+ // ── Resolve sessionId (REVISION R1 grouping model) ──────────────────
323
+ // Resolution order: explicit caller arg > ambient (enclosing agent) >
324
+ // session-less. We never fabricate a uuid — a caller that omits
325
+ // sessionId with no enclosing agent gets a SESSION-LESS run (no
326
+ // `gen_ai.conversation.id` attribute at all). Ports core.py:628-644
327
+ // exactly (`self._session_id = ""` there is `undefined` here).
328
+ const sessionId = explicitSessionId !== undefined
329
+ ? explicitSessionId
330
+ : enclosingSessionId !== undefined
331
+ ? enclosingSessionId
332
+ : undefined;
333
+ // ── Break-out detection (spawned-by Link) ───────────────────────────
334
+ // Caller supplied an EXPLICIT session id that differs from the
335
+ // enclosing agent's session AND there IS an enclosing Struct agent
336
+ // span in scope. This agent starts a fresh ROOT trace (no OTel
337
+ // parent) and carries a causal Link back to the enclosing agent's
338
+ // span context. Ports struct_sdk.core._AgentContext._start_span's
339
+ // `break_out` branch (struct-sdk-python/src/struct_sdk/core.py:651-656)
340
+ // exactly.
341
+ const breakOut = explicitSessionId !== undefined &&
342
+ enclosingSessionId !== undefined &&
343
+ explicitSessionId !== enclosingSessionId &&
344
+ enclosingAgentSpan !== undefined;
345
+ // ── Parentage: PURE NEST (Option D) ─────────────────────────────────
346
+ // A non-break-out agent nests under whatever OTel span is active — the
347
+ // host's ambient context — like every peer LLM/agent SDK. We do NOT
348
+ // second-guess the active span or unilaterally re-root: the old
349
+ // `foreignRoot` heuristic detached from ANY active span and so ripped
350
+ // agents out of legitimate host request traces. Cross-conversation
351
+ // grouping is carried by gen_ai.conversation.id, never by trace
352
+ // parentage. A runtime that propagates a stale/unwanted span across a
353
+ // boundary (e.g. our own persistent_agent Temporal workflow span) clears
354
+ // it at the SOURCE, never here. (A provenance-gated detach — re-root only
355
+ // a leaked Struct-OWNED span — is a documented, deferred follow-up.)
356
+ let startContext;
357
+ let links;
358
+ if (breakOut) {
359
+ // Explicit different session under another Struct agent: fresh ROOT
360
+ // trace + a spawned-by Link. The one deliberate, opt-in re-root.
361
+ // enclosingAgentSpan is guaranteed defined by the breakOut guard above.
362
+ links = [{ context: enclosingAgentSpan.spanContext() }];
363
+ startContext = ROOT_CONTEXT;
364
+ }
365
+ else {
366
+ startContext = enclosingAgentSpan
367
+ ? trace.setSpan(otelContext.active(), enclosingAgentSpan)
368
+ : otelContext.active();
369
+ }
319
370
  // Span creation itself can fail (custom tracer, broken context). If it
320
371
  // does, fall through to running `fn` directly so the user's call
321
372
  // always runs uninstrumented rather than blowing up on telemetry.
322
373
  let span;
323
374
  safe(() => {
324
- span = tracer.startSpan(`invoke_agent ${agentName}`, { kind: SpanKind.INTERNAL }, parentCtx);
375
+ span = tracer.startSpan(`invoke_agent ${agentName}`, { kind: SpanKind.INTERNAL, links }, startContext);
325
376
  }, "agent.start_span", this._internalLogger);
326
377
  if (span === undefined) {
327
378
  return await fn();
@@ -342,17 +393,24 @@ export class StructSDK {
342
393
  startedSpan.setAttribute(GEN_AI.AGENT_VERSION, options.version);
343
394
  }
344
395
  // gen_ai.conversation.id is the spec-blessed name for
345
- // session/thread id.
346
- startedSpan.setAttribute(GEN_AI.CONVERSATION_ID, sessionId);
347
- // Always set parent_session_id when there's a parent agent — even
348
- // when the value matches sessionId (which happens when a nested
349
- // ``struct.agent()`` inherits ambient session). The attribute is
350
- // structural ("this agent has a parent agent"), not a uniqueness
351
- // marker. The UI uses it to render an inline subagent expansion
352
- // under the triggering call (vs the drill-in flow used when
353
- // sessionIds differ).
354
- if (parentSessionId) {
355
- startedSpan.setAttribute(STRUCT.AGENT_PARENT_SESSION_ID, parentSessionId);
396
+ // session/thread id. Omitted when session-less (never fabricated).
397
+ // Ports core.py:723-725 (`if self._session_id:`) exactly.
398
+ if (sessionId) {
399
+ startedSpan.setAttribute(GEN_AI.CONVERSATION_ID, sessionId);
400
+ }
401
+ // ``struct.agent.parent_session_id`` is a SPAWNED-BY marker — set it
402
+ // ONLY when this agent has an enclosing session that DIFFERS from
403
+ // its own resolved session id. An inline nested agent (same session
404
+ // as the enclosing agent, whether by explicit same id or ambient
405
+ // inheritance) already has its parent relationship encoded by the
406
+ // OTel span tree (ParentSpanId) — stamping
407
+ // parent_session_id === own sessionId would be self-referential
408
+ // noise. Structure comes from the tree/Link, not this attr
409
+ // (Link-canonical decision). Ports core.py:736-745 exactly (both
410
+ // the break-out and the "legacy" differing-id-no-span branches
411
+ // collapse to this one condition).
412
+ if (enclosingSessionId !== undefined && enclosingSessionId !== sessionId) {
413
+ startedSpan.setAttribute(STRUCT.AGENT_PARENT_SESSION_ID, enclosingSessionId);
356
414
  }
357
415
  if (options.metadata) {
358
416
  for (const [key, value] of Object.entries(options.metadata)) {
@@ -365,6 +423,7 @@ export class StructSDK {
365
423
  conversationId: sessionId,
366
424
  agentSpan: startedSpan,
367
425
  pendingToolCalls: {},
426
+ manualAgentSpan: startedSpan,
368
427
  }, async () => {
369
428
  const activeCtx = trace.setSpan(otelContext.active(), startedSpan);
370
429
  try {
@@ -49,7 +49,7 @@ export function emitAnthropicMessageEvents(logger, messages, system) {
49
49
  parts: truncateParts(parts),
50
50
  }),
51
51
  extraAttrs: {
52
- [GEN_AI.SYSTEM]: "anthropic",
52
+ [GEN_AI.PROVIDER_NAME]: "anthropic",
53
53
  [GEN_AI.MESSAGE_INDEX]: msgIndex,
54
54
  },
55
55
  });
@@ -67,7 +67,7 @@ export function emitAnthropicMessageEvents(logger, messages, system) {
67
67
  eventName,
68
68
  payload: safeJsonStringify({ role, parts: truncateParts(parts) }),
69
69
  extraAttrs: {
70
- [GEN_AI.SYSTEM]: "anthropic",
70
+ [GEN_AI.PROVIDER_NAME]: "anthropic",
71
71
  [GEN_AI.MESSAGE_INDEX]: msgIndex,
72
72
  },
73
73
  });
@@ -92,7 +92,7 @@ export function emitAnthropicChoiceEvent(logger, contentBlocks, stopReason) {
92
92
  eventName: EVENT_NAMES.CHOICE,
93
93
  payload,
94
94
  extraAttrs: {
95
- [GEN_AI.SYSTEM]: "anthropic",
95
+ [GEN_AI.PROVIDER_NAME]: "anthropic",
96
96
  },
97
97
  });
98
98
  }
@@ -22,6 +22,8 @@ export declare function patch(sdk: StructSDK): Promise<void>;
22
22
  export declare function unpatch(): Promise<void>;
23
23
  type CreateMethod = (this: unknown, params: CreateParams, opts?: unknown) => unknown;
24
24
  type StreamMethod = (this: unknown, params: CreateParams, opts?: unknown) => unknown;
25
+ export declare function wrapCreate(original: CreateMethod): CreateMethod;
26
+ export declare function wrapStream(original: StreamMethod): StreamMethod;
25
27
  /** @internal */
26
28
  export type _PatchContextForTest = PatchContext;
27
29
  /** @internal */