@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 +3 -1
- package/lib/client.js +36 -7
- package/lib/index.js +317 -18
- package/lib/types/client.d.ts +5 -0
- package/lib/types/index.d.ts +24 -0
- package/package.json +1 -1
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.
|
|
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
|
|
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:
|
|
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 (
|
|
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.
|
|
331
|
-
?
|
|
332
|
-
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
378
|
-
//
|
|
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
|
|
400
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
557
|
-
//
|
|
558
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
package/lib/types/client.d.ts
CHANGED
|
@@ -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),
|
package/lib/types/index.d.ts
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,
|
|
@@ -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.
|
|
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",
|