@@ -213,42 +213,57 @@ export class TraceloopCallbackHandler extends BaseCallbackHandler {
213213 span . setAttribute ( ATTR_GEN_AI_RESPONSE_ID , responseId ) ;
214214 }
215215
216- // Map raw finish reason to OTel standard value; unknown reasons pass through unchanged
217- const rawFinishReason = this . extractFinishReason ( output ) ;
218- const mappedFinishReason = rawFinishReason
219- ? ( langchainFinishReasonMap [ rawFinishReason ] ?? rawFinishReason )
220- : null ;
216+ // Collect finish reasons from ALL candidates across ALL generation groups.
217+ // LLMResult.generations is Generation[][] where outer = prompt batch, inner = n candidates.
218+ // ATTR_GEN_AI_RESPONSE_FINISH_REASONS should contain one entry per candidate (OTel spec).
219+ const allFinishReasons : string [ ] = [ ] ;
220+ if ( output . generations ) {
221+ for ( const group of output . generations ) {
222+ if ( group ) {
223+ for ( const gen of group ) {
224+ const raw =
225+ gen ?. generationInfo ?. finish_reason ||
226+ gen ?. generationInfo ?. stop_reason ||
227+ gen ?. generationInfo ?. done_reason ||
228+ null ;
229+ if ( raw ) {
230+ allFinishReasons . push (
231+ langchainFinishReasonMap [ raw ] ?? raw ,
232+ ) ;
233+ }
234+ }
235+ }
236+ }
237+ }
221238
222239 // Set finish reasons on span (metadata — NOT gated by traceContent)
223- if ( mappedFinishReason ) {
224- span . setAttribute ( ATTR_GEN_AI_RESPONSE_FINISH_REASONS , [
225- mappedFinishReason ,
226- ] ) ;
240+ if ( allFinishReasons . length > 0 ) {
241+ span . setAttribute ( ATTR_GEN_AI_RESPONSE_FINISH_REASONS , allFinishReasons ) ;
227242 }
228243
229244 if (
230245 this . traceContent &&
231246 output . generations &&
232247 output . generations . length > 0
233248 ) {
234- const outputMessages = output . generations . map ( ( generation ) => {
235- const text =
236- generation && generation . length > 0 ? generation [ 0 ] . text : "" ;
237- // Extract per-generation finish reason
238- const genRaw =
239- generation ?. [ 0 ] ?. generationInfo ?. finish_reason ||
240- generation ?. [ 0 ] ?. generationInfo ?. stop_reason ||
241- generation ?. [ 0 ] ?. generationInfo ?. done_reason ||
242- null ;
243- const genFinishReason = genRaw
244- ? ( langchainFinishReasonMap [ genRaw ] ?? genRaw )
245- : ( mappedFinishReason ?? "" ) ;
246- return {
247- role : "assistant" ,
248- parts : [ { type : "text" , content : text } ] ,
249- finish_reason : genFinishReason ,
250- } ;
251- } ) ;
249+ // flatMap over all candidates in all groups — one output message per candidate
250+ const outputMessages = output . generations . flatMap ( ( group ) =>
251+ ( group ?? [ ] ) . map ( ( gen ) => {
252+ const raw =
253+ gen ?. generationInfo ?. finish_reason ||
254+ gen ?. generationInfo ?. stop_reason ||
255+ gen ?. generationInfo ?. done_reason ||
256+ null ;
257+ const genFinishReason = raw
258+ ? ( langchainFinishReasonMap [ raw ] ?? raw )
259+ : ( allFinishReasons [ 0 ] ?? "" ) ;
260+ return {
261+ role : "assistant" ,
262+ parts : [ { type : "text" , content : gen ?. text ?? "" } ] ,
263+ finish_reason : genFinishReason ,
264+ } ;
265+ } ) ,
266+ ) ;
252267 span . setAttribute (
253268 ATTR_GEN_AI_OUTPUT_MESSAGES ,
254269 JSON . stringify ( outputMessages ) ,
@@ -264,9 +279,11 @@ export class TraceloopCallbackHandler extends BaseCallbackHandler {
264279 if ( usage . output_tokens || usage . output_tokens === 0 ) {
265280 span . setAttribute ( ATTR_GEN_AI_USAGE_OUTPUT_TOKENS , usage . output_tokens ) ;
266281 }
267- const totalTokens =
268- ( usage . input_tokens || 0 ) + ( usage . output_tokens || 0 ) ;
269- if ( totalTokens > 0 ) {
282+ const hasUsage =
283+ usage . input_tokens != null || usage . output_tokens != null ;
284+ if ( hasUsage ) {
285+ const totalTokens =
286+ ( usage . input_tokens || 0 ) + ( usage . output_tokens || 0 ) ;
270287 span . setAttribute (
271288 SpanAttributes . GEN_AI_USAGE_TOTAL_TOKENS ,
272289 totalTokens ,
@@ -331,23 +348,34 @@ export class TraceloopCallbackHandler extends BaseCallbackHandler {
331348 _extra ?: Record < string , unknown > ,
332349 ) : Promise < void > {
333350 const chainName = chain . id ?. [ chain . id . length - 1 ] || "unknown" ;
334- const agentName = runName || chainName ;
335-
336- const span = this . tracer . startSpan (
337- `${ GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT } ${ agentName } ` ,
338- {
339- kind : SpanKind . INTERNAL ,
340- } ,
341- ) ;
351+ const displayName = runName || chainName ;
352+
353+ // Detect whether this chain is an agent executor vs a regular chain.
354+ // Both pass runType: undefined in LangChain 1.x, so we use the class name.
355+ // Only agent executors get invoke_agent; regular chains use "workflow" (custom,
356+ // no OTel well-known value exists for generic chain execution).
357+ const isAgent = this . isAgentChain ( chainName ) ;
358+ const operationName = isAgent
359+ ? GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT
360+ : "workflow" ;
361+
362+ const span = this . tracer . startSpan ( `${ operationName } ${ displayName } ` , {
363+ kind : SpanKind . INTERNAL ,
364+ } ) ;
342365
343- span . setAttributes ( {
344- [ ATTR_GEN_AI_OPERATION_NAME ] : GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT ,
366+ const attributes : Record < string , string > = {
367+ [ ATTR_GEN_AI_OPERATION_NAME ] : operationName ,
345368 [ ATTR_GEN_AI_PROVIDER_NAME ] : "langchain" ,
346- [ ATTR_GEN_AI_AGENT_NAME ] : agentName ,
347369 // Backward compatibility
348370 "traceloop.span.kind" : "workflow" ,
349- "traceloop.workflow.name" : agentName ,
350- } ) ;
371+ "traceloop.workflow.name" : displayName ,
372+ } ;
373+
374+ if ( isAgent ) {
375+ attributes [ ATTR_GEN_AI_AGENT_NAME ] = displayName ;
376+ }
377+
378+ span . setAttributes ( attributes ) ;
351379
352380 if ( this . traceContent ) {
353381 span . setAttributes ( {
@@ -505,26 +533,6 @@ export class TraceloopCallbackHandler extends BaseCallbackHandler {
505533 return null ;
506534 }
507535
508- private extractFinishReason ( output : LLMResult ) : string | null {
509- // Try to extract finish reason from LangChain's LLMResult
510- // LangChain exposes it in generationInfo or llmOutput
511- if ( output . generations && output . generations . length > 0 ) {
512- const firstGen = output . generations [ 0 ] ;
513- if ( firstGen && firstGen . length > 0 ) {
514- const genInfo = firstGen [ 0 ] . generationInfo ;
515- if ( genInfo ) {
516- // Different providers use different field names
517- const reason =
518- genInfo . finish_reason || genInfo . stop_reason || genInfo . done_reason ;
519- if ( reason && typeof reason === "string" ) {
520- return reason ;
521- }
522- }
523- }
524- }
525- return null ;
526- }
527-
528536 private detectVendor ( llm : Serialized ) : string {
529537 const className = llm . id ?. [ llm . id . length - 1 ] || "" ;
530538
@@ -648,6 +656,15 @@ export class TraceloopCallbackHandler extends BaseCallbackHandler {
648656 return "langchain" ;
649657 }
650658
659+ private isAgentChain ( chainName : string ) : boolean {
660+ const lower = chainName . toLowerCase ( ) ;
661+ return (
662+ lower . includes ( "agent" ) ||
663+ lower . includes ( "executor" ) ||
664+ lower === "agentexecutor"
665+ ) ;
666+ }
667+
651668 private mapMessageTypeToRole ( messageType : string ) : string {
652669 // Map LangChain message types to standard OpenTelemetry roles
653670 switch ( messageType ) {
0 commit comments