@amaster.ai/dsh-langfuse 0.1.20 → 0.1.22

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
@@ -89,11 +89,73 @@ Compaction and session-title calls passing through `llm/stream` already receive
89
89
 
90
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.
91
91
 
92
+ ### Custom hook producers
93
+
94
+ Custom bridges use the same `hook/invoked` and `hook/result` session records. The writer and consumer accept an unrestricted `dialect` source string (`gemini`, `codex`, `company-validator`, etc.); admission to the session log still depends on installed runtime invariants. No dialect registration or separate exporter is needed. The plugin observes records only and never executes hooks or changes their decisions.
95
+
96
+ Import `appendHookEvent`, `HookInvoked`, and `HookResult` from `@amaster.ai/dsh-langfuse`. This typed writer uses the real `session.append` publication path without redeclaring `SessionEventMap`. Its TypeScript adaptation allows custom payloads despite the optional upstream `dsh-hook-protocol` type augmentation; it does not widen that package's runtime contract, runner, or bridge APIs. The writer validates fields, catches append failures, logs `[dsh-langfuse]`, and returns a boolean; telemetry success must never control a hook's decision.
97
+
98
+ **Runtime compatibility boundary (verified with `dsh-hook-protocol@0.2.0-rc.2`).** When its invariant companion is registered and enabled through `dsh-invariants`, it rejects dialects other than `claude-code`/`codex`, invocations outside an open turn, and results after their turn closes or while another turn is open. A type assertion cannot bypass these pre-commit checks. `appendHookEvent` returns `false` for rejected records; they are not committed or published and produce no Langfuse hook observation.
99
+
100
+ | Runtime composition | Claude Code/Codex pairs in the open turn | Custom dialects | Detached invocations / late results |
101
+ | --- | --- | --- | --- |
102
+ | No active hook-protocol invariant | Supported | Supported | Supported with `invocationId` |
103
+ | Active hook-protocol invariant 0.2.0-rc.2 | Supported | Rejected | Rejected |
104
+
105
+ The writer never disables or reconfigures invariants. Applications using the active companion need an upstream contract change before adopting custom or late records. The producer still owns hook execution and decisions even when telemetry is rejected. The following custom example requires a runtime composition that admits those records.
106
+
107
+ ```ts
108
+ import { randomUUID } from 'node:crypto';
109
+ import { appendHookEvent } from '@amaster.ai/dsh-langfuse';
110
+
111
+ // The bridge determines turn/point, executes the command, and owns its outcome.
112
+ const invocationId = randomUUID();
113
+ const identity = { turn, point: 'BeforeAgent', handlerId: 'request-validator', invocationId };
114
+ appendHookEvent(session, 'hook/invoked', {
115
+ ...identity,
116
+ dialect: 'company-validator',
117
+ name: 'Validate request',
118
+ });
119
+ // After execution, including exceptions, timeout or cancellation:
120
+ appendHookEvent(session, 'hook/result', {
121
+ ...identity,
122
+ decision: 'pass',
123
+ outcome: 'success',
124
+ exitCode: 0,
125
+ durationMs: 37,
126
+ });
127
+ ```
128
+
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.
130
+
131
+ `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
+
133
+ 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
+
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).
136
+
92
137
  Observability is a no-throw seam: backend errors are logged with a `[dsh-langfuse]` prefix and never escape into the agent loop.
93
138
 
94
139
  ## Compatibility
95
140
 
96
- 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, and agent-less tool unload. It is separate from the mocked unit suite and the gateway/real-Langfuse E2E legs.
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.
142
+
143
+ ### Trace overview compatibility
144
+
145
+ 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.
146
+
147
+ The plugin also sends deprecated `langfuse.trace.input` / `langfuse.trace.output` attributes on independently owned roots for legacy Trace overview compatibility. It never writes these fields when joining an externally owned trace. [Langfuse recommends root-observation IO](https://langfuse.com/faq/all/empty-trace-input-and-output); empty Trace overview fields alone do not establish missing observation data.
148
+
149
+ Real Trace/root API read-back was verified on self-hosted Langfuse **3.172.1** with JS SDK **5.10.1** and **5.11.1**. This verifies new synthetic turns, including children exported before the root and an external observation joining the trace. It does not establish compatibility for every server version or repair historical traces. [Issue #59](https://github.com/TGYD-helige/dsh-plugins/issues/59) contains a historical SDK 5.11.1 trace whose root IO is present but Trace IO remains empty; the cause of that discrepancy has not been reproduced on new turns. For that trace, select the `dsh-turn` observation to read the overall request and response.
150
+
151
+ To verify your deployment without an LLM, set `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`, and `LANGFUSE_BASE_URL` in the process environment, then run:
152
+
153
+ ```bash
154
+ pnpm --filter @amaster.ai/dsh-langfuse build
155
+ node .github/scripts/integration/lib/langfuse-trace-io-check.mjs
156
+ ```
157
+
158
+ The check creates a synthetic trace in environment `ci` and polls both v1 APIs within a shared two-minute deadline, retrying transient network errors, HTTP 429 (honoring `Retry-After` seconds), and 5xx responses. It reads each observation directly by ID, retrying 404 while ingestion settles; other permanent 4xx errors fail immediately. It explicitly requests Trace IO (`fields=core,io`), rather than relying on server-configured default fields. It first verifies the owner's root and Trace IO, then exports an external observation and waits for its distinct IO in the same trace while checking the owner's Trace IO remains intact. Credentials and response bodies are not printed. A root-only success does not pass this check.
97
159
 
98
160
  Pinned dsh/cordis versions live in the [root compat matrix](../../README.md#compatibility). Event payloads ride pre-release dsh APIs — check the `TODO(verify)` markers in `src/` before upgrading dsh.
99
161
 
package/lib/hooks.js ADDED
@@ -0,0 +1,52 @@
1
+ function baseFields(data) {
2
+ if (!data || typeof data !== 'object')
3
+ return false;
4
+ const value = data;
5
+ return (Number.isSafeInteger(value.turn) &&
6
+ value.turn >= 0 &&
7
+ typeof value.point === 'string' &&
8
+ value.point.length > 0 &&
9
+ typeof value.handlerId === 'string' &&
10
+ value.handlerId.length > 0 &&
11
+ (value.invocationId === undefined ||
12
+ (typeof value.invocationId === 'string' && value.invocationId.length > 0)));
13
+ }
14
+ export function isHookInvoked(data) {
15
+ return (baseFields(data) &&
16
+ typeof data.dialect === 'string' &&
17
+ data.dialect.length > 0 &&
18
+ (data.matcher === undefined || typeof data.matcher === 'string') &&
19
+ (data.name === undefined || typeof data.name === 'string'));
20
+ }
21
+ export function isHookResult(data) {
22
+ return (baseFields(data) &&
23
+ typeof data.decision === 'string' &&
24
+ Number.isFinite(data.durationMs) &&
25
+ data.durationMs >= 0 &&
26
+ (data.outcome === undefined ||
27
+ ['success', 'failed', 'timed-out', 'cancelled'].includes(data.outcome)) &&
28
+ (data.exitCode === undefined || Number.isSafeInteger(data.exitCode)) &&
29
+ (data.signal === undefined || typeof data.signal === 'string') &&
30
+ (data.stderrSummary === undefined || typeof data.stderrSummary === 'string'));
31
+ }
32
+ /**
33
+ * Append standard telemetry without changing hook decisions.
34
+ * Runtime invariants still apply; rejected custom/late records return false.
35
+ */
36
+ export function appendHookEvent(session, type, data) {
37
+ try {
38
+ if (!(type === 'hook/invoked'
39
+ ? isHookInvoked(data)
40
+ : type === 'hook/result' && isHookResult(data))) {
41
+ throw new TypeError('invalid hook telemetry fields');
42
+ }
43
+ // Widen TypeScript only; session.append still enforces installed runtime invariants.
44
+ const append = session.append;
45
+ append.call(session, type, data);
46
+ return true;
47
+ }
48
+ catch (error) {
49
+ console.error('[dsh-langfuse] hook reporting failed:', error);
50
+ return false;
51
+ }
52
+ }
package/lib/index.js CHANGED
@@ -30,7 +30,9 @@
30
30
  */
31
31
  import Schema from '@deepseek-ai/schemastery';
32
32
  import { LangfuseReporter } from './client.js';
33
+ import { isHookInvoked, isHookResult } from './hooks.js';
33
34
  import { displayBlocks } from './media.js';
35
+ export { appendHookEvent } from './hooks.js';
34
36
  export const name = 'dsh-langfuse';
35
37
  /**
36
38
  * Waterfall listeners only need the events to exist; no hard service
@@ -270,11 +272,19 @@ export function apply(ctx, config) {
270
272
  if (state)
271
273
  state.hadErrors = true;
272
274
  }
273
- function closeHooks(sessionId) {
274
- for (const span of openHooks.get(sessionId)?.values() ?? []) {
275
- reporter.endSpan(span, { level: 'WARNING', statusMessage: 'hook result not observed' });
275
+ function closeHooks(sessionId, legacyOnly = false) {
276
+ const pending = openHooks.get(sessionId);
277
+ for (const [key, hook] of pending ?? []) {
278
+ // Per-execution IDs allow results after turn closure without colliding with the next turn.
279
+ if (legacyOnly && hook.persistent)
280
+ continue;
281
+ const update = { level: 'WARNING', statusMessage: 'hook result not observed' };
282
+ reporter.endSpan(hook.span, update);
283
+ reporter.endSpan(hook.root ?? null, update);
284
+ pending?.delete(key);
276
285
  }
277
- openHooks.delete(sessionId);
286
+ if (pending?.size === 0)
287
+ openHooks.delete(sessionId);
278
288
  }
279
289
  /**
280
290
  * The trace an observation for `sessionId` belongs to: the session's open
@@ -328,7 +338,13 @@ export function apply(ctx, config) {
328
338
  delegationDepth: session.header.delegationDepth,
329
339
  },
330
340
  });
331
- children.set(childId, { parentSessionId, span, hasInput: false });
341
+ children.set(childId, {
342
+ parentSessionId,
343
+ span,
344
+ hasInput: false,
345
+ turnTrace: children.get(parentSessionId)?.turnTrace ??
346
+ sessions.get(rootSessionId(parentSessionId) ?? '')?.trace,
347
+ });
332
348
  });
333
349
  ctx.on('subagent/start', (info) => {
334
350
  const child = children.get(info.id);
@@ -364,16 +380,30 @@ export function apply(ctx, config) {
364
380
  const hookEvent = event;
365
381
  if (hookEvent.type === 'hook/invoked') {
366
382
  const data = hookEvent.data;
367
- if (!data || typeof data.handlerId !== 'string' || typeof data.point !== 'string')
383
+ if (!isHookInvoked(data))
368
384
  return;
369
385
  const pending = openHooks.get(sessionId) ?? new Map();
370
- if (pending.has(data.handlerId)) {
371
- reporter.endSpan(pending.get(data.handlerId) ?? null, {
386
+ const key = data.invocationId ? `run:${data.invocationId}` : `handler:${data.handlerId}`;
387
+ if (pending.has(key)) {
388
+ reporter.endSpan(pending.get(key)?.span ?? null, {
372
389
  level: 'WARNING',
373
390
  statusMessage: 'hook result not observed',
374
391
  });
375
392
  }
376
- pending.set(data.handlerId, reporter.startSpan(parentFor(sessionId), {
393
+ const previous = pending.get(key);
394
+ if (previous?.root)
395
+ reporter.endSpan(previous.root, { level: 'WARNING' });
396
+ const child = children.get(sessionId);
397
+ const inTurn = child ? child.turn === data.turn : sessions.get(sessionId)?.turn === data.turn;
398
+ const root = data.invocationId && !inTurn
399
+ ? // A late hook must not consume context queued for the next turn.
400
+ reporter.openTrace({
401
+ name: config.traceName,
402
+ sessionId,
403
+ metadata: { turn: data.turn, detachedHook: true },
404
+ })
405
+ : undefined;
406
+ const span = reporter.startSpan(root === undefined ? parentFor(sessionId) : root, {
377
407
  name: `hook:${data.point}`,
378
408
  metadata: {
379
409
  turn: data.turn,
@@ -381,43 +411,72 @@ export function apply(ctx, config) {
381
411
  dialect: data.dialect,
382
412
  matcher: data.matcher,
383
413
  handlerId: data.handlerId,
414
+ ...(data.invocationId ? { invocationId: data.invocationId } : {}),
415
+ ...(config.captureContent && data.name ? { name: data.name } : {}),
384
416
  },
385
- }));
417
+ });
418
+ pending.set(key, {
419
+ span,
420
+ root,
421
+ turn: data.turn,
422
+ point: data.point,
423
+ handlerId: data.handlerId,
424
+ persistent: !!data.invocationId,
425
+ turnTrace: root === undefined ? (child?.turnTrace ?? sessions.get(sessionId)?.trace) : undefined,
426
+ });
386
427
  openHooks.set(sessionId, pending);
387
428
  return;
388
429
  }
389
430
  if (hookEvent.type === 'hook/result') {
390
431
  const data = hookEvent.data;
391
- if (!data || typeof data.handlerId !== 'string' || typeof data.decision !== 'string')
432
+ if (!isHookResult(data))
392
433
  return;
393
434
  const pending = openHooks.get(sessionId);
394
- const span = pending?.get(data.handlerId);
435
+ const key = data.invocationId ? `run:${data.invocationId}` : `handler:${data.handlerId}`;
436
+ const hook = pending?.get(key);
437
+ if (!hook ||
438
+ hook.handlerId !== data.handlerId ||
439
+ hook.turn !== data.turn ||
440
+ hook.point !== data.point)
441
+ return;
442
+ const span = hook.span;
443
+ const failed = data.outcome
444
+ ? data.outcome === 'failed' || data.outcome === 'timed-out'
445
+ : data.exitCode === undefined || (data.exitCode !== 0 && data.exitCode !== 2);
446
+ const level = failed
447
+ ? 'ERROR'
448
+ : data.outcome === 'cancelled' || ['block', 'deny', 'stop'].includes(data.decision)
449
+ ? 'WARNING'
450
+ : 'DEFAULT';
395
451
  if (span) {
396
- if (data.exitCode === undefined || (data.exitCode !== 0 && data.exitCode !== 2))
452
+ if (failed && sessions.get(rootSessionId(sessionId) ?? '')?.trace === hook.turnTrace)
397
453
  noteError(sessionId);
398
454
  reporter.endSpan(span, {
399
455
  output: {
400
456
  decision: data.decision,
457
+ ...(data.outcome ? { outcome: data.outcome } : {}),
458
+ ...(data.signal ? { signal: data.signal } : {}),
401
459
  exitCode: data.exitCode,
402
460
  stderrSummary: config.captureContent ? data.stderrSummary : undefined,
403
461
  },
404
- level: data.exitCode === undefined || (data.exitCode !== 0 && data.exitCode !== 2)
405
- ? 'ERROR'
406
- : data.decision === 'block' || data.decision === 'deny' || data.decision === 'stop'
407
- ? 'WARNING'
408
- : 'DEFAULT',
462
+ level,
409
463
  metadata: { durationMs: data.durationMs },
410
464
  });
411
465
  }
412
- pending?.delete(data.handlerId);
466
+ reporter.endSpan(hook.root ?? null, { level });
467
+ pending?.delete(key);
413
468
  if (pending?.size === 0)
414
469
  openHooks.delete(sessionId);
415
470
  return;
416
471
  }
417
472
  if (event.type === 'turn/end')
418
- closeHooks(sessionId);
473
+ closeHooks(sessionId, true);
419
474
  const child = children.get(sessionId);
420
475
  if (child) {
476
+ if (event.type === 'turn/start')
477
+ child.turn = event.data.turn;
478
+ if (event.type === 'turn/end')
479
+ child.turn = undefined;
421
480
  // The delegated prompt (the child's first user/message) is the
422
481
  // subagent span's input; the descriptor enriches the span name.
423
482
  if (event.type === 'user/message' && !child.hasInput && config.captureContent) {
@@ -441,13 +500,13 @@ export function apply(ctx, config) {
441
500
  // whose `turn/end` was never seen (crash-orphaned): end and replace
442
501
  // it — v5 spans only export on end, so the stale root must close to
443
502
  // keep what it already recorded.
444
- closeHooks(sessionId);
503
+ closeHooks(sessionId, true);
445
504
  const stale = sessions.get(sessionId);
446
505
  if (stale)
447
506
  afterMedia(stale.trace, (endedAt) => reporter.endSpan(stale.trace, {}, endedAt));
448
507
  const trace = openTrace(sessionId, { turn: event.data.turn });
449
508
  if (trace)
450
- sessions.set(sessionId, { trace, hasInput: false });
509
+ sessions.set(sessionId, { trace, hasInput: false, turn: event.data.turn });
451
510
  break;
452
511
  }
453
512
  case 'user/message': {
@@ -0,0 +1,36 @@
1
+ import type { Session } from '@deepseek-ai/dsh-session';
2
+ /** Standard log-only hook records; producers own matching, execution and decisions. */
3
+ export interface HookInvoked {
4
+ turn: number;
5
+ point: string;
6
+ dialect: string;
7
+ matcher?: string;
8
+ handlerId: string;
9
+ /** Unique per execution, shared with its result; required for concurrent runs. */
10
+ invocationId?: string;
11
+ name?: string;
12
+ }
13
+ export interface HookResult {
14
+ turn: number;
15
+ point: string;
16
+ handlerId: string;
17
+ invocationId?: string;
18
+ decision: string;
19
+ outcome?: 'success' | 'failed' | 'timed-out' | 'cancelled';
20
+ exitCode?: number;
21
+ signal?: string;
22
+ stderrSummary?: string;
23
+ durationMs: number;
24
+ }
25
+ interface HookEventMap {
26
+ 'hook/invoked': HookInvoked;
27
+ 'hook/result': HookResult;
28
+ }
29
+ export declare function isHookInvoked(data: unknown): data is HookInvoked;
30
+ export declare function isHookResult(data: unknown): data is HookResult;
31
+ /**
32
+ * Append standard telemetry without changing hook decisions.
33
+ * Runtime invariants still apply; rejected custom/late records return false.
34
+ */
35
+ export declare function appendHookEvent<K extends keyof HookEventMap>(session: Session, type: K, data: HookEventMap[K]): boolean;
36
+ export {};
@@ -32,6 +32,7 @@ import type { Context } from '@deepseek-ai/cordis';
32
32
  import Schema from '@deepseek-ai/schemastery';
33
33
  import { type LangfuseTraceContext } from './client.js';
34
34
  export type { LangfuseTraceContext } from './client.js';
35
+ export { appendHookEvent, type HookInvoked, type HookResult } from './hooks.js';
35
36
  declare module '@deepseek-ai/cordis' {
36
37
  interface Events {
37
38
  /** Bind standard labels/parent context before a session's next trace starts. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amaster.ai/dsh-langfuse",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
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",
@@ -67,6 +67,8 @@
67
67
  "@deepseek-ai/cordis": "^4.0.4",
68
68
  "@deepseek-ai/dsh-agent": "0.2.0-rc.2",
69
69
  "@deepseek-ai/dsh-attachment": "0.2.0-rc.2",
70
+ "@deepseek-ai/dsh-hook-protocol": "0.2.0-rc.2",
71
+ "@deepseek-ai/dsh-invariants": "0.2.0-rc.2",
70
72
  "@deepseek-ai/dsh-llm": "0.2.0-rc.2",
71
73
  "@deepseek-ai/dsh-ptc-runtime": "0.2.0-rc.2",
72
74
  "@deepseek-ai/dsh-session": "0.2.0-rc.2",