@amaster.ai/dsh-langfuse 0.1.24 → 0.1.26

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 CHANGED
@@ -134,7 +134,7 @@ Tool hooks should include the execution's `callId` in both records. The plugin m
134
134
 
135
135
  `hook/result.outcome` describes process execution independently of `decision`: `success` is normal completion, `failed` is execution failure, `timed-out` is deadline termination, and `cancelled` is cancellation. The producer maps its process semantics to these values; an exit code expressing a policy denial can still be a successful execution. Optional `exitCode` and `signal` preserve process details. Failed/timed-out executions receive ERROR observations, cancellation receives WARNING, and successful executions retain decision-based warnings. Records without an explicit outcome preserve the legacy exit-code interpretation (0/2 are non-error). The plugin records `durationMs` and never infers a timeout or cancellation from a missing exit code.
136
136
 
137
- For records admitted by the session, runs with `invocationId` retain correlation after `turn/end` and a subsequent `turn/start`; a late result ends the original hook span without attaching it to the new turn. Invocations arriving outside their originating turn receive a separate root with `detachedHook: true` and the original turn number; their result ends both hook and root so they export without waiting for another turn. These detached hook roots do not consume `langfuse/context` queued for the next turn. Detached producers must finish reporting before the session is disposed. Session disposal/plugin unload closes unfinished runs with `hook result not observed`; this means missing telemetry, not proof of execution failure. Legacy records without `invocationId` still close at turn boundaries.
137
+ For records admitted by the session, runs with `invocationId` retain correlation after `turn/end` and a subsequent `turn/start`; a late result ends the original hook span without attaching it to the new turn. Delayed invocations, including `UserCancel`, reuse their originating turn's parent span even after the next turn starts. The plugin retains the latest 128 completed-turn associations across all sessions, clearing a session's associations on disposal and all associations on unload. Reporting never reopens the ended root or changes its end time or final status; each hook records its own duration. When the originating parent is unavailable or evicted, the invocation receives a separate root with `detachedHook: true` and the original turn number; its result ends both hook and fallback root. Neither path consumes `langfuse/context` queued for the next turn. Detached producers must finish reporting before the session is disposed. Session disposal/plugin unload closes unfinished runs with `hook result not observed`; this means missing telemetry, not proof of execution failure. Legacy records without `invocationId` still close at turn boundaries.
138
138
 
139
139
  Custom names, hook input, parsed results and raw process output follow `captureContent`; credentials and `redactFields` are masked by the existing reporter. New outcomes, correlation IDs, dialect, point, duration and process metadata remain available with content capture disabled. The event writer does not sanitize the session log itself: application producers remain responsible for what they persist. Applications must explicitly replace any private hook events with this standard producer path, reuse the same `invocationId`, and report the actual outcome; installing/upgrading the plugin does not adapt an application bridge or repair historical Trace overview IO (Issue #59).
140
140
 
@@ -146,6 +146,8 @@ Run `pnpm test:sdk` at the repository root for a secrets-free check using the re
146
146
 
147
147
  The independent `dsh-langfuse.tool-hooks` E2E leg boots the packed plugin in the real headless profile, asks the model to read a marker file, and executes BeforeTool/AfterTool scripts through an E2E-only producer. It requires both gateway and Langfuse credentials, then polls the real Observations API to verify named hooks, tool-call parenting, enclosing tool timestamps, script input and parsed output, credential masking, and stdout truncation. The existing `dsh-langfuse.hook` leg continues to check the upstream Codex UserPromptSubmit bridge; the new fixture verifies the shared event contract and does not substitute for the application's Gemini bridge integration.
148
148
 
149
+ The `dsh-langfuse.cancel-hook` E2E leg cancels a real agent at a tool boundary, queues its next turn, and asynchronously runs a UserCancel process after that turn starts. Real Langfuse API read-back must show the hook under the cancelled turn's root and trace ID, with the root still aborted and ended before the hook's own duration. It requires the existing gateway and Langfuse secrets in `integration.yml`.
150
+
149
151
  ### Trace overview compatibility
150
152
 
151
153
  Overall input/output belongs to the `dsh-turn` root observation. The root is exported when the turn ends; while a long turn is running, child observations can already be visible before the root appears. After completion, allow asynchronous ingestion to settle.
package/lib/client.js CHANGED
@@ -252,18 +252,23 @@ export class LangfuseReporter {
252
252
  if (!tracing || !api)
253
253
  return null;
254
254
  try {
255
- const parent = parentContext(input.context?.traceparent);
255
+ const externalParent = parentContext(input.context?.traceparent);
256
+ const parent = externalParent === NO_PARENT && input.traceId
257
+ ? { traceId: input.traceId, spanId: '0000000000000001', traceFlags: 1 }
258
+ : externalParent;
256
259
  input = this.mask(input);
257
- const root = api.context.with(api.ROOT_CONTEXT, () => tracing.startObservation(input.name, { metadata: input.metadata }, { parentSpanContext: parent }));
260
+ const root = api.context.with(api.ROOT_CONTEXT, () => tracing.startObservation(input.name, { metadata: input.metadata }, { parentSpanContext: parent, startTime: input.startTime }));
258
261
  this.stampTraceContext(root, {
259
262
  sessionId: input.sessionId,
260
- traceName: parent === NO_PARENT ? input.name : undefined,
263
+ traceName: externalParent === NO_PARENT ? input.name : undefined,
261
264
  userId: input.context?.userId ?? this.config.userId,
262
265
  tags: input.context?.tags ?? this.config.tags,
263
266
  });
264
267
  // External traces retain their owner's trace name and trace-level IO.
265
- if (parent === NO_PARENT)
268
+ if (externalParent === NO_PARENT) {
266
269
  this.roots.add(root);
270
+ root.otelSpan.setAttribute(tracing.LangfuseOtelSpanAttributes.IS_APP_ROOT, true);
271
+ }
267
272
  else
268
273
  root.otelSpan.setAttribute(tracing.LangfuseOtelSpanAttributes.IS_APP_ROOT, false);
269
274
  return root;
@@ -273,6 +278,25 @@ export class LangfuseReporter {
273
278
  return null;
274
279
  }
275
280
  }
281
+ /** A running task keeps its trace identity; late host context is a link. */
282
+ updateContext(span, context) {
283
+ if (!span)
284
+ return;
285
+ try {
286
+ const previous = this.traceContext.get(span);
287
+ this.stampTraceContext(span, {
288
+ ...previous,
289
+ userId: context.userId ?? previous?.userId,
290
+ tags: context.tags ?? previous?.tags,
291
+ });
292
+ const parent = parentContext(context.traceparent);
293
+ if (parent !== NO_PARENT)
294
+ span.otelSpan.addLink({ context: parent });
295
+ }
296
+ catch (error) {
297
+ console.error('[dsh-langfuse] context update failed:', error);
298
+ }
299
+ }
276
300
  /** Open a generation (one per LLM call) under any observation parent. */
277
301
  startGeneration(parent, input) {
278
302
  const api = this.api;
@@ -327,9 +351,14 @@ export class LangfuseReporter {
327
351
  input: input.input,
328
352
  metadata: input.metadata,
329
353
  };
330
- const span = api.context.with(api.ROOT_CONTEXT, () => input.asType === 'tool'
331
- ? parent.startObservation(input.name, attributes, { asType: 'tool' })
332
- : parent.startObservation(input.name, attributes));
354
+ const span = api.context.with(api.ROOT_CONTEXT, () => input.startTime && this.tracing
355
+ ? this.tracing.startObservation(input.name, attributes, {
356
+ parentSpanContext: parent.otelSpan.spanContext(),
357
+ startTime: input.startTime,
358
+ })
359
+ : input.asType === 'tool'
360
+ ? parent.startObservation(input.name, attributes, { asType: 'tool' })
361
+ : parent.startObservation(input.name, attributes));
333
362
  this.stampTraceContext(span, this.traceContext.get(parent));
334
363
  return span;
335
364
  }
package/lib/index.js CHANGED
@@ -16,6 +16,8 @@
16
16
  * (`turn/start` opens a root span — in the v5 SDK the root observation IS
17
17
  * the trace — the turn's first `user/message` sets the input, `turn/end`
18
18
  * stamps the end reason and ends the root, which is what exports it).
19
+ * - Native A2A inbox tasks own a trace spanning admission through task termination;
20
+ * agent turns and queue controls are children of that task.
19
21
  * - subagent child sessions (`session/created` with `header.parentSession`)
20
22
  * ride the parent's trace: a `subagent` **span** opened under the enclosing
21
23
  * delegation tool span, the child's generations/tool spans nested under it,
@@ -28,6 +30,7 @@
28
30
  *
29
31
  * @module dsh-langfuse
30
32
  */
33
+ import { createHash } from 'node:crypto';
31
34
  import Schema from '@deepseek-ai/schemastery';
32
35
  import { LangfuseReporter } from './client.js';
33
36
  import { isHookInvoked, isHookResult } from './hooks.js';
@@ -203,7 +206,22 @@ export function apply(ctx, config) {
203
206
  return;
204
207
  const reporter = new LangfuseReporter(config);
205
208
  const sessions = new Map();
209
+ const nativeInboxes = new Set();
210
+ const pendingTurns = new Map();
211
+ const inboxInputs = new Map();
212
+ const inboxTasks = new Map();
206
213
  const children = new Map();
214
+ const completedTurns = new Map();
215
+ function rememberTurn(sessionId, turn, parent, turnTrace) {
216
+ if (!parent)
217
+ return;
218
+ const key = JSON.stringify([sessionId, turn]);
219
+ completedTurns.delete(key);
220
+ completedTurns.set(key, { sessionId, parent, turnTrace });
221
+ // Bound retained SDK handles across all sessions; older hooks use the standalone fallback.
222
+ if (completedTurns.size > 128)
223
+ completedTurns.delete(completedTurns.keys().next().value);
224
+ }
207
225
  /** Per-session stack of currently open tool pipeline spans (for subagent parenting). */
208
226
  const openToolSpans = new Map();
209
227
  const toolCalls = new Map();
@@ -257,11 +275,98 @@ export function apply(ctx, config) {
257
275
  tags: context.tags ? [...context.tags] : undefined,
258
276
  });
259
277
  });
260
- function openTrace(sessionId, metadata) {
278
+ function openTrace(sessionId, metadata, startTime) {
261
279
  const context = sessionId ? traceContexts.get(sessionId) : undefined;
262
280
  if (sessionId)
263
281
  traceContexts.delete(sessionId);
264
- return reporter.openTrace({ name: config.traceName, sessionId, metadata, context });
282
+ return reporter.openTrace({ name: config.traceName, sessionId, metadata, context, startTime });
283
+ }
284
+ const inboxKey = (contextId, taskId) => JSON.stringify([contextId, taskId]);
285
+ function taskTrace(event) {
286
+ if (!event.taskId)
287
+ return;
288
+ const key = inboxKey(event.contextId, event.taskId);
289
+ let task = inboxTasks.get(key);
290
+ if (!task) {
291
+ const context = traceContexts.get(event.contextId);
292
+ traceContexts.delete(event.contextId);
293
+ task = {
294
+ key,
295
+ contextId: event.contextId,
296
+ taskId: event.taskId,
297
+ trace: reporter.openTrace({
298
+ name: config.traceName === 'dsh-turn' ? 'a2a-task' : config.traceName,
299
+ sessionId: event.contextId,
300
+ metadata: { taskId: event.taskId, taskState: 'queued' },
301
+ traceId: createHash('sha256').update(key).digest('hex').slice(0, 32),
302
+ startTime: new Date(event.queuedAt ?? event.at),
303
+ }),
304
+ hasInput: false,
305
+ messageIds: new Set(),
306
+ nativeMessageIds: new Set(),
307
+ openTurns: 0,
308
+ };
309
+ if (context)
310
+ reporter.updateContext(task.trace, context);
311
+ inboxTasks.set(key, task);
312
+ }
313
+ if (event.messageId)
314
+ task.messageIds.add(event.messageId);
315
+ if (event.nativeMessageId)
316
+ task.nativeMessageIds.add(event.nativeMessageId);
317
+ if (!task.hasInput && config.captureContent && event.input) {
318
+ reporter.updateSpan(task.trace, { input: event.input });
319
+ task.hasInput = true;
320
+ }
321
+ reporter.updateSpan(task.trace, {
322
+ metadata: {
323
+ referenceTaskIds: event.referenceTaskIds?.length ? event.referenceTaskIds : undefined,
324
+ a2aMessageIds: [...task.messageIds],
325
+ nativeMessageIds: [...task.nativeMessageIds],
326
+ },
327
+ });
328
+ return task;
329
+ }
330
+ function closeTask(task, endedAt = new Date()) {
331
+ if (!task.end || task.openTurns)
332
+ return;
333
+ const end = task.end;
334
+ if (inboxInputs.get(task.contextId)?.[0]?.taskId === task.taskId)
335
+ inboxInputs.delete(task.contextId);
336
+ if (inboxTasks.get(task.key) === task)
337
+ inboxTasks.delete(task.key);
338
+ afterMedia(task.trace, (at) => reporter.endSpan(task.trace, {
339
+ output: end.phase === 'completed' && config.captureContent && task.lastOutput !== undefined
340
+ ? task.lastOutput
341
+ : end.output,
342
+ level: end.level,
343
+ metadata: { taskState: end.phase },
344
+ }, at), new Date(Math.max(end.at, endedAt.getTime())));
345
+ }
346
+ function endTurn(state, update = {}, endedAt) {
347
+ const task = state.taskKey ? inboxTasks.get(state.taskKey) : undefined;
348
+ if (task && state.lastAssistantText !== undefined)
349
+ task.lastOutput = state.lastAssistantText;
350
+ afterMedia(state.trace, (at) => {
351
+ reporter.endSpan(state.trace, update, at);
352
+ if (task) {
353
+ task.openTurns--;
354
+ closeTask(task, at);
355
+ }
356
+ }, endedAt);
357
+ }
358
+ function suspendTasks(contextId) {
359
+ for (const task of [...inboxTasks.values()]) {
360
+ if (contextId !== undefined && task.contextId !== contextId)
361
+ continue;
362
+ task.end ??= {
363
+ phase: 'suspended',
364
+ at: Date.now(),
365
+ output: { state: 'suspended' },
366
+ level: 'DEFAULT',
367
+ };
368
+ closeTask(task);
369
+ }
265
370
  }
266
371
  function rootSessionId(sessionId) {
267
372
  while (sessionId && children.has(sessionId))
@@ -303,10 +408,36 @@ export function apply(ctx, config) {
303
408
  const existing = id ? sessions.get(id) : undefined;
304
409
  if (existing)
305
410
  return existing.trace;
306
- const trace = openTrace(id);
411
+ const pending = id ? pendingTurns.get(id) : undefined;
412
+ const turn = pending?.turn;
413
+ if (id && nativeInboxes.has(id)) {
414
+ const selected = inboxInputs.get(id)?.[0];
415
+ const task = selected ? taskTrace(selected) : undefined;
416
+ if (!task || task.end)
417
+ return null;
418
+ const context = traceContexts.get(id);
419
+ if (context) {
420
+ reporter.updateContext(task.trace, context);
421
+ traceContexts.delete(id);
422
+ }
423
+ const trace = reporter.startSpan(task.trace, {
424
+ name: 'agent:turn',
425
+ metadata: { turn, taskId: task.taskId },
426
+ startTime: pending?.startTime,
427
+ });
428
+ pendingTurns.delete(id);
429
+ if (trace) {
430
+ task.openTurns++;
431
+ sessions.set(id, { trace, hasInput: turn === undefined, turn, taskKey: task.key });
432
+ }
433
+ return trace;
434
+ }
435
+ const trace = openTrace(id, turn === undefined ? undefined : { turn }, pending?.startTime);
436
+ if (id)
437
+ pendingTurns.delete(id);
307
438
  // hasInput starts true: one-offs never take a turn's first-message input.
308
439
  if (trace && id)
309
- sessions.set(id, { trace, hasInput: true });
440
+ sessions.set(id, { trace, hasInput: turn === undefined, turn });
310
441
  return trace;
311
442
  }
312
443
  /**
@@ -317,6 +448,135 @@ export function apply(ctx, config) {
317
448
  const child = sessionId ? children.get(sessionId) : undefined;
318
449
  return child?.span ?? traceFor(sessionId);
319
450
  }
451
+ ctx.on('a2a/inbox', (event) => {
452
+ try {
453
+ const id = event.contextId;
454
+ nativeInboxes.add(id);
455
+ if (event.phase === 'resolved')
456
+ return;
457
+ const metadata = {
458
+ taskId: event.taskId,
459
+ taskIds: event.taskIds,
460
+ messageId: event.messageId,
461
+ nativeMessageId: event.nativeMessageId,
462
+ pending: event.pending,
463
+ target: event.target,
464
+ position: event.position,
465
+ paused: event.paused,
466
+ agentStatus: event.agentStatus,
467
+ turn: event.turn,
468
+ step: event.step,
469
+ };
470
+ const input = config.captureContent ? event.input : undefined;
471
+ const waitMs = event.queuedAt === undefined
472
+ ? undefined
473
+ : Math.max(0, (event.selectedAt ?? event.at) - event.queuedAt);
474
+ const output = {
475
+ state: event.phase,
476
+ target: event.target,
477
+ position: event.position,
478
+ pending: event.pending,
479
+ paused: event.paused,
480
+ agentStatus: event.agentStatus,
481
+ };
482
+ if (event.phase === 'selected') {
483
+ const task = taskTrace(event);
484
+ if (task && task.selectedAt === undefined) {
485
+ task.selectedAt = event.selectedAt ?? event.at;
486
+ task.waitMs = waitMs;
487
+ }
488
+ if (task)
489
+ reporter.updateSpan(task.trace, {
490
+ metadata: { taskState: 'running', waitMs: task.waitMs },
491
+ });
492
+ const inputs = inboxInputs.get(id) ?? [];
493
+ if (!inputs.some((input) => input.nativeMessageId === event.nativeMessageId))
494
+ inputs.push(event);
495
+ inboxInputs.set(id, inputs);
496
+ return;
497
+ }
498
+ if (['prepared', 'prepare-failed', 'prepare-aborted'].includes(event.phase)) {
499
+ const parent = parentFor(id);
500
+ const inputs = inboxInputs.get(id) ?? [event];
501
+ const first = inputs[0];
502
+ const turnWaitMs = first.queuedAt === undefined
503
+ ? undefined
504
+ : Math.max(0, (first.selectedAt ?? first.at) - first.queuedAt);
505
+ reporter.updateSpan(parent, {
506
+ input: sessions.get(id)?.hasInput ? undefined : input,
507
+ metadata: {
508
+ taskId: event.taskId,
509
+ waitMs: turnWaitMs,
510
+ a2aMessageIds: inputs.map((input) => input.messageId),
511
+ nativeMessageIds: inputs.map((input) => input.nativeMessageId),
512
+ },
513
+ });
514
+ const prepare = reporter.startSpan(parent, {
515
+ name: event.target === 'next-step' ? 'inbox:steering' : 'inbox:prepare',
516
+ input,
517
+ startTime: new Date(event.selectedAt ?? event.at),
518
+ metadata: {
519
+ ...metadata,
520
+ waitMs,
521
+ prepareMs: event.selectedAt === undefined ? undefined : Math.max(0, event.at - event.selectedAt),
522
+ },
523
+ });
524
+ reporter.endSpan(prepare, {
525
+ output,
526
+ level: event.phase === 'prepare-failed'
527
+ ? 'ERROR'
528
+ : event.phase === 'prepare-aborted'
529
+ ? 'WARNING'
530
+ : 'DEFAULT',
531
+ }, new Date(event.at));
532
+ return;
533
+ }
534
+ const tasks = event.taskId
535
+ ? [
536
+ ['completed', 'failed', 'canceled'].includes(event.phase)
537
+ ? inboxTasks.get(inboxKey(id, event.taskId))
538
+ : taskTrace(event),
539
+ ].filter((task) => !!task)
540
+ : [...inboxTasks.values()].filter((task) => task.contextId === id &&
541
+ !task.end &&
542
+ (event.taskIds === undefined || event.taskIds.includes(task.taskId)));
543
+ for (const task of tasks) {
544
+ if (task.end)
545
+ continue;
546
+ const details = { ...metadata, taskId: task.taskId };
547
+ const operation = reporter.startSpan(task.trace, {
548
+ name: `inbox:${event.phase}`,
549
+ input,
550
+ startTime: new Date(event.at),
551
+ metadata: details,
552
+ });
553
+ reporter.endSpan(operation, { output }, new Date(event.at));
554
+ const phase = event.phase === 'cleared' ? 'canceled' : event.phase;
555
+ if (['completed', 'failed', 'canceled'].includes(phase)) {
556
+ task.end = {
557
+ phase,
558
+ at: event.at,
559
+ output: { ...output, state: phase },
560
+ level: phase === 'failed' ? 'ERROR' : phase === 'canceled' ? 'WARNING' : 'DEFAULT',
561
+ };
562
+ closeTask(task, new Date(event.at));
563
+ }
564
+ else
565
+ reporter.updateSpan(task.trace, {
566
+ metadata: { taskState: event.phase === 'paused' ? 'paused' : undefined },
567
+ });
568
+ }
569
+ if (event.phase === 'disposed')
570
+ suspendTasks(id);
571
+ if (event.phase === 'disposed' || event.phase === 'cleared') {
572
+ nativeInboxes.delete(id);
573
+ inboxInputs.delete(id);
574
+ }
575
+ }
576
+ catch (error) {
577
+ console.error('[dsh-langfuse] inbox reporting failed:', error);
578
+ }
579
+ });
320
580
  // ------------------------------------------------------------------
321
581
  // Subagent child sessions: session/created precedes both subagent/start
322
582
  // and the child's first generation, and the durable header already links
@@ -374,8 +634,8 @@ export function apply(ctx, config) {
374
634
  }, endedAt));
375
635
  });
376
636
  // ------------------------------------------------------------------
377
- // Trace lifecycle: one trace per turn (top-level sessions only — child
378
- // sessions ride the parent's trace under their subagent span).
637
+ // Turn lifecycle: native tasks keep their root across turns; other sessions
638
+ // open one trace per turn. Subagents stay under their parent's trace.
379
639
  // ------------------------------------------------------------------
380
640
  ctx.on('session/event', (session, event) => {
381
641
  const sessionId = session.id;
@@ -396,8 +656,13 @@ export function apply(ctx, config) {
396
656
  if (previous?.root)
397
657
  reporter.endSpan(previous.root, { level: 'WARNING' });
398
658
  const child = children.get(sessionId);
399
- const inTurn = child ? child.turn === data.turn : sessions.get(sessionId)?.turn === data.turn;
400
- const root = data.invocationId && !inTurn
659
+ const inTurn = child
660
+ ? child.turn === data.turn
661
+ : (sessions.get(sessionId)?.turn ?? pendingTurns.get(sessionId)?.turn) === data.turn;
662
+ const completed = data.invocationId && !inTurn
663
+ ? completedTurns.get(JSON.stringify([sessionId, data.turn]))
664
+ : undefined;
665
+ const root = data.invocationId && !inTurn && !completed
401
666
  ? // A late hook must not consume context queued for the next turn.
402
667
  reporter.openTrace({
403
668
  name: config.traceName,
@@ -407,7 +672,12 @@ export function apply(ctx, config) {
407
672
  : undefined;
408
673
  const tool = data.callId ? toolCalls.get(sessionId)?.get(data.callId) : undefined;
409
674
  const hookName = config.captureContent ? reporter.mask({ name: data.name }).name : undefined;
410
- const span = reporter.startSpan(root === undefined ? (tool?.turn === data.turn ? tool.span : parentFor(sessionId)) : root, {
675
+ const span = reporter.startSpan(completed?.parent ??
676
+ (root === undefined
677
+ ? tool?.turn === data.turn
678
+ ? tool.span
679
+ : parentFor(sessionId)
680
+ : root), {
411
681
  name: `hook:${data.point}${hookName ? `:${hookName}` : ''}`,
412
682
  input: config.captureContent ? data.input : undefined,
413
683
  metadata: {
@@ -429,7 +699,11 @@ export function apply(ctx, config) {
429
699
  handlerId: data.handlerId,
430
700
  persistent: !!data.invocationId,
431
701
  callId: data.callId,
432
- turnTrace: root === undefined ? (child?.turnTrace ?? sessions.get(sessionId)?.trace) : undefined,
702
+ turnTrace: completed
703
+ ? completed.turnTrace
704
+ : root === undefined
705
+ ? (child?.turnTrace ?? sessions.get(sessionId)?.trace)
706
+ : undefined,
433
707
  });
434
708
  openHooks.set(sessionId, pending);
435
709
  return;
@@ -500,8 +774,10 @@ export function apply(ctx, config) {
500
774
  if (child) {
501
775
  if (event.type === 'turn/start')
502
776
  child.turn = event.data.turn;
503
- if (event.type === 'turn/end')
777
+ if (event.type === 'turn/end') {
778
+ rememberTurn(sessionId, event.data.turn, child.span, child.turnTrace ?? null);
504
779
  child.turn = undefined;
780
+ }
505
781
  // The delegated prompt (the child's first user/message) is the
506
782
  // subagent span's input; the descriptor enriches the span name.
507
783
  if (event.type === 'user/message' && !child.hasInput && config.captureContent) {
@@ -528,7 +804,13 @@ export function apply(ctx, config) {
528
804
  closeHooks(sessionId, true);
529
805
  const stale = sessions.get(sessionId);
530
806
  if (stale)
531
- afterMedia(stale.trace, (endedAt) => reporter.endSpan(stale.trace, {}, endedAt));
807
+ endTurn(stale);
808
+ inboxInputs.delete(sessionId);
809
+ if (nativeInboxes.has(sessionId)) {
810
+ sessions.delete(sessionId);
811
+ pendingTurns.set(sessionId, { turn: event.data.turn, startTime: new Date(event.time) });
812
+ break;
813
+ }
532
814
  const trace = openTrace(sessionId, { turn: event.data.turn });
533
815
  if (trace)
534
816
  sessions.set(sessionId, { trace, hasInput: false, turn: event.data.turn });
@@ -550,12 +832,14 @@ export function apply(ctx, config) {
550
832
  break;
551
833
  }
552
834
  case 'turn/end': {
835
+ if (pendingTurns.has(sessionId))
836
+ traceFor(sessionId);
553
837
  const state = sessions.get(sessionId);
554
838
  if (state) {
555
839
  sessions.delete(sessionId);
556
- // The turn's final answer becomes the trace's output; ending the
557
- // root span is what exports the trace (v5 exports spans on end).
558
- afterMedia(state.trace, (endedAt) => reporter.endSpan(state.trace, {
840
+ rememberTurn(sessionId, event.data.turn, state.trace, state.trace);
841
+ // Native task roots stay open until the A2A terminal signal.
842
+ endTurn(state, {
559
843
  output: state.lastAssistantText,
560
844
  level: event.data.reason.kind === 'error'
561
845
  ? 'ERROR'
@@ -573,7 +857,7 @@ export function apply(ctx, config) {
573
857
  ? { errorCode: event.data.reason.error.code }
574
858
  : {}),
575
859
  },
576
- }, endedAt));
860
+ });
577
861
  }
578
862
  break;
579
863
  }
@@ -585,13 +869,21 @@ export function apply(ctx, config) {
585
869
  const state = sessions.get(sessionId);
586
870
  sessions.delete(sessionId);
587
871
  traceContexts.delete(sessionId);
872
+ pendingTurns.delete(sessionId);
873
+ nativeInboxes.delete(sessionId);
874
+ inboxInputs.delete(sessionId);
875
+ for (const [key, completed] of completedTurns) {
876
+ if (completed.sessionId === sessionId)
877
+ completedTurns.delete(key);
878
+ }
588
879
  openToolSpans.delete(sessionId);
589
880
  toolCalls.delete(sessionId);
590
881
  // End an abandoned root (crash-orphaned turn or between-turns one-off
591
882
  // trace) — v5 spans only export on end, so dropping it open would lose
592
883
  // everything it recorded.
593
884
  if (state)
594
- afterMedia(state.trace, (endedAt) => reporter.endSpan(state.trace, {}, endedAt));
885
+ endTurn(state);
886
+ suspendTasks(sessionId);
595
887
  // Children: keep the entry — background/continuable subagents dispose
596
888
  // their session BEFORE subagent/end fires, and the span's close must ride
597
889
  // subagent/end (disposing first would either leak the span or close it
@@ -890,10 +1182,17 @@ export function apply(ctx, config) {
890
1182
  // down — v5 spans only export on end, so unloading mid-turn (profile
891
1183
  // reload) would otherwise silently drop them.
892
1184
  for (const state of sessions.values())
893
- reporter.endSpan(state.trace, {}, endedAt);
1185
+ endTurn(state, {}, endedAt);
1186
+ suspendTasks();
1187
+ await drainMedia();
894
1188
  for (const child of children.values())
895
1189
  reporter.endSpan(child.span, {}, endedAt);
896
1190
  sessions.clear();
1191
+ pendingTurns.clear();
1192
+ nativeInboxes.clear();
1193
+ inboxInputs.clear();
1194
+ inboxTasks.clear();
1195
+ completedTurns.clear();
897
1196
  traceContexts.clear();
898
1197
  children.clear();
899
1198
  openToolSpans.clear();
@@ -106,7 +106,11 @@ export declare class LangfuseReporter {
106
106
  sessionId?: string;
107
107
  metadata?: Record<string, unknown>;
108
108
  context?: LangfuseTraceContext;
109
+ startTime?: Date;
110
+ traceId?: string;
109
111
  }): LangfuseSpan | null;
112
+ /** A running task keeps its trace identity; late host context is a link. */
113
+ updateContext(span: Observation | null, context: LangfuseTraceContext): void;
110
114
  /** Open a generation (one per LLM call) under any observation parent. */
111
115
  startGeneration(parent: Observation | null, input: {
112
116
  name: string;
@@ -130,6 +134,7 @@ export declare class LangfuseReporter {
130
134
  input?: unknown;
131
135
  metadata?: Record<string, unknown>;
132
136
  asType?: 'tool';
137
+ startTime?: Date;
133
138
  }): LangfuseSpan | null;
134
139
  /**
135
140
  * Merge fields into an open span — subagent enrichment (label, provider),
@@ -16,6 +16,8 @@
16
16
  * (`turn/start` opens a root span — in the v5 SDK the root observation IS
17
17
  * the trace — the turn's first `user/message` sets the input, `turn/end`
18
18
  * stamps the end reason and ends the root, which is what exports it).
19
+ * - Native A2A inbox tasks own a trace spanning admission through task termination;
20
+ * agent turns and queue controls are children of that task.
19
21
  * - subagent child sessions (`session/created` with `header.parentSession`)
20
22
  * ride the parent's trace: a `subagent` **span** opened under the enclosing
21
23
  * delegation tool span, the child's generations/tool spans nested under it,
@@ -33,8 +35,30 @@ import Schema from '@deepseek-ai/schemastery';
33
35
  import { type LangfuseTraceContext } from './client.js';
34
36
  export type { LangfuseTraceContext } from './client.js';
35
37
  export { appendHookEvent, type HookInvoked, type HookResult } from './hooks.js';
38
+ /** Optional protocol telemetry; no dependency on the A2A plugin. */
39
+ interface A2aInboxEvent {
40
+ phase: string;
41
+ contextId: string;
42
+ at: number;
43
+ pending: number;
44
+ target?: 'next-turn' | 'next-step';
45
+ input?: string;
46
+ position?: number;
47
+ paused?: boolean;
48
+ agentStatus?: string;
49
+ taskId?: string;
50
+ taskIds?: string[];
51
+ messageId?: string;
52
+ referenceTaskIds?: string[];
53
+ nativeMessageId?: string;
54
+ queuedAt?: number;
55
+ selectedAt?: number;
56
+ turn?: number;
57
+ step?: number;
58
+ }
36
59
  declare module '@deepseek-ai/cordis' {
37
60
  interface Events {
61
+ 'a2a/inbox'(detail: A2aInboxEvent): void;
38
62
  /** Bind standard labels/parent context before a session's next trace starts. */
39
63
  'langfuse/context': (sessionId: string, context: LangfuseTraceContext) => void;
40
64
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amaster.ai/dsh-langfuse",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "Langfuse observability plugin for DeepSeek Harness: one generation per LLM call with a nested llm-request span, one span per tool call, one trace per session turn, subagent child sessions nested under the parent's tree.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",