@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.
- package/README.md +101 -16
- package/dist/commonjs/context.d.ts +45 -0
- package/dist/commonjs/context.js +78 -1
- package/dist/commonjs/core.js +184 -29
- package/dist/commonjs/events.d.ts +17 -6
- package/dist/commonjs/events.js +82 -59
- package/dist/commonjs/genai-content.d.ts +52 -0
- package/dist/commonjs/genai-content.js +143 -0
- package/dist/commonjs/instrument.d.ts +47 -0
- package/dist/commonjs/instrument.js +158 -0
- package/dist/commonjs/integrations/anthropic-content.js +18 -6
- package/dist/commonjs/integrations/anthropic.d.ts +8 -1
- package/dist/commonjs/integrations/anthropic.js +515 -104
- package/dist/commonjs/integrations/index.js +8 -0
- package/dist/commonjs/integrations/langchain-callback.d.ts +182 -27
- package/dist/commonjs/integrations/langchain-callback.js +754 -87
- package/dist/commonjs/integrations/langchain-content.js +1 -1
- package/dist/commonjs/integrations/langchain.d.ts +3 -0
- package/dist/commonjs/integrations/langchain.js +353 -7
- package/dist/commonjs/integrations/openai-content.d.ts +34 -0
- package/dist/commonjs/integrations/openai-content.js +375 -0
- package/dist/commonjs/integrations/openai.d.ts +39 -0
- package/dist/commonjs/integrations/openai.js +305 -0
- package/dist/commonjs/semconv.d.ts +12 -0
- package/dist/commonjs/semconv.js +13 -1
- package/dist/commonjs/truncation.d.ts +29 -0
- package/dist/commonjs/truncation.js +184 -10
- package/dist/commonjs/version.d.ts +2 -0
- package/dist/commonjs/version.js +6 -0
- package/dist/esm/context.d.ts +45 -0
- package/dist/esm/context.js +74 -1
- package/dist/esm/core.js +185 -30
- package/dist/esm/events.d.ts +17 -6
- package/dist/esm/events.js +82 -61
- package/dist/esm/genai-content.d.ts +52 -0
- package/dist/esm/genai-content.js +137 -0
- package/dist/esm/instrument.d.ts +47 -0
- package/dist/esm/instrument.js +155 -0
- package/dist/esm/integrations/anthropic-content.js +19 -7
- package/dist/esm/integrations/anthropic.d.ts +8 -1
- package/dist/esm/integrations/anthropic.js +514 -107
- package/dist/esm/integrations/index.js +8 -0
- package/dist/esm/integrations/langchain-callback.d.ts +182 -27
- package/dist/esm/integrations/langchain-callback.js +756 -89
- package/dist/esm/integrations/langchain-content.js +1 -1
- package/dist/esm/integrations/langchain.d.ts +3 -0
- package/dist/esm/integrations/langchain.js +352 -7
- package/dist/esm/integrations/openai-content.d.ts +34 -0
- package/dist/esm/integrations/openai-content.js +360 -0
- package/dist/esm/integrations/openai.d.ts +39 -0
- package/dist/esm/integrations/openai.js +296 -0
- package/dist/esm/semconv.d.ts +12 -0
- package/dist/esm/semconv.js +12 -0
- package/dist/esm/truncation.d.ts +29 -0
- package/dist/esm/truncation.js +182 -10
- package/dist/esm/version.d.ts +2 -0
- package/dist/esm/version.js +3 -0
- 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.
|
|
30
|
-
*
|
|
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
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* own
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
-
* -
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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:
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
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
|
|
118
|
-
//
|
|
119
|
-
//
|
|
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
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
348
|
-
// or fallback to the pending queue populated by the LLM's
|
|
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
|
-
(
|
|
605
|
+
popFromQueue(queueOwnership.toolCallQueue, String(toolName));
|
|
352
606
|
if (callId)
|
|
353
607
|
startedSpan.setAttribute(semconv_js_1.GEN_AI.TOOL_CALL_ID, callId);
|
|
354
|
-
|
|
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)(() =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
*
|
|
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
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
630
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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.
|
|
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.
|
|
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
|