@amaster.ai/dsh-langfuse 0.1.34 → 0.1.36

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
@@ -7,7 +7,7 @@ Langfuse observability for [DeepSeek Harness (dsh)](https://github.com/deepseek-
7
7
  - one **generation** per LLM call (`llm/stream` waterfall), plus a nested `llm-request` span carrying the verbatim loop-built request
8
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
- - one **trace** per session turn (`session/event`) — in the v5 SDK the trace IS its root span, ended (and thereby exported) at `turn/end`
10
+ - one **trace** per session turn with observed activity (`session/event`) — the root is created on the first message or observed operation, retains the original turn start time, and ends at `turn/end`; completed turns without activity export nothing, while errors and cancellation remain visible
11
11
  - subagent child sessions nested under the parent's tree (`session/created` header link + `subagent/start` / `subagent/end`)
12
12
  - structured reasoning in generation output, optional image display, inherited user/tags, and final turn severity
13
13
  - optional W3C parent context supplied per session, without adopting the host's ambient OTEL context
@@ -37,6 +37,7 @@ Disabled by default. Configure via the profile's `cordis.patch.yml`:
37
37
  baseUrl: https://cloud.langfuse.com
38
38
  traceName: dsh-turn
39
39
  captureContent: true # set false to record metadata only
40
+ maxPayloadBytes: 262144 # per-LLM-call request/raw/output cap (256 KiB)
40
41
  userId: developer
41
42
  tags: [coding, experiment-a]
42
43
  environment: development
@@ -53,6 +54,8 @@ Buffered spans drain on `session/flush(session)`; the exporter shuts down with t
53
54
 
54
55
  With `captureContent: true`, both `llm-call` and `llm-request` inputs contain every `llm/stream` request option except the non-serializable `AbortSignal`. New request fields pass through without a plugin update. For image-bearing requests, generation input is updated to a display projection; `llm-request` retains the original attachment references. Langfuse's separate generation `modelParameters` attribute summarizes the known sampling fields; it is not part of either request input. These are dsh-side values, not exact provider HTTP payloads: adapters may add defaults, project messages/tools, or rename fields after this seam. With `captureContent: false`, the collector keeps chunk/tool-call counts and usage without buffering text, reasoning, tool arguments, or raw chunks; inputs keep structural counts while the separate `modelParameters` attribute keeps safe parameter metadata; reply excerpts, reasoning, tool arguments/results, error text, and media are withheld from observation names and IO.
55
56
 
57
+ `maxPayloadBytes` independently bounds each captured request, raw chunk stream, and assembled output per LLM call (default 256 KiB). Oversized requests become a bounded preview; oversized streams retain only fitting chunks/prefixes and set `metadata.truncated`. These limits apply only to telemetry copies: forwarded chunks and original messages/results stay unchanged. Session disposal closes that session's active LLM/tool observations; a child whose activation rolls back before `subagent/start` closes immediately, while an already-started child still closes at `subagent/end`.
58
+
56
59
  Configured Langfuse credentials and `pk-lf-*` / `sk-lf-*` values are redacted across reporter payloads, names, status messages, and labels. `redactFields` replaces matching payload fields recursively, including JSON-encoded arguments, ignoring case. Name excerpts are derived after redaction. Delta blocks are also checked as assembled text so fragmented credentials cannot bypass the mask: affected blocks keep their record count/type/index, with sanitized text in the first delta and later deltas cleared. Redaction only changes telemetry copies; original messages/results and the forwarded stream stay intact. The exporter applies the same mask to IO and metadata as a final safeguard. User/tags are explicit metadata and remain when content capture is off.
57
60
 
58
61
  ## Reasoning, images, and turn results
package/lib/client.js CHANGED
@@ -115,16 +115,38 @@ export class LangfuseReporter {
115
115
  this.config = config;
116
116
  this.ready = this.init();
117
117
  }
118
+ /** Whether a payload property should be replaced without inspecting its value. */
119
+ shouldRedactField(key) {
120
+ return !!this.config.redactFields?.some((field) => field.toLowerCase() === key.toLowerCase());
121
+ }
118
122
  /** Clone telemetry only; never mutate harness-owned messages or tool results. */
119
123
  mask(data) {
120
124
  const secrets = [this.config.publicKey, this.config.secretKey].filter(Boolean);
121
125
  const fields = new Set(this.config.redactFields?.map((key) => key.toLowerCase()));
122
126
  const seen = new WeakSet();
123
127
  const redactText = (text) => {
124
- const ranges = Array.from(text.matchAll(/(?:pk|sk)-lf-[\w-]+/g), (match) => [match.index, match.index + match[0].length]);
128
+ const markedRanges = Array.from(text.matchAll(/\[REDACTED\]/g), (match) => [match.index, match.index + match[0].length]);
129
+ const ranges = [];
130
+ let markerIndex = 0;
131
+ const addRange = (start, stop) => {
132
+ // Existing markers are atomic: a second mask must never expand them.
133
+ // Walk the sorted marker list once instead of rescanning it per match.
134
+ while (markerIndex < markedRanges.length && markedRanges[markerIndex][1] <= start)
135
+ markerIndex += 1;
136
+ if (markerIndex < markedRanges.length &&
137
+ start < markedRanges[markerIndex][1] &&
138
+ stop > markedRanges[markerIndex][0])
139
+ return;
140
+ ranges.push([start, stop]);
141
+ };
142
+ for (const match of text.matchAll(/(?:pk|sk)-lf-[\w-]+/g))
143
+ addRange(match.index, match.index + match[0].length);
125
144
  for (const secret of secrets) {
145
+ // Each independent scan starts at the first marker. Do not reset for
146
+ // every match, or a large marker list becomes quadratic again.
147
+ markerIndex = 0;
126
148
  for (let index = text.indexOf(secret); index !== -1; index = text.indexOf(secret, index + 1)) {
127
- ranges.push([index, index + secret.length]);
149
+ addRange(index, index + secret.length);
128
150
  }
129
151
  }
130
152
  let output = '';
@@ -346,6 +368,12 @@ export class LangfuseReporter {
346
368
  // v5 observation attributes have no `name` — renames ride the OTEL span.
347
369
  if (update.name)
348
370
  generation.otelSpan.updateName(update.name);
371
+ }
372
+ catch (error) {
373
+ console.error('[dsh-langfuse] generation end failed:', error);
374
+ }
375
+ // An update failure must not strand the observation: v5 exports only ended spans.
376
+ try {
349
377
  generation.end(endedAt);
350
378
  }
351
379
  catch (error) {
@@ -418,6 +446,12 @@ export class LangfuseReporter {
418
446
  if (update.output !== undefined && this.roots.has(span)) {
419
447
  span.setTraceIO({ output: update.output });
420
448
  }
449
+ }
450
+ catch (error) {
451
+ console.error('[dsh-langfuse] span end failed:', error);
452
+ }
453
+ // Always close the span, even when its final attributes could not be written.
454
+ try {
421
455
  span.end(endedAt);
422
456
  }
423
457
  catch (error) {
package/lib/index.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * shared identity).
14
14
  * - `hook/invoked` / `hook/result` session records → one **span** per hook run.
15
15
  * - `session/event` emit → one Langfuse **trace** per agent turn
16
- * (`turn/start` opens a root span — in the v5 SDK the root observation IS
16
+ * (`turn/start` records the start; actual activity opens the root — in v5 it 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
19
  * - Native A2A inbox tasks own a trace spanning admission through task termination;
@@ -54,6 +54,11 @@ export const Config = Schema.object({
54
54
  traceName: Schema.string().default('dsh-turn'),
55
55
  /** Include full message/tool payloads in Langfuse IO. Disable to log metadata only. */
56
56
  captureContent: Schema.boolean().default(true),
57
+ maxPayloadBytes: Schema.number()
58
+ .min(1)
59
+ .step(1)
60
+ .default(256 * 1024)
61
+ .description('maximum captured request, raw-stream, and assembled-output bytes per LLM call'),
57
62
  redactFields: Schema.array(Schema.string())
58
63
  .default([])
59
64
  .description('case-insensitive payload fields to redact'),
@@ -125,6 +130,260 @@ function requestBodyOf(options, captureContent) {
125
130
  const { signal: _, ...body } = options;
126
131
  return body;
127
132
  }
133
+ const newPayloadBudget = (maxBytes) => ({
134
+ remaining: maxBytes,
135
+ truncated: false,
136
+ });
137
+ /** Take a UTF-8 prefix without ever exceeding the remaining content-byte budget. */
138
+ function takePayloadText(value, budget) {
139
+ const bytes = Buffer.byteLength(value);
140
+ if (bytes <= budget.remaining) {
141
+ budget.remaining -= bytes;
142
+ return value;
143
+ }
144
+ budget.truncated = true;
145
+ if (budget.remaining <= 0)
146
+ return '';
147
+ let low = 0;
148
+ let high = value.length;
149
+ while (low < high) {
150
+ const middle = Math.ceil((low + high) / 2);
151
+ if (Buffer.byteLength(value.slice(0, middle)) <= budget.remaining)
152
+ low = middle;
153
+ else
154
+ high = middle - 1;
155
+ }
156
+ budget.remaining = 0;
157
+ return value.slice(0, low);
158
+ }
159
+ /** Build a preview whose complete JSON value, including its wrapper, fits the remaining cap. */
160
+ function previewPayload(serialized, budget) {
161
+ const remaining = budget.remaining;
162
+ const marker = { truncated: true };
163
+ const withPreview = { ...marker, preview: '' };
164
+ if (Buffer.byteLength(JSON.stringify(withPreview)) <= remaining) {
165
+ let low = 0;
166
+ let high = serialized.length;
167
+ while (low < high) {
168
+ const middle = Math.ceil((low + high) / 2);
169
+ const candidate = { ...marker, preview: serialized.slice(0, middle) };
170
+ if (Buffer.byteLength(JSON.stringify(candidate)) <= remaining)
171
+ low = middle;
172
+ else
173
+ high = middle - 1;
174
+ }
175
+ budget.remaining = 0;
176
+ return { ...marker, preview: serialized.slice(0, low) };
177
+ }
178
+ if (Buffer.byteLength(JSON.stringify(marker)) <= remaining) {
179
+ budget.remaining = 0;
180
+ return marker;
181
+ }
182
+ // Even the marker is too large: retain a plain string prefix, still capped
183
+ // after JSON quoting/escaping rather than by source-string bytes.
184
+ let low = 0;
185
+ let high = serialized.length;
186
+ while (low < high) {
187
+ const middle = Math.ceil((low + high) / 2);
188
+ if (Buffer.byteLength(JSON.stringify(serialized.slice(0, middle))) <= remaining)
189
+ low = middle;
190
+ else
191
+ high = middle - 1;
192
+ }
193
+ budget.remaining = 0;
194
+ return low > 0 ? serialized.slice(0, low) : remaining >= 2 ? '' : 0;
195
+ }
196
+ const MAX_ATOMIC_MASK_BYTES = 64 * 1024;
197
+ /** Redact leaves while serializing, so an oversized payload never needs a full mask or stringify. */
198
+ function boundMaskedPayload(input, budget, reporter) {
199
+ const seen = new WeakSet();
200
+ const parts = [];
201
+ let remaining = budget.remaining;
202
+ let overflow = false;
203
+ const append = (text) => {
204
+ if (overflow || text === undefined)
205
+ return;
206
+ const bytes = Buffer.byteLength(text);
207
+ if (bytes > remaining) {
208
+ // The node was already masked atomically; a serialized prefix is safe.
209
+ parts.push(takePayloadText(text, { remaining, truncated: true }));
210
+ remaining = 0;
211
+ overflow = true;
212
+ return;
213
+ }
214
+ parts.push(text);
215
+ remaining -= bytes;
216
+ };
217
+ const write = (value) => {
218
+ if (overflow || value === undefined)
219
+ return undefined;
220
+ if (value === null || typeof value !== 'object' || value instanceof Date) {
221
+ if (typeof value === 'string' &&
222
+ Buffer.byteLength(value) > MAX_ATOMIC_MASK_BYTES &&
223
+ Buffer.byteLength(value) > remaining) {
224
+ overflow = true;
225
+ return undefined;
226
+ }
227
+ const masked = reporter.mask(value);
228
+ append(JSON.stringify(masked));
229
+ return masked;
230
+ }
231
+ if (seen.has(value)) {
232
+ append(JSON.stringify('[Circular]'));
233
+ return '[Circular]';
234
+ }
235
+ seen.add(value);
236
+ if (Array.isArray(value)) {
237
+ append('[');
238
+ const result = [];
239
+ for (const [index, item] of value.entries()) {
240
+ if (overflow)
241
+ break;
242
+ if (index > 0)
243
+ append(',');
244
+ if (item === undefined) {
245
+ append('null');
246
+ result.push(null);
247
+ }
248
+ else {
249
+ const masked = write(item);
250
+ if (!overflow)
251
+ result.push(masked);
252
+ }
253
+ }
254
+ append(']');
255
+ seen.delete(value);
256
+ return result;
257
+ }
258
+ append('{');
259
+ const result = Object.create(null);
260
+ let first = true;
261
+ for (const key in value) {
262
+ if (overflow)
263
+ break;
264
+ if (!Object.hasOwn(value, key))
265
+ continue;
266
+ const child = value[key];
267
+ if (child === undefined && !reporter.shouldRedactField(key))
268
+ continue;
269
+ if (!first)
270
+ append(',');
271
+ first = false;
272
+ append(JSON.stringify(reporter.mask(key)));
273
+ append(':');
274
+ if (reporter.shouldRedactField(key)) {
275
+ append(JSON.stringify('[REDACTED]'));
276
+ result[key] = '[REDACTED]';
277
+ }
278
+ else {
279
+ const masked = write(child);
280
+ if (!overflow)
281
+ result[key] = masked;
282
+ }
283
+ }
284
+ append('}');
285
+ seen.delete(value);
286
+ return result;
287
+ };
288
+ try {
289
+ const masked = write(input);
290
+ if (!overflow) {
291
+ budget.remaining = remaining;
292
+ return masked;
293
+ }
294
+ }
295
+ catch {
296
+ budget.truncated = true;
297
+ return previewPayload('', budget);
298
+ }
299
+ budget.truncated = true;
300
+ return previewPayload(parts.join(''), budget);
301
+ }
302
+ /** Bound a structured payload without changing its shape when it fits. */
303
+ function boundPayload(input, budget, reporter) {
304
+ if (reporter)
305
+ return boundMaskedPayload(input, budget, reporter);
306
+ let serialized;
307
+ try {
308
+ serialized = JSON.stringify(input) ?? '';
309
+ }
310
+ catch {
311
+ budget.truncated = true;
312
+ return previewPayload('', budget);
313
+ }
314
+ const bytes = Buffer.byteLength(serialized);
315
+ if (bytes <= budget.remaining) {
316
+ budget.remaining -= bytes;
317
+ return input;
318
+ }
319
+ budget.truncated = true;
320
+ return previewPayload(serialized, budget);
321
+ }
322
+ /** Keep only whole chunks that fit; stream accounting still processes every original chunk. */
323
+ function capturePayloadChunk(chunk, chunks, budget) {
324
+ if (budget.remaining <= 0) {
325
+ budget.truncated = true;
326
+ return;
327
+ }
328
+ let serialized;
329
+ try {
330
+ serialized = JSON.stringify(chunk) ?? '';
331
+ }
332
+ catch {
333
+ chunks.length = 0;
334
+ budget.truncated = true;
335
+ budget.remaining = 0;
336
+ return;
337
+ }
338
+ // Account for the final raw array too: [] around the first chunk and one
339
+ // comma per additional retained chunk, after redaction has changed lengths.
340
+ const bytes = Buffer.byteLength(serialized) + (chunks.length === 0 ? 2 : 1);
341
+ if (bytes > budget.remaining) {
342
+ // Partial groups are unsafe: a retained prefix may be a secret fragment.
343
+ chunks.length = 0;
344
+ budget.truncated = true;
345
+ budget.remaining = 0;
346
+ return;
347
+ }
348
+ chunks.push(chunk);
349
+ budget.remaining -= bytes;
350
+ }
351
+ /** Append only while the full assembled text fits; otherwise drop the whole channel. */
352
+ function appendAssembledText(current, delta, budget) {
353
+ const bytes = Buffer.byteLength(delta);
354
+ if (bytes > budget.remaining) {
355
+ budget.truncated = true;
356
+ budget.remaining = 0;
357
+ return '';
358
+ }
359
+ budget.remaining -= bytes;
360
+ return current + delta;
361
+ }
362
+ /** Charge the whole tool-call envelope, not only its argument text. */
363
+ function captureToolCall(call, calls, budget) {
364
+ if (budget.remaining <= 0) {
365
+ budget.truncated = true;
366
+ return;
367
+ }
368
+ const envelope = {
369
+ ...call,
370
+ function: { ...call.function, arguments: '' },
371
+ };
372
+ const bytes = Buffer.byteLength(JSON.stringify(envelope));
373
+ if (bytes > budget.remaining) {
374
+ budget.truncated = true;
375
+ budget.remaining = 0;
376
+ return;
377
+ }
378
+ budget.remaining -= bytes;
379
+ calls.push({
380
+ ...call,
381
+ function: {
382
+ ...call.function,
383
+ arguments: takePayloadText(call.function.arguments, budget),
384
+ },
385
+ });
386
+ }
128
387
  /** Generation names carry the reply's first non-empty line (capped) for list readability. */
129
388
  function enhanceWithFirstLine(base, text) {
130
389
  const firstLine = text
@@ -269,6 +528,7 @@ export function apply(ctx, config) {
269
528
  const toolResults = new WeakMap();
270
529
  const openHooks = new Map();
271
530
  const openOperations = new Set();
531
+ const operationSessions = new Map();
272
532
  const traceContexts = new Map();
273
533
  const pendingMedia = new Set();
274
534
  const mediaFor = new WeakMap();
@@ -330,8 +590,13 @@ export function apply(ctx, config) {
330
590
  }
331
591
  });
332
592
  function openTrace(sessionId, metadata, startTime) {
333
- const context = sessionId ? traceContexts.get(sessionId) : undefined;
334
- if (sessionId)
593
+ const pending = sessionId ? pendingTurns.get(sessionId) : undefined;
594
+ const context = pending
595
+ ? pending.context
596
+ : sessionId
597
+ ? traceContexts.get(sessionId)
598
+ : undefined;
599
+ if (sessionId && !pending)
335
600
  traceContexts.delete(sessionId);
336
601
  return reporter.openTrace({ name: config.traceName, sessionId, metadata, context, startTime });
337
602
  }
@@ -635,10 +900,11 @@ export function apply(ctx, config) {
635
900
  if (!parentSessionId)
636
901
  return;
637
902
  const childId = session.id;
638
- // Parent under the enclosing delegation tool span when one is open
639
- // (foreground runs execute inside the parent's tools/execute), else the
640
- // parent's own subagent span (nested delegation), else the root trace.
641
- const parentObservation = openToolSpans.get(parentSessionId)?.at(-1) ??
903
+ // Parent under the enclosing delegation tool span only when it is unique;
904
+ // concurrent tools have no reliable stack-top ordering, so use the known
905
+ // parent subagent/root instead of attributing the child to an arbitrary tool.
906
+ const openTools = openToolSpans.get(parentSessionId) ?? [];
907
+ const parentObservation = (openTools.length === 1 ? openTools[0] : undefined) ??
642
908
  children.get(parentSessionId)?.span ??
643
909
  traceFor(parentSessionId);
644
910
  const span = reporter.startSpan(parentObservation, {
@@ -651,6 +917,7 @@ export function apply(ctx, config) {
651
917
  children.set(childId, {
652
918
  parentSessionId,
653
919
  span,
920
+ started: false,
654
921
  hasInput: false,
655
922
  turnTrace: children.get(parentSessionId)?.turnTrace ??
656
923
  sessions.get(rootSessionId(parentSessionId) ?? '')?.trace,
@@ -660,13 +927,17 @@ export function apply(ctx, config) {
660
927
  const child = children.get(info.id);
661
928
  if (!child)
662
929
  return; // remote (ACP) runs have no local session — nothing to nest
930
+ child.started = true;
663
931
  reporter.updateSpan(child.span, { metadata: { provider: info.provider, local: info.local } });
664
932
  });
665
933
  ctx.on('subagent/end', (info) => {
666
934
  const child = children.get(info.id);
667
935
  if (!child)
668
936
  return;
669
- if (info.stopReason === 'error')
937
+ // A background child can finish after the parent moved to another turn.
938
+ // Only mark the root that was active when this child was created.
939
+ if (info.stopReason === 'error' &&
940
+ sessions.get(rootSessionId(info.id) ?? '')?.trace === child.turnTrace)
670
941
  noteError(info.id);
671
942
  children.delete(info.id);
672
943
  afterMedia(child.span, (endedAt) => reporter.endSpan(child.span, {
@@ -854,17 +1125,20 @@ export function apply(ctx, config) {
854
1125
  if (stale)
855
1126
  endTurn(stale);
856
1127
  inboxInputs.delete(sessionId);
857
- if (nativeInboxes.has(sessionId)) {
858
- sessions.delete(sessionId);
859
- pendingTurns.set(sessionId, { turn: event.data.turn, startTime: new Date(event.time) });
860
- break;
861
- }
862
- const trace = openTrace(sessionId, { turn: event.data.turn });
863
- if (trace)
864
- sessions.set(sessionId, { trace, hasInput: false, turn: event.data.turn });
1128
+ sessions.delete(sessionId);
1129
+ pendingTurns.set(sessionId, {
1130
+ turn: event.data.turn,
1131
+ startTime: new Date(event.time),
1132
+ context: nativeInboxes.has(sessionId) ? undefined : traceContexts.get(sessionId),
1133
+ });
1134
+ // Ordinary turns consume request context at start, even if they remain empty.
1135
+ if (!nativeInboxes.has(sessionId))
1136
+ traceContexts.delete(sessionId);
865
1137
  break;
866
1138
  }
867
1139
  case 'user/message': {
1140
+ if (pendingTurns.has(sessionId))
1141
+ traceFor(sessionId);
868
1142
  const state = sessions.get(sessionId);
869
1143
  if (state && !state.hasInput && config.captureContent) {
870
1144
  setMessageInput(state.trace, event.data.content);
@@ -873,6 +1147,8 @@ export function apply(ctx, config) {
873
1147
  break;
874
1148
  }
875
1149
  case 'assistant/message': {
1150
+ if (pendingTurns.has(sessionId))
1151
+ traceFor(sessionId);
876
1152
  const state = sessions.get(sessionId);
877
1153
  if (state && config.captureContent) {
878
1154
  state.lastAssistantText = messageText(event.data.message) ?? state.lastAssistantText;
@@ -880,8 +1156,10 @@ export function apply(ctx, config) {
880
1156
  break;
881
1157
  }
882
1158
  case 'turn/end': {
883
- if (pendingTurns.has(sessionId))
1159
+ if (pendingTurns.has(sessionId) &&
1160
+ (nativeInboxes.has(sessionId) || event.data.reason.kind !== 'completed'))
884
1161
  traceFor(sessionId);
1162
+ pendingTurns.delete(sessionId);
885
1163
  const state = sessions.get(sessionId);
886
1164
  if (state) {
887
1165
  sessions.delete(sessionId);
@@ -913,6 +1191,16 @@ export function apply(ctx, config) {
913
1191
  });
914
1192
  ctx.on('session/disposed', (session) => {
915
1193
  const sessionId = session.id;
1194
+ for (const close of [...openOperations]) {
1195
+ if (operationSessions.get(close) === sessionId)
1196
+ close();
1197
+ }
1198
+ const child = children.get(sessionId);
1199
+ if (child && !child.started) {
1200
+ // Activation rollback disposes the session without subagent/start/end.
1201
+ children.delete(sessionId);
1202
+ afterMedia(child.span, (endedAt) => reporter.endSpan(child.span, { level: 'WARNING', statusMessage: 'subagent activation rolled back' }, endedAt));
1203
+ }
916
1204
  closeHooks(sessionId);
917
1205
  const state = sessions.get(sessionId);
918
1206
  sessions.delete(sessionId);
@@ -951,7 +1239,12 @@ export function apply(ctx, config) {
951
1239
  const oneOffRoot = options.sessionId ? null : parent;
952
1240
  const generationName = options.purpose ? `llm-call [${options.purpose}]` : 'llm-call';
953
1241
  const modelParameters = modelParametersOf(options, config.captureContent);
954
- const input = requestBodyOf(options, config.captureContent);
1242
+ const maxPayloadBytes = config.maxPayloadBytes ?? 256 * 1024;
1243
+ const requestBudget = newPayloadBudget(maxPayloadBytes);
1244
+ const rawBudget = newPayloadBudget(maxPayloadBytes);
1245
+ const outputBudget = newPayloadBudget(maxPayloadBytes);
1246
+ const requestBody = requestBodyOf(options, config.captureContent);
1247
+ const input = boundPayload(requestBody, requestBudget, reporter);
955
1248
  const generation = reporter.startGeneration(parent, {
956
1249
  name: generationName,
957
1250
  model: options.model,
@@ -966,7 +1259,12 @@ export function apply(ctx, config) {
966
1259
  enrichInput(generation, Promise.all(options.messages.map(async (message) => ({
967
1260
  ...message,
968
1261
  content: await displayBlocks(message.content, ctx.get('attachments'), mediaOptions, options.signal, budget),
969
- }))).then((messages) => ({ ...input, messages })));
1262
+ }))).then((messages) => {
1263
+ const enrichmentBudget = newPayloadBudget(maxPayloadBytes);
1264
+ const enriched = boundPayload({ ...requestBody, messages }, enrichmentBudget, reporter);
1265
+ requestBudget.truncated ||= enrichmentBudget.truncated;
1266
+ return enriched;
1267
+ }));
970
1268
  }
971
1269
  // The rawest request a plugin can observe: the loop-built GenerateOptions
972
1270
  // verbatim, pre-adapter (the provider HTTP body is assembled inside the
@@ -1005,6 +1303,7 @@ export function apply(ctx, config) {
1005
1303
  closed = true;
1006
1304
  const endedAt = new Date();
1007
1305
  openOperations.delete(closeCall);
1306
+ operationSessions.delete(closeCall);
1008
1307
  const failure = finish && (finish.kind === 'error' || finish.kind === 'aborted') ? finish : undefined;
1009
1308
  // No terminal finish chunk (the consumer abandoned the stream or
1010
1309
  // the adapter cut it short): the call's outcome is unknown — mark
@@ -1026,20 +1325,33 @@ export function apply(ctx, config) {
1026
1325
  level = 'WARNING';
1027
1326
  statusMessage = 'stream closed before the terminal finish chunk';
1028
1327
  }
1328
+ // Redact complete grouped deltas before the final serialized bound;
1329
+ // streaming capture has already bounded memory and dropped overflow whole.
1330
+ const finalRawBudget = newPayloadBudget(maxPayloadBytes);
1331
+ const rawOutput = boundPayload(reporter.mask(redactChunks(rawChunks, reporter)), finalRawBudget);
1332
+ rawBudget.truncated ||= finalRawBudget.truncated;
1029
1333
  reporter.endSpan(requestSpan, {
1030
- output: config.captureContent ? redactChunks(rawChunks, reporter) : undefined,
1334
+ output: config.captureContent ? rawOutput : undefined,
1031
1335
  level,
1032
1336
  statusMessage,
1033
- metadata: { chunkCount },
1337
+ metadata: {
1338
+ chunkCount,
1339
+ truncated: requestBudget.truncated || rawBudget.truncated || undefined,
1340
+ },
1034
1341
  }, endedAt);
1035
1342
  if (level === 'ERROR')
1036
1343
  noteError(options.sessionId);
1344
+ // A final independent safety bound covers the complete output envelope;
1345
+ // an exhausted streaming budget must not erase an otherwise valid prefix.
1346
+ const finalOutputBudget = newPayloadBudget(maxPayloadBytes);
1347
+ const output = boundPayload(outputBody(), finalOutputBudget, reporter);
1348
+ outputBudget.truncated ||= finalOutputBudget.truncated;
1037
1349
  // Partial progress counts on every path: whatever streamed is kept.
1038
1350
  afterMedia(generation, (endedAt) => reporter.endGeneration(generation, {
1039
1351
  name: config.captureContent
1040
1352
  ? enhanceWithFirstLine(generationName, reporter.mask(text))
1041
1353
  : undefined,
1042
- output: outputBody(),
1354
+ output,
1043
1355
  usage,
1044
1356
  completionStartTime,
1045
1357
  level,
@@ -1049,11 +1361,16 @@ export function apply(ctx, config) {
1049
1361
  errorCode: failure?.failure?.code,
1050
1362
  incomplete: finish ? undefined : true,
1051
1363
  toolCallCount: toolCallCount > 0 ? toolCallCount : undefined,
1364
+ truncated: requestBudget.truncated ||
1365
+ rawBudget.truncated ||
1366
+ outputBudget.truncated ||
1367
+ undefined,
1052
1368
  },
1053
1369
  }, endedAt), endedAt);
1054
1370
  reporter.endSpan(oneOffRoot, {}, endedAt);
1055
1371
  };
1056
1372
  openOperations.add(closeCall);
1373
+ operationSessions.set(closeCall, options.sessionId);
1057
1374
  let stream;
1058
1375
  try {
1059
1376
  stream = next();
@@ -1073,7 +1390,7 @@ export function apply(ctx, config) {
1073
1390
  }
1074
1391
  chunkCount += 1;
1075
1392
  if (config.captureContent)
1076
- rawChunks.push(chunk);
1393
+ capturePayloadChunk(chunk, rawChunks, rawBudget);
1077
1394
  // TTFT is the first real token — block boundaries, usage frames and
1078
1395
  // empty heartbeat deltas are not completions (the predicate above).
1079
1396
  if (completionStartTime === undefined && isTokenDelta(chunk)) {
@@ -1082,21 +1399,39 @@ export function apply(ctx, config) {
1082
1399
  switch (chunk.type) {
1083
1400
  case 'text-delta':
1084
1401
  if (config.captureContent)
1085
- text += chunk.text;
1402
+ text = appendAssembledText(text, chunk.text, outputBudget);
1086
1403
  break;
1087
1404
  case 'reasoning-delta':
1088
1405
  if (config.captureContent)
1089
- reasoning += chunk.text;
1406
+ reasoning = appendAssembledText(reasoning, chunk.text, outputBudget);
1090
1407
  break;
1091
1408
  case 'block-end':
1092
1409
  if (chunk.block.type === 'tool-call') {
1093
1410
  toolCallCount += 1;
1094
- if (config.captureContent)
1095
- toolCalls.push({
1411
+ if (config.captureContent) {
1412
+ if (outputBudget.remaining <= 0) {
1413
+ outputBudget.truncated = true;
1414
+ break;
1415
+ }
1416
+ const envelopeBytes = Buffer.byteLength(JSON.stringify({
1417
+ id: chunk.block.id,
1418
+ type: 'function',
1419
+ function: { name: chunk.block.name, arguments: '' },
1420
+ }));
1421
+ if (envelopeBytes > outputBudget.remaining) {
1422
+ outputBudget.truncated = true;
1423
+ outputBudget.remaining = 0;
1424
+ break;
1425
+ }
1426
+ captureToolCall({
1096
1427
  id: chunk.block.id,
1097
1428
  type: 'function',
1098
- function: { name: chunk.block.name, arguments: chunk.block.arguments },
1099
- });
1429
+ function: {
1430
+ name: chunk.block.name,
1431
+ arguments: reporter.mask(chunk.block.arguments),
1432
+ },
1433
+ }, toolCalls, outputBudget);
1434
+ }
1100
1435
  }
1101
1436
  break;
1102
1437
  case 'usage':
@@ -1159,6 +1494,7 @@ export function apply(ctx, config) {
1159
1494
  closed = true;
1160
1495
  const endedAt = new Date();
1161
1496
  openOperations.delete(closeTool);
1497
+ operationSessions.delete(closeTool);
1162
1498
  toolResults.delete(exec);
1163
1499
  if (sessionId && toolCalls.get(sessionId)?.get(exec.callId)?.span === span) {
1164
1500
  const calls = toolCalls.get(sessionId);
@@ -1186,6 +1522,7 @@ export function apply(ctx, config) {
1186
1522
  end();
1187
1523
  }
1188
1524
  openOperations.add(closeTool);
1525
+ operationSessions.set(closeTool, sessionId);
1189
1526
  toolResults.set(exec, (result) => {
1190
1527
  if (closed)
1191
1528
  return;
@@ -92,6 +92,8 @@ export declare class LangfuseReporter {
92
92
  /** Settles (never rejects) once the lazy SDK import+wiring finished. */
93
93
  readonly ready: Promise<void>;
94
94
  constructor(config: LangfuseConnectionConfig);
95
+ /** Whether a payload property should be replaced without inspecting its value. */
96
+ shouldRedactField(key: string): boolean;
95
97
  /** Clone telemetry only; never mutate harness-owned messages or tool results. */
96
98
  mask<T>(data: T): T;
97
99
  /** Dynamically import the SDK and wire the isolated export pipeline. */
@@ -13,7 +13,7 @@
13
13
  * shared identity).
14
14
  * - `hook/invoked` / `hook/result` session records → one **span** per hook run.
15
15
  * - `session/event` emit → one Langfuse **trace** per agent turn
16
- * (`turn/start` opens a root span — in the v5 SDK the root observation IS
16
+ * (`turn/start` records the start; actual activity opens the root — in v5 it 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
19
  * - Native A2A inbox tasks own a trace spanning admission through task termination;
@@ -70,39 +70,41 @@ export declare const name = "dsh-langfuse";
70
70
  * prefer fail-fast wiring.
71
71
  */
72
72
  export declare const inject: string[];
73
- export declare const Config: Schema<Schemastery.ObjectS<{
74
- enabled: Schema<boolean, boolean>;
75
- publicKey: Schema<string, string>;
76
- secretKey: Schema<string, string>;
77
- baseUrl: Schema<string, string>;
78
- traceName: Schema<string, string>;
73
+ export declare const Config: Schema<Schemastery.ObjectS<NoInfer<{
74
+ enabled: Schema<boolean, boolean, "defined">;
75
+ publicKey: Schema<string, string, "defined">;
76
+ secretKey: Schema<string, string, "defined">;
77
+ baseUrl: Schema<string, string, "defined">;
78
+ traceName: Schema<string, string, "defined">;
79
79
  /** Include full message/tool payloads in Langfuse IO. Disable to log metadata only. */
80
- captureContent: Schema<boolean, boolean>;
81
- redactFields: Schema<string[], string[]>;
82
- userId: Schema<string, string>;
83
- tags: Schema<string[], string[]>;
84
- environment: Schema<string, string>;
85
- release: Schema<string, string>;
86
- captureMedia: Schema<boolean, boolean>;
87
- maxMediaBytes: Schema<number, number>;
88
- mediaTimeoutMs: Schema<number, number>;
89
- }>, Schemastery.ObjectT<{
90
- enabled: Schema<boolean, boolean>;
91
- publicKey: Schema<string, string>;
92
- secretKey: Schema<string, string>;
93
- baseUrl: Schema<string, string>;
94
- traceName: Schema<string, string>;
80
+ captureContent: Schema<boolean, boolean, "defined">;
81
+ maxPayloadBytes: Schema<number, number, "defined">;
82
+ redactFields: Schema<string[], string[], "defined">;
83
+ userId: Schema<string, string, "defined">;
84
+ tags: Schema<string[], string[], "defined">;
85
+ environment: Schema<string, string, "defined">;
86
+ release: Schema<string, string, "defined">;
87
+ captureMedia: Schema<boolean, boolean, "defined">;
88
+ maxMediaBytes: Schema<number, number, "defined">;
89
+ mediaTimeoutMs: Schema<number, number, "defined">;
90
+ }>>, Schemastery.ObjectT<NoInfer<{
91
+ enabled: Schema<boolean, boolean, "defined">;
92
+ publicKey: Schema<string, string, "defined">;
93
+ secretKey: Schema<string, string, "defined">;
94
+ baseUrl: Schema<string, string, "defined">;
95
+ traceName: Schema<string, string, "defined">;
95
96
  /** Include full message/tool payloads in Langfuse IO. Disable to log metadata only. */
96
- captureContent: Schema<boolean, boolean>;
97
- redactFields: Schema<string[], string[]>;
98
- userId: Schema<string, string>;
99
- tags: Schema<string[], string[]>;
100
- environment: Schema<string, string>;
101
- release: Schema<string, string>;
102
- captureMedia: Schema<boolean, boolean>;
103
- maxMediaBytes: Schema<number, number>;
104
- mediaTimeoutMs: Schema<number, number>;
105
- }>>;
97
+ captureContent: Schema<boolean, boolean, "defined">;
98
+ maxPayloadBytes: Schema<number, number, "defined">;
99
+ redactFields: Schema<string[], string[], "defined">;
100
+ userId: Schema<string, string, "defined">;
101
+ tags: Schema<string[], string[], "defined">;
102
+ environment: Schema<string, string, "defined">;
103
+ release: Schema<string, string, "defined">;
104
+ captureMedia: Schema<boolean, boolean, "defined">;
105
+ maxMediaBytes: Schema<number, number, "defined">;
106
+ mediaTimeoutMs: Schema<number, number, "defined">;
107
+ }>>, "plain">;
106
108
  export interface LangfusePluginConfig {
107
109
  enabled: boolean;
108
110
  publicKey: string;
@@ -110,6 +112,7 @@ export interface LangfusePluginConfig {
110
112
  baseUrl: string;
111
113
  traceName: string;
112
114
  captureContent: boolean;
115
+ maxPayloadBytes?: number;
113
116
  redactFields?: string[];
114
117
  userId?: string;
115
118
  tags?: string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amaster.ai/dsh-langfuse",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
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",
@@ -32,7 +32,7 @@
32
32
  }
33
33
  },
34
34
  "dependencies": {
35
- "@deepseek-ai/schemastery": "^3.18.1"
35
+ "@deepseek-ai/schemastery": "^3.18.5"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@deepseek-ai/cordis": "^4.0.1",
@@ -65,16 +65,16 @@
65
65
  },
66
66
  "devDependencies": {
67
67
  "@deepseek-ai/cordis": "4.0.5",
68
- "@deepseek-ai/dsh-agent": "0.2.1-rc.4",
69
- "@deepseek-ai/dsh-attachment": "0.2.1-rc.4",
70
- "@deepseek-ai/dsh-hook-protocol": "0.2.1-rc.4",
71
- "@deepseek-ai/dsh-llm": "0.2.1-rc.4",
72
- "@deepseek-ai/dsh-ptc-runtime": "0.2.1-rc.4",
73
- "@deepseek-ai/dsh-session": "0.2.1-rc.4",
74
- "@deepseek-ai/dsh-session-projection": "0.2.1-rc.4",
75
- "@deepseek-ai/dsh-subagent": "0.2.1-rc.4",
76
- "@deepseek-ai/dsh-tools": "0.2.1-rc.4",
77
- "@deepseek-ai/dsh-util-time": "0.2.1-rc.4",
68
+ "@deepseek-ai/dsh-agent": "0.2.1-rc.5",
69
+ "@deepseek-ai/dsh-attachment": "0.2.1-rc.5",
70
+ "@deepseek-ai/dsh-hook-protocol": "0.2.1-rc.5",
71
+ "@deepseek-ai/dsh-llm": "0.2.1-rc.5",
72
+ "@deepseek-ai/dsh-ptc-runtime": "0.2.1-rc.5",
73
+ "@deepseek-ai/dsh-session": "0.2.1-rc.5",
74
+ "@deepseek-ai/dsh-session-projection": "0.2.1-rc.5",
75
+ "@deepseek-ai/dsh-subagent": "0.2.1-rc.5",
76
+ "@deepseek-ai/dsh-tools": "0.2.1-rc.5",
77
+ "@deepseek-ai/dsh-util-time": "0.2.1-rc.5",
78
78
  "@langfuse/otel": "5.10.1",
79
79
  "@langfuse/tracing": "5.10.1",
80
80
  "@opentelemetry/api": "^1.9.1",