ronds_ai 0.1.21 → 0.1.23

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.
@@ -0,0 +1,736 @@
1
+ /*
2
+ * 此实现基于 Langfuse Claude Observability Plugin(MIT)进行等价迁移:
3
+ * https://github.com/langfuse/Claude-Observability-Plugin
4
+ */
5
+
6
+ const path = require('path');
7
+ const {
8
+ buildTurns,
9
+ extractTextFromContent,
10
+ getContentFromRow,
11
+ getModel,
12
+ getToolUseBlocks,
13
+ getUsageDetailsFromRow,
14
+ parseTimestamp,
15
+ readSubagentJsonl,
16
+ truncateText,
17
+ } = require('./analyze_transcript');
18
+
19
+ /**
20
+ * 返回多个时间中的最晚值。
21
+ */
22
+ function latestTimestamp(...timestamps) {
23
+ const values = timestamps.filter((value) => value instanceof Date);
24
+ return values.length > 0
25
+ ? new Date(Math.max(...values.map((value) => value.getTime())))
26
+ : null;
27
+ }
28
+
29
+ /**
30
+ * 创建具有历史时间和明确父级的 Langfuse Observation。
31
+ */
32
+ function startHistoricalObservation(api, parent, name, attributes, asType, startTime) {
33
+ const options = { asType };
34
+ if (startTime instanceof Date) {
35
+ options.startTime = startTime;
36
+ }
37
+ if (parent && parent.otelSpan) {
38
+ options.parentSpanContext = parent.otelSpan.spanContext();
39
+ }
40
+ return api.startObservation(name, attributes, options);
41
+ }
42
+
43
+ /**
44
+ * 使用历史时间结束 Observation。
45
+ */
46
+ function endObservation(observation, endTime) {
47
+ observation.end(endTime instanceof Date ? endTime : undefined);
48
+ }
49
+
50
+ /**
51
+ * 获取用于 Langfuse 显示的短会话标识。
52
+ */
53
+ function shortSessionLabel(sessionId, maxLength = 12) {
54
+ const normalized = String(sessionId || '').trim();
55
+ if (!normalized) {
56
+ return 'unknown';
57
+ }
58
+ const parts = normalized.split('-');
59
+ if (parts.length === 5 && parts[0].length === 8) {
60
+ return parts[0];
61
+ }
62
+ return normalized.length <= maxLength
63
+ ? normalized
64
+ : normalized.slice(0, maxLength).replace(/-+$/, '');
65
+ }
66
+
67
+ /**
68
+ * 构造每个 Turn 的 Trace 名称。
69
+ */
70
+ function traceDisplayName(sessionId, turnNumber) {
71
+ return `Claude Code - Turn ${turnNumber} (${shortSessionLabel(sessionId)})`;
72
+ }
73
+
74
+ /**
75
+ * 收集当前 Turn 调用的 Skill 标签。
76
+ */
77
+ function collectSkillTags(turn) {
78
+ const tags = [];
79
+ for (const assistantMessage of turn.assistantMsgs) {
80
+ for (const toolUse of getToolUseBlocks(getContentFromRow(assistantMessage))) {
81
+ const skill = toolUse.name === 'Skill'
82
+ && toolUse.input
83
+ && typeof toolUse.input.skill === 'string'
84
+ ? toolUse.input.skill
85
+ : '';
86
+ if (skill && !tags.includes(`skill:${skill}`)) {
87
+ tags.push(`skill:${skill}`);
88
+ }
89
+ }
90
+ }
91
+ return tags;
92
+ }
93
+
94
+ /**
95
+ * 构造与原插件一致的 Trace 标签。
96
+ */
97
+ function getTraceTags(turn, config) {
98
+ return config.skillTags
99
+ ? ['claude-code', ...collectSkillTags(turn)]
100
+ : ['claude-code'];
101
+ }
102
+
103
+ /**
104
+ * 构造 LLM Generation 的输入。
105
+ */
106
+ function buildGenerationInput(
107
+ assistantIndex,
108
+ userText,
109
+ previousToolResults,
110
+ readyAsyncToolResults,
111
+ ) {
112
+ if (assistantIndex === 0) {
113
+ return { role: 'user', content: userText };
114
+ }
115
+ const toolResults = previousToolResults.concat(
116
+ readyAsyncToolResults.map((entry) => entry.toolResult),
117
+ );
118
+ return toolResults.length > 0
119
+ ? { role: 'tool', tool_results: toolResults }
120
+ : null;
121
+ }
122
+
123
+ /**
124
+ * 构造 LLM Generation 的输出。
125
+ */
126
+ function buildGenerationOutput(assistantText, toolUses) {
127
+ const output = { role: 'assistant' };
128
+ if (assistantText) {
129
+ output.content = assistantText;
130
+ }
131
+ if (toolUses.length > 0) {
132
+ output.tool_calls = toolUses.map((toolUse) => ({
133
+ id: toolUse.id,
134
+ name: toolUse.name,
135
+ }));
136
+ }
137
+ return output;
138
+ }
139
+
140
+ /**
141
+ * 规范化工具输入,并对字符串执行截断。
142
+ */
143
+ function getToolInputForObservation(toolUse, config) {
144
+ const input = toolUse && toolUse.input;
145
+ if (typeof input === 'string') {
146
+ const truncated = truncateText(input, config.maxChars);
147
+ return { input: truncated.text, inputMetadata: truncated.metadata };
148
+ }
149
+ const supported = input == null
150
+ || typeof input === 'string'
151
+ || typeof input === 'number'
152
+ || typeof input === 'boolean'
153
+ || Array.isArray(input)
154
+ || typeof input === 'object';
155
+ return { input: supported ? input : {}, inputMetadata: null };
156
+ }
157
+
158
+ /**
159
+ * 规范化工具返回,并保留异步 Agent 的初始和最终结果。
160
+ */
161
+ function getToolResultForObservation(entry, config) {
162
+ if (!entry || typeof entry !== 'object') {
163
+ return {
164
+ output: null,
165
+ outputMetadata: null,
166
+ resultTimestamp: null,
167
+ finalOutput: null,
168
+ finalResultTimestamp: null,
169
+ };
170
+ }
171
+ const rawOutput = typeof entry.content === 'string'
172
+ ? entry.content
173
+ : JSON.stringify(entry.content);
174
+ const output = truncateText(rawOutput, config.maxChars);
175
+ let finalOutput = null;
176
+ if (entry.finalContent != null) {
177
+ const rawFinal = typeof entry.finalContent === 'string'
178
+ ? entry.finalContent
179
+ : JSON.stringify(entry.finalContent);
180
+ finalOutput = truncateText(rawFinal, config.maxChars).text;
181
+ }
182
+ return {
183
+ output: output.text,
184
+ outputMetadata: output.metadata,
185
+ resultTimestamp: parseTimestamp(entry.timestamp),
186
+ finalOutput,
187
+ finalResultTimestamp: parseTimestamp(entry.finalTimestamp),
188
+ };
189
+ }
190
+
191
+ /**
192
+ * 读取 metadata 中使用的短 transcript 文件名。
193
+ */
194
+ function shortTranscriptPath(filePath) {
195
+ return typeof filePath === 'string' && filePath ? path.basename(filePath) : null;
196
+ }
197
+
198
+ /**
199
+ * 构造 Tool Observation metadata。
200
+ */
201
+ function buildToolMetadata(toolName, toolUseId, inputMetadata, toolResult, subagent) {
202
+ const metadata = {
203
+ tool_name: toolName,
204
+ tool_id: toolUseId,
205
+ input_meta: inputMetadata,
206
+ output_meta: toolResult.outputMetadata,
207
+ };
208
+ if (subagent) {
209
+ metadata.subagent_type = subagent.agentType;
210
+ metadata.subagent_description = subagent.description;
211
+ metadata.subagent_transcript_path = shortTranscriptPath(subagent.path);
212
+ }
213
+ return metadata;
214
+ }
215
+
216
+ /**
217
+ * 读取 Turn 的结束时间。
218
+ */
219
+ function getTurnEndTimestamp(turn) {
220
+ const timestamps = [];
221
+ if (turn.assistantMsgs.length > 0) {
222
+ timestamps.push(parseTimestamp(turn.assistantMsgs[turn.assistantMsgs.length - 1]));
223
+ }
224
+ for (const result of Object.values(turn.toolResultsById)) {
225
+ timestamps.push(parseTimestamp(result));
226
+ }
227
+ return latestTimestamp(...timestamps);
228
+ }
229
+
230
+ /**
231
+ * 从等待队列中取出当前 Generation 前已完成的子 Agent。
232
+ */
233
+ function takeReadySubagents(pendingSubagents, assistantTimestamp) {
234
+ const ready = [];
235
+ const pending = [];
236
+ for (const entry of pendingSubagents) {
237
+ if (entry.readyTimestamp instanceof Date
238
+ && (!(assistantTimestamp instanceof Date)
239
+ || entry.readyTimestamp.getTime() <= assistantTimestamp.getTime())) {
240
+ ready.push(entry);
241
+ } else {
242
+ pending.push(entry);
243
+ }
244
+ }
245
+ return { ready, pending };
246
+ }
247
+
248
+ /**
249
+ * 从等待队列中取出当前 Generation 前已完成的异步工具结果。
250
+ */
251
+ function takeReadyAsyncToolResults(pendingResults, assistantTimestamp) {
252
+ const ready = [];
253
+ const pending = [];
254
+ for (const entry of pendingResults) {
255
+ if (entry.timestamp instanceof Date
256
+ && (!(assistantTimestamp instanceof Date)
257
+ || entry.timestamp.getTime() <= assistantTimestamp.getTime())) {
258
+ ready.push(entry);
259
+ } else {
260
+ pending.push(entry);
261
+ }
262
+ }
263
+ return {
264
+ ready,
265
+ pending,
266
+ latestReadyTimestamp: latestTimestamp(...ready.map((entry) => entry.timestamp)),
267
+ };
268
+ }
269
+
270
+ /**
271
+ * 在启动响应被下一次 Generation 消费后微调子 Agent 的展示起点。
272
+ */
273
+ function updatePendingSubagentDisplayStart(
274
+ pendingSubagents,
275
+ generationInputToolResults,
276
+ generationStartTimestamp,
277
+ ) {
278
+ if (!(generationStartTimestamp instanceof Date)) {
279
+ return;
280
+ }
281
+ const toolUseIds = new Set(
282
+ generationInputToolResults
283
+ .map((entry) => entry && entry.tool_use_id)
284
+ .filter(Boolean)
285
+ .map(String),
286
+ );
287
+ for (const entry of pendingSubagents) {
288
+ if (!entry.displayStartTimestamp && toolUseIds.has(entry.toolUseId)) {
289
+ entry.displayStartTimestamp = new Date(generationStartTimestamp.getTime() + 1);
290
+ }
291
+ }
292
+ }
293
+
294
+ /**
295
+ * 导出完整的子 Agent Span 及其内部 Generation 和 Tool。
296
+ */
297
+ function emitSubagentObservations(api, parent, subagent, startTimestamp, config) {
298
+ if (!subagent || typeof subagent.path !== 'string') {
299
+ return startTimestamp;
300
+ }
301
+ let rows;
302
+ try {
303
+ rows = readSubagentJsonl(subagent.path);
304
+ } catch {
305
+ return startTimestamp;
306
+ }
307
+ const turns = buildTurns(rows);
308
+ if (turns.length === 0) {
309
+ return startTimestamp;
310
+ }
311
+
312
+ const firstTurn = turns[0];
313
+ const subagentStart = startTimestamp || parseTimestamp(firstTurn.userMsg);
314
+ const input = truncateText(
315
+ extractTextFromContent(getContentFromRow(firstTurn.userMsg)),
316
+ config.maxChars,
317
+ );
318
+ const lastTurn = turns[turns.length - 1];
319
+ const lastAssistant = lastTurn.assistantMsgs[lastTurn.assistantMsgs.length - 1];
320
+ const output = truncateText(
321
+ extractTextFromContent(getContentFromRow(lastAssistant)),
322
+ config.maxChars,
323
+ );
324
+ const name = subagent.description
325
+ ? `Subagent: ${subagent.description}`
326
+ : 'Subagent';
327
+ const span = startHistoricalObservation(api, parent, name, {
328
+ input: { role: 'user', content: input.text },
329
+ metadata: {
330
+ agent_type: subagent.agentType,
331
+ description: subagent.description,
332
+ transcript_path: shortTranscriptPath(subagent.path),
333
+ user_text: input.metadata,
334
+ },
335
+ }, 'span', subagentStart);
336
+
337
+ let latestEnd = subagentStart;
338
+ let previousStart = subagentStart;
339
+ for (const turn of turns) {
340
+ const turnEnd = emitTurnObservations(
341
+ api,
342
+ span,
343
+ turn,
344
+ previousStart,
345
+ config,
346
+ 'Subagent LLM Call',
347
+ null,
348
+ );
349
+ latestEnd = latestTimestamp(latestEnd, turnEnd);
350
+ if (turnEnd) {
351
+ previousStart = turnEnd;
352
+ }
353
+ }
354
+ span.update({ output: { role: 'assistant', content: output.text } });
355
+ endObservation(span, latestTimestamp(latestEnd, subagentStart));
356
+ return latestEnd;
357
+ }
358
+
359
+ /**
360
+ * 导出单个 Tool Observation,并调度关联的异步结果和子 Agent。
361
+ */
362
+ function emitSingleToolObservation(
363
+ api,
364
+ parent,
365
+ turn,
366
+ assistantTimestamp,
367
+ toolUse,
368
+ subagents,
369
+ pendingSubagents,
370
+ pendingAsyncToolResults,
371
+ config,
372
+ ) {
373
+ const toolUseId = String(toolUse.id || '');
374
+ const toolName = toolUse.name || 'unknown';
375
+ const toolInput = getToolInputForObservation(toolUse, config);
376
+ const resultEntry = toolUseId ? turn.toolResultsById[toolUseId] : null;
377
+ const toolResult = getToolResultForObservation(resultEntry, config);
378
+ const subagent = subagents && toolUseId ? subagents[toolUseId] : null;
379
+
380
+ let toolOutput = toolResult.output;
381
+ if (config.captureSkillContent && turn.injectedByToolId[toolUseId]) {
382
+ toolOutput = {
383
+ result: toolResult.output,
384
+ injected_instructions: truncateText(
385
+ turn.injectedByToolId[toolUseId],
386
+ config.maxChars,
387
+ ).text,
388
+ };
389
+ }
390
+
391
+ const toolStart = parseTimestamp(turn.toolUseTimestampsById[toolUseId])
392
+ || assistantTimestamp;
393
+ const toolSpan = startHistoricalObservation(api, parent, `Tool: ${toolName}`, {
394
+ input: toolInput.input,
395
+ metadata: buildToolMetadata(
396
+ toolName,
397
+ toolUseId,
398
+ toolInput.inputMetadata,
399
+ toolResult,
400
+ subagent,
401
+ ),
402
+ }, 'tool', toolStart);
403
+ toolSpan.update({ output: toolOutput });
404
+
405
+ let subagentEnd = null;
406
+ if (subagent) {
407
+ if (toolResult.finalResultTimestamp) {
408
+ pendingSubagents.push({
409
+ toolUseId,
410
+ subagent,
411
+ startTimestamp: toolStart,
412
+ readyTimestamp: toolResult.finalResultTimestamp,
413
+ });
414
+ } else {
415
+ subagentEnd = emitSubagentObservations(api, parent, subagent, toolStart, config);
416
+ }
417
+ }
418
+
419
+ const toolEnd = latestTimestamp(toolResult.resultTimestamp, toolStart);
420
+ const handoffTimestamp = toolResult.resultTimestamp
421
+ || toolResult.finalResultTimestamp
422
+ || subagentEnd
423
+ || assistantTimestamp;
424
+ endObservation(toolSpan, toolEnd);
425
+
426
+ if (toolResult.finalResultTimestamp && toolResult.finalOutput != null) {
427
+ pendingAsyncToolResults.push({
428
+ timestamp: toolResult.finalResultTimestamp,
429
+ toolResult: {
430
+ tool_use_id: toolUseId,
431
+ tool_name: toolName,
432
+ output: toolResult.finalOutput,
433
+ },
434
+ });
435
+ }
436
+ return {
437
+ handoffTimestamp,
438
+ toolResult: {
439
+ tool_use_id: toolUseId,
440
+ tool_name: toolName,
441
+ output: toolResult.output,
442
+ },
443
+ latestEndTimestamp: latestTimestamp(toolEnd, subagentEnd),
444
+ };
445
+ }
446
+
447
+ /**
448
+ * 导出某次 Assistant 消息中的全部工具调用。
449
+ */
450
+ function emitToolObservationBatch(
451
+ api,
452
+ parent,
453
+ turn,
454
+ assistantMessage,
455
+ toolUses,
456
+ subagents,
457
+ pendingSubagents,
458
+ pendingAsyncToolResults,
459
+ config,
460
+ ) {
461
+ const assistantTimestamp = parseTimestamp(assistantMessage);
462
+ const resultTimestamps = [];
463
+ const toolResults = [];
464
+ let latestEndTimestamp = null;
465
+ for (const toolUse of toolUses) {
466
+ const emitted = emitSingleToolObservation(
467
+ api,
468
+ parent,
469
+ turn,
470
+ assistantTimestamp,
471
+ toolUse,
472
+ subagents,
473
+ pendingSubagents,
474
+ pendingAsyncToolResults,
475
+ config,
476
+ );
477
+ if (emitted.handoffTimestamp) {
478
+ resultTimestamps.push(emitted.handoffTimestamp);
479
+ }
480
+ toolResults.push(emitted.toolResult);
481
+ latestEndTimestamp = latestTimestamp(
482
+ latestEndTimestamp,
483
+ emitted.latestEndTimestamp,
484
+ );
485
+ }
486
+ return { resultTimestamps, toolResults, latestEndTimestamp };
487
+ }
488
+
489
+ /**
490
+ * 构造单个 Assistant Generation 的 Langfuse 属性。
491
+ */
492
+ function buildGenerationAttributes(
493
+ assistantIndex,
494
+ assistantMessage,
495
+ userText,
496
+ previousToolResults,
497
+ readyAsyncToolResults,
498
+ config,
499
+ ) {
500
+ const assistantText = truncateText(
501
+ extractTextFromContent(getContentFromRow(assistantMessage)),
502
+ config.maxChars,
503
+ );
504
+ const toolUses = getToolUseBlocks(getContentFromRow(assistantMessage));
505
+ const attributes = {
506
+ model: getModel(assistantMessage),
507
+ input: buildGenerationInput(
508
+ assistantIndex,
509
+ userText,
510
+ previousToolResults,
511
+ readyAsyncToolResults,
512
+ ),
513
+ output: buildGenerationOutput(assistantText.text, toolUses),
514
+ metadata: {
515
+ assistant_index: assistantIndex,
516
+ assistant_text: assistantText.metadata,
517
+ tool_count: toolUses.length,
518
+ },
519
+ };
520
+ const usageDetails = getUsageDetailsFromRow(assistantMessage);
521
+ if (usageDetails) {
522
+ attributes.usageDetails = usageDetails;
523
+ }
524
+ return { attributes, toolUses };
525
+ }
526
+
527
+ /**
528
+ * 导出一个 Turn 内的 Generation、Tool 和 Subagent Observations。
529
+ */
530
+ function emitTurnObservations(
531
+ api,
532
+ parent,
533
+ turn,
534
+ startTimestamp,
535
+ config,
536
+ generationPrefix = 'LLM Call',
537
+ subagents = null,
538
+ ) {
539
+ const userText = truncateText(
540
+ extractTextFromContent(getContentFromRow(turn.userMsg)),
541
+ config.maxChars,
542
+ ).text;
543
+ let previousTimestamp = startTimestamp;
544
+ let previousToolResults = [];
545
+ let pendingAsyncToolResults = [];
546
+ let pendingSubagents = [];
547
+ let latestEndTimestamp = startTimestamp;
548
+
549
+ for (let index = 0; index < turn.assistantMsgs.length; index += 1) {
550
+ const assistantMessage = turn.assistantMsgs[index];
551
+ const assistantTimestamp = parseTimestamp(assistantMessage);
552
+
553
+ if (index > 0 && pendingSubagents.length > 0) {
554
+ const subagentBatch = takeReadySubagents(pendingSubagents, assistantTimestamp);
555
+ pendingSubagents = subagentBatch.pending;
556
+ for (const entry of subagentBatch.ready) {
557
+ const subagentEnd = emitSubagentObservations(
558
+ api,
559
+ parent,
560
+ entry.subagent,
561
+ entry.displayStartTimestamp || entry.startTimestamp,
562
+ config,
563
+ );
564
+ latestEndTimestamp = latestTimestamp(latestEndTimestamp, subagentEnd);
565
+ }
566
+ }
567
+
568
+ let readyAsyncToolResults = [];
569
+ if (index > 0 && pendingAsyncToolResults.length > 0) {
570
+ const resultBatch = takeReadyAsyncToolResults(
571
+ pendingAsyncToolResults,
572
+ assistantTimestamp,
573
+ );
574
+ readyAsyncToolResults = resultBatch.ready;
575
+ pendingAsyncToolResults = resultBatch.pending;
576
+ previousTimestamp = latestTimestamp(
577
+ previousTimestamp,
578
+ resultBatch.latestReadyTimestamp,
579
+ );
580
+ }
581
+
582
+ const generationData = buildGenerationAttributes(
583
+ index,
584
+ assistantMessage,
585
+ userText,
586
+ previousToolResults,
587
+ readyAsyncToolResults,
588
+ config,
589
+ );
590
+ const generationStart = previousTimestamp || assistantTimestamp;
591
+ const generation = startHistoricalObservation(
592
+ api,
593
+ parent,
594
+ `${generationPrefix} ${index + 1}`,
595
+ generationData.attributes,
596
+ 'generation',
597
+ generationStart,
598
+ );
599
+ updatePendingSubagentDisplayStart(
600
+ pendingSubagents,
601
+ previousToolResults,
602
+ generationStart,
603
+ );
604
+
605
+ const tools = emitToolObservationBatch(
606
+ api,
607
+ parent,
608
+ turn,
609
+ assistantMessage,
610
+ generationData.toolUses,
611
+ subagents,
612
+ pendingSubagents,
613
+ pendingAsyncToolResults,
614
+ config,
615
+ );
616
+ latestEndTimestamp = latestTimestamp(
617
+ latestEndTimestamp,
618
+ tools.latestEndTimestamp,
619
+ );
620
+ const generationEnd = tools.resultTimestamps.length > 0
621
+ ? latestTimestamp(...tools.resultTimestamps)
622
+ : assistantTimestamp;
623
+ endObservation(
624
+ generation,
625
+ generationEnd || assistantTimestamp || previousTimestamp,
626
+ );
627
+ latestEndTimestamp = latestTimestamp(latestEndTimestamp, generationEnd);
628
+ previousToolResults = tools.toolResults;
629
+ previousTimestamp = tools.resultTimestamps.length > 0
630
+ ? latestTimestamp(...tools.resultTimestamps)
631
+ : assistantTimestamp || previousTimestamp;
632
+ }
633
+
634
+ for (const entry of pendingSubagents) {
635
+ const subagentEnd = emitSubagentObservations(
636
+ api,
637
+ parent,
638
+ entry.subagent,
639
+ entry.displayStartTimestamp || entry.startTimestamp,
640
+ config,
641
+ );
642
+ latestEndTimestamp = latestTimestamp(latestEndTimestamp, subagentEnd);
643
+ }
644
+ return latestEndTimestamp;
645
+ }
646
+
647
+ /**
648
+ * 构造根 Trace metadata。
649
+ */
650
+ function buildTraceMetadata(sessionId, turnNumber, turn, transcriptPath, userTextMetadata) {
651
+ const metadata = {
652
+ source: 'claude-code',
653
+ session_id: sessionId,
654
+ turn_number: turnNumber,
655
+ transcript_path: shortTranscriptPath(transcriptPath),
656
+ user_text: userTextMetadata,
657
+ assistant_message_count: turn.assistantMsgs.length,
658
+ };
659
+ if (typeof turn.userMsg.cwd === 'string' && turn.userMsg.cwd) {
660
+ metadata.cwd = turn.userMsg.cwd;
661
+ }
662
+ if (typeof turn.userMsg.gitBranch === 'string' && turn.userMsg.gitBranch) {
663
+ metadata.git_branch = turn.userMsg.gitBranch;
664
+ }
665
+ return metadata;
666
+ }
667
+
668
+ /**
669
+ * 导出单个 Claude Code Turn。
670
+ */
671
+ function emitTurn(api, sessionId, turnNumber, turn, transcriptPath, subagents, config) {
672
+ const userText = truncateText(
673
+ extractTextFromContent(getContentFromRow(turn.userMsg)),
674
+ config.maxChars,
675
+ );
676
+ const lastAssistant = turn.assistantMsgs[turn.assistantMsgs.length - 1];
677
+ const assistantText = truncateText(
678
+ extractTextFromContent(getContentFromRow(lastAssistant)),
679
+ config.maxChars,
680
+ );
681
+ const userTimestamp = parseTimestamp(turn.userMsg);
682
+ const assistantTimestamp = parseTimestamp(lastAssistant);
683
+ const tags = getTraceTags(turn, config);
684
+
685
+ api.propagateAttributes({
686
+ sessionId,
687
+ userId: config.userId,
688
+ traceName: traceDisplayName(sessionId, turnNumber),
689
+ tags,
690
+ }, () => {
691
+ const root = startHistoricalObservation(api, null, 'Conversational Turn', {
692
+ input: { role: 'user', content: userText.text },
693
+ metadata: buildTraceMetadata(
694
+ sessionId,
695
+ turnNumber,
696
+ turn,
697
+ transcriptPath,
698
+ userText.metadata,
699
+ ),
700
+ }, 'span', userTimestamp);
701
+ const observationEnd = emitTurnObservations(
702
+ api,
703
+ root,
704
+ turn,
705
+ userTimestamp,
706
+ config,
707
+ 'LLM Call',
708
+ subagents,
709
+ );
710
+ root.update({
711
+ output: { role: 'assistant', content: assistantText.text },
712
+ });
713
+ endObservation(
714
+ root,
715
+ latestTimestamp(
716
+ getTurnEndTimestamp(turn),
717
+ assistantTimestamp,
718
+ observationEnd,
719
+ userTimestamp,
720
+ ),
721
+ );
722
+ });
723
+ }
724
+
725
+ module.exports = {
726
+ buildGenerationInput,
727
+ buildGenerationOutput,
728
+ buildTraceMetadata,
729
+ collectSkillTags,
730
+ emitTurn,
731
+ emitTurnObservations,
732
+ getToolResultForObservation,
733
+ latestTimestamp,
734
+ shortSessionLabel,
735
+ traceDisplayName,
736
+ };