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