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,7 +1,7 @@
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, toJsonValue } from './marks.js';
4
- import { evictExpiredCorrelationRecords, ensureSession, insertBoundedRecord, resolveSessionKey, } from './session.js';
4
+ import { evictExpiredCorrelationRecords, ensureSession, insertBoundedRecord, resolveSessionOwnerKey, } from './session.js';
5
5
  import { llmKey, modelTimingKey, modelTimingLlmKey, nowMicros, startMicrosFromDuration } from './correlation.js';
6
6
  /**
7
7
  * Store one OpenClaw llm_input snapshot and replay it immediately if the matching
@@ -9,12 +9,14 @@ import { llmKey, modelTimingKey, modelTimingLlmKey, nowMicros, startMicrosFromDu
9
9
  */
10
10
  export function recordLlmInput(manager, event, ctx) {
11
11
  evictExpiredReplayRecords(manager);
12
+ const observedAtMicros = nowMicros();
12
13
  const session = ensureSession(manager, {
13
14
  sessionId: event.sessionId,
14
15
  sessionKey: ctx.sessionKey,
15
16
  runId: event.runId,
16
17
  agentId: ctx.agentId,
17
18
  source: 'lazy_session',
19
+ timestamp: observedAtMicros,
18
20
  });
19
21
  if (!session) {
20
22
  return;
@@ -26,7 +28,7 @@ export function recordLlmInput(manager, event, ctx) {
26
28
  const key = llmKey(event);
27
29
  const input = createInputRecord(session, event);
28
30
  insertBoundedRecord(manager.state.llmInputs, key, input, manager.config.correlation.maxRecordsPerKey);
29
- const pending = shiftOldest(manager.state.llmOutputsPendingInput, key, (record) => record.sessionKey === session.sessionId);
31
+ const pending = shiftOldest(manager.state.llmOutputsPendingInput, key, (record) => record.sessionOwnerKey === session.ownerKey);
30
32
  if (!pending) {
31
33
  return;
32
34
  }
@@ -48,22 +50,24 @@ export function recordLlmInput(manager, event, ctx) {
48
50
  */
49
51
  export function recordLlmOutput(manager, event, ctx) {
50
52
  evictExpiredReplayRecords(manager);
53
+ const observedAtMicros = nowMicros();
51
54
  const session = ensureSession(manager, {
52
55
  sessionId: event.sessionId,
53
56
  sessionKey: ctx.sessionKey,
54
57
  runId: event.runId,
55
58
  agentId: ctx.agentId,
56
59
  source: 'lazy_session',
60
+ timestamp: observedAtMicros,
57
61
  });
58
62
  if (!session) {
59
63
  return;
60
64
  }
61
65
  const key = llmKey(event);
62
66
  if (hasTrajectoryReplay(session, event.runId)) {
63
- shiftOldest(manager.state.llmInputs, key, (record) => record.sessionKey === session.sessionId);
67
+ shiftOldest(manager.state.llmInputs, key, (record) => record.sessionOwnerKey === session.ownerKey);
64
68
  return;
65
69
  }
66
- const input = shiftOldest(manager.state.llmInputs, key, (record) => record.sessionKey === session.sessionId);
70
+ const input = shiftOldest(manager.state.llmInputs, key, (record) => record.sessionOwnerKey === session.ownerKey);
67
71
  if (input) {
68
72
  replayLlmOutput({
69
73
  manager,
@@ -75,7 +79,7 @@ export function recordLlmOutput(manager, event, ctx) {
75
79
  return;
76
80
  }
77
81
  const pending = {
78
- sessionKey: session.sessionId,
82
+ sessionOwnerKey: session.ownerKey,
79
83
  sessionId: event.sessionId,
80
84
  runId: event.runId,
81
85
  provider: event.provider,
@@ -120,7 +124,7 @@ export function recordBeforeMessageWrite(manager, event, ctx) {
120
124
  if (provider && model && (assistantTexts.length > 0 || assistantToolCalls.length > 0 || usage !== undefined)) {
121
125
  session.assistantMessageWrites ??= [];
122
126
  session.assistantMessageWrites.push({
123
- sessionKey: session.sessionId,
127
+ sessionOwnerKey: session.ownerKey,
124
128
  provider,
125
129
  model,
126
130
  assistantTexts,
@@ -158,7 +162,7 @@ export function recordModelCallStarted(manager, event, ctx) {
158
162
  return;
159
163
  }
160
164
  insertBoundedRecord(manager.state.modelCallsByCallId, modelTimingKey(event), {
161
- sessionKey: session.sessionId,
165
+ sessionOwnerKey: session.ownerKey,
162
166
  sessionId: session.sessionId,
163
167
  runId: event.runId,
164
168
  callId: event.callId,
@@ -191,7 +195,7 @@ export function recordModelCallEnded(manager, event, ctx) {
191
195
  const existing = latestUnendedRecord(manager.state.modelCallsByCallId.get(byCallKey), session);
192
196
  const record = existing ??
193
197
  {
194
- sessionKey: session.sessionId,
198
+ sessionOwnerKey: session.ownerKey,
195
199
  sessionId: session.sessionId,
196
200
  runId: event.runId,
197
201
  callId: event.callId,
@@ -205,7 +209,7 @@ export function recordModelCallEnded(manager, event, ctx) {
205
209
  insertBoundedRecord(manager.state.modelCallsByCallId, byCallKey, record, manager.config.correlation.maxRecordsPerKey);
206
210
  }
207
211
  insertBoundedRecord(manager.state.modelTimingsByLlmKey, modelTimingLlmKey({
208
- sessionId: session.sessionId,
212
+ sessionId: session.ownerKey,
209
213
  runId: event.runId,
210
214
  provider: event.provider,
211
215
  model: event.model,
@@ -219,7 +223,7 @@ export function replayPendingLlmOutputsForSession(manager, session, options) {
219
223
  for (const [key, records] of [...manager.state.llmOutputsPendingInput]) {
220
224
  const remaining = [];
221
225
  for (const record of records) {
222
- if (record.sessionKey !== session.sessionId) {
226
+ if (record.sessionOwnerKey !== session.ownerKey) {
223
227
  remaining.push(record);
224
228
  continue;
225
229
  }
@@ -274,7 +278,7 @@ export function replayAgentEndMessages(manager, event, ctx, session) {
274
278
  export function emitUnpairedModelCallTimingMarks(manager, session) {
275
279
  for (const records of manager.state.modelCallsByCallId.values()) {
276
280
  for (const record of records) {
277
- if (record.sessionKey !== session.sessionId || record.consumed || record.endedAtMs !== undefined) {
281
+ if (record.sessionOwnerKey !== session.ownerKey || record.consumed || record.endedAtMs !== undefined) {
278
282
  continue;
279
283
  }
280
284
  emitModelTimingMark(manager, session, 'openclaw.model_call_timing_unpaired', record);
@@ -284,7 +288,7 @@ export function emitUnpairedModelCallTimingMarks(manager, session) {
284
288
  const unpairedEnded = [];
285
289
  for (const records of manager.state.modelTimingsByLlmKey.values()) {
286
290
  for (const record of records) {
287
- if (record.sessionKey !== session.sessionId || record.consumed) {
291
+ if (record.sessionOwnerKey !== session.ownerKey || record.consumed) {
288
292
  continue;
289
293
  }
290
294
  unpairedEnded.push(record);
@@ -362,7 +366,7 @@ function replayExpiredPendingOutput(manager, key, record) {
362
366
  if (!removeRecord(manager.state.llmOutputsPendingInput, key, record)) {
363
367
  return;
364
368
  }
365
- const session = manager.state.sessions.get(record.sessionKey);
369
+ const session = manager.state.sessions.get(record.sessionOwnerKey);
366
370
  if (!session) {
367
371
  manager.state.counters.skippedEvents += 1;
368
372
  return;
@@ -408,7 +412,9 @@ function replayLlmOutput(params) {
408
412
  const metadata = toJsonRecord({
409
413
  source,
410
414
  runId: event.runId,
411
- sessionId: event.sessionId,
415
+ sessionId: session.sessionId,
416
+ sessionKey: session.sessionKey,
417
+ agentId: session.agentId,
412
418
  provider: event.provider,
413
419
  model: event.model,
414
420
  callId: timing?.callId,
@@ -452,7 +458,7 @@ function replayAssistantMessageWrites(manager, session, event, ctx) {
452
458
  },
453
459
  ctx,
454
460
  input: {
455
- sessionKey: session.sessionId,
461
+ sessionOwnerKey: session.ownerKey,
456
462
  sessionId: session.sessionId,
457
463
  runId,
458
464
  provider: record.provider,
@@ -475,13 +481,13 @@ function replayAssistantMessageWrites(manager, session, event, ctx) {
475
481
  /** Consume the next unpaired timing record for message-write trajectory replay. */
476
482
  function consumeNextTimingCandidate(manager, session, input) {
477
483
  const key = modelTimingLlmKey({
478
- sessionId: session.sessionId,
484
+ sessionId: session.ownerKey,
479
485
  runId: input.runId,
480
486
  provider: input.provider,
481
487
  model: input.model,
482
488
  });
483
489
  const records = manager.state.modelTimingsByLlmKey.get(key) ?? [];
484
- const candidate = records.find((record) => record.sessionKey === session.sessionId && !record.consumed);
490
+ const candidate = records.find((record) => record.sessionOwnerKey === session.ownerKey && !record.consumed);
485
491
  if (!candidate) {
486
492
  return undefined;
487
493
  }
@@ -491,12 +497,12 @@ function consumeNextTimingCandidate(manager, session, input) {
491
497
  /** Consume a timing candidate only when the public hook data makes it unambiguous. */
492
498
  function consumeTimingCandidate(manager, session, event) {
493
499
  const key = modelTimingLlmKey({
494
- sessionId: session.sessionId,
500
+ sessionId: session.ownerKey,
495
501
  runId: event.runId,
496
502
  provider: event.provider,
497
503
  model: event.model,
498
504
  });
499
- const candidates = (manager.state.modelTimingsByLlmKey.get(key) ?? []).filter((record) => record.sessionKey === session.sessionId && !record.consumed);
505
+ const candidates = (manager.state.modelTimingsByLlmKey.get(key) ?? []).filter((record) => record.sessionOwnerKey === session.ownerKey && !record.consumed);
500
506
  if (candidates.length === 1) {
501
507
  const candidate = candidates[0];
502
508
  if (!candidate) {
@@ -580,7 +586,7 @@ function emitModelTimingSummaryMark(manager, session, records) {
580
586
  /** Convert an OpenClaw llm_input event into the buffered request record. */
581
587
  function createInputRecord(session, event) {
582
588
  return {
583
- sessionKey: session.sessionId,
589
+ sessionOwnerKey: session.ownerKey,
584
590
  sessionId: event.sessionId,
585
591
  runId: event.runId,
586
592
  provider: event.provider,
@@ -594,15 +600,23 @@ function createInputRecord(session, event) {
594
600
  }
595
601
  /** Resolve an existing session for before_message_write without creating a fake one. */
596
602
  function existingSessionForMessageWrite(manager, event, ctx) {
597
- const key = resolveSessionKey(manager.state, {
603
+ const key = resolveSessionOwnerKey(manager.state, {
598
604
  sessionKey: event.sessionKey ?? ctx.sessionKey,
599
605
  });
600
- return key === undefined ? undefined : manager.state.sessions.get(key);
606
+ if (key === undefined) {
607
+ return undefined;
608
+ }
609
+ const session = manager.state.sessions.get(key);
610
+ const sessionKey = event.sessionKey ?? ctx.sessionKey;
611
+ if (session && session.sessionKey === undefined && sessionKey !== undefined) {
612
+ session.sessionKey = sessionKey;
613
+ }
614
+ return session;
601
615
  }
602
616
  /** Build a minimal request placeholder when only an llm_output hook is available. */
603
617
  function placeholderInputRecord(record) {
604
618
  return {
605
- sessionKey: record.sessionKey,
619
+ sessionOwnerKey: record.sessionOwnerKey,
606
620
  sessionId: record.sessionId,
607
621
  runId: record.runId,
608
622
  provider: record.provider,
@@ -941,19 +955,33 @@ function incrementHookLlmOutputReplayCount(session, runId, maxRuns) {
941
955
  }
942
956
  /** Build the per-session run key used for trajectory de-duplication. */
943
957
  function trajectoryRunKey(session, runId) {
944
- return runId ?? session.sessionId;
958
+ return runId ?? session.ownerKey;
945
959
  }
946
- /** Normalize provider usage into OpenInference-friendly token/cost fields. */
960
+ /** Normalize provider usage into NeMo Relay token and cost fields. */
947
961
  function mapUsage(usage) {
948
962
  if (!isRecord(usage)) {
949
963
  return undefined;
950
964
  }
951
965
  const mapped = {};
952
- const input = numberField(usage, 'input') ?? numberField(usage, 'prompt_tokens');
953
- const output = numberField(usage, 'output') ?? numberField(usage, 'completion_tokens');
954
- const cacheRead = numberField(usage, 'cacheRead') ?? numberField(usage, 'cache_read_tokens');
955
- const cacheWrite = numberField(usage, 'cacheWrite') ?? numberField(usage, 'cache_write_tokens');
956
- const total = numberField(usage, 'total') ?? numberField(usage, 'totalTokens') ?? numberField(usage, 'total_tokens');
966
+ const input = numberField(usage, 'input') ??
967
+ numberField(usage, 'prompt_tokens') ??
968
+ numberField(usage, 'input_tokens') ??
969
+ numberField(usage, 'inputTokens');
970
+ const output = numberField(usage, 'output') ??
971
+ numberField(usage, 'completion_tokens') ??
972
+ numberField(usage, 'output_tokens') ??
973
+ numberField(usage, 'outputTokens');
974
+ const cacheRead = numberField(usage, 'cacheRead') ??
975
+ numberField(usage, 'cache_read_tokens') ??
976
+ numberField(usage, 'cache_read_input_tokens') ??
977
+ nestedNumberField(usage, 'input_tokens_details', 'cached_tokens') ??
978
+ nestedNumberField(usage, 'prompt_tokens_details', 'cached_tokens');
979
+ const cacheWrite = numberField(usage, 'cacheWrite') ??
980
+ numberField(usage, 'cache_write_tokens') ??
981
+ numberField(usage, 'cache_creation_input_tokens');
982
+ const total = numberField(usage, 'total') ??
983
+ numberField(usage, 'totalTokens') ??
984
+ numberField(usage, 'total_tokens');
957
985
  const totalCanIncludeCompletion = total === undefined || output === undefined || total >= output;
958
986
  const prompt = total !== undefined && output !== undefined && totalCanIncludeCompletion ? total - output : input;
959
987
  const totalCanIncludePrompt = total === undefined || prompt === undefined || total >= prompt;
@@ -993,6 +1021,11 @@ function numberField(record, key) {
993
1021
  const value = record[key];
994
1022
  return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
995
1023
  }
1024
+ /** Read a finite numeric field nested one object deep from a generic hook record. */
1025
+ function nestedNumberField(record, objectKey, fieldKey) {
1026
+ const value = record[objectKey];
1027
+ return isRecord(value) ? numberField(value, fieldKey) : undefined;
1028
+ }
996
1029
  /** Copy model_call_ended details into a retained timing record. */
997
1030
  function applyModelCallEnd(record, event, nowMs) {
998
1031
  record.observedAtMs = nowMs;
@@ -1015,7 +1048,7 @@ function latestUnendedRecord(records, session) {
1015
1048
  }
1016
1049
  for (let index = records.length - 1; index >= 0; index -= 1) {
1017
1050
  const record = records[index];
1018
- if (record?.sessionKey === session.sessionId && record.endedAtMs === undefined) {
1051
+ if (record?.sessionOwnerKey === session.ownerKey && record.endedAtMs === undefined) {
1019
1052
  return record;
1020
1053
  }
1021
1054
  }
@@ -16,6 +16,7 @@ export declare function emitMark(params: {
16
16
  session: SessionState;
17
17
  name: string;
18
18
  data: JsonRecord;
19
+ metadata?: JsonRecord | null;
19
20
  timestamp?: number;
20
21
  }): void;
21
22
  /** Return a blocked-tool event payload when OpenClaw denied the tool call. */
@@ -6,7 +6,7 @@ export function emitMark(params) {
6
6
  params.state.counters.skippedEvents += 1;
7
7
  return;
8
8
  }
9
- params.nf.event(params.name, params.session.rootHandle, params.data, null, params.timestamp ?? null);
9
+ params.nf.event(params.name, params.session.rootHandle, params.data, params.metadata ?? null, params.timestamp ?? null);
10
10
  params.state.counters.marksEmitted += 1;
11
11
  }
12
12
  /** Return a blocked-tool event payload when OpenClaw denied the tool call. */
@@ -19,11 +19,16 @@ export type SessionLookupInput = {
19
19
  };
20
20
  export type EnsureSessionInput = SessionLookupInput & {
21
21
  agentId?: string | undefined;
22
+ parentHandle?: ReturnType<NemoRelayRuntimeModule['pushScope']> | undefined;
23
+ scopeRole?: 'subagent' | undefined;
22
24
  source: 'session_start' | 'lazy_session';
23
25
  resumedFrom?: string | undefined;
24
26
  timestamp?: number | undefined;
27
+ deferRootOpen?: boolean | undefined;
25
28
  };
26
29
  export type SessionState = {
30
+ /** Immutable internal owner key used for replay buffers and alias lookups. */
31
+ ownerKey: string;
27
32
  sessionId: string;
28
33
  sessionKey?: string;
29
34
  agentId?: string;
@@ -42,9 +47,17 @@ export type SessionState = {
42
47
  assistantMessageWrites?: AssistantMessageRecord[];
43
48
  stack: ReturnType<NemoRelayRuntimeModule['createScopeStack']>;
44
49
  rootHandle?: ReturnType<NemoRelayRuntimeModule['pushScope']>;
50
+ scopeRole?: 'subagent';
51
+ pendingRootOpen?: boolean;
52
+ pendingRootTimestampMicros?: number;
53
+ pendingRootRunId?: string;
54
+ pendingCapturedEmits?: Array<{
55
+ label: string;
56
+ emit: () => void;
57
+ }>;
45
58
  };
46
59
  export type PendingLlmOutputRecord = {
47
- sessionKey: string;
60
+ sessionOwnerKey: string;
48
61
  sessionId: string;
49
62
  runId: string;
50
63
  provider: string;
@@ -55,7 +68,7 @@ export type PendingLlmOutputRecord = {
55
68
  timer?: ReturnType<typeof setTimeout> | undefined;
56
69
  };
57
70
  export type LlmInputRecord = {
58
- sessionKey: string;
71
+ sessionOwnerKey: string;
59
72
  sessionId: string;
60
73
  runId: string;
61
74
  provider: string;
@@ -68,7 +81,7 @@ export type LlmInputRecord = {
68
81
  placeholderRequest?: boolean | undefined;
69
82
  };
70
83
  export type AssistantMessageRecord = {
71
- sessionKey: string;
84
+ sessionOwnerKey: string;
72
85
  provider: string;
73
86
  model: string;
74
87
  assistantTexts: string[];
@@ -80,7 +93,7 @@ export type AssistantMessageRecord = {
80
93
  usage?: unknown;
81
94
  };
82
95
  export type ModelCallRecord = {
83
- sessionKey: string;
96
+ sessionOwnerKey: string;
84
97
  runId: string;
85
98
  callId: string;
86
99
  provider: string;
@@ -130,23 +143,26 @@ export type SessionManager = {
130
143
  }) => void;
131
144
  emitUnpairedModelCallTimingMarks: (session: SessionState) => void;
132
145
  logBoundedWarn: (key: string, message: string) => void;
146
+ resolveSessionRootContext?: (input: EnsureSessionInput) => Partial<EnsureSessionInput> | undefined;
133
147
  };
134
148
  /** Return all keys that may identify an existing OpenClaw session. */
135
149
  export declare function lookupSessionKeys(input: SessionLookupInput): string[];
136
- /** Return keys that should alias to a canonical session once it is known. */
150
+ /** Return keys that should alias to one stable internal session owner. */
137
151
  export declare function aliasSessionKeys(input: SessionLookupInput): string[];
138
- /** Resolve a hook's session identity to the canonical session id used in replay state. */
139
- export declare function resolveSessionKey(state: HookReplayBackendState, input: SessionLookupInput): string | undefined;
140
- /** Remember equivalent hook identifiers so later events attach to the same root span. */
152
+ /** Resolve a hook's session identity to the stable owner key used in replay state. */
153
+ export declare function resolveSessionOwnerKey(state: HookReplayBackendState, input: SessionLookupInput): string | undefined;
154
+ /** Remember equivalent hook identifiers so later events attach to the same owner and root span. */
141
155
  export declare function rememberSessionAliases(state: HookReplayBackendState, session: SessionState, input: SessionLookupInput): void;
142
156
  /** Create the mutable in-memory state used by the hook replay backend. */
143
157
  export declare function createHookReplayState(): HookReplayBackendState;
144
158
  /** Return an existing session or lazily create a root session scope for replay. */
145
159
  export declare function ensureSession(manager: SessionManager, input: EnsureSessionInput): SessionState | undefined;
160
+ /** Queue an emit callback until a deferred session root can open with honest lineage. */
161
+ export declare function queueCapturedEmit(session: SessionState, label: string, emit: () => void): boolean;
146
162
  /** Flush pending LLM output/timing state before the root session closes. */
147
163
  export declare function drainSession(manager: SessionManager, session: SessionState): void;
148
164
  /** Close the root session scope with separate lifecycle summary and user-visible output. */
149
- export declare function closeSessionRoot(manager: SessionManager, session: SessionState, summary: JsonRecord, rootOutput?: JsonRecord, timestamp?: number): void;
165
+ export declare function closeSessionRoot(manager: SessionManager, session: SessionState, summary: JsonRecord, rootOutput?: JsonRecord, metadata?: JsonRecord | null, timestamp?: number): void;
150
166
  /** Remove a closed session from active replay state. */
151
167
  export declare function deleteSession(state: HookReplayBackendState, session: SessionState): void;
152
168
  /** Insert a correlation record while bounding retained entries per key. */
@@ -155,3 +171,5 @@ export declare function insertBoundedRecord<T>(map: Map<string, T[]>, key: strin
155
171
  export declare function tupleKey(parts: Array<string | undefined>): string;
156
172
  /** Evict stale cross-hook correlation records across all replay maps. */
157
173
  export declare function evictExpiredCorrelationRecords(state: HookReplayBackendState, nowMs: number, ttlMs: number): void;
174
+ /** Open a deferred or new root session scope and flush queued child emissions. */
175
+ export declare function materializeSessionRoot(manager: SessionManager, session: SessionState, input: EnsureSessionInput): void;