@amaster.ai/dsh-langfuse 0.1.23 → 0.1.24

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
@@ -5,7 +5,7 @@
5
5
  Langfuse observability for [DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness):
6
6
 
7
7
  - one **generation** per LLM call (`llm/stream` waterfall), plus a nested `llm-request` span carrying the verbatim loop-built request
8
- - one **tool observation** per tool call (`tools/execute` waterfall)
8
+ - one **tool observation** per tool call (`tools/pre-execute` through `tools/result`)
9
9
  - one **span** per `hook/invoked` / `hook/result` pair, with hook point, decision, exit code, and duration (stderr summary follows `captureContent`)
10
10
  - one **trace** per session turn (`session/event`) — in the v5 SDK the trace IS its root span, ended (and thereby exported) at `turn/end`
11
11
  - subagent child sessions nested under the parent's tree (`session/created` header link + `subagent/start` / `subagent/end`)
@@ -87,7 +87,7 @@ Generation `usageDetails` has disjoint `input`, `cache_read_input_tokens`, `cach
87
87
 
88
88
  Compaction and session-title calls passing through `llm/stream` already receive their own generation and `purpose` metadata, including out-of-turn calls. Tools using the same LLM service are observed there too; adding a second generation from tool-result usage would duplicate accounting.
89
89
 
90
- Hook spans appear when a dsh hook bridge emits the paired session records. The current `hook/result` record does not contain stdout; successful script output can appear in the LLM request when the bridge injects it as context, while the hook span reports decision, exit code, duration, and bounded stderr. `SessionStart` runs before the first turn and does not emit these records, so it has no hook span.
90
+ Hook spans appear when a dsh hook bridge emits the paired session records. With content capture enabled, they show `hook:<point>:<name>`, optional input and parsed result, and available bounded process output alongside execution status. Producers must emit these fields; upgrading this plugin cannot recover omitted data. `SessionStart` only appears if a producer reports it and the installed runtime admits the records.
91
91
 
92
92
  ### Custom hook producers
93
93
 
@@ -115,6 +115,7 @@ appendHookEvent(session, 'hook/invoked', {
115
115
  ...identity,
116
116
  dialect: 'company-validator',
117
117
  name: 'Validate request',
118
+ input: { request: 'example' },
118
119
  });
119
120
  // After execution, including exceptions, timeout or cancellation:
120
121
  appendHookEvent(session, 'hook/result', {
@@ -123,22 +124,27 @@ appendHookEvent(session, 'hook/result', {
123
124
  outcome: 'success',
124
125
  exitCode: 0,
125
126
  durationMs: 37,
127
+ result: { reason: 'valid', additionalContext: 'checked', continue: true },
126
128
  });
127
129
  ```
128
130
 
129
- Every custom execution should have a unique `invocationId` and a result emitted in the producer's `finally` path. Both records must share `turn`, `point`, `handlerId`, and `invocationId`. `handlerId` identifies the configured handler; `invocationId` identifies one execution, including concurrent executions of that handler. Optional `name` labels the hook. Unmatched/skipped hooks must not emit an invocation: these records represent actual execution. The producer bounds `stderrSummary` and omits unavailable optional fields rather than writing `undefined` into the JSON session log.
131
+ Every custom execution should have a unique `invocationId` and a result emitted in the producer's `finally` path. Both records must share `turn`, `point`, `handlerId`, and `invocationId`. `handlerId` identifies the configured handler; `invocationId` identifies one execution, including concurrent executions of that handler. Optional `name` labels the observation when content capture is enabled. `HookInvoked.input` carries the script payload; `HookResult.result` carries the parsed output object, including available reasons, additional context and continue/stop instructions. The consumer preserves these producer-defined fields without interpreting them as decisions. Unmatched/skipped hooks must not emit an invocation: these records represent actual execution. Optional `stdout`, `stderr` and legacy `stderrSummary` are masked, then limited to 4096 characters each in observations, with `stdoutTruncated`, `stderrTruncated` and `stderrSummaryTruncated` metadata. Producers should also bound persisted process output and omit unavailable optional fields rather than writing `undefined` into the JSON session log.
132
+
133
+ Tool hooks should include the execution's `callId` in both records. The plugin matches it within the originating session and turn; it never chooses the last active tool. Tool observations begin at `tools/pre-execute` (a prepended observer) and end at `tools/result`, covering synchronous/awaited BeforeTool and AfterTool hooks, pre-dispatch denials, and final post-policy output. Bridges must run BeforeTool within the pre-execute waterfall and AfterTool within post-execute, before the final result notification. A bridge registered with prepend ahead of this observer must delegate before reporting its hook. Agent hooks and legacy records without `callId` remain under the turn/subagent. Unknown or already finished calls fall back to that parent; late invocations outside the originating turn use the detached-root behavior below.
130
134
 
131
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.
132
136
 
133
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.
134
138
 
135
- Custom names and stderr 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).
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).
136
140
 
137
141
  Observability is a no-throw seam: backend errors are logged with a `[dsh-langfuse]` prefix and never escape into the agent loop.
138
142
 
139
143
  ## Compatibility
140
144
 
141
- Run `pnpm test:sdk` at the repository root for a secrets-free check using the real Langfuse SDK and the existing local OTLP receiver. It verifies export, native tool type, SDK label redaction, ambient-baggage isolation, external trace ownership, agent-less tool unload, and custom hook export across turn closure. It is separate from the mocked unit suite and the gateway/real-Langfuse E2E legs.
145
+ Run `pnpm test:sdk` at the repository root for a secrets-free check using the real Langfuse SDK and the existing local OTLP receiver. It verifies export, native tool type, SDK label redaction, ambient-baggage isolation, external trace ownership, agent-less tool unload, the real tool runtime and hook processes, tool-hook parenting and enclosing timestamps, content masking, and custom hook export across turn closure. It is separate from the mocked unit suite and the gateway/real-Langfuse E2E legs.
146
+
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.
142
148
 
143
149
  ### Trace overview compatibility
144
150
 
package/lib/hooks.js CHANGED
@@ -8,6 +8,7 @@ function baseFields(data) {
8
8
  value.point.length > 0 &&
9
9
  typeof value.handlerId === 'string' &&
10
10
  value.handlerId.length > 0 &&
11
+ (value.callId === undefined || (typeof value.callId === 'string' && value.callId.length > 0)) &&
11
12
  (value.invocationId === undefined ||
12
13
  (typeof value.invocationId === 'string' && value.invocationId.length > 0)));
13
14
  }
@@ -27,7 +28,11 @@ export function isHookResult(data) {
27
28
  ['success', 'failed', 'timed-out', 'cancelled'].includes(data.outcome)) &&
28
29
  (data.exitCode === undefined || Number.isSafeInteger(data.exitCode)) &&
29
30
  (data.signal === undefined || typeof data.signal === 'string') &&
30
- (data.stderrSummary === undefined || typeof data.stderrSummary === 'string'));
31
+ (data.stderrSummary === undefined || typeof data.stderrSummary === 'string') &&
32
+ (data.stdout === undefined || typeof data.stdout === 'string') &&
33
+ (data.stderr === undefined || typeof data.stderr === 'string') &&
34
+ (data.result === undefined ||
35
+ (data.result !== null && typeof data.result === 'object' && !Array.isArray(data.result))));
31
36
  }
32
37
  /**
33
38
  * Append standard telemetry without changing hook decisions.
package/lib/index.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * from the chunk stream), plus a nested `llm-request` **span** holding the
9
9
  * same options and collected chunk stream (the rawest request/response a
10
10
  * plugin can observe — the provider HTTP body is assembled inside the adapter).
11
- * - `tools/execute` waterfall → one Langfuse **span** per tool dispatch
11
+ * - `tools/pre-execute` → `tools/result` → one Langfuse **span** per tool pipeline
12
12
  * (routed to the session's trace via `exec.agent.id` — the agent/session
13
13
  * shared identity).
14
14
  * - `hook/invoked` / `hook/result` session records → one **span** per hook run.
@@ -204,8 +204,10 @@ 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
- /** Per-session stack of currently open tools/execute spans (for subagent parenting). */
207
+ /** Per-session stack of currently open tool pipeline spans (for subagent parenting). */
208
208
  const openToolSpans = new Map();
209
+ const toolCalls = new Map();
210
+ const toolResults = new WeakMap();
209
211
  const openHooks = new Map();
210
212
  const openOperations = new Set();
211
213
  const traceContexts = new Map();
@@ -403,8 +405,11 @@ export function apply(ctx, config) {
403
405
  metadata: { turn: data.turn, detachedHook: true },
404
406
  })
405
407
  : undefined;
406
- const span = reporter.startSpan(root === undefined ? parentFor(sessionId) : root, {
407
- name: `hook:${data.point}`,
408
+ const tool = data.callId ? toolCalls.get(sessionId)?.get(data.callId) : undefined;
409
+ 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, {
411
+ name: `hook:${data.point}${hookName ? `:${hookName}` : ''}`,
412
+ input: config.captureContent ? data.input : undefined,
408
413
  metadata: {
409
414
  turn: data.turn,
410
415
  point: data.point,
@@ -412,6 +417,7 @@ export function apply(ctx, config) {
412
417
  matcher: data.matcher,
413
418
  handlerId: data.handlerId,
414
419
  ...(data.invocationId ? { invocationId: data.invocationId } : {}),
420
+ ...(data.callId ? { callId: data.callId } : {}),
415
421
  ...(config.captureContent && data.name ? { name: data.name } : {}),
416
422
  },
417
423
  });
@@ -422,6 +428,7 @@ export function apply(ctx, config) {
422
428
  point: data.point,
423
429
  handlerId: data.handlerId,
424
430
  persistent: !!data.invocationId,
431
+ callId: data.callId,
425
432
  turnTrace: root === undefined ? (child?.turnTrace ?? sessions.get(sessionId)?.trace) : undefined,
426
433
  });
427
434
  openHooks.set(sessionId, pending);
@@ -437,7 +444,8 @@ export function apply(ctx, config) {
437
444
  if (!hook ||
438
445
  hook.handlerId !== data.handlerId ||
439
446
  hook.turn !== data.turn ||
440
- hook.point !== data.point)
447
+ hook.point !== data.point ||
448
+ (data.callId !== undefined && hook.callId !== data.callId))
441
449
  return;
442
450
  const span = hook.span;
443
451
  const failed = data.outcome
@@ -449,6 +457,21 @@ export function apply(ctx, config) {
449
457
  ? 'WARNING'
450
458
  : 'DEFAULT';
451
459
  if (span) {
460
+ const processOutput = {};
461
+ const truncation = {};
462
+ if (config.captureContent) {
463
+ const masked = reporter.mask({
464
+ stdout: data.stdout,
465
+ stderr: data.stderr,
466
+ stderrSummary: data.stderrSummary,
467
+ });
468
+ for (const [field, text] of Object.entries(masked)) {
469
+ if (text === undefined)
470
+ continue;
471
+ processOutput[field] = text.slice(0, 4096);
472
+ truncation[`${field}Truncated`] = text.length > 4096;
473
+ }
474
+ }
452
475
  if (failed && sessions.get(rootSessionId(sessionId) ?? '')?.trace === hook.turnTrace)
453
476
  noteError(sessionId);
454
477
  reporter.endSpan(span, {
@@ -457,10 +480,10 @@ export function apply(ctx, config) {
457
480
  ...(data.outcome ? { outcome: data.outcome } : {}),
458
481
  ...(data.signal ? { signal: data.signal } : {}),
459
482
  exitCode: data.exitCode,
460
- stderrSummary: config.captureContent ? data.stderrSummary : undefined,
483
+ ...(config.captureContent ? { result: data.result, ...processOutput } : {}),
461
484
  },
462
485
  level,
463
- metadata: { durationMs: data.durationMs },
486
+ metadata: { durationMs: data.durationMs, ...truncation },
464
487
  });
465
488
  }
466
489
  reporter.endSpan(hook.root ?? null, { level });
@@ -469,6 +492,8 @@ export function apply(ctx, config) {
469
492
  openHooks.delete(sessionId);
470
493
  return;
471
494
  }
495
+ if (event.type === 'turn/end' || event.type === 'turn/start')
496
+ toolCalls.delete(sessionId);
472
497
  if (event.type === 'turn/end')
473
498
  closeHooks(sessionId, true);
474
499
  const child = children.get(sessionId);
@@ -561,6 +586,7 @@ export function apply(ctx, config) {
561
586
  sessions.delete(sessionId);
562
587
  traceContexts.delete(sessionId);
563
588
  openToolSpans.delete(sessionId);
589
+ toolCalls.delete(sessionId);
564
590
  // End an abandoned root (crash-orphaned turn or between-turns one-off
565
591
  // trace) — v5 spans only export on end, so dropping it open would lose
566
592
  // everything it recorded.
@@ -756,12 +782,12 @@ export function apply(ctx, config) {
756
782
  })();
757
783
  });
758
784
  // ------------------------------------------------------------------
759
- // Span per tool dispatch. This waterfall IS async: `next()` returns the
760
- // normalized dispatch result promise. Open spans are tracked per session
785
+ // Span per tool pipeline, including pre/post hooks and denied calls.
786
+ // Open spans are tracked per session
761
787
  // so a subagent span opened mid-dispatch (foreground delegation) parents
762
788
  // under the enclosing tool span.
763
789
  // ------------------------------------------------------------------
764
- ctx.on('tools/execute', async (exec, next) => {
790
+ ctx.on('tools/pre-execute', (exec, next) => {
765
791
  const sessionId = exec.agent?.id;
766
792
  const parent = parentFor(sessionId);
767
793
  // An agent-less dispatch's trace root has no session lifecycle to close
@@ -778,6 +804,12 @@ export function apply(ctx, config) {
778
804
  });
779
805
  if (sessionId && span) {
780
806
  openToolSpans.set(sessionId, [...(openToolSpans.get(sessionId) ?? []), span]);
807
+ const calls = toolCalls.get(sessionId) ?? new Map();
808
+ calls.set(exec.callId, {
809
+ span,
810
+ turn: children.get(sessionId)?.turn ?? sessions.get(sessionId)?.turn,
811
+ });
812
+ toolCalls.set(sessionId, calls);
781
813
  }
782
814
  let closed = false;
783
815
  const closeTool = () => finishTool({ level: 'WARNING', statusMessage: 'plugin unloaded during tool call' });
@@ -787,6 +819,13 @@ export function apply(ctx, config) {
787
819
  closed = true;
788
820
  const endedAt = new Date();
789
821
  openOperations.delete(closeTool);
822
+ toolResults.delete(exec);
823
+ if (sessionId && toolCalls.get(sessionId)?.get(exec.callId)?.span === span) {
824
+ const calls = toolCalls.get(sessionId);
825
+ calls.delete(exec.callId);
826
+ if (!calls.size)
827
+ toolCalls.delete(sessionId);
828
+ }
790
829
  if (sessionId && span) {
791
830
  const remaining = (openToolSpans.get(sessionId) ?? []).filter((s) => s !== span);
792
831
  if (remaining.length)
@@ -807,10 +846,9 @@ export function apply(ctx, config) {
807
846
  end();
808
847
  }
809
848
  openOperations.add(closeTool);
810
- try {
811
- const result = await next();
849
+ toolResults.set(exec, (result) => {
812
850
  if (closed)
813
- return result;
851
+ return;
814
852
  if (result.isError)
815
853
  noteError(sessionId);
816
854
  const update = {
@@ -819,21 +857,18 @@ export function apply(ctx, config) {
819
857
  statusMessage: result.isError ? contentMessage(config, result.error.message) : undefined,
820
858
  metadata: { errorCode: result.isError ? result.error.info?.code : undefined },
821
859
  };
822
- if (span && config.captureContent && result.content.some((block) => block.type === 'image')) {
860
+ if (span &&
861
+ config.captureContent &&
862
+ result.content.some((block) => block.type === 'image')) {
823
863
  finishTool(update, displayBlocks(result.content, ctx.get('attachments'), mediaOptions, exec.signal));
824
864
  }
825
865
  else
826
866
  finishTool(update);
827
- return result;
828
- }
829
- catch (error) {
830
- noteError(sessionId);
831
- finishTool({
832
- level: 'ERROR',
833
- statusMessage: contentMessage(config, messageOf(error)),
834
- });
835
- throw error;
836
- }
867
+ });
868
+ return next();
869
+ }, { prepend: true });
870
+ ctx.on('tools/result', (exec, result) => {
871
+ toolResults.get(exec)?.(result);
837
872
  });
838
873
  // Durability checkpoint: the store awaits every session/flush listener.
839
874
  ctx.on('session/flush', async () => {
@@ -862,6 +897,7 @@ export function apply(ctx, config) {
862
897
  traceContexts.clear();
863
898
  children.clear();
864
899
  openToolSpans.clear();
900
+ toolCalls.clear();
865
901
  await reporter.shutdown();
866
902
  };
867
903
  });
@@ -9,12 +9,20 @@ export interface HookInvoked {
9
9
  /** Unique per execution, shared with its result; required for concurrent runs. */
10
10
  invocationId?: string;
11
11
  name?: string;
12
+ /** Tool execution identity; omit for agent hooks. */
13
+ callId?: string;
14
+ input?: unknown;
12
15
  }
13
16
  export interface HookResult {
14
17
  turn: number;
15
18
  point: string;
16
19
  handlerId: string;
17
20
  invocationId?: string;
21
+ callId?: string;
22
+ /** Parsed hook output; process status remains in the fields below. */
23
+ result?: Record<string, unknown>;
24
+ stdout?: string;
25
+ stderr?: string;
18
26
  decision: string;
19
27
  outcome?: 'success' | 'failed' | 'timed-out' | 'cancelled';
20
28
  exitCode?: number;
@@ -8,7 +8,7 @@
8
8
  * from the chunk stream), plus a nested `llm-request` **span** holding the
9
9
  * same options and collected chunk stream (the rawest request/response a
10
10
  * plugin can observe — the provider HTTP body is assembled inside the adapter).
11
- * - `tools/execute` waterfall → one Langfuse **span** per tool dispatch
11
+ * - `tools/pre-execute` → `tools/result` → one Langfuse **span** per tool pipeline
12
12
  * (routed to the session's trace via `exec.agent.id` — the agent/session
13
13
  * shared identity).
14
14
  * - `hook/invoked` / `hook/result` session records → one **span** per hook run.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amaster.ai/dsh-langfuse",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
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",