@struct-ai/sdk 0.3.0 → 0.4.2

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.
Files changed (58) hide show
  1. package/README.md +101 -16
  2. package/dist/commonjs/context.d.ts +45 -0
  3. package/dist/commonjs/context.js +78 -1
  4. package/dist/commonjs/core.js +184 -29
  5. package/dist/commonjs/events.d.ts +17 -6
  6. package/dist/commonjs/events.js +82 -59
  7. package/dist/commonjs/genai-content.d.ts +52 -0
  8. package/dist/commonjs/genai-content.js +143 -0
  9. package/dist/commonjs/instrument.d.ts +47 -0
  10. package/dist/commonjs/instrument.js +158 -0
  11. package/dist/commonjs/integrations/anthropic-content.js +18 -6
  12. package/dist/commonjs/integrations/anthropic.d.ts +8 -1
  13. package/dist/commonjs/integrations/anthropic.js +515 -104
  14. package/dist/commonjs/integrations/index.js +8 -0
  15. package/dist/commonjs/integrations/langchain-callback.d.ts +182 -27
  16. package/dist/commonjs/integrations/langchain-callback.js +754 -87
  17. package/dist/commonjs/integrations/langchain-content.js +1 -1
  18. package/dist/commonjs/integrations/langchain.d.ts +3 -0
  19. package/dist/commonjs/integrations/langchain.js +353 -7
  20. package/dist/commonjs/integrations/openai-content.d.ts +34 -0
  21. package/dist/commonjs/integrations/openai-content.js +375 -0
  22. package/dist/commonjs/integrations/openai.d.ts +39 -0
  23. package/dist/commonjs/integrations/openai.js +305 -0
  24. package/dist/commonjs/semconv.d.ts +12 -0
  25. package/dist/commonjs/semconv.js +13 -1
  26. package/dist/commonjs/truncation.d.ts +29 -0
  27. package/dist/commonjs/truncation.js +184 -10
  28. package/dist/commonjs/version.d.ts +2 -0
  29. package/dist/commonjs/version.js +6 -0
  30. package/dist/esm/context.d.ts +45 -0
  31. package/dist/esm/context.js +74 -1
  32. package/dist/esm/core.js +185 -30
  33. package/dist/esm/events.d.ts +17 -6
  34. package/dist/esm/events.js +82 -61
  35. package/dist/esm/genai-content.d.ts +52 -0
  36. package/dist/esm/genai-content.js +137 -0
  37. package/dist/esm/instrument.d.ts +47 -0
  38. package/dist/esm/instrument.js +155 -0
  39. package/dist/esm/integrations/anthropic-content.js +19 -7
  40. package/dist/esm/integrations/anthropic.d.ts +8 -1
  41. package/dist/esm/integrations/anthropic.js +514 -107
  42. package/dist/esm/integrations/index.js +8 -0
  43. package/dist/esm/integrations/langchain-callback.d.ts +182 -27
  44. package/dist/esm/integrations/langchain-callback.js +756 -89
  45. package/dist/esm/integrations/langchain-content.js +1 -1
  46. package/dist/esm/integrations/langchain.d.ts +3 -0
  47. package/dist/esm/integrations/langchain.js +352 -7
  48. package/dist/esm/integrations/openai-content.d.ts +34 -0
  49. package/dist/esm/integrations/openai-content.js +360 -0
  50. package/dist/esm/integrations/openai.d.ts +39 -0
  51. package/dist/esm/integrations/openai.js +296 -0
  52. package/dist/esm/semconv.d.ts +12 -0
  53. package/dist/esm/semconv.js +12 -0
  54. package/dist/esm/truncation.d.ts +29 -0
  55. package/dist/esm/truncation.js +182 -10
  56. package/dist/esm/version.d.ts +2 -0
  57. package/dist/esm/version.js +3 -0
  58. package/package.json +11 -3
@@ -11,6 +11,14 @@ const INTEGRATIONS = [
11
11
  await mod.patch(sdk);
12
12
  },
13
13
  },
14
+ {
15
+ name: "openai",
16
+ probe: () => import("openai"),
17
+ apply: async (sdk) => {
18
+ const mod = await import("./openai.js");
19
+ await mod.patch(sdk);
20
+ },
21
+ },
14
22
  {
15
23
  name: "langchain",
16
24
  probe: () => import("@langchain/core/language_models/chat_models"),
@@ -36,29 +36,37 @@ interface LLMResultLike {
36
36
  *
37
37
  * - `gen_ai.conversation.id` is the OTel GenAI-spec conversation identifier
38
38
  * and Struct's UI grouping unit — one value = one entry in the sessions
39
- * list. For the top-level agent we auto-derive it from thread_id, so a
40
- * stable thread_id across multi-turn chats collapses into one session.
39
+ * list. It is one id per run: every span belonging to a top-level agent
40
+ * invocation AND any subagents it spawns shares the SAME
41
+ * `gen_ai.conversation.id`, so the whole call tree collapses into one
42
+ * session (OTel's `conversation.id` models "the thread", not "the
43
+ * agent").
41
44
  *
42
- * For SUBAGENTS (an agent invoked from inside another's tool body) we
43
- * deliberately assign a DIFFERENT conversation.id — either from the subagent's
44
- * own thread_id if supplied, or a fresh UUID. The resulting session is
45
- * linked back to the outer agent's session via the
46
- * `struct.agent.parent_session_id` span attribute (what powers "Spawned
47
- * by" / subagent navigation in the UI). Without this split the subagent's
48
- * spans would collapse into the outer session, burying delegation.
45
+ * SUBAGENTS (an agent invoked from inside another's tool body) therefore
46
+ * INHERIT the parent run's `gen_ai.conversation.id` rather than minting
47
+ * their own. Structural linkage — "this invoke_agent span was spawned by
48
+ * that one" — is carried separately via the `struct.agent.parent_session_id`
49
+ * span attribute (what powers "Spawned by" / subagent navigation in the
50
+ * UI), not by giving the subagent a different session id.
49
51
  *
50
- * LangChain quirk (handled automatically): when `agent.invoke(...)` runs
51
- * nested inside a parent call, LangChain's config-merge inherits the
52
- * parent's `metadata.thread_id` onto the child even if its config provided
53
- * a different one. We detect that by comparing against the nearest agent
54
- * ancestor's session; if they match, treat it as "inherited, not
55
- * user-intended" and assign a fresh UUID to the subagent.
52
+ * A subagent MAY still be started with its own explicit thread_id (e.g. a
53
+ * LangGraph checkpoint key distinct from the parent's). When that happens
54
+ * we preserve it as `struct.agent.thread_id` for observability, but it does
55
+ * NOT split the session — `gen_ai.conversation.id` still follows the
56
+ * parent, because the grouping unit is the run, not the checkpoint.
57
+ *
58
+ * Chat / tool / retriever spans NEVER fabricate a conversation id. If no
59
+ * ancestor run, metadata thread_id, or ambient session supplies one, the
60
+ * span (and any log events it emits) simply omits `gen_ai.conversation.id`
61
+ * — an orphan span with no session is preferable to a fake one that can
62
+ * never be joined to anything else.
56
63
  *
57
64
  * End-user guidance:
58
65
  * - Use thread_id per conversation; multi-turn chats reuse it.
59
- * - For a subagent call, pass a DIFFERENT thread_id (or omit it and let
60
- * LangGraph generate one). Each subagent then surfaces as its own
61
- * session in the UI, linked back via parent_session_id.
66
+ * - Subagents inherit the enclosing run's session automatically; you
67
+ * don't need to (and shouldn't) pass a different thread_id purely to
68
+ * separate them — use `struct.agent.parent_session_id` /
69
+ * "Spawned by" navigation in the UI instead.
62
70
  */
63
71
  export declare class StructCallbackHandler {
64
72
  private readonly sdk;
@@ -83,6 +91,54 @@ export declare class StructCallbackHandler {
83
91
  readonly raiseError = false;
84
92
  private readonly runs;
85
93
  private readonly internalLogger;
94
+ /**
95
+ * Index of live `execute_tool` spans by their `langgraph_checkpoint_ns`,
96
+ * keyed to an ARRAY of currently-live spans rather than a single span.
97
+ *
98
+ * The re-parenting trick this supports: LangGraph stamps a namespace
99
+ * (`tools:<uuid>`) on a tool-call branch AND the same value on the
100
+ * sub-agent graph that tool triggers, so a sub-agent's chain-start (whose
101
+ * `parentRunId` points at the parent GRAPH — a sibling of the tool in the
102
+ * run tree, not the tool itself) can look itself up by ns and re-parent
103
+ * under its triggering tool call instead of landing as a sibling.
104
+ *
105
+ * That assumption — "ns is unique even across parallel same-named tool
106
+ * calls" — is FALSE on every `@langchain/langgraph` release checked, up
107
+ * to and including the latest published as of this writing (`1.4.7`, and
108
+ * confirmed byte-identical on `main` at HEAD): `ToolNode.run()`
109
+ * (`0.2.x`: `dist/prebuilt/tool_node.js`; `1.x`: moved to
110
+ * `libs/langgraph-core/src/prebuilt/tool_node.ts`) invokes every parallel
111
+ * `tool_call` within one step (`Promise.all(...map(call => this.runTool(
112
+ * call, config, input)))`) with the exact SAME `config` object reference
113
+ * — no per-call config cloning, no call-index-derived namespace — so
114
+ * `config.metadata.langgraph_checkpoint_ns` collides across parallel
115
+ * same-named tool calls in that step on every version, not just 0.2.x.
116
+ * (One opt-in exception: routing tool calls through LangGraph `Send`
117
+ * objects, added ~PR #1498, gets Pregel's normal per-task namespacing —
118
+ * but `createReactAgent`/`ToolNode`'s default path does not use it.) If a
119
+ * future release fixes this, update this comment with the version and
120
+ * consider re-enabling direct pairing unconditionally on it. (Python's
121
+ * `langgraph.prebuilt.tool_node.ToolNode._afunc` does not have this
122
+ * problem: it calls `get_config_list(config, len(tool_calls))` to build a
123
+ * genuinely per-call config before fanning out.)
124
+ *
125
+ * An array-per-ns (instead of one span per ns) makes the collision
126
+ * DETECTABLE instead of silently last-write-wins: at consume time
127
+ * (`handleChainStart`'s sub-agent lookup), exactly one live candidate for
128
+ * the ns is an unambiguous pair (re-parent as before); zero or more than
129
+ * one live candidates means we cannot tell which tool call the sub-agent
130
+ * actually belongs to, so we deliberately do NOT re-parent — the
131
+ * sub-agent falls back to the normal `resolveParent` chain (renders as a
132
+ * graph sibling: degraded, but never wrong). Cross-wiring two unrelated
133
+ * spans is worse than under-wiring one. Entries are evicted (their
134
+ * specific span removed, not the whole ns bucket) in
135
+ * `handleToolEnd`/`handleToolError` so the map never grows unboundedly and
136
+ * so a tool call that finishes frees its slot for ambiguity resolution.
137
+ * Parity: python `_tool_spans_by_ns` / `_checkpoint_ns` (python's
138
+ * `langgraph` >= 1.2.0 does not need this same guard, since its ns values
139
+ * never collide — see above).
140
+ */
141
+ private readonly checkpointNsToolSpans;
86
142
  constructor(sdk: StructSDK, tracer: Tracer, logger: Logger | undefined);
87
143
  handleChainStart: (chain: Serialized, inputs: unknown, runId: string, parentRunId?: string, _tags?: string[], metadata?: Record<string, unknown>, runType?: string, runName?: string) => void;
88
144
  handleChainEnd: (_outputs: unknown, runId: string) => void;
@@ -92,6 +148,14 @@ export declare class StructCallbackHandler {
92
148
  handleLLMEnd: (output: LLMResultLike, runId: string) => void;
93
149
  handleLLMError: (err: Error, runId: string) => void;
94
150
  handleToolStart: (tool: Serialized, input: string, runId: string, parentRunId?: string, _tags?: string[], metadata?: Record<string, unknown>, runName?: string) => void;
151
+ /**
152
+ * Remove one specific span from its checkpoint-ns bucket (not the whole
153
+ * bucket) — a sibling parallel tool call under the same colliding ns may
154
+ * still be live and must keep its own candidate-list membership. Deletes
155
+ * the ns key entirely once its bucket is empty so the map never grows
156
+ * unboundedly.
157
+ */
158
+ private evictCheckpointNsSpan;
95
159
  handleToolEnd: (output: unknown, runId: string) => void;
96
160
  handleToolError: (err: Error, runId: string) => void;
97
161
  handleRetrieverStart: (retriever: Serialized, query: string, runId: string, parentRunId?: string, _tags?: string[], metadata?: Record<string, unknown>, runName?: string) => void;
@@ -100,7 +164,11 @@ export declare class StructCallbackHandler {
100
164
  private resolveParent;
101
165
  /**
102
166
  * Conversation-id resolution for chat/tool/retriever spans — INHERIT from
103
- * the parent agent so everything rolls up under one gen_ai.conversation.id.
167
+ * the parent run so everything rolls up under one gen_ai.conversation.id.
168
+ * Returns `undefined` (never fabricates a UUID) when no ancestor run,
169
+ * metadata thread_id, or ambient session supplies one — an orphan span
170
+ * with no conversation.id is preferable to a fake one that can never be
171
+ * joined to anything else (parity: python `_resolve_session_id`).
104
172
  */
105
173
  private resolveSessionId;
106
174
  /**
@@ -117,18 +185,105 @@ export declare class StructCallbackHandler {
117
185
  */
118
186
  private inheritedAgentSessionId;
119
187
  /**
120
- * Conversation-id resolution for AGENT spans — each agent invocation gets
121
- * its own gen_ai.conversation.id. We deliberately do NOT inherit from the
122
- * parent run, so subagents surface as separate entries in the sessions
123
- * list. Parent linkage is preserved via `struct.agent.parent_session_id`.
188
+ * Nearest ``invoke_agent`` ancestor SPAN — O(1) lookup, mirrors
189
+ * ``inheritedAgentSessionId`` for the span pointer itself. Cached at every
190
+ * RunState creation so descendants (and prompt propagation) can reach the
191
+ * ancestor without walking a parent chain we don't retain.
124
192
  *
125
- * LangChain inherits `metadata.thread_id` from the parent invoke's config
126
- * when a nested invoke runs inside it (even if the child config supplied
127
- * its own). We detect that inheritance by comparing against the parent's
128
- * resolved session and ignore the inherited value.
193
+ * Unlike ``inheritedAgentSessionId``, a missing ``parentRunId`` falls back
194
+ * to the ambient `getAgentSpan()` (matching `resolveParent`'s top-level
195
+ * fallback) rather than `undefined` — a run with no LangChain parent may
196
+ * still be nested inside a manually-created `struct.agent()` span.
197
+ * Parity: python `_inherited_agent_span`.
198
+ */
199
+ private inheritedAgentSpan;
200
+ /**
201
+ * Resolve the pending-tool-call queue OBJECT a run should push/pop
202
+ * against for `gen_ai.tool.call.id` autofill, per `RunState.toolCallQueue`'s
203
+ * doc comment.
204
+ *
205
+ * PINNED AT THE RUN-TREE ROOT, INHERITED DOWN AS THE SAME OBJECT
206
+ * REFERENCE — this is the crux of the design, and it is the OBJECT that
207
+ * must be pinned, not merely a manual-vs-bare boolean. Whenever a parent
208
+ * run is known (`parentRunId` resolves to a live `RunState`), we copy its
209
+ * `toolCallQueue` reference VERBATIM rather than re-deriving anything from
210
+ * the live ALS frame. Only a run with no resolvable parent (a genuine
211
+ * root, or an orphaned `parentRunId` whose `RunState` was never
212
+ * registered) consults the live `getManualAgentSpan()` signal — and even
213
+ * then, only ONCE, at that run's creation.
214
+ *
215
+ * Why the OBJECT, not just a boolean: a descendant run's OWN callback can
216
+ * fire in a lost or foreign ALS frame — the LangChain FRAMEWORK layer
217
+ * (`langchain.ts`'s `BaseChatModel.generate`/`.stream` patch) opens its
218
+ * own short-lived ALS scope (`runWithContext({ suppressGenAi: true },
219
+ * fn)`) around each chat completion, entirely independent of any customer
220
+ * `struct.agent()` call; more generally, LangGraph's internal scheduling
221
+ * can run a node's callback outside the async context that originally
222
+ * held the manual scope. A design that pins only a manual-vs-bare
223
+ * ownership BOOLEAN but still routes the actual push (`handleLLMEnd`) and
224
+ * pop (`handleToolStart`) through the ALS-reading
225
+ * `pushPendingToolCalls`/`popPendingToolCallId` helpers (context.ts) is
226
+ * still broken: those helpers read whatever `pendingToolCalls` object is
227
+ * on the CURRENT frame, so a push in one lost/foreign frame and a pop in a
228
+ * DIFFERENT lost/foreign frame silently disagree, even though both frames
229
+ * correctly resolved the same boolean. Operating directly on the
230
+ * RunState-pinned `toolCallQueue` object sidesteps the ALS frame entirely
231
+ * for both operations: whatever object the root observed is what the
232
+ * whole run tree pushes into and pops from, regardless of ALS frame state
233
+ * at either callback.
234
+ *
235
+ * Three cases:
236
+ * 1. `parentRunId` resolves to a registered parent `RunState` — inherit
237
+ * `toolCallQueue` from it verbatim (decided once at the tree root) —
238
+ * UNLESS a NEW manual scope began mid-tree (see FIX F below).
239
+ * 2. No parent (root, or an orphaned `parentRunId`) AND a MANUAL
240
+ * `struct.agent()` scope is live right now (`getManualAgentSpan()`
241
+ * set) — capture `getPendingToolCalls()`, the SAME object
242
+ * `struct.agent()` seeded onto the ALS store and that manual
243
+ * `struct.tool()` calls pop from via `popPendingToolCallId`
244
+ * (context.ts). Captured IN-FRAME here (this callback necessarily
245
+ * runs inside the live manual scope), so the RunState-pinned
246
+ * reference and the ALS store's `pendingToolCalls` stay identical.
247
+ * 3. No parent AND no manual scope — this run IS the top of a bare run
248
+ * tree; mint a fresh queue `{}`.
249
+ *
250
+ * Self-audit round 5 (FIX F): case 1's "inherit verbatim" is only correct
251
+ * when the parent's queue was captured under the SAME manual ownership
252
+ * that is live right now. A NESTED manual `struct.agent()` — opened from
253
+ * inside a tool body, whose own LangChain graph LangChain threads as a
254
+ * CHILD of the outer run tree, parented on a REAL tool span rather than
255
+ * reaching the manual span through skipped chains (so it never reaches
256
+ * the suppressed-twin branch in `handleChainStart`) — would otherwise
257
+ * silently inherit the OUTER run's
258
+ * queue object, even though the CURRENT ambient `getManualAgentSpan()` is
259
+ * the INNER agent's own (different) span and the inner agent's own
260
+ * `pendingToolCalls` (a fresh object seeded by that inner `struct.agent()`
261
+ * call) is what its descendants actually push/pop against. Detect this by
262
+ * comparing the live `getManualAgentSpan()` against the PARENT run's
263
+ * `queueManualSpan` (the manual span that owned the parent's queue): if
264
+ * they differ and the live one is truthy, a new manual scope has begun —
265
+ * re-capture `getPendingToolCalls()` fresh, exactly like case 2. When they
266
+ * match (including both `undefined`, i.e. bare on both sides — preserves
267
+ * the F2 frame-independence fix, where a merely lost/foreign ALS frame
268
+ * reads as `undefined` and must NOT trigger a spurious re-capture),
269
+ * inherit verbatim as before.
270
+ */
271
+ private resolveQueueOwnership;
272
+ /**
273
+ * Conversation-id resolution for AGENT spans — INHERIT from the parent
274
+ * run first, so a subagent shares its outer agent's gen_ai.conversation.id
275
+ * (one id per run; parity: python `_resolve_agent_session_id`). Only when
276
+ * there's no parent session do we fall back to metadata.thread_id, the
277
+ * ambient session, or — as a last resort, since agent spans should always
278
+ * have a coherent id — a fresh UUID. A subagent's own divergent thread_id
279
+ * (if any) is preserved separately as `struct.agent.thread_id`; see the
280
+ * caller in `handleChainStart`.
129
281
  */
130
282
  private resolveAgentSessionId;
131
283
  private propagateUserPrompt;
132
284
  }
285
+ declare function detectProviderFromSerialized(llm: Serialized): string;
286
+ /** @internal */
287
+ export declare const _detectProviderFromSerializedForTest: typeof detectProviderFromSerialized;
133
288
  export {};
134
289
  //# sourceMappingURL=langchain-callback.d.ts.map