@struct-ai/sdk 0.2.1 → 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/README.md CHANGED
@@ -55,9 +55,9 @@ await struct.agent({ name: "checkout" }, async () => {
55
55
 
56
56
  | Library | Hook | Span type | Notes |
57
57
  |---|---|---|---|
58
- | `@anthropic-ai/sdk` | `Messages.prototype.create`, `.stream` | `chat {model}` | Cache-token accounting, streaming with tool-use reconstruction |
58
+ | `@anthropic-ai/sdk` | `Messages.prototype.create`, `.stream` | `chat {model}` | Cache-token accounting, streaming with tool-use reconstruction. Defers to a LangChain `BaseChatModel` call already in progress (e.g. `ChatAnthropic`) so the two integrations don't double-emit — see ownership order below |
59
59
  | `@anthropic-ai/bedrock-sdk`, `@anthropic-ai/vertex-sdk` | `Messages.prototype.*` | `chat {model}` | Best-effort, if installed |
60
- | `@langchain/core` `BaseChatModel` | `.invoke`, `.stream` | `chat {model}` | Skipped when a provider-direct instrumentor is active (e.g. ChatAnthropic + Anthropic patch → single span) |
60
+ | `@langchain/core` `BaseChatModel` | `.invoke`, `.stream` | `chat {model}` | Always owns the `chat` span for `ChatAnthropic`/etc. calls, even when a provider-direct instrumentor (e.g. the Anthropic patch) is also active — the framework layer outranks the provider layer (single span; see [AGENTS.md](./AGENTS.md) §5) |
61
61
  | `@langchain/core` `StructuredTool` | `.invoke` | `execute_tool {name}` | Extracts `tool_call_id` from LangChain ToolCall input or pending queue |
62
62
  | `@langchain/core` `BaseRetriever` | `.invoke` | `retrieval {name}` | |
63
63
  | `@langchain/langgraph` `Pregel` | `.invoke`, `.stream` | `invoke_agent {name}` | Covers `createReactAgent` and custom graphs. Reads conversation id from any of: `configurable.thread_id` (LangGraph canonical), or `metadata.{thread_id, session_id, conversation_id}` (LangSmith conventions). For multi-turn HTTP-style threading, wrap your entry point in [`struct.agent({ sessionId: convId }, ...)`](#recommended-pattern-wrap-langchain-entry-points-in-structagent) — the struct-native replacement for LangSmith's `tracing_context(parent=run_tree)`. |
@@ -189,8 +189,8 @@ await struct.agent({ name: "my-agent" }, async () => {
189
189
  ```
190
190
 
191
191
  When you do use `ChatAnthropic` *and* have `@anthropic-ai/sdk` installed,
192
- the chat span comes from the Anthropic patch (single span); the LangChain
193
- layer suppresses its duplicate.
192
+ the chat span comes from the LangChain layer (single span); the Anthropic
193
+ patch detects the framework already owns the call and defers.
194
194
 
195
195
  ## Content capture
196
196
 
@@ -264,6 +264,36 @@ Note: `gen_ai.usage.input_tokens` for Anthropic is the TRUE total — we add bac
264
264
  `cache_read_input_tokens + cache_creation_input_tokens` (which Anthropic's raw
265
265
  response excludes). Matches the Python SDK.
266
266
 
267
+ ## Development
268
+
269
+ - `pnpm test` — unit + e2e tests (mocked, no network, no API keys). Excludes
270
+ `test/live/**`.
271
+ - `pnpm typecheck` — `tsc --noEmit`.
272
+ - `pnpm build` — `tshy`, producing the dual ESM/CJS `dist/`.
273
+ - `pnpm test:live` — the live real-model suite: real `@langchain/anthropic` +
274
+ real Anthropic API + real `@langchain/langgraph`, verifying emitted spans
275
+ in-memory (no ingester needed). Skips cleanly (exit 0, all tests skipped)
276
+ when no API key is present — safe to leave in normal CI.
277
+
278
+ To actually run it, point `STRUCT_LIVE_ENV_FILE` at a file containing
279
+ `ANTHROPIC_API_KEY=...` (dotenv-style `KEY=value` lines; quotes optional).
280
+ The path is never committed to the repo — it's supplied per-invocation:
281
+
282
+ ```bash
283
+ STRUCT_LIVE_ENV_FILE=/path/to/your/.env pnpm test:live
284
+ ```
285
+
286
+ Uses the cheapest current Anthropic model alias with small `max_tokens`
287
+ budgets — a handful of real API calls per run, not dozens.
288
+ - `pnpm test:parity` — the cross-language conformance harness: drives the
289
+ same canonical agent topology through both this SDK and `struct-sdk-python`
290
+ and diffs their normalized span shapes, failing (non-zero exit) on any
291
+ divergence. No network calls or API keys required.
292
+
293
+ See [AGENTS.md](./AGENTS.md) for the full set of governance rules (fault
294
+ isolation, OTel citizenship, semconv conformance, the release-version-bump
295
+ gate) that apply to any change in this package.
296
+
267
297
  ## Troubleshooting
268
298
 
269
299
  - **Spans missing after instrumenting:** Import `@struct-ai/sdk` (or `struct.init()`)
@@ -273,10 +303,20 @@ response excludes). Matches the Python SDK.
273
303
  - **No logs appearing:** `LogRecord`s only emit when `sdk.emitEvents` is true
274
304
  (`EventOnly` or `SpanAndEvent` capture mode, which is the default). If you set
275
305
  `captureContent: false` you disable them.
276
- - **Duplicate chat spans:** The LangChain integration suppresses its own chat
277
- span when a provider-direct patch is active (e.g. ChatAnthropic calls through
278
- to `@anthropic-ai/sdk`, which emits its own `chat` span). If you see doubles,
279
- confirm both integrations are auto-instrumenting (check `struct.initialized`).
306
+ - **Duplicate chat spans:** Ownership of the `chat` span is fixed (manual >
307
+ framework > provider — see [AGENTS.md](./AGENTS.md) §5): when you call
308
+ `ChatAnthropic.invoke()`/`.stream()`, the LangChain integration always owns
309
+ the `chat` span (`handleChatModelStart` in `langchain-callback.ts`). It runs
310
+ the underlying provider call inside an internal scope
311
+ (`suppressGenAi: true`) that the direct `@anthropic-ai/sdk` patch checks via
312
+ `isGenAiSuppressed()` — when set, the provider patch defers and emits
313
+ nothing, so only the LangChain-owned span is produced. If you see doubles,
314
+ the two integrations are likely observing different `@anthropic-ai/sdk`
315
+ module instances (common with pnpm hoisting a nested copy under
316
+ `@langchain/anthropic`), so the provider patch never sees the suppression
317
+ scope set by the framework layer; confirm both integrations are
318
+ auto-instrumenting the SAME module instance (check `struct.initialized` and
319
+ your lockfile's dedupe of `@anthropic-ai/sdk`).
280
320
  - **Subagent in a different trace / missing from parent's "Subagents" list:**
281
321
  If you invoke a nested agent (`subagent.invoke(...)`) from inside a tool body,
282
322
  define the outer tool with `tool(func, { name, description, schema })` from
@@ -4,12 +4,38 @@ export interface StructContext {
4
4
  conversationId?: string;
5
5
  agentSpan?: Span;
6
6
  pendingToolCalls?: Record<string, string[]>;
7
+ /**
8
+ * When true, a framework-layer integration (e.g. LangChain's
9
+ * BaseChatModel.generate/.stream) already owns the chat span for the call
10
+ * in progress. Provider-SDK patches (anthropic.ts) check this and skip
11
+ * emitting their own chat span to avoid a duplicate.
12
+ */
13
+ suppressGenAi?: boolean;
14
+ /**
15
+ * Set by `struct.agent()` while its body is running. Signals ownership:
16
+ * when the LangChain callback handler sees a TOP-LEVEL chain start (no
17
+ * parentRunId) while this is set, the manual `struct.agent()` call already
18
+ * owns the `invoke_agent` span for this run — the handler must not emit a
19
+ * duplicate ("twin") span, only register the run so descendants parent
20
+ * under the manual span. Parity: python `_manual_agent_active` contextvar
21
+ * (core.py:64-68).
22
+ */
23
+ manualAgentSpan?: Span;
7
24
  }
8
25
  export declare function getStore(): StructContext | undefined;
9
26
  export declare function getSessionId(): string | undefined;
10
27
  export declare function getConversationId(): string | undefined;
11
28
  export declare function getAgentSpan(): Span | undefined;
29
+ /**
30
+ * The manually-created `struct.agent()` span for the currently-running
31
+ * scope, if any. Read by the LangChain callback handler to detect
32
+ * "manual struct.agent() wraps a top-level LangChain chain" and suppress
33
+ * the twin `invoke_agent` span it would otherwise emit.
34
+ */
35
+ export declare function getManualAgentSpan(): Span | undefined;
12
36
  export declare function getPendingToolCalls(): Record<string, string[]> | undefined;
37
+ /** True when a framework layer (e.g. LangChain) already owns the chat span. */
38
+ export declare function isGenAiSuppressed(): boolean;
13
39
  export declare function runWithContext<T>(patch: Partial<StructContext>, fn: () => T): T;
14
40
  export declare function ensurePendingToolCallsSlot(): Record<string, string[]>;
15
41
  /**
@@ -4,7 +4,9 @@ exports.getStore = getStore;
4
4
  exports.getSessionId = getSessionId;
5
5
  exports.getConversationId = getConversationId;
6
6
  exports.getAgentSpan = getAgentSpan;
7
+ exports.getManualAgentSpan = getManualAgentSpan;
7
8
  exports.getPendingToolCalls = getPendingToolCalls;
9
+ exports.isGenAiSuppressed = isGenAiSuppressed;
8
10
  exports.runWithContext = runWithContext;
9
11
  exports.ensurePendingToolCallsSlot = ensurePendingToolCallsSlot;
10
12
  exports.popPendingToolCallId = popPendingToolCallId;
@@ -26,9 +28,22 @@ function getConversationId() {
26
28
  function getAgentSpan() {
27
29
  return als.getStore()?.agentSpan;
28
30
  }
31
+ /**
32
+ * The manually-created `struct.agent()` span for the currently-running
33
+ * scope, if any. Read by the LangChain callback handler to detect
34
+ * "manual struct.agent() wraps a top-level LangChain chain" and suppress
35
+ * the twin `invoke_agent` span it would otherwise emit.
36
+ */
37
+ function getManualAgentSpan() {
38
+ return als.getStore()?.manualAgentSpan;
39
+ }
29
40
  function getPendingToolCalls() {
30
41
  return als.getStore()?.pendingToolCalls;
31
42
  }
43
+ /** True when a framework layer (e.g. LangChain) already owns the chat span. */
44
+ function isGenAiSuppressed() {
45
+ return als.getStore()?.suppressGenAi === true;
46
+ }
32
47
  function runWithContext(patch, fn) {
33
48
  const current = als.getStore() ?? {};
34
49
  const next = { ...current, ...patch };
@@ -80,7 +95,11 @@ function pushPendingToolCalls(pairs) {
80
95
  /** Snapshot the store for wrapping async iterators. */
81
96
  function snapshotStore() {
82
97
  const store = als.getStore();
83
- return store ? { ...store } : undefined;
98
+ if (!store)
99
+ return undefined;
100
+ if (!store.pendingToolCalls)
101
+ store.pendingToolCalls = {}; // materialize BEFORE copying so the queue object is shared by reference
102
+ return { ...store };
84
103
  }
85
104
  function runWithStore(store, fn) {
86
105
  if (!store)
@@ -65,6 +65,8 @@ export declare class StructSDK {
65
65
  private _initialized;
66
66
  private _tracerProvider;
67
67
  private _loggerProvider;
68
+ private _spanFanout;
69
+ private _logFanout;
68
70
  private _ingestKey;
69
71
  private _endpoint;
70
72
  private _contentCapture;
@@ -2,7 +2,6 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.StructSDK = exports.firstFailureLogged = exports.DEFAULT_ENDPOINT = void 0;
4
4
  exports.safe = safe;
5
- const node_crypto_1 = require("node:crypto");
6
5
  const api_1 = require("@opentelemetry/api");
7
6
  const exporter_logs_otlp_proto_1 = require("@opentelemetry/exporter-logs-otlp-proto");
8
7
  const exporter_trace_otlp_proto_1 = require("@opentelemetry/exporter-trace-otlp-proto");
@@ -14,6 +13,7 @@ const context_js_1 = require("./context.js");
14
13
  const index_js_1 = require("./integrations/index.js");
15
14
  const semconv_js_1 = require("./semconv.js");
16
15
  const truncation_js_1 = require("./truncation.js");
16
+ const version_js_1 = require("./version.js");
17
17
  exports.DEFAULT_ENDPOINT = "https://ingest.struct.ai";
18
18
  const LOG_ENABLED = process.env.STRUCT_SDK_LOG === "1";
19
19
  const defaultInternalLogger = {
@@ -37,6 +37,43 @@ const defaultInternalLogger = {
37
37
  * so consumers cannot reach it via the package's public API surface.
38
38
  */
39
39
  exports.firstFailureLogged = new Set();
40
+ /**
41
+ * OTel SDK 2.x providers accept processors only at construction time —
42
+ * `addSpanProcessor` / `addLogRecordProcessor` no longer exist on the
43
+ * providers. These fan-outs are registered at construction and delegate to a
44
+ * mutable list, preserving the SDK's test-only `addSpanProcessor` /
45
+ * `addLogRecordProcessor` hooks.
46
+ */
47
+ class FanoutSpanProcessor {
48
+ delegates = [];
49
+ onStart(...args) {
50
+ for (const p of this.delegates)
51
+ p.onStart(...args);
52
+ }
53
+ onEnd(...args) {
54
+ for (const p of this.delegates)
55
+ p.onEnd(...args);
56
+ }
57
+ forceFlush() {
58
+ return Promise.all(this.delegates.map((p) => p.forceFlush())).then(() => undefined);
59
+ }
60
+ shutdown() {
61
+ return Promise.all(this.delegates.map((p) => p.shutdown())).then(() => undefined);
62
+ }
63
+ }
64
+ class FanoutLogRecordProcessor {
65
+ delegates = [];
66
+ onEmit(...args) {
67
+ for (const p of this.delegates)
68
+ p.onEmit(...args);
69
+ }
70
+ forceFlush() {
71
+ return Promise.all(this.delegates.map((p) => p.forceFlush())).then(() => undefined);
72
+ }
73
+ shutdown() {
74
+ return Promise.all(this.delegates.map((p) => p.shutdown())).then(() => undefined);
75
+ }
76
+ }
40
77
  /**
41
78
  * Run `fn`; swallow any exception. The first failure per `site` logs at WARN
42
79
  * (with the error attached); subsequent failures at the same site log at
@@ -64,6 +101,8 @@ class StructSDK {
64
101
  _initialized = false;
65
102
  _tracerProvider;
66
103
  _loggerProvider;
104
+ _spanFanout;
105
+ _logFanout;
67
106
  _ingestKey = "";
68
107
  _endpoint = exports.DEFAULT_ENDPOINT;
69
108
  _contentCapture = content_capture_js_1.ContentCaptureMode.EventOnly;
@@ -137,26 +176,31 @@ class StructSDK {
137
176
  maxExportBatchSize: 100,
138
177
  scheduledDelayMillis: 1000,
139
178
  });
140
- const resource = new resources_1.Resource({
179
+ const resource = (0, resources_1.resourceFromAttributes)({
141
180
  "service.name": serviceName,
142
181
  "service.version": serviceVersion,
143
182
  "deployment.environment": environment,
144
183
  });
184
+ this._spanFanout = new FanoutSpanProcessor();
145
185
  this._tracerProvider = new sdk_trace_node_1.NodeTracerProvider({
146
186
  resource,
147
- spanProcessors: [spanProcessor],
187
+ spanProcessors: [spanProcessor, this._spanFanout],
148
188
  });
149
189
  const logExporter = new exporter_logs_otlp_proto_1.OTLPLogExporter({
150
190
  url: `${this._endpoint}/v1/logs`,
151
191
  headers,
152
192
  });
153
- const logProcessor = new sdk_logs_1.BatchLogRecordProcessor(logExporter, {
193
+ const logProcessor = new sdk_logs_1.BatchLogRecordProcessor({
194
+ exporter: logExporter,
154
195
  maxQueueSize: 10000,
155
196
  maxExportBatchSize: 100,
156
197
  scheduledDelayMillis: 1000,
157
198
  });
158
- this._loggerProvider = new sdk_logs_1.LoggerProvider({ resource });
159
- this._loggerProvider.addLogRecordProcessor(logProcessor);
199
+ this._logFanout = new FanoutLogRecordProcessor();
200
+ this._loggerProvider = new sdk_logs_1.LoggerProvider({
201
+ resource,
202
+ processors: [logProcessor, this._logFanout],
203
+ });
160
204
  this._initialized = true;
161
205
  this._shutdownHook = () => {
162
206
  void this.shutdown();
@@ -176,6 +220,8 @@ class StructSDK {
176
220
  this._initialized = false;
177
221
  this._tracerProvider = undefined;
178
222
  this._loggerProvider = undefined;
223
+ this._spanFanout = undefined;
224
+ this._logFanout = undefined;
179
225
  this._shutdownHook = undefined;
180
226
  this._readyPromise = Promise.resolve();
181
227
  return;
@@ -186,24 +232,24 @@ class StructSDK {
186
232
  if (!this._tracerProvider) {
187
233
  throw new Error("Call struct.init() before using the SDK");
188
234
  }
189
- return this._tracerProvider.getTracer(name);
235
+ return this._tracerProvider.getTracer(name, version_js_1.SDK_VERSION);
190
236
  }
191
237
  getLogger(name = "struct-sdk") {
192
238
  if (!this._loggerProvider) {
193
239
  throw new Error("Call struct.init() before using the SDK");
194
240
  }
195
- return this._loggerProvider.getLogger(name);
241
+ return this._loggerProvider.getLogger(name, version_js_1.SDK_VERSION);
196
242
  }
197
243
  getInternalLogger() {
198
244
  return this._internalLogger;
199
245
  }
200
246
  /** Test-only: attach a custom span processor to the internal provider. */
201
247
  addSpanProcessor(processor) {
202
- this._tracerProvider?.addSpanProcessor(processor);
248
+ this._spanFanout?.delegates.push(processor);
203
249
  }
204
250
  /** Test-only: attach a custom log record processor. */
205
251
  addLogRecordProcessor(processor) {
206
- this._loggerProvider?.addLogRecordProcessor(processor);
252
+ this._logFanout?.delegates.push(processor);
207
253
  }
208
254
  /**
209
255
  * Shut down the SDK and flush pending telemetry.
@@ -260,8 +306,7 @@ class StructSDK {
260
306
  return await fn();
261
307
  }
262
308
  const agentName = options.name;
263
- const sessionId = options.sessionId ?? (0, node_crypto_1.randomUUID)();
264
- const parentSessionId = (0, context_js_1.getSessionId)();
309
+ const explicitSessionId = options.sessionId;
265
310
  const tracer = this.getTracer("struct-sdk");
266
311
  // Parent the new agent span on the nearest enclosing agent span (if
267
312
  // any) so nested `struct.agent()` / `struct.tool()` calls build a
@@ -270,16 +315,68 @@ class StructSDK {
270
315
  // nothing to hang off. We do NOT rely on the global OTel context
271
316
  // manager being installed — the SDK manages its own parent chain via
272
317
  // StructContext ALS to stay neutral in multi-tenant OTel setups.
273
- const parentSpan = (0, context_js_1.getAgentSpan)();
274
- const parentCtx = parentSpan
275
- ? api_1.trace.setSpan(api_1.context.active(), parentSpan)
276
- : api_1.context.active();
318
+ //
319
+ // Captured BEFORE we resolve/overwrite anything below — mirrors
320
+ // python's `enclosing_session_id` / `enclosing_agent_span` capture at
321
+ // core.py:625-626. Used to (1) resolve this agent's own session id via
322
+ // ambient inheritance, (2) detect break-out, and (3) decide whether to
323
+ // stamp `struct.agent.parent_session_id`.
324
+ const enclosingSessionId = (0, context_js_1.getSessionId)();
325
+ const enclosingAgentSpan = (0, context_js_1.getAgentSpan)();
326
+ // ── Resolve sessionId (REVISION R1 grouping model) ──────────────────
327
+ // Resolution order: explicit caller arg > ambient (enclosing agent) >
328
+ // session-less. We never fabricate a uuid — a caller that omits
329
+ // sessionId with no enclosing agent gets a SESSION-LESS run (no
330
+ // `gen_ai.conversation.id` attribute at all). Ports core.py:628-644
331
+ // exactly (`self._session_id = ""` there is `undefined` here).
332
+ const sessionId = explicitSessionId !== undefined
333
+ ? explicitSessionId
334
+ : enclosingSessionId !== undefined
335
+ ? enclosingSessionId
336
+ : undefined;
337
+ // ── Break-out detection (spawned-by Link) ───────────────────────────
338
+ // Caller supplied an EXPLICIT session id that differs from the
339
+ // enclosing agent's session AND there IS an enclosing Struct agent
340
+ // span in scope. This agent starts a fresh ROOT trace (no OTel
341
+ // parent) and carries a causal Link back to the enclosing agent's
342
+ // span context. Ports struct_sdk.core._AgentContext._start_span's
343
+ // `break_out` branch (struct-sdk-python/src/struct_sdk/core.py:651-656)
344
+ // exactly.
345
+ const breakOut = explicitSessionId !== undefined &&
346
+ enclosingSessionId !== undefined &&
347
+ explicitSessionId !== enclosingSessionId &&
348
+ enclosingAgentSpan !== undefined;
349
+ // ── Parentage: PURE NEST (Option D) ─────────────────────────────────
350
+ // A non-break-out agent nests under whatever OTel span is active — the
351
+ // host's ambient context — like every peer LLM/agent SDK. We do NOT
352
+ // second-guess the active span or unilaterally re-root: the old
353
+ // `foreignRoot` heuristic detached from ANY active span and so ripped
354
+ // agents out of legitimate host request traces. Cross-conversation
355
+ // grouping is carried by gen_ai.conversation.id, never by trace
356
+ // parentage. A runtime that propagates a stale/unwanted span across a
357
+ // boundary (e.g. our own persistent_agent Temporal workflow span) clears
358
+ // it at the SOURCE, never here. (A provenance-gated detach — re-root only
359
+ // a leaked Struct-OWNED span — is a documented, deferred follow-up.)
360
+ let startContext;
361
+ let links;
362
+ if (breakOut) {
363
+ // Explicit different session under another Struct agent: fresh ROOT
364
+ // trace + a spawned-by Link. The one deliberate, opt-in re-root.
365
+ // enclosingAgentSpan is guaranteed defined by the breakOut guard above.
366
+ links = [{ context: enclosingAgentSpan.spanContext() }];
367
+ startContext = api_1.ROOT_CONTEXT;
368
+ }
369
+ else {
370
+ startContext = enclosingAgentSpan
371
+ ? api_1.trace.setSpan(api_1.context.active(), enclosingAgentSpan)
372
+ : api_1.context.active();
373
+ }
277
374
  // Span creation itself can fail (custom tracer, broken context). If it
278
375
  // does, fall through to running `fn` directly so the user's call
279
376
  // always runs uninstrumented rather than blowing up on telemetry.
280
377
  let span;
281
378
  safe(() => {
282
- span = tracer.startSpan(`invoke_agent ${agentName}`, { kind: api_1.SpanKind.INTERNAL }, parentCtx);
379
+ span = tracer.startSpan(`invoke_agent ${agentName}`, { kind: api_1.SpanKind.INTERNAL, links }, startContext);
283
380
  }, "agent.start_span", this._internalLogger);
284
381
  if (span === undefined) {
285
382
  return await fn();
@@ -300,17 +397,24 @@ class StructSDK {
300
397
  startedSpan.setAttribute(semconv_js_1.GEN_AI.AGENT_VERSION, options.version);
301
398
  }
302
399
  // gen_ai.conversation.id is the spec-blessed name for
303
- // session/thread id.
304
- startedSpan.setAttribute(semconv_js_1.GEN_AI.CONVERSATION_ID, sessionId);
305
- // Always set parent_session_id when there's a parent agent — even
306
- // when the value matches sessionId (which happens when a nested
307
- // ``struct.agent()`` inherits ambient session). The attribute is
308
- // structural ("this agent has a parent agent"), not a uniqueness
309
- // marker. The UI uses it to render an inline subagent expansion
310
- // under the triggering call (vs the drill-in flow used when
311
- // sessionIds differ).
312
- if (parentSessionId) {
313
- startedSpan.setAttribute(semconv_js_1.STRUCT.AGENT_PARENT_SESSION_ID, parentSessionId);
400
+ // session/thread id. Omitted when session-less (never fabricated).
401
+ // Ports core.py:723-725 (`if self._session_id:`) exactly.
402
+ if (sessionId) {
403
+ startedSpan.setAttribute(semconv_js_1.GEN_AI.CONVERSATION_ID, sessionId);
404
+ }
405
+ // ``struct.agent.parent_session_id`` is a SPAWNED-BY marker — set it
406
+ // ONLY when this agent has an enclosing session that DIFFERS from
407
+ // its own resolved session id. An inline nested agent (same session
408
+ // as the enclosing agent, whether by explicit same id or ambient
409
+ // inheritance) already has its parent relationship encoded by the
410
+ // OTel span tree (ParentSpanId) — stamping
411
+ // parent_session_id === own sessionId would be self-referential
412
+ // noise. Structure comes from the tree/Link, not this attr
413
+ // (Link-canonical decision). Ports core.py:736-745 exactly (both
414
+ // the break-out and the "legacy" differing-id-no-span branches
415
+ // collapse to this one condition).
416
+ if (enclosingSessionId !== undefined && enclosingSessionId !== sessionId) {
417
+ startedSpan.setAttribute(semconv_js_1.STRUCT.AGENT_PARENT_SESSION_ID, enclosingSessionId);
314
418
  }
315
419
  if (options.metadata) {
316
420
  for (const [key, value] of Object.entries(options.metadata)) {
@@ -323,6 +427,7 @@ class StructSDK {
323
427
  conversationId: sessionId,
324
428
  agentSpan: startedSpan,
325
429
  pendingToolCalls: {},
430
+ manualAgentSpan: startedSpan,
326
431
  }, async () => {
327
432
  const activeCtx = api_1.trace.setSpan(api_1.context.active(), startedSpan);
328
433
  try {
@@ -53,7 +53,7 @@ function emitAnthropicMessageEvents(logger, messages, system) {
53
53
  parts: (0, truncation_js_1.truncateParts)(parts),
54
54
  }),
55
55
  extraAttrs: {
56
- [semconv_js_1.GEN_AI.SYSTEM]: "anthropic",
56
+ [semconv_js_1.GEN_AI.PROVIDER_NAME]: "anthropic",
57
57
  [semconv_js_1.GEN_AI.MESSAGE_INDEX]: msgIndex,
58
58
  },
59
59
  });
@@ -71,7 +71,7 @@ function emitAnthropicMessageEvents(logger, messages, system) {
71
71
  eventName,
72
72
  payload: (0, truncation_js_1.safeJsonStringify)({ role, parts: (0, truncation_js_1.truncateParts)(parts) }),
73
73
  extraAttrs: {
74
- [semconv_js_1.GEN_AI.SYSTEM]: "anthropic",
74
+ [semconv_js_1.GEN_AI.PROVIDER_NAME]: "anthropic",
75
75
  [semconv_js_1.GEN_AI.MESSAGE_INDEX]: msgIndex,
76
76
  },
77
77
  });
@@ -96,7 +96,7 @@ function emitAnthropicChoiceEvent(logger, contentBlocks, stopReason) {
96
96
  eventName: semconv_js_1.EVENT_NAMES.CHOICE,
97
97
  payload,
98
98
  extraAttrs: {
99
- [semconv_js_1.GEN_AI.SYSTEM]: "anthropic",
99
+ [semconv_js_1.GEN_AI.PROVIDER_NAME]: "anthropic",
100
100
  },
101
101
  });
102
102
  }
@@ -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 */