nemo-relay-openclaw 0.3.0 → 0.5.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.
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import type { NemoRelayHookBackendConfig } from './config.js';
12
12
  import { emitMark, toJsonRecord } from './hook-replay/marks.js';
13
- import { llmKey } from './hook-replay/correlation.js';
13
+ import { llmKey, nowMicros } from './hook-replay/correlation.js';
14
14
  import {
15
15
  emitUnpairedModelCallTimingMarks,
16
16
  recordBeforeMessageWrite,
@@ -28,7 +28,9 @@ import {
28
28
  closeSessionRoot,
29
29
  deleteSession,
30
30
  ensureSession,
31
- resolveSessionKey,
31
+ materializeSessionRoot,
32
+ queueCapturedEmit,
33
+ resolveSessionOwnerKey,
32
34
  type HookReplayBackendState,
33
35
  type SessionLookupInput,
34
36
  type SessionState,
@@ -66,6 +68,14 @@ export type HookReplayBackendOptions = {
66
68
  agentVersion: string;
67
69
  };
68
70
 
71
+ type PendingSubagentLineage = {
72
+ childSessionKey: string;
73
+ requesterSessionKey: string;
74
+ runId?: string;
75
+ agentId?: string;
76
+ observedAtMs: number;
77
+ };
78
+
69
79
  /** Replays OpenClaw public hook events into NeMo Relay scopes, spans, and marks. */
70
80
  export class HookReplayBackend {
71
81
  private readonly nf: NemoRelayRuntimeModule;
@@ -74,6 +84,8 @@ export class HookReplayBackend {
74
84
  private readonly agentVersion: string;
75
85
  private readonly stateValue = createHookReplayState();
76
86
  private readonly warningCounts = new Map<string, number>();
87
+ private readonly pendingSubagentLineageByChildSessionKey = new Map<string, PendingSubagentLineage>();
88
+ private readonly pendingSubagentChildKeyByRunId = new Map<string, string>();
77
89
 
78
90
  constructor(options: HookReplayBackendOptions) {
79
91
  this.nf = options.nf;
@@ -95,14 +107,18 @@ export class HookReplayBackend {
95
107
 
96
108
  /** Open or alias an explicit OpenClaw session root. */
97
109
  onSessionStart(event: PluginHookSessionStartEvent, ctx: PluginHookSessionContext): void {
98
- this.ensureSession({
110
+ const observedAtMicros = nowMicros();
111
+ const session = this.ensureSession({
99
112
  sessionId: event.sessionId,
100
113
  sessionKey: event.sessionKey ?? ctx.sessionKey,
101
114
  agentId: ctx.agentId,
102
115
  source: 'session_start',
103
116
  resumedFrom: event.resumedFrom,
117
+ timestamp: observedAtMicros,
104
118
  });
105
119
 
120
+ this.promoteDeferredSubagentSessionsForRequester(session?.sessionKey ?? event.sessionKey ?? ctx.sessionKey);
121
+
106
122
  // ensureSession opens the root scope and emits openclaw.session_start for both explicit and lazy sessions.
107
123
  }
108
124
 
@@ -119,7 +135,17 @@ export class HookReplayBackend {
119
135
  return;
120
136
  }
121
137
 
122
- await this.closeSession(session, sessionEndSummary(event));
138
+ await this.closeSession(
139
+ session,
140
+ sessionEndSummary(event),
141
+ toJsonRecord({
142
+ source: 'openclaw.session_end',
143
+ hook_event_name: 'session_end',
144
+ sessionId: session.sessionId,
145
+ sessionKey: event.sessionKey ?? ctx.sessionKey,
146
+ agentId: ctx.agentId,
147
+ }),
148
+ );
123
149
  }
124
150
 
125
151
  /** Buffer an LLM request snapshot until a matching response or trajectory replay arrives. */
@@ -159,12 +185,14 @@ export class HookReplayBackend {
159
185
 
160
186
  /** Finalize one agent run, replaying message-write trajectory when needed. */
161
187
  onAgentEnd(event: PluginHookAgentEndEvent, ctx: PluginHookAgentContext): void {
188
+ const observedAtMicros = nowMicros();
162
189
  const session = this.ensureSession({
163
190
  sessionId: ctx.sessionId,
164
191
  sessionKey: ctx.sessionKey,
165
192
  runId: event.runId ?? ctx.runId,
166
193
  agentId: ctx.agentId,
167
194
  source: 'lazy_session',
195
+ timestamp: observedAtMicros,
168
196
  });
169
197
 
170
198
  if (!session) {
@@ -186,17 +214,20 @@ export class HookReplayBackend {
186
214
  durationMs: event.durationMs,
187
215
  messageCount: event.messages.length,
188
216
  }),
217
+ observedAtMicros,
189
218
  );
190
219
  }
191
220
 
192
221
  /** Remember the last assistant text before OpenClaw finalizes the response. */
193
222
  onBeforeAgentFinalize(event: PluginHookBeforeAgentFinalizeEvent, ctx: PluginHookAgentContext): void {
223
+ const observedAtMicros = nowMicros();
194
224
  const session = this.ensureSession({
195
225
  sessionId: event.sessionId,
196
226
  sessionKey: event.sessionKey ?? ctx.sessionKey,
197
227
  runId: event.runId ?? ctx.runId,
198
228
  agentId: ctx.agentId,
199
229
  source: 'lazy_session',
230
+ timestamp: observedAtMicros,
200
231
  });
201
232
 
202
233
  if (!session) {
@@ -224,21 +255,24 @@ export class HookReplayBackend {
224
255
  stopHookActive: event.stopHookActive,
225
256
  messageCount: event.messages?.length,
226
257
  }),
258
+ observedAtMicros,
227
259
  );
228
260
  }
229
261
 
230
262
  /** Attach subagent spawn metadata to the requester session when possible. */
231
263
  onSubagentSpawned(event: PluginHookSubagentSpawnedEvent, ctx: PluginHookSubagentContext): void {
264
+ const observedAtMicros = nowMicros();
265
+ this.trackPendingSubagentLineage(event, ctx, Math.trunc(observedAtMicros / 1000));
266
+ const requesterSession = this.ensureRequesterSessionAnchor(ctx.requesterSessionKey, observedAtMicros);
267
+ const childSessionKey = ctx.childSessionKey ?? event.childSessionKey;
232
268
  const session =
269
+ requesterSession ??
233
270
  this.ensureSession({
234
- requesterSessionKey: ctx.requesterSessionKey,
235
- source: 'lazy_session',
236
- }) ??
237
- this.ensureSession({
238
- childSessionKey: ctx.childSessionKey ?? event.childSessionKey,
271
+ childSessionKey,
239
272
  runId: ctx.runId ?? event.runId,
240
273
  agentId: event.agentId,
241
274
  source: 'lazy_session',
275
+ timestamp: observedAtMicros,
242
276
  });
243
277
 
244
278
  if (!session) {
@@ -256,26 +290,36 @@ export class HookReplayBackend {
256
290
  mode: event.mode,
257
291
  threadRequested: event.threadRequested,
258
292
  }),
293
+ observedAtMicros,
259
294
  );
295
+
296
+ if (!ctx.requesterSessionKey || requesterSession?.rootHandle) {
297
+ this.promoteDeferredSubagentSession(event.childSessionKey);
298
+ }
260
299
  }
261
300
 
262
301
  /** Attach subagent completion metadata to the requester or child session. */
263
302
  onSubagentEnded(event: PluginHookSubagentEndedEvent, ctx: PluginHookSubagentContext): void {
303
+ const observedAtMicros = nowMicros();
264
304
  const session =
265
305
  this.ensureSession({
266
306
  requesterSessionKey: ctx.requesterSessionKey,
267
307
  source: 'lazy_session',
308
+ timestamp: observedAtMicros,
268
309
  }) ??
269
310
  this.ensureSession({
270
311
  childSessionKey: ctx.childSessionKey ?? event.targetSessionKey,
271
312
  runId: ctx.runId ?? event.runId,
272
313
  source: 'lazy_session',
314
+ timestamp: observedAtMicros,
273
315
  });
274
316
 
275
317
  if (!session) {
276
318
  return;
277
319
  }
278
320
 
321
+ this.materializeDeferredSessionRoot(session);
322
+
279
323
  this.emitSessionMark(
280
324
  'openclaw.subagent_ended',
281
325
  session,
@@ -290,6 +334,7 @@ export class HookReplayBackend {
290
334
  sendFarewell: event.sendFarewell,
291
335
  accountId: event.accountId,
292
336
  }),
337
+ observedAtMicros,
293
338
  );
294
339
  }
295
340
 
@@ -300,12 +345,12 @@ export class HookReplayBackend {
300
345
 
301
346
  /** Close one session selected by a runtime lifecycle cleanup hook. */
302
347
  async cleanupSession(input: SessionLookupInput & { reason: string }): Promise<void> {
303
- const key = resolveSessionKey(this.stateValue, input);
304
- if (!key) {
348
+ const ownerKey = resolveSessionOwnerKey(this.stateValue, input);
349
+ if (!ownerKey) {
305
350
  return;
306
351
  }
307
352
 
308
- const session = this.stateValue.sessions.get(key);
353
+ const session = this.stateValue.sessions.get(ownerKey);
309
354
  if (!session) {
310
355
  return;
311
356
  }
@@ -346,6 +391,10 @@ export class HookReplayBackend {
346
391
 
347
392
  /** Emit spans/marks under the stored session scope stack and ATIF capture window. */
348
393
  emitCapturedUnderSession(label: string, session: SessionState, emit: () => void): void {
394
+ if (queueCapturedEmit(session, label, emit)) {
395
+ return;
396
+ }
397
+
349
398
  this.safeReplay(label, session, () => {
350
399
  const previousStack = this.nf.currentScopeStack();
351
400
  try {
@@ -373,23 +422,39 @@ export class HookReplayBackend {
373
422
  }
374
423
 
375
424
  /** Drain, close, export, and delete one session. */
376
- private async closeSession(session: SessionState, summary: JsonRecord): Promise<void> {
425
+ private async closeSession(session: SessionState, summary: JsonRecord, metadata?: JsonRecord): Promise<void> {
426
+ this.materializeDeferredSessionRoot(session);
377
427
  drainSession(this.sessionManager(), session);
378
- closeSessionRoot(this.sessionManager(), session, summary, session.finalOutput ?? summary);
428
+ closeSessionRoot(this.sessionManager(), session, summary, session.finalOutput ?? summary, metadata);
379
429
  this.flushSubscriberDelivery('session_close');
430
+ this.forgetPendingSubagentLineage(session);
380
431
  deleteSession(this.stateValue, session);
381
432
  }
382
433
 
383
434
  /** Emit a session-level OpenClaw lifecycle mark. */
384
- private emitSessionMark(name: string, session: SessionState, data: JsonRecord): void {
435
+ private emitSessionMark(name: string, session: SessionState, data: JsonRecord, timestampMicros?: number): void {
385
436
  this.emitCapturedUnderSession(name, session, () => {
386
- emitMark({
437
+ const params: Parameters<typeof emitMark>[0] = {
387
438
  nf: this.nf,
388
439
  state: this.stateValue,
389
440
  session,
390
441
  name,
391
442
  data,
392
- });
443
+ metadata: toJsonRecord({
444
+ source: name,
445
+ hook_event_name: name.startsWith('openclaw.') ? name.slice('openclaw.'.length) : undefined,
446
+ sessionId: session.sessionId,
447
+ sessionKey: session.sessionKey,
448
+ agentId: session.agentId,
449
+ runId: typeof data.runId === 'string' ? data.runId : undefined,
450
+ }),
451
+ };
452
+
453
+ if (timestampMicros !== undefined) {
454
+ params.timestamp = timestampMicros;
455
+ }
456
+
457
+ emitMark(params);
393
458
  });
394
459
  }
395
460
 
@@ -426,9 +491,202 @@ export class HookReplayBackend {
426
491
  this.replayPendingLlmOutputsForSession(session, options),
427
492
  emitUnpairedModelCallTimingMarks: (session: SessionState) => this.emitUnpairedModelCallTimingMarks(session),
428
493
  logBoundedWarn: (key: string, message: string) => this.logBoundedWarn(key, message),
494
+ resolveSessionRootContext: (input: Parameters<typeof ensureSession>[1]) => this.resolveSessionRootContext(input),
429
495
  };
430
496
  }
431
497
 
498
+ /** Prefer nested child scopes only when the hook surface provides real subagent lineage. */
499
+ private resolveSessionRootContext(input: Parameters<typeof ensureSession>[1]): Partial<Parameters<typeof ensureSession>[1]> | undefined {
500
+ this.pruneExpiredPendingSubagentLineage(microsToMs(input.timestamp) ?? Date.now());
501
+ const lineage = this.resolvePendingSubagentLineage(input);
502
+ if (lineage) {
503
+ const parentSession = this.resolveTrackedSession({ requesterSessionKey: lineage.requesterSessionKey });
504
+ return {
505
+ childSessionKey: input.childSessionKey ?? lineage.childSessionKey,
506
+ runId: input.runId ?? lineage.runId,
507
+ agentId: input.agentId ?? lineage.agentId,
508
+ scopeRole: 'subagent',
509
+ parentHandle: parentSession?.rootHandle,
510
+ deferRootOpen: input.deferRootOpen ?? (parentSession?.rootHandle ? false : true),
511
+ };
512
+ }
513
+
514
+ if (this.isDocumentedSubagentSessionKey(input.sessionKey ?? input.childSessionKey)) {
515
+ return {
516
+ scopeRole: 'subagent',
517
+ deferRootOpen: input.deferRootOpen ?? true,
518
+ };
519
+ }
520
+
521
+ return undefined;
522
+ }
523
+
524
+ /** Track stable parent/child lineage from subagent hooks until child session hooks can use it. */
525
+ private trackPendingSubagentLineage(
526
+ event: PluginHookSubagentSpawnedEvent,
527
+ ctx: PluginHookSubagentContext,
528
+ observedAtMs: number,
529
+ ): void {
530
+ this.pruneExpiredPendingSubagentLineage(observedAtMs);
531
+ const requesterSessionKey = ctx.requesterSessionKey?.trim();
532
+ const childSessionKey = (ctx.childSessionKey ?? event.childSessionKey)?.trim();
533
+ if (!requesterSessionKey || !childSessionKey) {
534
+ return;
535
+ }
536
+
537
+ this.pendingSubagentLineageByChildSessionKey.set(childSessionKey, {
538
+ childSessionKey,
539
+ requesterSessionKey,
540
+ runId: ctx.runId ?? event.runId,
541
+ agentId: event.agentId,
542
+ observedAtMs,
543
+ });
544
+ if (ctx.runId ?? event.runId) {
545
+ this.pendingSubagentChildKeyByRunId.set(ctx.runId ?? event.runId, childSessionKey);
546
+ }
547
+ }
548
+
549
+ /** Resolve the requester session if it exists, or seed a deferred lazy root placeholder for later promotion. */
550
+ private ensureRequesterSessionAnchor(requesterSessionKey: string | undefined, timestampMicros?: number): SessionState | undefined {
551
+ const trimmedRequesterSessionKey = requesterSessionKey?.trim();
552
+ if (!trimmedRequesterSessionKey) {
553
+ return undefined;
554
+ }
555
+
556
+ return (
557
+ this.resolveTrackedSession({ requesterSessionKey: trimmedRequesterSessionKey }) ??
558
+ this.ensureSession({
559
+ sessionKey: trimmedRequesterSessionKey,
560
+ requesterSessionKey: trimmedRequesterSessionKey,
561
+ source: 'lazy_session',
562
+ timestamp: timestampMicros,
563
+ deferRootOpen: true,
564
+ })
565
+ );
566
+ }
567
+
568
+ /** Open a deferred child session root once the requester scope is known. */
569
+ private promoteDeferredSubagentSession(childSessionKey: string): void {
570
+ const session = this.resolveTrackedSession({ sessionKey: childSessionKey, childSessionKey });
571
+ if (!session) {
572
+ return;
573
+ }
574
+
575
+ this.materializeDeferredSessionRoot(session);
576
+ }
577
+
578
+ /** Promote any deferred child sessions waiting on a requester root once it exists. */
579
+ private promoteDeferredSubagentSessionsForRequester(requesterSessionKey: string | undefined): void {
580
+ const trimmedRequesterSessionKey = requesterSessionKey?.trim();
581
+ if (!trimmedRequesterSessionKey) {
582
+ return;
583
+ }
584
+
585
+ const requesterSession = this.resolveTrackedSession({ requesterSessionKey: trimmedRequesterSessionKey });
586
+ if (!requesterSession?.rootHandle) {
587
+ return;
588
+ }
589
+
590
+ for (const lineage of this.pendingSubagentLineageByChildSessionKey.values()) {
591
+ if (lineage.requesterSessionKey === trimmedRequesterSessionKey) {
592
+ this.promoteDeferredSubagentSession(lineage.childSessionKey);
593
+ }
594
+ }
595
+ }
596
+
597
+ /** Materialize one deferred session root with nested lineage when available. */
598
+ private materializeDeferredSessionRoot(session: SessionState): void {
599
+ if (session.rootHandle) {
600
+ return;
601
+ }
602
+
603
+ this.pruneExpiredPendingSubagentLineage(Date.now());
604
+ const lineage = this.resolvePendingSubagentLineage({
605
+ sessionId: session.sessionId,
606
+ sessionKey: session.sessionKey,
607
+ });
608
+ const parentSession =
609
+ lineage === undefined
610
+ ? undefined
611
+ : this.ensureRequesterSessionAnchor(lineage.requesterSessionKey, session.pendingRootTimestampMicros);
612
+ if (parentSession && !parentSession.rootHandle) {
613
+ materializeSessionRoot(this.sessionManager(), parentSession, {
614
+ sessionId: parentSession.sessionId,
615
+ sessionKey: parentSession.sessionKey ?? lineage?.requesterSessionKey,
616
+ source: parentSession.source,
617
+ resumedFrom: parentSession.resumedFrom,
618
+ timestamp: parentSession.pendingRootTimestampMicros,
619
+ });
620
+ }
621
+ const parentHandle = parentSession?.rootHandle;
622
+
623
+ materializeSessionRoot(this.sessionManager(), session, {
624
+ sessionId: session.sessionId,
625
+ sessionKey: session.sessionKey,
626
+ runId: lineage?.runId,
627
+ agentId: session.agentId ?? lineage?.agentId,
628
+ source: session.source,
629
+ resumedFrom: session.resumedFrom,
630
+ scopeRole: session.scopeRole,
631
+ parentHandle,
632
+ });
633
+ }
634
+
635
+ /** Resolve stable subagent lineage from child session key first, then run id as a fallback. */
636
+ private resolvePendingSubagentLineage(input: SessionLookupInput): PendingSubagentLineage | undefined {
637
+ const childSessionKey = [input.sessionKey, input.childSessionKey]
638
+ .find((value): value is string => this.isDocumentedSubagentSessionKey(value))
639
+ ?.trim();
640
+ if (childSessionKey) {
641
+ return this.pendingSubagentLineageByChildSessionKey.get(childSessionKey);
642
+ }
643
+
644
+ const runChildSessionKey =
645
+ typeof input.runId === 'string' && input.runId.length > 0 ? this.pendingSubagentChildKeyByRunId.get(input.runId) : undefined;
646
+ return runChildSessionKey === undefined ? undefined : this.pendingSubagentLineageByChildSessionKey.get(runChildSessionKey);
647
+ }
648
+
649
+ /** Drop stale pending lineage entries so abandoned subagent spawns do not accumulate indefinitely. */
650
+ private pruneExpiredPendingSubagentLineage(nowMs: number): void {
651
+ const ttlMs = this.config.correlation.recordTtlMs;
652
+ for (const [childSessionKey, lineage] of this.pendingSubagentLineageByChildSessionKey) {
653
+ if (nowMs - lineage.observedAtMs > ttlMs) {
654
+ this.pendingSubagentLineageByChildSessionKey.delete(childSessionKey);
655
+ }
656
+ }
657
+
658
+ for (const [runId, childSessionKey] of this.pendingSubagentChildKeyByRunId) {
659
+ if (!this.pendingSubagentLineageByChildSessionKey.has(childSessionKey)) {
660
+ this.pendingSubagentChildKeyByRunId.delete(runId);
661
+ }
662
+ }
663
+ }
664
+
665
+ /** Resolve one session through the same alias map used by the replay state. */
666
+ private resolveTrackedSession(input: SessionLookupInput): SessionState | undefined {
667
+ const ownerKey = resolveSessionOwnerKey(this.stateValue, input);
668
+ return ownerKey === undefined ? undefined : this.stateValue.sessions.get(ownerKey);
669
+ }
670
+
671
+ /** Free lineage bookkeeping once the child session is closed. */
672
+ private forgetPendingSubagentLineage(session: SessionState): void {
673
+ const childSessionKey = session.sessionKey;
674
+ if (childSessionKey) {
675
+ this.pendingSubagentLineageByChildSessionKey.delete(childSessionKey);
676
+ }
677
+
678
+ for (const [runId, trackedChildSessionKey] of this.pendingSubagentChildKeyByRunId) {
679
+ if (trackedChildSessionKey === childSessionKey) {
680
+ this.pendingSubagentChildKeyByRunId.delete(runId);
681
+ }
682
+ }
683
+ }
684
+
685
+ /** Match the documented native subagent session key shape without depending on private OpenClaw internals. */
686
+ private isDocumentedSubagentSessionKey(value?: string): value is string {
687
+ return typeof value === 'string' && /^agent:[^:]+:subagent:/.test(value);
688
+ }
689
+
432
690
  /** Log one warning per key to avoid noisy repeated hook failures. */
433
691
  private logBoundedWarn(key: string, message: string): void {
434
692
  const count = this.warningCounts.get(key) ?? 0;
@@ -441,12 +699,16 @@ export class HookReplayBackend {
441
699
 
442
700
  export { llmKey };
443
701
 
444
- /** Expose session-key resolution for tests without exporting the full session module. */
445
- export function resolveBackendSessionKey(
702
+ /** Expose owner-key resolution for tests without exporting the full session module. */
703
+ export function resolveBackendSessionOwnerKey(
446
704
  state: HookReplayBackendState,
447
- input: Parameters<typeof resolveSessionKey>[1],
705
+ input: Parameters<typeof resolveSessionOwnerKey>[1],
448
706
  ): string | undefined {
449
- return resolveSessionKey(state, input);
707
+ return resolveSessionOwnerKey(state, input);
708
+ }
709
+
710
+ function microsToMs(timestampMicros: number | undefined): number | undefined {
711
+ return timestampMicros === undefined ? undefined : Math.trunc(timestampMicros / 1000);
450
712
  }
451
713
 
452
714
  /** Build the lifecycle summary stored as the session_end mark payload. */