@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/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)
@@ -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 = {
@@ -232,13 +232,13 @@ class StructSDK {
232
232
  if (!this._tracerProvider) {
233
233
  throw new Error("Call struct.init() before using the SDK");
234
234
  }
235
- return this._tracerProvider.getTracer(name);
235
+ return this._tracerProvider.getTracer(name, version_js_1.SDK_VERSION);
236
236
  }
237
237
  getLogger(name = "struct-sdk") {
238
238
  if (!this._loggerProvider) {
239
239
  throw new Error("Call struct.init() before using the SDK");
240
240
  }
241
- return this._loggerProvider.getLogger(name);
241
+ return this._loggerProvider.getLogger(name, version_js_1.SDK_VERSION);
242
242
  }
243
243
  getInternalLogger() {
244
244
  return this._internalLogger;
@@ -306,8 +306,7 @@ class StructSDK {
306
306
  return await fn();
307
307
  }
308
308
  const agentName = options.name;
309
- const sessionId = options.sessionId ?? (0, node_crypto_1.randomUUID)();
310
- const parentSessionId = (0, context_js_1.getSessionId)();
309
+ const explicitSessionId = options.sessionId;
311
310
  const tracer = this.getTracer("struct-sdk");
312
311
  // Parent the new agent span on the nearest enclosing agent span (if
313
312
  // any) so nested `struct.agent()` / `struct.tool()` calls build a
@@ -316,16 +315,68 @@ class StructSDK {
316
315
  // nothing to hang off. We do NOT rely on the global OTel context
317
316
  // manager being installed — the SDK manages its own parent chain via
318
317
  // StructContext ALS to stay neutral in multi-tenant OTel setups.
319
- const parentSpan = (0, context_js_1.getAgentSpan)();
320
- const parentCtx = parentSpan
321
- ? api_1.trace.setSpan(api_1.context.active(), parentSpan)
322
- : 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
+ }
323
374
  // Span creation itself can fail (custom tracer, broken context). If it
324
375
  // does, fall through to running `fn` directly so the user's call
325
376
  // always runs uninstrumented rather than blowing up on telemetry.
326
377
  let span;
327
378
  safe(() => {
328
- 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);
329
380
  }, "agent.start_span", this._internalLogger);
330
381
  if (span === undefined) {
331
382
  return await fn();
@@ -346,17 +397,24 @@ class StructSDK {
346
397
  startedSpan.setAttribute(semconv_js_1.GEN_AI.AGENT_VERSION, options.version);
347
398
  }
348
399
  // gen_ai.conversation.id is the spec-blessed name for
349
- // session/thread id.
350
- startedSpan.setAttribute(semconv_js_1.GEN_AI.CONVERSATION_ID, sessionId);
351
- // Always set parent_session_id when there's a parent agent — even
352
- // when the value matches sessionId (which happens when a nested
353
- // ``struct.agent()`` inherits ambient session). The attribute is
354
- // structural ("this agent has a parent agent"), not a uniqueness
355
- // marker. The UI uses it to render an inline subagent expansion
356
- // under the triggering call (vs the drill-in flow used when
357
- // sessionIds differ).
358
- if (parentSessionId) {
359
- 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);
360
418
  }
361
419
  if (options.metadata) {
362
420
  for (const [key, value] of Object.entries(options.metadata)) {
@@ -369,6 +427,7 @@ class StructSDK {
369
427
  conversationId: sessionId,
370
428
  agentSpan: startedSpan,
371
429
  pendingToolCalls: {},
430
+ manualAgentSpan: startedSpan,
372
431
  }, async () => {
373
432
  const activeCtx = api_1.trace.setSpan(api_1.context.active(), startedSpan);
374
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 */