nemo-relay-openclaw 0.3.0 → 0.4.0

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.
@@ -1,10 +1,10 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  import { emitMark, toJsonRecord } from './hook-replay/marks.js';
4
- import { llmKey } from './hook-replay/correlation.js';
4
+ import { llmKey, nowMicros } from './hook-replay/correlation.js';
5
5
  import { emitUnpairedModelCallTimingMarks, recordBeforeMessageWrite, recordLlmInput, recordLlmOutput, recordModelCallEnded, recordModelCallStarted, replayAgentEndMessages, replayPendingLlmOutputsForSession, } from './hook-replay/llm.js';
6
6
  import { guardBeforeToolCall, replayAfterToolCall } from './hook-replay/tool.js';
7
- import { createHookReplayState, drainSession, closeSessionRoot, deleteSession, ensureSession, resolveSessionKey, } from './hook-replay/session.js';
7
+ import { createHookReplayState, drainSession, closeSessionRoot, deleteSession, ensureSession, materializeSessionRoot, queueCapturedEmit, resolveSessionOwnerKey, } from './hook-replay/session.js';
8
8
  /** Replays OpenClaw public hook events into NeMo Relay scopes, spans, and marks. */
9
9
  export class HookReplayBackend {
10
10
  nf;
@@ -13,6 +13,8 @@ export class HookReplayBackend {
13
13
  agentVersion;
14
14
  stateValue = createHookReplayState();
15
15
  warningCounts = new Map();
16
+ pendingSubagentLineageByChildSessionKey = new Map();
17
+ pendingSubagentChildKeyByRunId = new Map();
16
18
  constructor(options) {
17
19
  this.nf = options.nf;
18
20
  this.config = options.config;
@@ -30,13 +32,16 @@ export class HookReplayBackend {
30
32
  }
31
33
  /** Open or alias an explicit OpenClaw session root. */
32
34
  onSessionStart(event, ctx) {
33
- this.ensureSession({
35
+ const observedAtMicros = nowMicros();
36
+ const session = this.ensureSession({
34
37
  sessionId: event.sessionId,
35
38
  sessionKey: event.sessionKey ?? ctx.sessionKey,
36
39
  agentId: ctx.agentId,
37
40
  source: 'session_start',
38
41
  resumedFrom: event.resumedFrom,
42
+ timestamp: observedAtMicros,
39
43
  });
44
+ this.promoteDeferredSubagentSessionsForRequester(session?.sessionKey ?? event.sessionKey ?? ctx.sessionKey);
40
45
  // ensureSession opens the root scope and emits openclaw.session_start for both explicit and lazy sessions.
41
46
  }
42
47
  /** Close one explicit OpenClaw session and export its ATIF artifact. */
@@ -50,7 +55,13 @@ export class HookReplayBackend {
50
55
  if (!session) {
51
56
  return;
52
57
  }
53
- await this.closeSession(session, sessionEndSummary(event));
58
+ await this.closeSession(session, sessionEndSummary(event), toJsonRecord({
59
+ source: 'openclaw.session_end',
60
+ hook_event_name: 'session_end',
61
+ sessionId: session.sessionId,
62
+ sessionKey: event.sessionKey ?? ctx.sessionKey,
63
+ agentId: ctx.agentId,
64
+ }));
54
65
  }
55
66
  /** Buffer an LLM request snapshot until a matching response or trajectory replay arrives. */
56
67
  onLlmInput(event, ctx) {
@@ -82,12 +93,14 @@ export class HookReplayBackend {
82
93
  }
83
94
  /** Finalize one agent run, replaying message-write trajectory when needed. */
84
95
  onAgentEnd(event, ctx) {
96
+ const observedAtMicros = nowMicros();
85
97
  const session = this.ensureSession({
86
98
  sessionId: ctx.sessionId,
87
99
  sessionKey: ctx.sessionKey,
88
100
  runId: event.runId ?? ctx.runId,
89
101
  agentId: ctx.agentId,
90
102
  source: 'lazy_session',
103
+ timestamp: observedAtMicros,
91
104
  });
92
105
  if (!session) {
93
106
  return;
@@ -102,16 +115,18 @@ export class HookReplayBackend {
102
115
  error: event.error,
103
116
  durationMs: event.durationMs,
104
117
  messageCount: event.messages.length,
105
- }));
118
+ }), observedAtMicros);
106
119
  }
107
120
  /** Remember the last assistant text before OpenClaw finalizes the response. */
108
121
  onBeforeAgentFinalize(event, ctx) {
122
+ const observedAtMicros = nowMicros();
109
123
  const session = this.ensureSession({
110
124
  sessionId: event.sessionId,
111
125
  sessionKey: event.sessionKey ?? ctx.sessionKey,
112
126
  runId: event.runId ?? ctx.runId,
113
127
  agentId: ctx.agentId,
114
128
  source: 'lazy_session',
129
+ timestamp: observedAtMicros,
115
130
  });
116
131
  if (!session) {
117
132
  return;
@@ -132,19 +147,21 @@ export class HookReplayBackend {
132
147
  transcriptPath: event.transcriptPath,
133
148
  stopHookActive: event.stopHookActive,
134
149
  messageCount: event.messages?.length,
135
- }));
150
+ }), observedAtMicros);
136
151
  }
137
152
  /** Attach subagent spawn metadata to the requester session when possible. */
138
153
  onSubagentSpawned(event, ctx) {
139
- const session = this.ensureSession({
140
- requesterSessionKey: ctx.requesterSessionKey,
141
- source: 'lazy_session',
142
- }) ??
154
+ const observedAtMicros = nowMicros();
155
+ this.trackPendingSubagentLineage(event, ctx, Math.trunc(observedAtMicros / 1000));
156
+ const requesterSession = this.ensureRequesterSessionAnchor(ctx.requesterSessionKey, observedAtMicros);
157
+ const childSessionKey = ctx.childSessionKey ?? event.childSessionKey;
158
+ const session = requesterSession ??
143
159
  this.ensureSession({
144
- childSessionKey: ctx.childSessionKey ?? event.childSessionKey,
160
+ childSessionKey,
145
161
  runId: ctx.runId ?? event.runId,
146
162
  agentId: event.agentId,
147
163
  source: 'lazy_session',
164
+ timestamp: observedAtMicros,
148
165
  });
149
166
  if (!session) {
150
167
  return;
@@ -156,22 +173,29 @@ export class HookReplayBackend {
156
173
  label: event.label,
157
174
  mode: event.mode,
158
175
  threadRequested: event.threadRequested,
159
- }));
176
+ }), observedAtMicros);
177
+ if (!ctx.requesterSessionKey || requesterSession?.rootHandle) {
178
+ this.promoteDeferredSubagentSession(event.childSessionKey);
179
+ }
160
180
  }
161
181
  /** Attach subagent completion metadata to the requester or child session. */
162
182
  onSubagentEnded(event, ctx) {
183
+ const observedAtMicros = nowMicros();
163
184
  const session = this.ensureSession({
164
185
  requesterSessionKey: ctx.requesterSessionKey,
165
186
  source: 'lazy_session',
187
+ timestamp: observedAtMicros,
166
188
  }) ??
167
189
  this.ensureSession({
168
190
  childSessionKey: ctx.childSessionKey ?? event.targetSessionKey,
169
191
  runId: ctx.runId ?? event.runId,
170
192
  source: 'lazy_session',
193
+ timestamp: observedAtMicros,
171
194
  });
172
195
  if (!session) {
173
196
  return;
174
197
  }
198
+ this.materializeDeferredSessionRoot(session);
175
199
  this.emitSessionMark('openclaw.subagent_ended', session, toJsonRecord({
176
200
  runId: event.runId ?? ctx.runId,
177
201
  targetSessionKey: event.targetSessionKey,
@@ -182,7 +206,7 @@ export class HookReplayBackend {
182
206
  endedAt: event.endedAt,
183
207
  sendFarewell: event.sendFarewell,
184
208
  accountId: event.accountId,
185
- }));
209
+ }), observedAtMicros);
186
210
  }
187
211
  /** Drain all active sessions when the OpenClaw gateway is stopping. */
188
212
  async drainForGatewayStop(reason) {
@@ -190,11 +214,11 @@ export class HookReplayBackend {
190
214
  }
191
215
  /** Close one session selected by a runtime lifecycle cleanup hook. */
192
216
  async cleanupSession(input) {
193
- const key = resolveSessionKey(this.stateValue, input);
194
- if (!key) {
217
+ const ownerKey = resolveSessionOwnerKey(this.stateValue, input);
218
+ if (!ownerKey) {
195
219
  return;
196
220
  }
197
- const session = this.stateValue.sessions.get(key);
221
+ const session = this.stateValue.sessions.get(ownerKey);
198
222
  if (!session) {
199
223
  return;
200
224
  }
@@ -226,6 +250,9 @@ export class HookReplayBackend {
226
250
  }
227
251
  /** Emit spans/marks under the stored session scope stack and ATIF capture window. */
228
252
  emitCapturedUnderSession(label, session, emit) {
253
+ if (queueCapturedEmit(session, label, emit)) {
254
+ return;
255
+ }
229
256
  this.safeReplay(label, session, () => {
230
257
  const previousStack = this.nf.currentScopeStack();
231
258
  try {
@@ -250,22 +277,36 @@ export class HookReplayBackend {
250
277
  return ensureSession(this.sessionManager(), input);
251
278
  }
252
279
  /** Drain, close, export, and delete one session. */
253
- async closeSession(session, summary) {
280
+ async closeSession(session, summary, metadata) {
281
+ this.materializeDeferredSessionRoot(session);
254
282
  drainSession(this.sessionManager(), session);
255
- closeSessionRoot(this.sessionManager(), session, summary, session.finalOutput ?? summary);
283
+ closeSessionRoot(this.sessionManager(), session, summary, session.finalOutput ?? summary, metadata);
256
284
  this.flushSubscriberDelivery('session_close');
285
+ this.forgetPendingSubagentLineage(session);
257
286
  deleteSession(this.stateValue, session);
258
287
  }
259
288
  /** Emit a session-level OpenClaw lifecycle mark. */
260
- emitSessionMark(name, session, data) {
289
+ emitSessionMark(name, session, data, timestampMicros) {
261
290
  this.emitCapturedUnderSession(name, session, () => {
262
- emitMark({
291
+ const params = {
263
292
  nf: this.nf,
264
293
  state: this.stateValue,
265
294
  session,
266
295
  name,
267
296
  data,
268
- });
297
+ metadata: toJsonRecord({
298
+ source: name,
299
+ hook_event_name: name.startsWith('openclaw.') ? name.slice('openclaw.'.length) : undefined,
300
+ sessionId: session.sessionId,
301
+ sessionKey: session.sessionKey,
302
+ agentId: session.agentId,
303
+ runId: typeof data.runId === 'string' ? data.runId : undefined,
304
+ }),
305
+ };
306
+ if (timestampMicros !== undefined) {
307
+ params.timestamp = timestampMicros;
308
+ }
309
+ emitMark(params);
269
310
  });
270
311
  }
271
312
  /** Close every active session with the same lifecycle summary. */
@@ -295,8 +336,170 @@ export class HookReplayBackend {
295
336
  replayPendingLlmOutputsForSession: (session, options) => this.replayPendingLlmOutputsForSession(session, options),
296
337
  emitUnpairedModelCallTimingMarks: (session) => this.emitUnpairedModelCallTimingMarks(session),
297
338
  logBoundedWarn: (key, message) => this.logBoundedWarn(key, message),
339
+ resolveSessionRootContext: (input) => this.resolveSessionRootContext(input),
298
340
  };
299
341
  }
342
+ /** Prefer nested child scopes only when the hook surface provides real subagent lineage. */
343
+ resolveSessionRootContext(input) {
344
+ this.pruneExpiredPendingSubagentLineage(microsToMs(input.timestamp) ?? Date.now());
345
+ const lineage = this.resolvePendingSubagentLineage(input);
346
+ if (lineage) {
347
+ const parentSession = this.resolveTrackedSession({ requesterSessionKey: lineage.requesterSessionKey });
348
+ return {
349
+ childSessionKey: input.childSessionKey ?? lineage.childSessionKey,
350
+ runId: input.runId ?? lineage.runId,
351
+ agentId: input.agentId ?? lineage.agentId,
352
+ scopeRole: 'subagent',
353
+ parentHandle: parentSession?.rootHandle,
354
+ deferRootOpen: input.deferRootOpen ?? (parentSession?.rootHandle ? false : true),
355
+ };
356
+ }
357
+ if (this.isDocumentedSubagentSessionKey(input.sessionKey ?? input.childSessionKey)) {
358
+ return {
359
+ scopeRole: 'subagent',
360
+ deferRootOpen: input.deferRootOpen ?? true,
361
+ };
362
+ }
363
+ return undefined;
364
+ }
365
+ /** Track stable parent/child lineage from subagent hooks until child session hooks can use it. */
366
+ trackPendingSubagentLineage(event, ctx, observedAtMs) {
367
+ this.pruneExpiredPendingSubagentLineage(observedAtMs);
368
+ const requesterSessionKey = ctx.requesterSessionKey?.trim();
369
+ const childSessionKey = (ctx.childSessionKey ?? event.childSessionKey)?.trim();
370
+ if (!requesterSessionKey || !childSessionKey) {
371
+ return;
372
+ }
373
+ this.pendingSubagentLineageByChildSessionKey.set(childSessionKey, {
374
+ childSessionKey,
375
+ requesterSessionKey,
376
+ runId: ctx.runId ?? event.runId,
377
+ agentId: event.agentId,
378
+ observedAtMs,
379
+ });
380
+ if (ctx.runId ?? event.runId) {
381
+ this.pendingSubagentChildKeyByRunId.set(ctx.runId ?? event.runId, childSessionKey);
382
+ }
383
+ }
384
+ /** Resolve the requester session if it exists, or seed a deferred lazy root placeholder for later promotion. */
385
+ ensureRequesterSessionAnchor(requesterSessionKey, timestampMicros) {
386
+ const trimmedRequesterSessionKey = requesterSessionKey?.trim();
387
+ if (!trimmedRequesterSessionKey) {
388
+ return undefined;
389
+ }
390
+ return (this.resolveTrackedSession({ requesterSessionKey: trimmedRequesterSessionKey }) ??
391
+ this.ensureSession({
392
+ sessionKey: trimmedRequesterSessionKey,
393
+ requesterSessionKey: trimmedRequesterSessionKey,
394
+ source: 'lazy_session',
395
+ timestamp: timestampMicros,
396
+ deferRootOpen: true,
397
+ }));
398
+ }
399
+ /** Open a deferred child session root once the requester scope is known. */
400
+ promoteDeferredSubagentSession(childSessionKey) {
401
+ const session = this.resolveTrackedSession({ sessionKey: childSessionKey, childSessionKey });
402
+ if (!session) {
403
+ return;
404
+ }
405
+ this.materializeDeferredSessionRoot(session);
406
+ }
407
+ /** Promote any deferred child sessions waiting on a requester root once it exists. */
408
+ promoteDeferredSubagentSessionsForRequester(requesterSessionKey) {
409
+ const trimmedRequesterSessionKey = requesterSessionKey?.trim();
410
+ if (!trimmedRequesterSessionKey) {
411
+ return;
412
+ }
413
+ const requesterSession = this.resolveTrackedSession({ requesterSessionKey: trimmedRequesterSessionKey });
414
+ if (!requesterSession?.rootHandle) {
415
+ return;
416
+ }
417
+ for (const lineage of this.pendingSubagentLineageByChildSessionKey.values()) {
418
+ if (lineage.requesterSessionKey === trimmedRequesterSessionKey) {
419
+ this.promoteDeferredSubagentSession(lineage.childSessionKey);
420
+ }
421
+ }
422
+ }
423
+ /** Materialize one deferred session root with nested lineage when available. */
424
+ materializeDeferredSessionRoot(session) {
425
+ if (session.rootHandle) {
426
+ return;
427
+ }
428
+ this.pruneExpiredPendingSubagentLineage(Date.now());
429
+ const lineage = this.resolvePendingSubagentLineage({
430
+ sessionId: session.sessionId,
431
+ sessionKey: session.sessionKey,
432
+ });
433
+ const parentSession = lineage === undefined
434
+ ? undefined
435
+ : this.ensureRequesterSessionAnchor(lineage.requesterSessionKey, session.pendingRootTimestampMicros);
436
+ if (parentSession && !parentSession.rootHandle) {
437
+ materializeSessionRoot(this.sessionManager(), parentSession, {
438
+ sessionId: parentSession.sessionId,
439
+ sessionKey: parentSession.sessionKey ?? lineage?.requesterSessionKey,
440
+ source: parentSession.source,
441
+ resumedFrom: parentSession.resumedFrom,
442
+ timestamp: parentSession.pendingRootTimestampMicros,
443
+ });
444
+ }
445
+ const parentHandle = parentSession?.rootHandle;
446
+ materializeSessionRoot(this.sessionManager(), session, {
447
+ sessionId: session.sessionId,
448
+ sessionKey: session.sessionKey,
449
+ runId: lineage?.runId,
450
+ agentId: session.agentId ?? lineage?.agentId,
451
+ source: session.source,
452
+ resumedFrom: session.resumedFrom,
453
+ scopeRole: session.scopeRole,
454
+ parentHandle,
455
+ });
456
+ }
457
+ /** Resolve stable subagent lineage from child session key first, then run id as a fallback. */
458
+ resolvePendingSubagentLineage(input) {
459
+ const childSessionKey = [input.sessionKey, input.childSessionKey]
460
+ .find((value) => this.isDocumentedSubagentSessionKey(value))
461
+ ?.trim();
462
+ if (childSessionKey) {
463
+ return this.pendingSubagentLineageByChildSessionKey.get(childSessionKey);
464
+ }
465
+ const runChildSessionKey = typeof input.runId === 'string' && input.runId.length > 0 ? this.pendingSubagentChildKeyByRunId.get(input.runId) : undefined;
466
+ return runChildSessionKey === undefined ? undefined : this.pendingSubagentLineageByChildSessionKey.get(runChildSessionKey);
467
+ }
468
+ /** Drop stale pending lineage entries so abandoned subagent spawns do not accumulate indefinitely. */
469
+ pruneExpiredPendingSubagentLineage(nowMs) {
470
+ const ttlMs = this.config.correlation.recordTtlMs;
471
+ for (const [childSessionKey, lineage] of this.pendingSubagentLineageByChildSessionKey) {
472
+ if (nowMs - lineage.observedAtMs > ttlMs) {
473
+ this.pendingSubagentLineageByChildSessionKey.delete(childSessionKey);
474
+ }
475
+ }
476
+ for (const [runId, childSessionKey] of this.pendingSubagentChildKeyByRunId) {
477
+ if (!this.pendingSubagentLineageByChildSessionKey.has(childSessionKey)) {
478
+ this.pendingSubagentChildKeyByRunId.delete(runId);
479
+ }
480
+ }
481
+ }
482
+ /** Resolve one session through the same alias map used by the replay state. */
483
+ resolveTrackedSession(input) {
484
+ const ownerKey = resolveSessionOwnerKey(this.stateValue, input);
485
+ return ownerKey === undefined ? undefined : this.stateValue.sessions.get(ownerKey);
486
+ }
487
+ /** Free lineage bookkeeping once the child session is closed. */
488
+ forgetPendingSubagentLineage(session) {
489
+ const childSessionKey = session.sessionKey;
490
+ if (childSessionKey) {
491
+ this.pendingSubagentLineageByChildSessionKey.delete(childSessionKey);
492
+ }
493
+ for (const [runId, trackedChildSessionKey] of this.pendingSubagentChildKeyByRunId) {
494
+ if (trackedChildSessionKey === childSessionKey) {
495
+ this.pendingSubagentChildKeyByRunId.delete(runId);
496
+ }
497
+ }
498
+ }
499
+ /** Match the documented native subagent session key shape without depending on private OpenClaw internals. */
500
+ isDocumentedSubagentSessionKey(value) {
501
+ return typeof value === 'string' && /^agent:[^:]+:subagent:/.test(value);
502
+ }
300
503
  /** Log one warning per key to avoid noisy repeated hook failures. */
301
504
  logBoundedWarn(key, message) {
302
505
  const count = this.warningCounts.get(key) ?? 0;
@@ -307,9 +510,12 @@ export class HookReplayBackend {
307
510
  }
308
511
  }
309
512
  export { llmKey };
310
- /** Expose session-key resolution for tests without exporting the full session module. */
311
- export function resolveBackendSessionKey(state, input) {
312
- return resolveSessionKey(state, input);
513
+ /** Expose owner-key resolution for tests without exporting the full session module. */
514
+ export function resolveBackendSessionOwnerKey(state, input) {
515
+ return resolveSessionOwnerKey(state, input);
516
+ }
517
+ function microsToMs(timestampMicros) {
518
+ return timestampMicros === undefined ? undefined : Math.trunc(timestampMicros / 1000);
313
519
  }
314
520
  /** Build the lifecycle summary stored as the session_end mark payload. */
315
521
  function sessionEndSummary(event) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nemo-relay-openclaw",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "NeMo Relay-authored observability plugin for OpenClaw.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -63,7 +63,7 @@
63
63
  "openclaw": "^2026.5.26"
64
64
  },
65
65
  "dependencies": {
66
- "nemo-relay-node": "0.3.0"
66
+ "nemo-relay-node": "0.4.0"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@types/node": "^24.0.0",