@amaster.ai/dsh-langfuse 0.1.24 → 0.1.25

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/index.js CHANGED
@@ -204,6 +204,17 @@ export function apply(ctx, config) {
204
204
  const reporter = new LangfuseReporter(config);
205
205
  const sessions = new Map();
206
206
  const children = new Map();
207
+ const completedTurns = new Map();
208
+ function rememberTurn(sessionId, turn, parent, turnTrace) {
209
+ if (!parent)
210
+ return;
211
+ const key = JSON.stringify([sessionId, turn]);
212
+ completedTurns.delete(key);
213
+ completedTurns.set(key, { sessionId, parent, turnTrace });
214
+ // Bound retained SDK handles across all sessions; older hooks use the standalone fallback.
215
+ if (completedTurns.size > 128)
216
+ completedTurns.delete(completedTurns.keys().next().value);
217
+ }
207
218
  /** Per-session stack of currently open tool pipeline spans (for subagent parenting). */
208
219
  const openToolSpans = new Map();
209
220
  const toolCalls = new Map();
@@ -397,7 +408,10 @@ export function apply(ctx, config) {
397
408
  reporter.endSpan(previous.root, { level: 'WARNING' });
398
409
  const child = children.get(sessionId);
399
410
  const inTurn = child ? child.turn === data.turn : sessions.get(sessionId)?.turn === data.turn;
400
- const root = data.invocationId && !inTurn
411
+ const completed = data.invocationId && !inTurn
412
+ ? completedTurns.get(JSON.stringify([sessionId, data.turn]))
413
+ : undefined;
414
+ const root = data.invocationId && !inTurn && !completed
401
415
  ? // A late hook must not consume context queued for the next turn.
402
416
  reporter.openTrace({
403
417
  name: config.traceName,
@@ -407,7 +421,12 @@ export function apply(ctx, config) {
407
421
  : undefined;
408
422
  const tool = data.callId ? toolCalls.get(sessionId)?.get(data.callId) : undefined;
409
423
  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, {
424
+ const span = reporter.startSpan(completed?.parent ??
425
+ (root === undefined
426
+ ? tool?.turn === data.turn
427
+ ? tool.span
428
+ : parentFor(sessionId)
429
+ : root), {
411
430
  name: `hook:${data.point}${hookName ? `:${hookName}` : ''}`,
412
431
  input: config.captureContent ? data.input : undefined,
413
432
  metadata: {
@@ -429,7 +448,11 @@ export function apply(ctx, config) {
429
448
  handlerId: data.handlerId,
430
449
  persistent: !!data.invocationId,
431
450
  callId: data.callId,
432
- turnTrace: root === undefined ? (child?.turnTrace ?? sessions.get(sessionId)?.trace) : undefined,
451
+ turnTrace: completed
452
+ ? completed.turnTrace
453
+ : root === undefined
454
+ ? (child?.turnTrace ?? sessions.get(sessionId)?.trace)
455
+ : undefined,
433
456
  });
434
457
  openHooks.set(sessionId, pending);
435
458
  return;
@@ -500,8 +523,10 @@ export function apply(ctx, config) {
500
523
  if (child) {
501
524
  if (event.type === 'turn/start')
502
525
  child.turn = event.data.turn;
503
- if (event.type === 'turn/end')
526
+ if (event.type === 'turn/end') {
527
+ rememberTurn(sessionId, event.data.turn, child.span, child.turnTrace ?? null);
504
528
  child.turn = undefined;
529
+ }
505
530
  // The delegated prompt (the child's first user/message) is the
506
531
  // subagent span's input; the descriptor enriches the span name.
507
532
  if (event.type === 'user/message' && !child.hasInput && config.captureContent) {
@@ -553,6 +578,7 @@ export function apply(ctx, config) {
553
578
  const state = sessions.get(sessionId);
554
579
  if (state) {
555
580
  sessions.delete(sessionId);
581
+ rememberTurn(sessionId, event.data.turn, state.trace, state.trace);
556
582
  // The turn's final answer becomes the trace's output; ending the
557
583
  // root span is what exports the trace (v5 exports spans on end).
558
584
  afterMedia(state.trace, (endedAt) => reporter.endSpan(state.trace, {
@@ -585,6 +611,10 @@ export function apply(ctx, config) {
585
611
  const state = sessions.get(sessionId);
586
612
  sessions.delete(sessionId);
587
613
  traceContexts.delete(sessionId);
614
+ for (const [key, completed] of completedTurns) {
615
+ if (completed.sessionId === sessionId)
616
+ completedTurns.delete(key);
617
+ }
588
618
  openToolSpans.delete(sessionId);
589
619
  toolCalls.delete(sessionId);
590
620
  // End an abandoned root (crash-orphaned turn or between-turns one-off
@@ -894,6 +924,7 @@ export function apply(ctx, config) {
894
924
  for (const child of children.values())
895
925
  reporter.endSpan(child.span, {}, endedAt);
896
926
  sessions.clear();
927
+ completedTurns.clear();
897
928
  traceContexts.clear();
898
929
  children.clear();
899
930
  openToolSpans.clear();
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.25",
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",