@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
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.StructCallbackHandler = void 0;
3
+ exports._detectProviderFromSerializedForTest = exports.StructCallbackHandler = void 0;
4
4
  const node_crypto_1 = require("node:crypto");
5
5
  const api_1 = require("@opentelemetry/api");
6
6
  const context_js_1 = require("../context.js");
@@ -26,29 +26,37 @@ const langchain_content_js_1 = require("./langchain-content.js");
26
26
  *
27
27
  * - `gen_ai.conversation.id` is the OTel GenAI-spec conversation identifier
28
28
  * and Struct's UI grouping unit — one value = one entry in the sessions
29
- * list. For the top-level agent we auto-derive it from thread_id, so a
30
- * stable thread_id across multi-turn chats collapses into one session.
29
+ * list. It is one id per run: every span belonging to a top-level agent
30
+ * invocation AND any subagents it spawns shares the SAME
31
+ * `gen_ai.conversation.id`, so the whole call tree collapses into one
32
+ * session (OTel's `conversation.id` models "the thread", not "the
33
+ * agent").
31
34
  *
32
- * For SUBAGENTS (an agent invoked from inside another's tool body) we
33
- * deliberately assign a DIFFERENT conversation.id — either from the subagent's
34
- * own thread_id if supplied, or a fresh UUID. The resulting session is
35
- * linked back to the outer agent's session via the
36
- * `struct.agent.parent_session_id` span attribute (what powers "Spawned
37
- * by" / subagent navigation in the UI). Without this split the subagent's
38
- * spans would collapse into the outer session, burying delegation.
35
+ * SUBAGENTS (an agent invoked from inside another's tool body) therefore
36
+ * INHERIT the parent run's `gen_ai.conversation.id` rather than minting
37
+ * their own. Structural linkage — "this invoke_agent span was spawned by
38
+ * that one" — is carried separately via the `struct.agent.parent_session_id`
39
+ * span attribute (what powers "Spawned by" / subagent navigation in the
40
+ * UI), not by giving the subagent a different session id.
39
41
  *
40
- * LangChain quirk (handled automatically): when `agent.invoke(...)` runs
41
- * nested inside a parent call, LangChain's config-merge inherits the
42
- * parent's `metadata.thread_id` onto the child even if its config provided
43
- * a different one. We detect that by comparing against the nearest agent
44
- * ancestor's session; if they match, treat it as "inherited, not
45
- * user-intended" and assign a fresh UUID to the subagent.
42
+ * A subagent MAY still be started with its own explicit thread_id (e.g. a
43
+ * LangGraph checkpoint key distinct from the parent's). When that happens
44
+ * we preserve it as `struct.agent.thread_id` for observability, but it does
45
+ * NOT split the session — `gen_ai.conversation.id` still follows the
46
+ * parent, because the grouping unit is the run, not the checkpoint.
47
+ *
48
+ * Chat / tool / retriever spans NEVER fabricate a conversation id. If no
49
+ * ancestor run, metadata thread_id, or ambient session supplies one, the
50
+ * span (and any log events it emits) simply omits `gen_ai.conversation.id`
51
+ * — an orphan span with no session is preferable to a fake one that can
52
+ * never be joined to anything else.
46
53
  *
47
54
  * End-user guidance:
48
55
  * - Use thread_id per conversation; multi-turn chats reuse it.
49
- * - For a subagent call, pass a DIFFERENT thread_id (or omit it and let
50
- * LangGraph generate one). Each subagent then surfaces as its own
51
- * session in the UI, linked back via parent_session_id.
56
+ * - Subagents inherit the enclosing run's session automatically; you
57
+ * don't need to (and shouldn't) pass a different thread_id purely to
58
+ * separate them — use `struct.agent.parent_session_id` /
59
+ * "Spawned by" navigation in the UI instead.
52
60
  */
53
61
  class StructCallbackHandler {
54
62
  sdk;
@@ -73,6 +81,54 @@ class StructCallbackHandler {
73
81
  raiseError = false;
74
82
  runs = new Map();
75
83
  internalLogger;
84
+ /**
85
+ * Index of live `execute_tool` spans by their `langgraph_checkpoint_ns`,
86
+ * keyed to an ARRAY of currently-live spans rather than a single span.
87
+ *
88
+ * The re-parenting trick this supports: LangGraph stamps a namespace
89
+ * (`tools:<uuid>`) on a tool-call branch AND the same value on the
90
+ * sub-agent graph that tool triggers, so a sub-agent's chain-start (whose
91
+ * `parentRunId` points at the parent GRAPH — a sibling of the tool in the
92
+ * run tree, not the tool itself) can look itself up by ns and re-parent
93
+ * under its triggering tool call instead of landing as a sibling.
94
+ *
95
+ * That assumption — "ns is unique even across parallel same-named tool
96
+ * calls" — is FALSE on every `@langchain/langgraph` release checked, up
97
+ * to and including the latest published as of this writing (`1.4.7`, and
98
+ * confirmed byte-identical on `main` at HEAD): `ToolNode.run()`
99
+ * (`0.2.x`: `dist/prebuilt/tool_node.js`; `1.x`: moved to
100
+ * `libs/langgraph-core/src/prebuilt/tool_node.ts`) invokes every parallel
101
+ * `tool_call` within one step (`Promise.all(...map(call => this.runTool(
102
+ * call, config, input)))`) with the exact SAME `config` object reference
103
+ * — no per-call config cloning, no call-index-derived namespace — so
104
+ * `config.metadata.langgraph_checkpoint_ns` collides across parallel
105
+ * same-named tool calls in that step on every version, not just 0.2.x.
106
+ * (One opt-in exception: routing tool calls through LangGraph `Send`
107
+ * objects, added ~PR #1498, gets Pregel's normal per-task namespacing —
108
+ * but `createReactAgent`/`ToolNode`'s default path does not use it.) If a
109
+ * future release fixes this, update this comment with the version and
110
+ * consider re-enabling direct pairing unconditionally on it. (Python's
111
+ * `langgraph.prebuilt.tool_node.ToolNode._afunc` does not have this
112
+ * problem: it calls `get_config_list(config, len(tool_calls))` to build a
113
+ * genuinely per-call config before fanning out.)
114
+ *
115
+ * An array-per-ns (instead of one span per ns) makes the collision
116
+ * DETECTABLE instead of silently last-write-wins: at consume time
117
+ * (`handleChainStart`'s sub-agent lookup), exactly one live candidate for
118
+ * the ns is an unambiguous pair (re-parent as before); zero or more than
119
+ * one live candidates means we cannot tell which tool call the sub-agent
120
+ * actually belongs to, so we deliberately do NOT re-parent — the
121
+ * sub-agent falls back to the normal `resolveParent` chain (renders as a
122
+ * graph sibling: degraded, but never wrong). Cross-wiring two unrelated
123
+ * spans is worse than under-wiring one. Entries are evicted (their
124
+ * specific span removed, not the whole ns bucket) in
125
+ * `handleToolEnd`/`handleToolError` so the map never grows unboundedly and
126
+ * so a tool call that finishes frees its slot for ambiguity resolution.
127
+ * Parity: python `_tool_spans_by_ns` / `_checkpoint_ns` (python's
128
+ * `langgraph` >= 1.2.0 does not need this same guard, since its ns values
129
+ * never collide — see above).
130
+ */
131
+ checkpointNsToolSpans = new Map();
76
132
  constructor(sdk, tracer, logger) {
77
133
  this.sdk = sdk;
78
134
  this.tracer = tracer;
@@ -85,7 +141,7 @@ class StructCallbackHandler {
85
141
  // LangGraph nodes): don't create a span, but record the runId so
86
142
  // downstream children with parentRunId pointing here can still find
87
143
  // their effective parent span.
88
- if (!isAgentChain(chain, runType, runName)) {
144
+ if (!isAgentChain(chain, runType, runName, metadata)) {
89
145
  // Skipped chains inherit session from parent (so chat/tool spans
90
146
  // created under them stay tied to the enclosing agent's session).
91
147
  const sessionId = this.resolveSessionId(parentRunId, metadata);
@@ -95,49 +151,171 @@ class StructCallbackHandler {
95
151
  effectiveParentSpan: parentSpan,
96
152
  sessionId,
97
153
  nearestAgentSessionId: this.inheritedAgentSessionId(parentRunId),
154
+ nearestAgentSpan: this.inheritedAgentSpan(parentRunId),
155
+ ...this.resolveQueueOwnership(parentRunId),
98
156
  kind: "skipped-chain",
99
157
  });
100
158
  return;
101
159
  }
160
+ // Manual struct.agent() wins over the framework's chain — twin
161
+ // suppression (ownership: manual > framework > provider).
162
+ //
163
+ // Generalized check (0.3.14; was: only a TOP-LEVEL chain, gated on
164
+ // `if (!parentRunId)`): suppress THIS agent chain as a
165
+ // "suppressed-twin" whenever a manual struct.agent() scope is live
166
+ // (`getManualAgentSpan()`) AND the run's EFFECTIVE parent span —
167
+ // resolved exactly like every other span's parent, via
168
+ // `resolveParent`, which already walks THROUGH intervening
169
+ // skipped-chains (RunnableSequence, ChannelWrite, Branch, a
170
+ // `prompt | graph` composition, ...) — resolves to that manual span
171
+ // itself. That condition means this run is the FIRST real agent chain
172
+ // reachable from the manual root through ONLY skipped chains, with no
173
+ // real framework `invoke_agent` in between.
174
+ //
175
+ // This SUBSUMES the old top-level-only case: a genuine top-level
176
+ // chain's effective parent is `resolveParent`'s no-known-parent
177
+ // fallback, `getAgentSpan()` — which core.ts's `agent()` seeds to the
178
+ // SAME span object as `manualAgentSpan`, so it equals `manual`
179
+ // whenever a manual scope is live. It ALSO now catches what the old
180
+ // `!parentRunId` gate missed: `sdk.agent(() => sequence.invoke(...))`
181
+ // wrapping `RunnableSequence -> CompiledStateGraph` (or any skipped
182
+ // chain wrapping the real graph). The sequence is registered as a
183
+ // `skipped-chain` whose `effectiveParentSpan` already resolves to the
184
+ // manual span; the graph's chain-start DOES carry a `parentRunId` (the
185
+ // sequence's) — which used to bypass this check entirely and fall
186
+ // through to a REAL `invoke_agent`, emitting a duplicate — but now
187
+ // resolves ITS OWN effective parent (via the same `resolveParent` every
188
+ // other span uses) to the manual span too, and gets suppressed exactly
189
+ // like a top-level twin would.
190
+ //
191
+ // A genuine nested sub-agent (agent -> tool -> sub-agent, or a
192
+ // sub-agent spawned under a REAL framework `invoke_agent`) instead
193
+ // resolves its effective parent to that real tool/agent SPAN — never
194
+ // the manual span — so `parentSpan !== manual` and it is correctly NOT
195
+ // suppressed, emitting its own `invoke_agent` as before.
196
+ //
197
+ // Record the run pointing at the manual span so descendants parent
198
+ // under it — but emit NO twin invoke_agent span, and NEVER end the
199
+ // manual span from chain callbacks (span: undefined guards that via
200
+ // the `if (!r || !r.span) return;` early-returns in
201
+ // handleChainEnd/handleChainError).
202
+ //
203
+ // Python parity note: `struct-sdk-python`'s `on_chain_start`
204
+ // (langchain.py:681-698) gates the identical suppression on
205
+ // `parent_key is None` — i.e. Python has this SAME top-level-only
206
+ // limitation today; it is not something this TS fix introduces or
207
+ // widens. This fix intentionally puts TS ahead of Python on the
208
+ // RunnableSequence/skipped-chain-wrapped-graph scenario until a
209
+ // matching Python fix lands — filed as a follow-up rather than silently
210
+ // diverging.
211
+ const manual = (0, context_js_1.getManualAgentSpan)();
212
+ const { parentSpan, parentSpanIsAgent: parentSpanIsAgentChain } = this.resolveParent(parentRunId);
213
+ void parentSpanIsAgentChain;
214
+ const parentAgentSessionId = this.inheritedAgentSessionId(parentRunId);
215
+ if (manual && parentSpan === manual) {
216
+ // Self-audit round 5 (FIX C): `getSessionId()` alone is the AMBIENT
217
+ // session — `undefined` for a SESSION-LESS manual `struct.agent()`
218
+ // (no explicit/enclosing sessionId; core.ts never fabricates one for
219
+ // its OWN span). Python still gives the suppressed-twin's
220
+ // descendants a COHERENT conversation.id in that case: it resolves
221
+ // `session_id` via `_resolve_agent_session_id` (metadata thread_id,
222
+ // then ambient session, then a fresh UUID) BEFORE checking manual
223
+ // ownership, then falls back to it — `_current_session_id.get(None)
224
+ // or session_id` (langchain.py:685-696) — rather than leaving the
225
+ // suppressed-twin subtree session-less just because the manual scope
226
+ // itself chose not to fabricate one. Mirror that fallback exactly:
227
+ // ambient session first, else the same resolution a real invoke_agent
228
+ // for THIS run would use — `resolveAgentSessionId(metadata,
229
+ // parentAgentSessionId)`, identical to the real-agent path below, so
230
+ // a suppressed twin reached through skipped chains resolves exactly
231
+ // like the real invoke_agent it stands in for would (for a genuinely
232
+ // top-level twin, parentAgentSessionId is undefined, same as before).
233
+ const session = (0, context_js_1.getSessionId)() ?? this.resolveAgentSessionId(metadata, parentAgentSessionId);
234
+ this.runs.set(runId, {
235
+ span: undefined,
236
+ effectiveParentSpan: manual,
237
+ sessionId: session,
238
+ nearestAgentSessionId: session,
239
+ nearestAgentSpan: manual,
240
+ // Queue ownership generalizes the same way as everything else here:
241
+ // `resolveQueueOwnership` already inherits verbatim from a
242
+ // registered parent (the skipped chain(s) in between) when the live
243
+ // manual span matches what the parent captured, and only
244
+ // re-captures `getPendingToolCalls()` fresh when there's no
245
+ // resolvable parent (the genuinely-top-level case, matching the old
246
+ // hardcoded direct-capture behavior) or a NEW nested manual scope
247
+ // began (FIX F). No behavior change for the top-level twin; correct
248
+ // inheritance for the new skipped-chain-reached twin.
249
+ ...this.resolveQueueOwnership(parentRunId),
250
+ kind: "suppressed-twin",
251
+ });
252
+ return;
253
+ }
102
254
  const agentName = runName ??
103
255
  extractClassName(chain) ??
104
256
  (typeof inputs === "object" && inputs
105
257
  ? inputs.name
106
258
  : undefined) ??
107
259
  "agent";
108
- // Agent-start: each agent invocation gets its OWN gen_ai.conversation.id.
109
- // Prefer config.configurable.thread_id, then a fresh UUID. Never inherit
110
- // from the parent agent — subagents should appear as separate sessions
111
- // in the UI, linked via ``struct.agent.parent_session_id``.
260
+ // Agent-start: every agent invocation shares ONE gen_ai.conversation.id
261
+ // with the run it belongs to — subagents INHERIT the parent agent's
262
+ // session rather than minting their own (OTel: conversation.id models
263
+ // the thread, not the agent). Only when there's no parent session at
264
+ // all do we fall back to metadata.thread_id, the ambient session, or
265
+ // (as a last resort, so agent spans always have a coherent id) a fresh
266
+ // UUID.
112
267
  //
113
268
  // Convention (documented publicly):
114
269
  // * thread_id is a LangGraph checkpoint identifier. Multiple turns
115
270
  // of one conversation reuse a thread_id → we group them into one
116
271
  // session.
117
- // * Subagents should be given a distinct thread_id (or none at all,
118
- // letting LangGraph generate one). That way a subagent surfaces
119
- // as its own entry in the sessions list.
272
+ // * Subagents don't need a distinct thread_id to appear distinctly —
273
+ // they surface via ``struct.agent.parent_session_id`` linkage
274
+ // ("Spawned by" navigation) while staying in the same session.
120
275
  //
121
- // LangChain quirk (handled below): when an invoke runs nested inside
122
- // a parent's call (e.g. the tool body invokes a subagent), LangChain
123
- // inherits the parent's ``metadata.thread_id`` into the child run's
124
- // metadata — even when the child config supplied its own. Treat that
125
- // inheritance as "no thread_id" and assign a fresh UUID.
276
+ // A subagent MAY still supply its own thread_id (e.g. a distinct
277
+ // LangGraph checkpoint key). We preserve that as ``struct.agent.thread_id``
278
+ // below for observability, but it does not override the inherited
279
+ // conversation.id.
126
280
  //
127
281
  // For the struct.agent.parent_session_id attribute, we need the
128
282
  // NEAREST agent ancestor's session — not just the immediate parent
129
283
  // run, which might be a tool span. That's what
130
- // ``inheritedAgentSessionId`` walks.
131
- const parentAgentSessionId = this.inheritedAgentSessionId(parentRunId);
284
+ // ``inheritedAgentSessionId`` walks. Both `parentAgentSessionId` and
285
+ // `parentSpan` were already resolved above (twin-suppression check),
286
+ // and are reused verbatim here — the run tree hasn't changed between
287
+ // the two reads within this single synchronous callback.
132
288
  const sessionId = this.resolveAgentSessionId(metadata, parentAgentSessionId);
133
289
  if (process.env.STRUCT_SDK_DEBUG === "1") {
134
290
  // eslint-disable-next-line no-console
135
291
  console.error("[struct-sdk] agent-start", agentName, "metadata.thread_id=", metadata?.thread_id, "parent.agent.session=", parentAgentSessionId, "→ resolved session=", sessionId);
136
292
  }
137
- const { parentSpan, parentSpanIsAgent } = this.resolveParent(parentRunId);
138
- void parentSpanIsAgent;
139
- const parentCtx = parentSpan
140
- ? api_1.trace.setSpan(api_1.context.active(), parentSpan)
293
+ // LangChain agent-as-tool: a sub-agent graph runs as a SIBLING of its
294
+ // triggering execute_tool (parentRunId points at the parent graph, not
295
+ // the tool), so normal resolution would emit this invoke_agent as a
296
+ // sibling. LangGraph stamps the tool branch and its sub-agent with the
297
+ // same `langgraph_checkpoint_ns`, so on versions where that ns really is
298
+ // unique per tool call we re-parent under the matching live execute_tool
299
+ // span to nest natively (the UI's direct-tool-child path).
300
+ //
301
+ // But on every `@langchain/langgraph` release checked (0.2.x through the
302
+ // latest published, 1.4.7 — see the doc comment on
303
+ // `checkpointNsToolSpans`) the ns is NOT unique across parallel
304
+ // same-named tool calls in one step — so we only ever act on an
305
+ // UNAMBIGUOUS match: exactly one live tool span currently registered
306
+ // under this ns. Zero candidates (no tool ever registered this ns, or it
307
+ // already ended) or two-or-more candidates (a collision — we cannot tell
308
+ // which tool call this sub-agent belongs to) both fall back to the
309
+ // normal resolved parent instead of guessing. Cross-wiring two unrelated
310
+ // spans is a worse outcome than under-wiring one to a sibling position.
311
+ // Parity: python `delegating_tool_span` in `on_chain_start` (python's
312
+ // `langgraph` does not need this ambiguity guard — see above).
313
+ const ns = checkpointNs(metadata);
314
+ const nsCandidates = ns ? this.checkpointNsToolSpans.get(ns) : undefined;
315
+ const nsToolSpan = nsCandidates?.length === 1 ? nsCandidates[0] : undefined;
316
+ const effectiveParent = nsToolSpan ?? parentSpan;
317
+ const parentCtx = effectiveParent
318
+ ? api_1.trace.setSpan(api_1.context.active(), effectiveParent)
141
319
  : api_1.context.active();
142
320
  // Span creation can fail (custom tracer / broken context). Skip
143
321
  // run-state insertion when it does — end/error callbacks already
@@ -152,14 +330,25 @@ class StructCallbackHandler {
152
330
  const startedSpan = span;
153
331
  (0, core_js_1.safe)(() => {
154
332
  startedSpan.setAttribute(semconv_js_1.GEN_AI.OPERATION_NAME, "invoke_agent");
155
- startedSpan.setAttribute(semconv_js_1.GEN_AI.PROVIDER_NAME, "langchain");
333
+ // gen_ai.provider.name is stamped later by the first chat run under
334
+ // this agent (write-once) — "langchain" is a framework, not a
335
+ // provider, and the real one isn't known at chain start.
156
336
  startedSpan.setAttribute(semconv_js_1.GEN_AI.AGENT_NAME, String(agentName));
157
337
  // Do NOT set gen_ai.agent.id from sessionId — that conflates agent
158
338
  // identity (spec: stable agent-definition id) with a per-invocation
159
339
  // session. LangChain doesn't surface a stable agent-id to us, so we
160
340
  // leave the attribute unset. Callers who use struct.agent() directly
161
341
  // can pass their own agentId via the scope options.
162
- startedSpan.setAttribute(semconv_js_1.GEN_AI.CONVERSATION_ID, sessionId);
342
+ if (sessionId)
343
+ startedSpan.setAttribute(semconv_js_1.GEN_AI.CONVERSATION_ID, sessionId);
344
+ // A subagent may supply its own local thread_id distinct from the
345
+ // inherited conversation.id (e.g. its own LangGraph checkpoint key).
346
+ // Preserve it for observability WITHOUT letting it split the
347
+ // session — struct.agent.thread_id is non-grouping metadata.
348
+ const localThread = metadataThreadId(metadata);
349
+ if (localThread && localThread !== sessionId) {
350
+ startedSpan.setAttribute(semconv_js_1.STRUCT.AGENT_THREAD_ID, localThread);
351
+ }
163
352
  // Link subagents back to the parent agent's session. Use the nearest
164
353
  // agent ancestor (NOT the immediate parent run) — a subagent spawned
165
354
  // from inside a tool has the tool as its immediate parent, but we want
@@ -192,6 +381,12 @@ class StructCallbackHandler {
192
381
  sessionId,
193
382
  // This agent IS itself the nearest agent ancestor for everything nested inside.
194
383
  nearestAgentSessionId: sessionId,
384
+ nearestAgentSpan: startedSpan,
385
+ // Unlike nearestAgentSessionId/nearestAgentSpan, this INHERITS rather
386
+ // than owns — a nested subagent (bare mode) shares its ancestor's
387
+ // queue so a tool call fired from deep inside a subagent's own
388
+ // tool-call cycle still resolves against the same run-tree queue.
389
+ ...this.resolveQueueOwnership(parentRunId),
195
390
  kind: "chain",
196
391
  });
197
392
  };
@@ -230,6 +425,17 @@ class StructCallbackHandler {
230
425
  const parentCtx = parentSpan
231
426
  ? api_1.trace.setSpan(api_1.context.active(), parentSpan)
232
427
  : api_1.context.active();
428
+ // CLASS RULE: detection => propagation BEFORE any child-span telemetry —
429
+ // a throwing tracer or failing chat-span write must not leave the healthy
430
+ // ancestor agent provider-less. ("langchain" is the unknown-model
431
+ // fallback sentinel, never propagated.) Parity: python
432
+ // on_chat_model_start stamp_ancestor.
433
+ (0, core_js_1.safe)(() => {
434
+ const providerAncestor = this.inheritedAgentSpan(parentRunId);
435
+ if (providerAncestor && provider !== "langchain") {
436
+ (0, context_js_1.stampProviderOnce)(providerAncestor, provider);
437
+ }
438
+ }, "langchain.handleChatModelStart.stamp_provider", this.internalLogger);
233
439
  let span;
234
440
  (0, core_js_1.safe)(() => {
235
441
  span = this.tracer.startSpan(`chat ${model}`, { kind: api_1.SpanKind.CLIENT }, parentCtx);
@@ -242,7 +448,8 @@ class StructCallbackHandler {
242
448
  startedSpan.setAttribute(semconv_js_1.GEN_AI.OPERATION_NAME, "chat");
243
449
  startedSpan.setAttribute(semconv_js_1.GEN_AI.PROVIDER_NAME, provider);
244
450
  startedSpan.setAttribute(semconv_js_1.GEN_AI.REQUEST_MODEL, String(model));
245
- startedSpan.setAttribute(semconv_js_1.GEN_AI.CONVERSATION_ID, sessionId);
451
+ if (sessionId)
452
+ startedSpan.setAttribute(semconv_js_1.GEN_AI.CONVERSATION_ID, sessionId);
246
453
  setRequestAttrsFromInvocation(startedSpan, extraParams?.invocation_params);
247
454
  const flat = messages.flat();
248
455
  if (flat.length > 0) {
@@ -253,8 +460,13 @@ class StructCallbackHandler {
253
460
  if (this.sdk.emitSpanContent) {
254
461
  startedSpan.setAttribute(semconv_js_1.GEN_AI.INPUT_MESSAGES, (0, langchain_content_js_1.langchainToInputMessages)(flat));
255
462
  }
256
- // Propagate the most recent user message onto the nearest agent span.
257
- const ancestorAgentSpan = this.resolveParent(parentRunId).parentSpan ?? (0, context_js_1.getAgentSpan)();
463
+ // Propagate the most recent user message onto the nearest AGENT
464
+ // span — never a tool/chat span. `resolveParent(...).parentSpan`
465
+ // is the immediate parent (which may be a tool span when the chat
466
+ // fires from inside a tool body), so we must use the cached
467
+ // nearest-agent-ancestor lookup instead. Parity: python
468
+ // `_find_agent_ancestor` used at `on_chat_model_start` (:905-907).
469
+ const ancestorAgentSpan = this.inheritedAgentSpan(parentRunId);
258
470
  if (ancestorAgentSpan)
259
471
  this.propagateUserPrompt(ancestorAgentSpan, flat);
260
472
  }
@@ -264,6 +476,13 @@ class StructCallbackHandler {
264
476
  effectiveParentSpan: startedSpan,
265
477
  sessionId,
266
478
  nearestAgentSessionId: this.inheritedAgentSessionId(parentRunId),
479
+ nearestAgentSpan: this.inheritedAgentSpan(parentRunId),
480
+ // No `parentRunId` here means a graph-less bare chat flow (e.g. a
481
+ // bound-tools chat model invoked directly, no AgentExecutor/graph
482
+ // wrapping it) — this run IS the top level, so it gets a fresh queue
483
+ // when bare, exactly like handleChainStart's top-level case.
484
+ ...this.resolveQueueOwnership(parentRunId),
485
+ forcedToolName: extractForcedToolName(extraParams?.invocation_params),
267
486
  kind: "llm",
268
487
  });
269
488
  };
@@ -298,17 +517,42 @@ class StructCallbackHandler {
298
517
  if (!Array.isArray(toolCalls) || toolCalls.length === 0)
299
518
  return;
300
519
  const pairs = [];
520
+ let forcedToolConsumed = false;
301
521
  for (const tc of toolCalls) {
302
522
  if (tc && typeof tc === "object") {
303
523
  const n = tc.name;
304
524
  const id = tc.id;
305
525
  if (typeof n === "string" && typeof id === "string" && n && id) {
526
+ // A forced tool_choice (structured output extraction) is not a
527
+ // real tool call the agent should autofill via @struct.tool() —
528
+ // exclude it from the pending queue. Parity: python forced-tool
529
+ // exclusion in `on_llm_end` (langchain.py:998-1027).
530
+ if (n === r.forcedToolName) {
531
+ forcedToolConsumed = true;
532
+ continue;
533
+ }
306
534
  pairs.push([n, id]);
307
535
  }
308
536
  }
309
537
  }
310
- if (pairs.length > 0)
311
- (0, context_js_1.pushPendingToolCalls)(pairs);
538
+ if (pairs.length > 0 && r.toolCallQueue) {
539
+ // Push directly onto the RunState-PINNED queue OBJECT (decided
540
+ // once at the run-tree root and inherited by reference — see
541
+ // RunState's `toolCallQueue` doc comment), NOT via the
542
+ // ALS-reading `pushPendingToolCalls` helper: this run's callback
543
+ // may fire in a lost/foreign ALS frame, and reading/writing the
544
+ // pinned object directly sidesteps that entirely (in manual mode
545
+ // this object IS the same one context.ts's ALS-scoped helpers
546
+ // read, since it was captured via `getPendingToolCalls()` at the
547
+ // run-tree root — see `resolveQueueOwnership`).
548
+ const q = r.toolCallQueue;
549
+ for (const [name, id] of pairs) {
550
+ (q[name] ??= []).push(id);
551
+ }
552
+ }
553
+ if (forcedToolConsumed) {
554
+ span.setAttribute(semconv_js_1.GEN_AI.OUTPUT_TYPE, "json");
555
+ }
312
556
  }, "langchain.handleLLMEnd.record_pending_tool_calls", this.internalLogger);
313
557
  (0, core_js_1.safe)(() => span.setStatus({ code: api_1.SpanStatusCode.OK }), "langchain.handleLLMEnd.set_status", this.internalLogger);
314
558
  (0, core_js_1.safe)(() => span.end(), "langchain.handleLLMEnd.span_end", this.internalLogger);
@@ -340,33 +584,86 @@ class StructCallbackHandler {
340
584
  return;
341
585
  const startedSpan = span;
342
586
  const sessionId = this.resolveSessionId(parentRunId, metadata);
587
+ const queueOwnership = this.resolveQueueOwnership(parentRunId);
343
588
  (0, core_js_1.safe)(() => {
344
589
  startedSpan.setAttribute(semconv_js_1.GEN_AI.OPERATION_NAME, "execute_tool");
345
- startedSpan.setAttribute(semconv_js_1.GEN_AI.PROVIDER_NAME, "langchain");
590
+ // No gen_ai.provider.name: the spec's execute_tool span does not
591
+ // define that attribute.
346
592
  startedSpan.setAttribute(semconv_js_1.GEN_AI.TOOL_NAME, String(toolName));
347
- // Tool call id from metadata (LangChain passes it there for ToolCall inputs)
348
- // or fallback to the pending queue populated by the LLM's tool_calls.
593
+ // Tool call id from metadata (LangChain passes it there for ToolCall
594
+ // inputs) or fallback to the pending queue populated by the LLM's
595
+ // tool_calls — popped directly off THIS run's PINNED queue OBJECT
596
+ // (`queueOwnership.toolCallQueue`, inherited from the run-tree root —
597
+ // see RunState's `toolCallQueue` doc comment), NOT via the
598
+ // ALS-reading `popPendingToolCallId` helper: this callback may fire
599
+ // in a lost/foreign ALS frame, and popping from the pinned object
600
+ // directly sidesteps that entirely (in manual mode this object IS
601
+ // the same one context.ts's ALS-scoped helpers read/write, since it
602
+ // was captured via `getPendingToolCalls()` at the run-tree root).
349
603
  const callId = metadata?.tool_call_id ??
350
604
  extractToolCallIdFromInput(input) ??
351
- (0, context_js_1.popPendingToolCallId)(String(toolName));
605
+ popFromQueue(queueOwnership.toolCallQueue, String(toolName));
352
606
  if (callId)
353
607
  startedSpan.setAttribute(semconv_js_1.GEN_AI.TOOL_CALL_ID, callId);
354
- startedSpan.setAttribute(semconv_js_1.GEN_AI.CONVERSATION_ID, sessionId);
608
+ if (sessionId)
609
+ startedSpan.setAttribute(semconv_js_1.GEN_AI.CONVERSATION_ID, sessionId);
355
610
  if (this.sdk.captureContent && input !== undefined) {
356
611
  startedSpan.setAttribute(semconv_js_1.GEN_AI.TOOL_CALL_ARGUMENTS, (0, truncation_js_1.safeJsonStringify)(input).slice(0, 8192));
357
612
  }
358
613
  }, "langchain.handleToolStart.set_attrs", this.internalLogger);
614
+ const tool_ns = checkpointNs(metadata);
359
615
  this.runs.set(runId, {
360
616
  span: startedSpan,
361
617
  effectiveParentSpan: startedSpan,
362
618
  sessionId,
363
619
  nearestAgentSessionId: this.inheritedAgentSessionId(parentRunId),
620
+ nearestAgentSpan: this.inheritedAgentSpan(parentRunId),
621
+ // Carried forward (not re-resolved) so a bare sub-agent spawned from
622
+ // inside this tool call inherits the SAME run-tree queue and pinned
623
+ // ownership.
624
+ ...queueOwnership,
625
+ checkpointNs: tool_ns,
364
626
  kind: "tool",
365
627
  });
628
+ // Index by checkpoint ns so a sub-agent graph triggered inside this tool
629
+ // (sharing this exact ns) can re-parent its invoke_agent span under us —
630
+ // PUSH onto the ns's candidate list rather than overwrite it, since
631
+ // `@langchain/langgraph@0.2.x` can hand two parallel same-named tool
632
+ // calls the identical ns (see `checkpointNsToolSpans`'s doc comment).
633
+ // Consume-side (`handleChainStart`) only acts when exactly one candidate
634
+ // is live for the ns; a 2+ list here is exactly the collision case it's
635
+ // built to detect.
636
+ if (tool_ns !== undefined) {
637
+ const bucket = this.checkpointNsToolSpans.get(tool_ns);
638
+ if (bucket)
639
+ bucket.push(startedSpan);
640
+ else
641
+ this.checkpointNsToolSpans.set(tool_ns, [startedSpan]);
642
+ }
366
643
  };
644
+ /**
645
+ * Remove one specific span from its checkpoint-ns bucket (not the whole
646
+ * bucket) — a sibling parallel tool call under the same colliding ns may
647
+ * still be live and must keep its own candidate-list membership. Deletes
648
+ * the ns key entirely once its bucket is empty so the map never grows
649
+ * unboundedly.
650
+ */
651
+ evictCheckpointNsSpan(ns, span) {
652
+ if (ns === undefined || span === undefined)
653
+ return;
654
+ const bucket = this.checkpointNsToolSpans.get(ns);
655
+ if (!bucket)
656
+ return;
657
+ const idx = bucket.indexOf(span);
658
+ if (idx !== -1)
659
+ bucket.splice(idx, 1);
660
+ if (bucket.length === 0)
661
+ this.checkpointNsToolSpans.delete(ns);
662
+ }
367
663
  handleToolEnd = (output, runId) => {
368
664
  const r = this.runs.get(runId);
369
665
  this.runs.delete(runId);
666
+ this.evictCheckpointNsSpan(r?.checkpointNs, r?.span);
370
667
  if (!r || !r.span)
371
668
  return;
372
669
  const span = r.span;
@@ -375,12 +672,24 @@ class StructCallbackHandler {
375
672
  span.setAttribute(semconv_js_1.GEN_AI.TOOL_CALL_RESULT, (0, truncation_js_1.safeJsonStringify)(output).slice(0, 8192));
376
673
  }
377
674
  }, "langchain.handleToolEnd.set_result", this.internalLogger);
378
- (0, core_js_1.safe)(() => span.setStatus({ code: api_1.SpanStatusCode.OK }), "langchain.handleToolEnd.set_status", this.internalLogger);
675
+ (0, core_js_1.safe)(() => {
676
+ if (toolOutputSignalsError(output)) {
677
+ span.setAttribute(semconv_js_1.ERROR_TYPE, "tool_error");
678
+ span.setStatus({
679
+ code: api_1.SpanStatusCode.ERROR,
680
+ message: "tool returned an error result",
681
+ });
682
+ }
683
+ else {
684
+ span.setStatus({ code: api_1.SpanStatusCode.OK });
685
+ }
686
+ }, "langchain.handleToolEnd.set_status", this.internalLogger);
379
687
  (0, core_js_1.safe)(() => span.end(), "langchain.handleToolEnd.span_end", this.internalLogger);
380
688
  };
381
689
  handleToolError = (err, runId) => {
382
690
  const r = this.runs.get(runId);
383
691
  this.runs.delete(runId);
692
+ this.evictCheckpointNsSpan(r?.checkpointNs, r?.span);
384
693
  if (!r || !r.span)
385
694
  return;
386
695
  const span = r.span;
@@ -407,9 +716,11 @@ class StructCallbackHandler {
407
716
  const sessionId = this.resolveSessionId(parentRunId, metadata);
408
717
  (0, core_js_1.safe)(() => {
409
718
  startedSpan.setAttribute(semconv_js_1.GEN_AI.OPERATION_NAME, "retrieval");
410
- startedSpan.setAttribute(semconv_js_1.GEN_AI.PROVIDER_NAME, "langchain");
719
+ // No gen_ai.provider.name: not an inference span, and "langchain"
720
+ // isn't a provider.
411
721
  startedSpan.setAttribute(semconv_js_1.GEN_AI.DATA_SOURCE_ID, String(name));
412
- startedSpan.setAttribute(semconv_js_1.GEN_AI.CONVERSATION_ID, sessionId);
722
+ if (sessionId)
723
+ startedSpan.setAttribute(semconv_js_1.GEN_AI.CONVERSATION_ID, sessionId);
413
724
  if (this.sdk.captureContent && typeof query === "string") {
414
725
  startedSpan.setAttribute(semconv_js_1.GEN_AI.RETRIEVAL_QUERY_TEXT, query.slice(0, 4096));
415
726
  }
@@ -419,6 +730,8 @@ class StructCallbackHandler {
419
730
  effectiveParentSpan: startedSpan,
420
731
  sessionId,
421
732
  nearestAgentSessionId: this.inheritedAgentSessionId(parentRunId),
733
+ nearestAgentSpan: this.inheritedAgentSpan(parentRunId),
734
+ ...this.resolveQueueOwnership(parentRunId),
422
735
  kind: "retriever",
423
736
  });
424
737
  };
@@ -454,7 +767,7 @@ class StructCallbackHandler {
454
767
  // filtered chains (RunnableSequence, ChannelWrite, ...).
455
768
  return {
456
769
  parentSpan: p.effectiveParentSpan,
457
- parentSpanIsAgent: p.kind === "chain",
770
+ parentSpanIsAgent: p.kind === "chain" || p.kind === "suppressed-twin",
458
771
  };
459
772
  }
460
773
  }
@@ -462,7 +775,11 @@ class StructCallbackHandler {
462
775
  }
463
776
  /**
464
777
  * Conversation-id resolution for chat/tool/retriever spans — INHERIT from
465
- * the parent agent so everything rolls up under one gen_ai.conversation.id.
778
+ * the parent run so everything rolls up under one gen_ai.conversation.id.
779
+ * Returns `undefined` (never fabricates a UUID) when no ancestor run,
780
+ * metadata thread_id, or ambient session supplies one — an orphan span
781
+ * with no conversation.id is preferable to a fake one that can never be
782
+ * joined to anything else (parity: python `_resolve_session_id`).
466
783
  */
467
784
  resolveSessionId(parentRunId, metadata) {
468
785
  if (parentRunId) {
@@ -473,10 +790,7 @@ class StructCallbackHandler {
473
790
  const threadId = metadataThreadId(metadata);
474
791
  if (threadId)
475
792
  return threadId;
476
- const ambient = (0, context_js_1.getSessionId)();
477
- if (ambient)
478
- return ambient;
479
- return (0, node_crypto_1.randomUUID)();
793
+ return (0, context_js_1.getSessionId)();
480
794
  }
481
795
  /**
482
796
  * Walk the run tree to find the nearest ``invoke_agent`` ancestor's
@@ -496,32 +810,137 @@ class StructCallbackHandler {
496
810
  return this.runs.get(parentRunId)?.nearestAgentSessionId;
497
811
  }
498
812
  /**
499
- * Conversation-id resolution for AGENT spans — each agent invocation gets
500
- * its own gen_ai.conversation.id. We deliberately do NOT inherit from the
501
- * parent run, so subagents surface as separate entries in the sessions
502
- * list. Parent linkage is preserved via `struct.agent.parent_session_id`.
813
+ * Nearest ``invoke_agent`` ancestor SPAN — O(1) lookup, mirrors
814
+ * ``inheritedAgentSessionId`` for the span pointer itself. Cached at every
815
+ * RunState creation so descendants (and prompt propagation) can reach the
816
+ * ancestor without walking a parent chain we don't retain.
817
+ *
818
+ * Unlike ``inheritedAgentSessionId``, a missing ``parentRunId`` falls back
819
+ * to the ambient `getAgentSpan()` (matching `resolveParent`'s top-level
820
+ * fallback) rather than `undefined` — a run with no LangChain parent may
821
+ * still be nested inside a manually-created `struct.agent()` span.
822
+ * Parity: python `_inherited_agent_span`.
823
+ */
824
+ inheritedAgentSpan(parentRunId) {
825
+ if (!parentRunId)
826
+ return (0, context_js_1.getAgentSpan)();
827
+ return this.runs.get(parentRunId)?.nearestAgentSpan ?? (0, context_js_1.getAgentSpan)();
828
+ }
829
+ /**
830
+ * Resolve the pending-tool-call queue OBJECT a run should push/pop
831
+ * against for `gen_ai.tool.call.id` autofill, per `RunState.toolCallQueue`'s
832
+ * doc comment.
503
833
  *
504
- * LangChain inherits `metadata.thread_id` from the parent invoke's config
505
- * when a nested invoke runs inside it (even if the child config supplied
506
- * its own). We detect that inheritance by comparing against the parent's
507
- * resolved session and ignore the inherited value.
834
+ * PINNED AT THE RUN-TREE ROOT, INHERITED DOWN AS THE SAME OBJECT
835
+ * REFERENCE — this is the crux of the design, and it is the OBJECT that
836
+ * must be pinned, not merely a manual-vs-bare boolean. Whenever a parent
837
+ * run is known (`parentRunId` resolves to a live `RunState`), we copy its
838
+ * `toolCallQueue` reference VERBATIM rather than re-deriving anything from
839
+ * the live ALS frame. Only a run with no resolvable parent (a genuine
840
+ * root, or an orphaned `parentRunId` whose `RunState` was never
841
+ * registered) consults the live `getManualAgentSpan()` signal — and even
842
+ * then, only ONCE, at that run's creation.
843
+ *
844
+ * Why the OBJECT, not just a boolean: a descendant run's OWN callback can
845
+ * fire in a lost or foreign ALS frame — the LangChain FRAMEWORK layer
846
+ * (`langchain.ts`'s `BaseChatModel.generate`/`.stream` patch) opens its
847
+ * own short-lived ALS scope (`runWithContext({ suppressGenAi: true },
848
+ * fn)`) around each chat completion, entirely independent of any customer
849
+ * `struct.agent()` call; more generally, LangGraph's internal scheduling
850
+ * can run a node's callback outside the async context that originally
851
+ * held the manual scope. A design that pins only a manual-vs-bare
852
+ * ownership BOOLEAN but still routes the actual push (`handleLLMEnd`) and
853
+ * pop (`handleToolStart`) through the ALS-reading
854
+ * `pushPendingToolCalls`/`popPendingToolCallId` helpers (context.ts) is
855
+ * still broken: those helpers read whatever `pendingToolCalls` object is
856
+ * on the CURRENT frame, so a push in one lost/foreign frame and a pop in a
857
+ * DIFFERENT lost/foreign frame silently disagree, even though both frames
858
+ * correctly resolved the same boolean. Operating directly on the
859
+ * RunState-pinned `toolCallQueue` object sidesteps the ALS frame entirely
860
+ * for both operations: whatever object the root observed is what the
861
+ * whole run tree pushes into and pops from, regardless of ALS frame state
862
+ * at either callback.
863
+ *
864
+ * Three cases:
865
+ * 1. `parentRunId` resolves to a registered parent `RunState` — inherit
866
+ * `toolCallQueue` from it verbatim (decided once at the tree root) —
867
+ * UNLESS a NEW manual scope began mid-tree (see FIX F below).
868
+ * 2. No parent (root, or an orphaned `parentRunId`) AND a MANUAL
869
+ * `struct.agent()` scope is live right now (`getManualAgentSpan()`
870
+ * set) — capture `getPendingToolCalls()`, the SAME object
871
+ * `struct.agent()` seeded onto the ALS store and that manual
872
+ * `struct.tool()` calls pop from via `popPendingToolCallId`
873
+ * (context.ts). Captured IN-FRAME here (this callback necessarily
874
+ * runs inside the live manual scope), so the RunState-pinned
875
+ * reference and the ALS store's `pendingToolCalls` stay identical.
876
+ * 3. No parent AND no manual scope — this run IS the top of a bare run
877
+ * tree; mint a fresh queue `{}`.
878
+ *
879
+ * Self-audit round 5 (FIX F): case 1's "inherit verbatim" is only correct
880
+ * when the parent's queue was captured under the SAME manual ownership
881
+ * that is live right now. A NESTED manual `struct.agent()` — opened from
882
+ * inside a tool body, whose own LangChain graph LangChain threads as a
883
+ * CHILD of the outer run tree, parented on a REAL tool span rather than
884
+ * reaching the manual span through skipped chains (so it never reaches
885
+ * the suppressed-twin branch in `handleChainStart`) — would otherwise
886
+ * silently inherit the OUTER run's
887
+ * queue object, even though the CURRENT ambient `getManualAgentSpan()` is
888
+ * the INNER agent's own (different) span and the inner agent's own
889
+ * `pendingToolCalls` (a fresh object seeded by that inner `struct.agent()`
890
+ * call) is what its descendants actually push/pop against. Detect this by
891
+ * comparing the live `getManualAgentSpan()` against the PARENT run's
892
+ * `queueManualSpan` (the manual span that owned the parent's queue): if
893
+ * they differ and the live one is truthy, a new manual scope has begun —
894
+ * re-capture `getPendingToolCalls()` fresh, exactly like case 2. When they
895
+ * match (including both `undefined`, i.e. bare on both sides — preserves
896
+ * the F2 frame-independence fix, where a merely lost/foreign ALS frame
897
+ * reads as `undefined` and must NOT trigger a spurious re-capture),
898
+ * inherit verbatim as before.
899
+ */
900
+ resolveQueueOwnership(parentRunId) {
901
+ const manual = (0, context_js_1.getManualAgentSpan)();
902
+ if (parentRunId) {
903
+ const parent = this.runs.get(parentRunId);
904
+ if (parent) {
905
+ if (manual && manual !== parent.queueManualSpan) {
906
+ return { toolCallQueue: (0, context_js_1.getPendingToolCalls)() ?? {}, queueManualSpan: manual };
907
+ }
908
+ return { toolCallQueue: parent.toolCallQueue, queueManualSpan: parent.queueManualSpan };
909
+ }
910
+ }
911
+ return {
912
+ toolCallQueue: manual ? ((0, context_js_1.getPendingToolCalls)() ?? {}) : {},
913
+ queueManualSpan: manual,
914
+ };
915
+ }
916
+ /**
917
+ * Conversation-id resolution for AGENT spans — INHERIT from the parent
918
+ * run first, so a subagent shares its outer agent's gen_ai.conversation.id
919
+ * (one id per run; parity: python `_resolve_agent_session_id`). Only when
920
+ * there's no parent session do we fall back to metadata.thread_id, the
921
+ * ambient session, or — as a last resort, since agent spans should always
922
+ * have a coherent id — a fresh UUID. A subagent's own divergent thread_id
923
+ * (if any) is preserved separately as `struct.agent.thread_id`; see the
924
+ * caller in `handleChainStart`.
508
925
  */
509
926
  resolveAgentSessionId(metadata, parentSessionId) {
927
+ if (parentSessionId)
928
+ return parentSessionId; // inherit — one id per run
510
929
  const threadId = metadataThreadId(metadata);
511
- if (threadId) {
512
- if (parentSessionId && threadId === parentSessionId) {
513
- // Inherited from parent — treat as unset, assign fresh.
514
- return (0, node_crypto_1.randomUUID)();
515
- }
930
+ if (threadId)
516
931
  return threadId;
517
- }
518
932
  const ambient = (0, context_js_1.getSessionId)();
519
933
  if (ambient)
520
934
  return ambient;
521
- return (0, node_crypto_1.randomUUID)();
935
+ return (0, node_crypto_1.randomUUID)(); // agents always get a coherent id
522
936
  }
523
937
  propagateUserPrompt(parentSpan, messages) {
524
938
  try {
939
+ // Parent-prompt preview is CONTENT — never emit it in ContentCaptureMode
940
+ // .None (same gate as the anthropic/openai provider paths; kept in
941
+ // EventOnly deliberately: shipped behavior the waterfall UI reads).
942
+ if (!this.sdk.captureContent)
943
+ return;
525
944
  const attrs = parentSpan
526
945
  .attributes;
527
946
  if (attrs && attrs[semconv_js_1.GEN_AI.INPUT_MESSAGES])
@@ -554,6 +973,77 @@ const AGENT_CLASSES = new Set([
554
973
  "Pregel",
555
974
  "LangGraph",
556
975
  ]);
976
+ /**
977
+ * LangChain/LangGraph fires chain-start for every internal Runnable. In
978
+ * Python, ``serialized`` is usually ``None`` for these, so we filter on
979
+ * run_name via a denylist. Matches LangSmith's promotion heuristic.
980
+ * Parity: python `_INTERNAL_RUN_NAMES` (langchain.py:267-306).
981
+ */
982
+ const INTERNAL_RUN_NAMES = new Set([
983
+ // Runnable wiring/plumbing
984
+ "RunnableSequence",
985
+ "RunnableLambda",
986
+ "RunnablePassthrough",
987
+ "RunnableParallel",
988
+ "RunnableBinding",
989
+ "RunnableMap",
990
+ "RunnableAssign",
991
+ "RunnableBranch",
992
+ "RunnableWithFallbacks",
993
+ "RunnableEach",
994
+ "RunnablePick",
995
+ "RunnableGenerator",
996
+ // Prompt templates
997
+ "Prompt",
998
+ "ChatPromptTemplate",
999
+ "PromptTemplate",
1000
+ // langchain.agents (1.x) / legacy create_react_agent internal node names
1001
+ "agent",
1002
+ "tools",
1003
+ "call_model",
1004
+ "should_continue",
1005
+ "__start__",
1006
+ "__end__",
1007
+ // Output parsers — invoked as Runnables but not agents. LangChain's
1008
+ // ``langchain.agents.create_agent`` with ``ToolStrategy`` (or fallback
1009
+ // from ``ProviderStrategy`` on models without native structured output)
1010
+ // invokes these as a separate step and they fire on_chain_start.
1011
+ "PydanticToolsParser",
1012
+ "PydanticOutputParser",
1013
+ "JsonOutputParser",
1014
+ "JsonOutputToolsParser",
1015
+ "JsonOutputKeyToolsParser",
1016
+ "StrOutputParser",
1017
+ "OutputParser",
1018
+ "BaseOutputParser",
1019
+ "OpenAIToolsAgentOutputParser",
1020
+ "OpenAIFunctionsAgentOutputParser",
1021
+ // TS-ecosystem addition beyond the Python `_INTERNAL_RUN_NAMES` list: this
1022
+ // class only exists in @langchain/anthropic (JS), not in Python LangChain,
1023
+ // so the byte-identical port doesn't cover it. `ChatAnthropic
1024
+ // .withStructuredOutput(schema)` (without `include_raw`) internally chains
1025
+ // through @langchain/anthropic's AnthropicToolsOutputParser
1026
+ // (dist/output_parsers.js), which fires on_chain_start with this run name.
1027
+ // Do not remove this during a cross-SDK denylist diff against Python.
1028
+ "AnthropicToolsOutputParser",
1029
+ // TS-ecosystem addition beyond Python's `_INTERNAL_RUN_NAMES`: needed for
1030
+ // @langchain/langgraph 0.2.x internal sub-chains. `createReactAgent`'s
1031
+ // prompt-application step (dist/prebuilt/react_agent_executor.js,
1032
+ // `PROMPT_RUNNABLE_NAME = "prompt"`) is wired via
1033
+ // `RunnableLambda.from(...).withConfig({ runName: "prompt" })` and invoked
1034
+ // as a step INSIDE the "agent" Pregel node — so its own
1035
+ // `metadata.langgraph_node` is stamped with the PARENT node's name (e.g.
1036
+ // "agent"), not "prompt" itself, and the self-match rule (3 above) can't
1037
+ // suppress it. Confirmed live: this fires on_chain_start once per model
1038
+ // turn on installed @langchain/langgraph@0.2.74, producing spurious
1039
+ // `invoke_agent prompt` spans (live-parity REPORT.md). Python's
1040
+ // currently-resolved langgraph (1.2.0, via `create_agent`) doesn't hit
1041
+ // this — a LangGraph major-version confound, not something struct-sdk
1042
+ // controls. Do not remove this during a cross-SDK denylist diff against
1043
+ // Python.
1044
+ "prompt",
1045
+ ]);
1046
+ const INTERNAL_RUN_NAME_PREFIXES = ["ChannelWrite<", "Branch<", "RunnableSequence<"];
557
1047
  /**
558
1048
  * Threading-id metadata keys, in resolution order.
559
1049
  *
@@ -580,11 +1070,67 @@ function metadataThreadId(metadata) {
580
1070
  }
581
1071
  return undefined;
582
1072
  }
583
- function isAgentChain(chain, runType, _runName) {
1073
+ /**
1074
+ * LangGraph stamps a unique `langgraph_checkpoint_ns` (`tools:<uuid>`) on
1075
+ * each tool-call branch, and the SAME value on the sub-agent graph that the
1076
+ * tool triggers — even across parallel same-named tool calls. Used to
1077
+ * re-parent a sub-agent's `invoke_agent` span under its triggering
1078
+ * `execute_tool` span. Returns the namespace, or `undefined` if absent.
1079
+ * Parity: python `_checkpoint_ns` (langchain.py:325-341).
1080
+ */
1081
+ function checkpointNs(metadata) {
1082
+ if (!metadata || typeof metadata !== "object")
1083
+ return undefined;
1084
+ const ns = metadata["langgraph_checkpoint_ns"];
1085
+ return typeof ns === "string" && ns.length > 0 ? ns : undefined;
1086
+ }
1087
+ /**
1088
+ * Only promote user-meaningful chains to `invoke_agent` spans.
1089
+ *
1090
+ * Decision order (parity: python `_is_agent_chain`, langchain.py:360-407):
1091
+ *
1092
+ * 1. Explicit `runType === "agent"` (legacy AgentExecutor) → agent.
1093
+ * 2. `chain` class identifies a Pregel/CompiledStateGraph → agent.
1094
+ * 3. `metadata.langgraph_node === runName` → INTERNAL Pregel node (every
1095
+ * internal step of a `create_agent` Pregel fires chain-start with
1096
+ * metadata.langgraph_node set to its node name; for real top-level
1097
+ * agents or sub-agents the names differ or langgraph_node is absent).
1098
+ * 4. Known LangChain plumbing run names (denylist) → not agent.
1099
+ * 5. Otherwise, if there's a runName → agent (user-named chain).
1100
+ */
1101
+ function isAgentChain(chain, runType, runName, metadata) {
584
1102
  if (runType === "agent")
585
1103
  return true;
586
1104
  const cls = extractClassName(chain);
587
- return !!cls && AGENT_CLASSES.has(cls);
1105
+ if (cls && AGENT_CLASSES.has(cls))
1106
+ return true;
1107
+ // Internal Pregel node detection: LangGraph populates metadata with
1108
+ // `langgraph_node` (and `langgraph_step`) on every internal node
1109
+ // callback. The run_name of an internal node matches its langgraph_node;
1110
+ // for the top-level Pregel invocation, langgraph_node is absent; for a
1111
+ // sub-agent invoked from a tool body, langgraph_node may be set BUT
1112
+ // contains the *parent's* node name (e.g. "tools"), which differs from
1113
+ // the sub-agent's own run_name. So equality is the discriminator.
1114
+ if (metadata && runName) {
1115
+ const lgNode = metadata["langgraph_node"];
1116
+ if (typeof lgNode === "string" && lgNode && lgNode === runName)
1117
+ return false;
1118
+ }
1119
+ if (runName) {
1120
+ // LangChain names parametrized runnables `Base<...>` — e.g.
1121
+ // `RunnableParallel<raw>` and `RunnableAssign<parsed,parsing_error>`
1122
+ // from `with_structured_output(include_raw=True)`. Strip the `<...>`
1123
+ // so the base class matches the denylist instead of falling through to
1124
+ // the user-named-chain promotion below (a real production phantom-agent
1125
+ // bug found via a customer's structured-output topology).
1126
+ const baseName = runName.split("<", 1)[0];
1127
+ if (INTERNAL_RUN_NAMES.has(baseName))
1128
+ return false;
1129
+ if (INTERNAL_RUN_NAME_PREFIXES.some((p) => runName.startsWith(p)))
1130
+ return false;
1131
+ return true; // user-named chain → promoted
1132
+ }
1133
+ return false;
588
1134
  }
589
1135
  function extractClassName(obj) {
590
1136
  if (!obj)
@@ -601,14 +1147,77 @@ function extractClassName(obj) {
601
1147
  function detectProviderFromSerialized(llm) {
602
1148
  const cls = extractClassName(llm) ?? "";
603
1149
  const fake = { constructor: { name: cls }, _llmType: undefined };
604
- return (0, langchain_content_js_1.detectProvider)(fake);
1150
+ const byClass = (0, langchain_content_js_1.detectProvider)(fake);
1151
+ if (byClass !== "langchain")
1152
+ return byClass;
1153
+ // Module-path fallback (parity with python's _detect_provider_from_serialized):
1154
+ // an unknown/wrapped class under a known provider module (e.g.
1155
+ // langchain_openai) still resolves to the real provider.
1156
+ const ids = llm.id;
1157
+ const modulePath = Array.isArray(ids) && typeof ids[0] === "string" ? ids[0] : "";
1158
+ // SEGMENT-exact matching, scoped to langchain partner packages — substring
1159
+ // classified lookalikes (langchain_notopenai -> openai), and the value gets
1160
+ // write-once stamped onto agent spans, so a false positive is sticky.
1161
+ if (modulePath === "langchain" ||
1162
+ modulePath.startsWith("langchain_") ||
1163
+ modulePath.startsWith("langchain.")) {
1164
+ const segments = modulePath.split(/[._]/).filter(Boolean);
1165
+ for (const [key, provider] of Object.entries(langchain_content_js_1.MODULE_PROVIDER_MAP)) {
1166
+ if (segments.includes(key))
1167
+ return provider;
1168
+ }
1169
+ }
1170
+ return "langchain";
605
1171
  }
1172
+ /** @internal */
1173
+ exports._detectProviderFromSerializedForTest = detectProviderFromSerialized;
606
1174
  function extractParam(obj, key) {
607
1175
  if (!obj || typeof obj !== "object")
608
1176
  return undefined;
609
1177
  const v = obj[key];
610
1178
  return typeof v === "string" && v.length > 0 ? v : undefined;
611
1179
  }
1180
+ /**
1181
+ * Extract the tool name the model was forced to call via `tool_choice`, if
1182
+ * any. Handles both provider shapes LangChain forwards in
1183
+ * `invocation_params.tool_choice`:
1184
+ * - Anthropic: `{type:"tool", name}`
1185
+ * - OpenAI: `{type:"function", function:{name}}`
1186
+ * `invocation` is unknown-typed (LangChain's `extraParams.invocation_params`
1187
+ * is untyped provider passthrough) — every layer is defensively checked.
1188
+ * Parity: python `_forced_tool_name` (langchain.py:410-432).
1189
+ */
1190
+ function extractForcedToolName(invocation) {
1191
+ if (!invocation || typeof invocation !== "object")
1192
+ return undefined;
1193
+ const tc = invocation["tool_choice"];
1194
+ if (!tc || typeof tc !== "object")
1195
+ return undefined;
1196
+ const t = tc;
1197
+ if (t.type === "tool" && typeof t.name === "string")
1198
+ return t.name;
1199
+ if (t.type === "function") {
1200
+ const fn = t.function;
1201
+ if (fn && typeof fn === "object" && typeof fn.name === "string") {
1202
+ return fn.name;
1203
+ }
1204
+ }
1205
+ return undefined;
1206
+ }
1207
+ /**
1208
+ * `@langchain/anthropic` (JS) keys whose value is a negative sentinel
1209
+ * meaning "not set by the caller" rather than a real request parameter.
1210
+ * `dist/chat_models.js` defaults BOTH `topK` and `topP` to `-1` and always
1211
+ * includes them in `invocationParams()` as `top_k`/`top_p` — unlike
1212
+ * Python's `ChatAnthropic`, which never forwards `top_k`/`top_p` unless the
1213
+ * caller genuinely set them. Confirmed live: TS was stamping
1214
+ * `gen_ai.request.top_k = -1` on every chat span (live-parity REPORT.md).
1215
+ * Scoped tightly to these two keys — do NOT extend blanket-negative
1216
+ * filtering to `frequencyPenalty`/`presencePenalty`, which are legitimately
1217
+ * negative in OpenAI's real range (-2.0..2.0). JS-ecosystem sentinel;
1218
+ * python parity: absent-when-unset.
1219
+ */
1220
+ const NEGATIVE_SENTINEL_KEYS = new Set(["topK", "top_k", "topP", "top_p"]);
612
1221
  function setRequestAttrsFromInvocation(span, invocation) {
613
1222
  if (!invocation || typeof invocation !== "object")
614
1223
  return;
@@ -626,8 +1235,11 @@ function setRequestAttrsFromInvocation(span, invocation) {
626
1235
  ];
627
1236
  for (const [src, dst] of mapping) {
628
1237
  const v = m[src];
629
- if (typeof v === "number")
630
- span.setAttribute(dst, v);
1238
+ if (typeof v !== "number")
1239
+ continue;
1240
+ if (NEGATIVE_SENTINEL_KEYS.has(src) && v < 0)
1241
+ continue;
1242
+ span.setAttribute(dst, v);
631
1243
  }
632
1244
  const stop = m.stop ?? m.stopSequences ?? m.stop_sequences;
633
1245
  if (Array.isArray(stop) && stop.length > 0) {
@@ -664,11 +1276,24 @@ function setLlmResponseAttrs(span, sdk, logger, message, provider, sessionId) {
664
1276
  const mapped = semconv_js_1.LANGCHAIN_FINISH_REASON_MAP[finish] ?? finish;
665
1277
  span.setAttribute(semconv_js_1.GEN_AI.RESPONSE_FINISH_REASONS, [mapped]);
666
1278
  }
667
- const respId = (typeof m.id === "string" && m.id) ||
668
- (typeof respMeta.id === "string" && respMeta.id) ||
669
- null;
670
- if (respId)
1279
+ // Prefer the provider message id (`msg_...` / `chatcmpl-...`) from
1280
+ // response_metadata over LangChain's own run id. ChatAnthropic (and most
1281
+ // LangChain chat model adapters) place the real API-level id in
1282
+ // `response_metadata.id` while `message.id` carries a LangChain-internal
1283
+ // run id. gen_ai.response.id is used as the duplicate-detection
1284
+ // fingerprint downstream, so the provider id must take priority. The
1285
+ // LangChain run id is preserved under `langchain.run.id` (only when it
1286
+ // diverges) so consumers can still join back to LangChain/LangSmith run
1287
+ // data. Parity: python `_set_llm_response_attrs` (langchain.py:1560-1576).
1288
+ const providerId = typeof respMeta.id === "string" ? respMeta.id : undefined;
1289
+ const lcRunId = typeof m.id === "string" ? m.id : undefined;
1290
+ const respId = providerId ?? lcRunId ?? null;
1291
+ if (respId) {
671
1292
  span.setAttribute(semconv_js_1.GEN_AI.RESPONSE_ID, respId);
1293
+ if (lcRunId && lcRunId !== respId) {
1294
+ span.setAttribute(semconv_js_1.LANGCHAIN.RUN_ID, lcRunId);
1295
+ }
1296
+ }
672
1297
  if (sdk.emitEvents && logger) {
673
1298
  emitChoiceEvent(logger, message, provider ?? "langchain", sessionId, span);
674
1299
  }
@@ -699,9 +1324,9 @@ function emitMessageEvents(logger, messages, provider, sessionId, span) {
699
1324
  attributes: {
700
1325
  [semconv_js_1.EVENT_NAME]: eventName,
701
1326
  body: payload,
702
- [semconv_js_1.GEN_AI.SYSTEM]: provider,
1327
+ [semconv_js_1.GEN_AI.PROVIDER_NAME]: provider,
703
1328
  [semconv_js_1.GEN_AI.MESSAGE_INDEX]: i,
704
- [semconv_js_1.GEN_AI.CONVERSATION_ID]: sessionId,
1329
+ ...(sessionId ? { [semconv_js_1.GEN_AI.CONVERSATION_ID]: sessionId } : {}),
705
1330
  },
706
1331
  });
707
1332
  }
@@ -726,8 +1351,8 @@ function emitChoiceEvent(logger, message, provider, sessionId, span) {
726
1351
  attributes: {
727
1352
  [semconv_js_1.EVENT_NAME]: semconv_js_1.EVENT_NAMES.CHOICE,
728
1353
  body: payload,
729
- [semconv_js_1.GEN_AI.SYSTEM]: provider,
730
- [semconv_js_1.GEN_AI.CONVERSATION_ID]: sessionId,
1354
+ [semconv_js_1.GEN_AI.PROVIDER_NAME]: provider,
1355
+ ...(sessionId ? { [semconv_js_1.GEN_AI.CONVERSATION_ID]: sessionId } : {}),
731
1356
  },
732
1357
  });
733
1358
  }
@@ -747,6 +1372,23 @@ function extractToolCallIdFromInput(input) {
747
1372
  }
748
1373
  return undefined;
749
1374
  }
1375
+ /**
1376
+ * Pop the first pending tool_use id matching `name` from a RunState-pinned
1377
+ * (run-tree-scoped, not ALS-scoped) queue object. FIFO semantics identical
1378
+ * to `context.ts`'s `popPendingToolCallId` — see `RunState.toolCallQueue`.
1379
+ * Used for BOTH manual and bare ownership: in manual mode the queue object
1380
+ * passed in is the same one `getPendingToolCalls()` (context.ts) reads, so
1381
+ * popping here and popping via that ALS-scoped helper observe the same
1382
+ * state.
1383
+ */
1384
+ function popFromQueue(queue, name) {
1385
+ if (!queue)
1386
+ return undefined;
1387
+ const ids = queue[name];
1388
+ if (!ids || ids.length === 0)
1389
+ return undefined;
1390
+ return ids.shift();
1391
+ }
750
1392
  function recordError(span, err) {
751
1393
  const errorType = err instanceof Error ? err.constructor.name : typeof err;
752
1394
  const message = err instanceof Error ? err.message : String(err);
@@ -755,4 +1397,29 @@ function recordError(span, err) {
755
1397
  if (err instanceof Error)
756
1398
  span.recordException(err);
757
1399
  }
1400
+ /**
1401
+ * Whether a LangChain tool OUTPUT signals in-band failure: a
1402
+ * ToolMessage with status "error", or an MCP-style isError/is_error
1403
+ * boolean-true flag on the top-level object. Mirrors python
1404
+ * langchain.on_tool_end semantics — keep in lockstep.
1405
+ */
1406
+ function toolOutputSignalsError(output) {
1407
+ if (typeof output !== "object" || output === null)
1408
+ return false;
1409
+ // Per-key probe isolation: a hostile getter on one key must not mask a
1410
+ // readable sibling. Mirrors python _tool_output_signals_error.
1411
+ return (probe(output, "status") === "error" ||
1412
+ probe(output, "isError") === true ||
1413
+ probe(output, "is_error") === true);
1414
+ }
1415
+ /** Isolated single-property read of untrusted host data (see core.ts
1416
+ * safeProbe — duplicated here because it is module-private there). */
1417
+ function probe(obj, key) {
1418
+ try {
1419
+ return obj[key];
1420
+ }
1421
+ catch {
1422
+ return undefined;
1423
+ }
1424
+ }
758
1425
  //# sourceMappingURL=langchain-callback.js.map