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.
@@ -1,16 +1,34 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
- import { evictExpiredRecords, tupleKey as tupleKeyFromCorrelation } from './correlation.js';
3
+ import { evictExpiredRecords, tupleKey as tupleKeyFromCorrelation, } from './correlation.js';
4
+ import { toJsonRecord } from './marks.js';
5
+ /** Merge lineage/root-context defaults without letting undefined hook fields erase them. */
6
+ function mergeEnsureSessionInput(input, context) {
7
+ if (!context) {
8
+ return input;
9
+ }
10
+ const merged = { ...context, ...input };
11
+ for (const [key, value] of Object.entries(context)) {
12
+ if (merged[key] === undefined && value !== undefined) {
13
+ merged[key] = value;
14
+ }
15
+ }
16
+ return merged;
17
+ }
18
+ /** Return the session key that belongs to the session itself, not a requester alias. */
19
+ function ownSessionKey(input) {
20
+ return input.sessionKey ?? input.childSessionKey;
21
+ }
4
22
  /** Return all keys that may identify an existing OpenClaw session. */
5
23
  export function lookupSessionKeys(input) {
6
24
  return [input.sessionId, input.sessionKey, input.requesterSessionKey, input.childSessionKey, input.runId].filter((value) => typeof value === 'string' && value.length > 0);
7
25
  }
8
- /** Return keys that should alias to a canonical session once it is known. */
26
+ /** Return keys that should alias to one stable internal session owner. */
9
27
  export function aliasSessionKeys(input) {
10
- return [input.sessionId, input.sessionKey, input.requesterSessionKey, input.runId].filter((value) => typeof value === 'string' && value.length > 0);
28
+ return [input.sessionId, input.sessionKey, input.childSessionKey, input.requesterSessionKey, input.runId].filter((value) => typeof value === 'string' && value.length > 0);
11
29
  }
12
- /** Resolve a hook's session identity to the canonical session id used in replay state. */
13
- export function resolveSessionKey(state, input) {
30
+ /** Resolve a hook's session identity to the stable owner key used in replay state. */
31
+ export function resolveSessionOwnerKey(state, input) {
14
32
  for (const key of lookupSessionKeys(input)) {
15
33
  const canonical = state.sessionAliases.get(key);
16
34
  if (canonical) {
@@ -19,10 +37,10 @@ export function resolveSessionKey(state, input) {
19
37
  }
20
38
  return input.sessionId ?? input.sessionKey ?? input.childSessionKey ?? input.runId;
21
39
  }
22
- /** Remember equivalent hook identifiers so later events attach to the same root span. */
40
+ /** Remember equivalent hook identifiers so later events attach to the same owner and root span. */
23
41
  export function rememberSessionAliases(state, session, input) {
24
42
  for (const alias of aliasSessionKeys(input)) {
25
- state.sessionAliases.set(alias, session.sessionId);
43
+ state.sessionAliases.set(alias, session.ownerKey);
26
44
  }
27
45
  }
28
46
  /** Create the mutable in-memory state used by the hook replay backend. */
@@ -45,42 +63,96 @@ export function createHookReplayState() {
45
63
  }
46
64
  /** Return an existing session or lazily create a root session scope for replay. */
47
65
  export function ensureSession(manager, input) {
48
- const key = resolveSessionKey(manager.state, input);
49
- if (!key) {
66
+ const resolvedInput = mergeEnsureSessionInput(input, manager.resolveSessionRootContext?.(input));
67
+ const ownerKey = resolveSessionOwnerKey(manager.state, resolvedInput);
68
+ if (!ownerKey) {
50
69
  manager.state.counters.skippedEvents += 1;
51
70
  manager.logBoundedWarn('missing-session-key', 'nemo-relay skipped replay because no session/run key was available');
52
71
  return undefined;
53
72
  }
54
- const existing = manager.state.sessions.get(key);
73
+ const existing = manager.state.sessions.get(ownerKey);
55
74
  if (existing) {
56
- rememberSessionAliases(manager.state, existing, input);
75
+ enrichSession(existing, resolvedInput);
76
+ rememberSessionAliases(manager.state, existing, resolvedInput);
77
+ if (!existing.rootHandle && resolvedInput.deferRootOpen !== true) {
78
+ materializeSessionRoot(manager, existing, resolvedInput);
79
+ }
57
80
  return existing;
58
81
  }
59
- const canonicalSessionId = input.sessionId ?? key;
60
- const aliased = manager.state.sessions.get(canonicalSessionId);
61
- if (aliased) {
62
- rememberSessionAliases(manager.state, aliased, input);
63
- return aliased;
64
- }
65
82
  const stack = manager.nf.createScopeStack();
66
83
  const session = {
67
- sessionId: canonicalSessionId,
68
- source: input.source,
84
+ ownerKey,
85
+ sessionId: resolvedInput.sessionId ?? ownerKey,
86
+ source: resolvedInput.source,
69
87
  stack,
70
88
  };
71
- if (input.sessionKey !== undefined) {
72
- session.sessionKey = input.sessionKey;
89
+ const sessionKey = ownSessionKey(resolvedInput);
90
+ if (sessionKey !== undefined) {
91
+ session.sessionKey = sessionKey;
92
+ }
93
+ if (resolvedInput.agentId !== undefined) {
94
+ session.agentId = resolvedInput.agentId;
95
+ }
96
+ if (resolvedInput.resumedFrom !== undefined) {
97
+ session.resumedFrom = resolvedInput.resumedFrom;
98
+ }
99
+ if (resolvedInput.scopeRole !== undefined) {
100
+ session.scopeRole = resolvedInput.scopeRole;
101
+ }
102
+ if (resolvedInput.deferRootOpen === true) {
103
+ session.pendingRootOpen = true;
104
+ if (resolvedInput.timestamp !== undefined) {
105
+ session.pendingRootTimestampMicros = resolvedInput.timestamp;
106
+ }
107
+ if (resolvedInput.runId !== undefined) {
108
+ session.pendingRootRunId = resolvedInput.runId;
109
+ }
110
+ session.pendingCapturedEmits = [];
111
+ }
112
+ else {
113
+ materializeSessionRoot(manager, session, resolvedInput);
114
+ }
115
+ manager.state.sessions.set(session.ownerKey, session);
116
+ rememberSessionAliases(manager.state, session, resolvedInput);
117
+ return session;
118
+ }
119
+ /** Fill stable session identifiers from later hooks without clobbering established values. */
120
+ function enrichSession(session, input) {
121
+ const sessionKey = ownSessionKey(input);
122
+ if (session.sessionKey === undefined && sessionKey !== undefined) {
123
+ session.sessionKey = sessionKey;
73
124
  }
74
- if (input.agentId !== undefined) {
125
+ if (session.agentId === undefined && input.agentId !== undefined) {
75
126
  session.agentId = input.agentId;
76
127
  }
77
- if (input.resumedFrom !== undefined) {
128
+ if (session.resumedFrom === undefined && input.resumedFrom !== undefined) {
78
129
  session.resumedFrom = input.resumedFrom;
79
130
  }
80
- openSessionRoot(manager, session, input);
81
- manager.state.sessions.set(session.sessionId, session);
82
- rememberSessionAliases(manager.state, session, input);
83
- return session;
131
+ if (session.scopeRole === undefined && input.scopeRole !== undefined) {
132
+ session.scopeRole = input.scopeRole;
133
+ }
134
+ if (!session.rootHandle && input.sessionId !== undefined) {
135
+ session.sessionId = input.sessionId;
136
+ }
137
+ if (!session.rootHandle && session.source === 'lazy_session' && input.source === 'session_start') {
138
+ session.source = 'session_start';
139
+ }
140
+ if (!session.rootHandle && input.timestamp !== undefined) {
141
+ if (input.source === 'session_start' || session.pendingRootTimestampMicros === undefined) {
142
+ session.pendingRootTimestampMicros = input.timestamp;
143
+ }
144
+ }
145
+ if (!session.rootHandle && session.pendingRootRunId === undefined && input.runId !== undefined) {
146
+ session.pendingRootRunId = input.runId;
147
+ }
148
+ }
149
+ /** Queue an emit callback until a deferred session root can open with honest lineage. */
150
+ export function queueCapturedEmit(session, label, emit) {
151
+ if (session.rootHandle || session.pendingRootOpen !== true) {
152
+ return false;
153
+ }
154
+ (session.pendingCapturedEmits ??= []).push({ label, emit });
155
+ return true;
84
156
  }
85
157
  /** Flush pending LLM output/timing state before the root session closes. */
86
158
  export function drainSession(manager, session) {
@@ -90,12 +162,12 @@ export function drainSession(manager, session) {
90
162
  evictSessionCorrelationRecords(manager.state, session);
91
163
  }
92
164
  /** Close the root session scope with separate lifecycle summary and user-visible output. */
93
- export function closeSessionRoot(manager, session, summary, rootOutput = summary, timestamp) {
165
+ export function closeSessionRoot(manager, session, summary, rootOutput = summary, metadata, timestamp) {
94
166
  manager.emitCapturedUnderSession('session_end', session, () => {
95
167
  if (!session.rootHandle) {
96
168
  return;
97
169
  }
98
- manager.nf.event('openclaw.session_end', session.rootHandle, summary, null, timestamp ?? null);
170
+ manager.nf.event('openclaw.session_end', session.rootHandle, summary, metadata ?? null, timestamp ?? null);
99
171
  manager.state.counters.marksEmitted += 1;
100
172
  manager.nf.popScope(session.rootHandle, rootOutput, timestamp ?? null);
101
173
  delete session.rootHandle;
@@ -103,7 +175,7 @@ export function closeSessionRoot(manager, session, summary, rootOutput = summary
103
175
  }
104
176
  /** Remove a closed session from active replay state. */
105
177
  export function deleteSession(state, session) {
106
- state.sessions.delete(session.sessionId);
178
+ state.sessions.delete(session.ownerKey);
107
179
  }
108
180
  /** Insert a correlation record while bounding retained entries per key. */
109
181
  export function insertBoundedRecord(map, key, record, maxRecordsPerKey) {
@@ -125,27 +197,55 @@ export function evictExpiredCorrelationRecords(state, nowMs, ttlMs) {
125
197
  evictExpiredRecords(state.modelCallsByCallId, nowMs, ttlMs);
126
198
  evictExpiredRecords(state.modelTimingsByLlmKey, nowMs, ttlMs);
127
199
  }
128
- /** Open the root NeMo Relay scope for one OpenClaw session and emit session_start. */
129
- function openSessionRoot(manager, session, input) {
200
+ /** Open a deferred or new root session scope and flush queued child emissions. */
201
+ export function materializeSessionRoot(manager, session, input) {
202
+ if (session.rootHandle) {
203
+ return;
204
+ }
205
+ enrichSession(session, input);
206
+ const timestampMicros = input.timestamp ?? session.pendingRootTimestampMicros ?? null;
207
+ const rootRunId = input.runId ?? session.pendingRootRunId;
130
208
  const data = {
131
209
  sessionId: session.sessionId,
132
210
  source: session.source,
133
211
  ...(session.sessionKey === undefined ? {} : { sessionKey: session.sessionKey }),
134
212
  ...(session.agentId === undefined ? {} : { agentId: session.agentId }),
135
- ...(input.runId === undefined ? {} : { runId: input.runId }),
213
+ ...(rootRunId === undefined ? {} : { runId: rootRunId }),
136
214
  ...(session.resumedFrom === undefined ? {} : { resumedFrom: session.resumedFrom }),
137
215
  };
138
- manager.emitCapturedUnderSession('session_start', session, () => {
139
- session.rootHandle = manager.nf.pushScope('openclaw.session', agentScopeType(manager.nf), null, null, data, null, null, input.timestamp ?? null);
140
- manager.nf.event('openclaw.session_start', session.rootHandle, data, null, input.timestamp ?? null);
141
- manager.state.counters.marksEmitted += 1;
216
+ const metadata = toJsonRecord({
217
+ source: session.source === 'session_start' ? 'openclaw.session_start' : 'openclaw.lazy_session',
218
+ hook_event_name: session.source === 'session_start' ? 'session_start' : undefined,
219
+ sessionId: session.sessionId,
220
+ sessionKey: session.sessionKey,
221
+ agentId: session.agentId,
222
+ runId: rootRunId,
223
+ nemo_relay_scope_role: session.scopeRole,
142
224
  });
225
+ const previousStack = manager.nf.currentScopeStack();
226
+ try {
227
+ manager.nf.setThreadScopeStack(session.stack);
228
+ session.rootHandle = manager.nf.pushScope('openclaw.session', agentScopeType(manager.nf), input.parentHandle ?? null, null, data, metadata, data, timestampMicros);
229
+ manager.nf.event('openclaw.session_start', session.rootHandle, data, metadata, timestampMicros);
230
+ manager.state.counters.marksEmitted += 1;
231
+ }
232
+ finally {
233
+ manager.nf.setThreadScopeStack(previousStack);
234
+ }
235
+ delete session.pendingRootOpen;
236
+ delete session.pendingRootTimestampMicros;
237
+ delete session.pendingRootRunId;
238
+ const pendingEmits = session.pendingCapturedEmits ?? [];
239
+ delete session.pendingCapturedEmits;
240
+ for (const pending of pendingEmits) {
241
+ manager.emitCapturedUnderSession(pending.label, session, pending.emit);
242
+ }
143
243
  }
144
244
  /** Cancel timers that would otherwise replay late LLM outputs after session close. */
145
245
  function cancelPendingLlmOutputTimers(state, session) {
146
246
  for (const records of state.llmOutputsPendingInput.values()) {
147
247
  for (const record of records) {
148
- if (record.sessionKey === session.sessionId && record.timer) {
248
+ if (record.sessionOwnerKey === session.ownerKey && record.timer) {
149
249
  clearTimeout(record.timer);
150
250
  record.timer = undefined;
151
251
  }
@@ -154,20 +254,20 @@ function cancelPendingLlmOutputTimers(state, session) {
154
254
  }
155
255
  /** Remove all correlation records and aliases owned by a closed session. */
156
256
  function evictSessionCorrelationRecords(state, session) {
157
- evictFromRecordMap(state.llmInputs, session.sessionId);
158
- evictFromRecordMap(state.llmOutputsPendingInput, session.sessionId);
159
- evictFromRecordMap(state.modelCallsByCallId, session.sessionId);
160
- evictFromRecordMap(state.modelTimingsByLlmKey, session.sessionId);
161
- for (const [alias, canonical] of state.sessionAliases) {
162
- if (canonical === session.sessionId || alias === session.sessionId) {
257
+ evictFromRecordMap(state.llmInputs, session.ownerKey);
258
+ evictFromRecordMap(state.llmOutputsPendingInput, session.ownerKey);
259
+ evictFromRecordMap(state.modelCallsByCallId, session.ownerKey);
260
+ evictFromRecordMap(state.modelTimingsByLlmKey, session.ownerKey);
261
+ for (const [alias, ownerKey] of state.sessionAliases) {
262
+ if (ownerKey === session.ownerKey) {
163
263
  state.sessionAliases.delete(alias);
164
264
  }
165
265
  }
166
266
  }
167
267
  /** Drop records for one session from a single keyed correlation map. */
168
- function evictFromRecordMap(map, sessionKey) {
268
+ function evictFromRecordMap(map, ownerKey) {
169
269
  for (const [key, records] of map) {
170
- const retained = records.filter((record) => record.sessionKey !== sessionKey);
270
+ const retained = records.filter((record) => record.sessionOwnerKey !== ownerKey);
171
271
  if (retained.length === 0) {
172
272
  map.delete(key);
173
273
  }
@@ -5,12 +5,14 @@ import { ensureSession } from './session.js';
5
5
  import { nowMicros, startMicrosFromDuration } from './correlation.js';
6
6
  /** Run NeMo Relay tool conditional-execution guardrails before OpenClaw executes a tool. */
7
7
  export async function guardBeforeToolCall(manager, event, ctx) {
8
+ const observedAtMicros = nowMicros();
8
9
  const session = ensureSession(manager, {
9
10
  sessionId: ctx.sessionId,
10
11
  sessionKey: ctx.sessionKey,
11
12
  runId: event.runId ?? ctx.runId,
12
13
  agentId: ctx.agentId,
13
14
  source: 'lazy_session',
15
+ timestamp: observedAtMicros,
14
16
  });
15
17
  const args = toJsonValue(event.params ?? {});
16
18
  if (session) {
@@ -29,12 +31,15 @@ export async function guardBeforeToolCall(manager, event, ctx) {
29
31
  }
30
32
  /** Convert one OpenClaw after_tool_call event into a NeMo Relay tool span or blocked-tool mark. */
31
33
  export function replayAfterToolCall(manager, event, ctx) {
34
+ const endMicros = nowMicros();
35
+ const sessionTimestamp = startMicrosFromDuration(endMicros, event.durationMs) ?? endMicros;
32
36
  const session = ensureSession(manager, {
33
37
  sessionId: ctx.sessionId,
34
38
  sessionKey: ctx.sessionKey,
35
39
  runId: event.runId ?? ctx.runId,
36
40
  agentId: ctx.agentId,
37
41
  source: 'lazy_session',
42
+ timestamp: sessionTimestamp,
38
43
  });
39
44
  const blockedDetails = blockedToolDetails(event, { runId: event.runId ?? ctx.runId });
40
45
  if (session && blockedDetails) {
@@ -45,6 +50,16 @@ export function replayAfterToolCall(manager, event, ctx) {
45
50
  session,
46
51
  name: 'openclaw.tool_blocked',
47
52
  data: blockedDetails,
53
+ metadata: toJsonRecord({
54
+ source: 'openclaw.after_tool_call',
55
+ hook_event_name: 'after_tool_call',
56
+ sessionId: session.sessionId,
57
+ sessionKey: session.sessionKey,
58
+ agentId: session.agentId,
59
+ runId: event.runId ?? ctx.runId,
60
+ toolCallId: event.toolCallId ?? ctx.toolCallId,
61
+ }),
62
+ timestamp: endMicros,
48
63
  });
49
64
  });
50
65
  return;
@@ -52,12 +67,12 @@ export function replayAfterToolCall(manager, event, ctx) {
52
67
  if (!session) {
53
68
  return;
54
69
  }
55
- const endMicros = nowMicros();
56
70
  const metadata = toJsonRecord({
57
71
  source: 'openclaw.after_tool_call',
58
72
  runId: event.runId ?? ctx.runId,
59
- sessionId: ctx.sessionId,
60
- sessionKey: ctx.sessionKey,
73
+ sessionId: session.sessionId,
74
+ sessionKey: session.sessionKey,
75
+ agentId: session.agentId,
61
76
  toolCallId: event.toolCallId ?? ctx.toolCallId,
62
77
  durationMs: event.durationMs,
63
78
  });
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import type { NemoRelayHookBackendConfig } from './config.js';
9
9
  import { llmKey } from './hook-replay/correlation.js';
10
- import { resolveSessionKey, type HookReplayBackendState, type SessionLookupInput, type SessionState } from './hook-replay/session.js';
10
+ import { resolveSessionOwnerKey, type HookReplayBackendState, type SessionLookupInput, type SessionState } from './hook-replay/session.js';
11
11
  import type { NemoRelayRuntimeModule } from './modules.js';
12
12
  import type { PluginHookAfterToolCallEvent, PluginHookAgentContext, PluginHookAgentEndEvent, PluginHookBeforeAgentFinalizeEvent, PluginHookBeforeMessageWriteContext, PluginHookBeforeMessageWriteEvent, PluginHookBeforeToolCallEvent, PluginHookGatewayContext, PluginHookGatewayStartEvent, PluginHookLlmInputEvent, PluginHookLlmOutputEvent, PluginHookModelCallEndedEvent, PluginHookModelCallStartedEvent, PluginHookSessionContext, PluginHookSessionEndEvent, PluginHookSessionStartEvent, PluginHookSubagentContext, PluginHookSubagentEndedEvent, PluginHookSubagentSpawnedEvent, PluginHookToolContext } from './openclaw-hook-types.js';
13
13
  import type { PluginLogger } from 'openclaw/plugin-sdk/plugin-entry';
@@ -25,6 +25,8 @@ export declare class HookReplayBackend {
25
25
  private readonly agentVersion;
26
26
  private readonly stateValue;
27
27
  private readonly warningCounts;
28
+ private readonly pendingSubagentLineageByChildSessionKey;
29
+ private readonly pendingSubagentChildKeyByRunId;
28
30
  constructor(options: HookReplayBackendOptions);
29
31
  /** Return mutable replay state for tests and health snapshots. */
30
32
  state(): HookReplayBackendState;
@@ -88,9 +90,31 @@ export declare class HookReplayBackend {
88
90
  private flushSubscriberDelivery;
89
91
  /** Build the narrow manager interface consumed by focused replay modules. */
90
92
  private sessionManager;
93
+ /** Prefer nested child scopes only when the hook surface provides real subagent lineage. */
94
+ private resolveSessionRootContext;
95
+ /** Track stable parent/child lineage from subagent hooks until child session hooks can use it. */
96
+ private trackPendingSubagentLineage;
97
+ /** Resolve the requester session if it exists, or seed a deferred lazy root placeholder for later promotion. */
98
+ private ensureRequesterSessionAnchor;
99
+ /** Open a deferred child session root once the requester scope is known. */
100
+ private promoteDeferredSubagentSession;
101
+ /** Promote any deferred child sessions waiting on a requester root once it exists. */
102
+ private promoteDeferredSubagentSessionsForRequester;
103
+ /** Materialize one deferred session root with nested lineage when available. */
104
+ private materializeDeferredSessionRoot;
105
+ /** Resolve stable subagent lineage from child session key first, then run id as a fallback. */
106
+ private resolvePendingSubagentLineage;
107
+ /** Drop stale pending lineage entries so abandoned subagent spawns do not accumulate indefinitely. */
108
+ private pruneExpiredPendingSubagentLineage;
109
+ /** Resolve one session through the same alias map used by the replay state. */
110
+ private resolveTrackedSession;
111
+ /** Free lineage bookkeeping once the child session is closed. */
112
+ private forgetPendingSubagentLineage;
113
+ /** Match the documented native subagent session key shape without depending on private OpenClaw internals. */
114
+ private isDocumentedSubagentSessionKey;
91
115
  /** Log one warning per key to avoid noisy repeated hook failures. */
92
116
  private logBoundedWarn;
93
117
  }
94
118
  export { llmKey };
95
- /** Expose session-key resolution for tests without exporting the full session module. */
96
- export declare function resolveBackendSessionKey(state: HookReplayBackendState, input: Parameters<typeof resolveSessionKey>[1]): string | undefined;
119
+ /** Expose owner-key resolution for tests without exporting the full session module. */
120
+ export declare function resolveBackendSessionOwnerKey(state: HookReplayBackendState, input: Parameters<typeof resolveSessionOwnerKey>[1]): string | undefined;