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.
@@ -9,7 +9,10 @@
9
9
  * those identifiers and owns the root `openclaw.session` scope lifecycle.
10
10
  */
11
11
  import type { NemoRelayHookBackendConfig } from '../config.js';
12
- import { evictExpiredRecords, tupleKey as tupleKeyFromCorrelation } from './correlation.js';
12
+ import {
13
+ evictExpiredRecords,
14
+ tupleKey as tupleKeyFromCorrelation,
15
+ } from './correlation.js';
13
16
  import type {
14
17
  PluginHookAgentContext,
15
18
  PluginHookLlmOutputEvent,
@@ -18,6 +21,7 @@ import type {
18
21
  import type { PluginLogger } from 'openclaw/plugin-sdk/plugin-entry';
19
22
  import type { JsonObject as JsonRecord } from 'nemo-relay-node/typed';
20
23
  import type { NemoRelayRuntimeModule } from '../modules.js';
24
+ import { toJsonRecord } from './marks.js';
21
25
 
22
26
  export type SessionLookupInput = {
23
27
  sessionId?: string | undefined;
@@ -29,12 +33,17 @@ export type SessionLookupInput = {
29
33
 
30
34
  export type EnsureSessionInput = SessionLookupInput & {
31
35
  agentId?: string | undefined;
36
+ parentHandle?: ReturnType<NemoRelayRuntimeModule['pushScope']> | undefined;
37
+ scopeRole?: 'subagent' | undefined;
32
38
  source: 'session_start' | 'lazy_session';
33
39
  resumedFrom?: string | undefined;
34
40
  timestamp?: number | undefined;
41
+ deferRootOpen?: boolean | undefined;
35
42
  };
36
43
 
37
44
  export type SessionState = {
45
+ /** Immutable internal owner key used for replay buffers and alias lookups. */
46
+ ownerKey: string;
38
47
  sessionId: string;
39
48
  sessionKey?: string;
40
49
  agentId?: string;
@@ -51,10 +60,15 @@ export type SessionState = {
51
60
  assistantMessageWrites?: AssistantMessageRecord[];
52
61
  stack: ReturnType<NemoRelayRuntimeModule['createScopeStack']>;
53
62
  rootHandle?: ReturnType<NemoRelayRuntimeModule['pushScope']>;
63
+ scopeRole?: 'subagent';
64
+ pendingRootOpen?: boolean;
65
+ pendingRootTimestampMicros?: number;
66
+ pendingRootRunId?: string;
67
+ pendingCapturedEmits?: Array<{ label: string; emit: () => void }>;
54
68
  };
55
69
 
56
70
  export type PendingLlmOutputRecord = {
57
- sessionKey: string;
71
+ sessionOwnerKey: string;
58
72
  sessionId: string;
59
73
  runId: string;
60
74
  provider: string;
@@ -66,7 +80,7 @@ export type PendingLlmOutputRecord = {
66
80
  };
67
81
 
68
82
  export type LlmInputRecord = {
69
- sessionKey: string;
83
+ sessionOwnerKey: string;
70
84
  sessionId: string;
71
85
  runId: string;
72
86
  provider: string;
@@ -80,7 +94,7 @@ export type LlmInputRecord = {
80
94
  };
81
95
 
82
96
  export type AssistantMessageRecord = {
83
- sessionKey: string;
97
+ sessionOwnerKey: string;
84
98
  provider: string;
85
99
  model: string;
86
100
  assistantTexts: string[];
@@ -93,7 +107,7 @@ export type AssistantMessageRecord = {
93
107
  };
94
108
 
95
109
  export type ModelCallRecord = {
96
- sessionKey: string;
110
+ sessionOwnerKey: string;
97
111
  runId: string;
98
112
  callId: string;
99
113
  provider: string;
@@ -144,8 +158,32 @@ export type SessionManager = {
144
158
  replayPendingLlmOutputsForSession: (session: SessionState, options: { allowPlaceholderRequest: boolean }) => void;
145
159
  emitUnpairedModelCallTimingMarks: (session: SessionState) => void;
146
160
  logBoundedWarn: (key: string, message: string) => void;
161
+ resolveSessionRootContext?: (input: EnsureSessionInput) => Partial<EnsureSessionInput> | undefined;
147
162
  };
148
163
 
164
+ /** Merge lineage/root-context defaults without letting undefined hook fields erase them. */
165
+ function mergeEnsureSessionInput(
166
+ input: EnsureSessionInput,
167
+ context: Partial<EnsureSessionInput> | undefined,
168
+ ): EnsureSessionInput {
169
+ if (!context) {
170
+ return input;
171
+ }
172
+
173
+ const merged: Record<string, unknown> = { ...context, ...input };
174
+ for (const [key, value] of Object.entries(context)) {
175
+ if (merged[key] === undefined && value !== undefined) {
176
+ merged[key] = value;
177
+ }
178
+ }
179
+ return merged as EnsureSessionInput;
180
+ }
181
+
182
+ /** Return the session key that belongs to the session itself, not a requester alias. */
183
+ function ownSessionKey(input: SessionLookupInput): string | undefined {
184
+ return input.sessionKey ?? input.childSessionKey;
185
+ }
186
+
149
187
  /** Return all keys that may identify an existing OpenClaw session. */
150
188
  export function lookupSessionKeys(input: SessionLookupInput): string[] {
151
189
  return [input.sessionId, input.sessionKey, input.requesterSessionKey, input.childSessionKey, input.runId].filter(
@@ -153,15 +191,15 @@ export function lookupSessionKeys(input: SessionLookupInput): string[] {
153
191
  );
154
192
  }
155
193
 
156
- /** Return keys that should alias to a canonical session once it is known. */
194
+ /** Return keys that should alias to one stable internal session owner. */
157
195
  export function aliasSessionKeys(input: SessionLookupInput): string[] {
158
- return [input.sessionId, input.sessionKey, input.requesterSessionKey, input.runId].filter(
196
+ return [input.sessionId, input.sessionKey, input.childSessionKey, input.requesterSessionKey, input.runId].filter(
159
197
  (value): value is string => typeof value === 'string' && value.length > 0,
160
198
  );
161
199
  }
162
200
 
163
- /** Resolve a hook's session identity to the canonical session id used in replay state. */
164
- export function resolveSessionKey(state: HookReplayBackendState, input: SessionLookupInput): string | undefined {
201
+ /** Resolve a hook's session identity to the stable owner key used in replay state. */
202
+ export function resolveSessionOwnerKey(state: HookReplayBackendState, input: SessionLookupInput): string | undefined {
165
203
  for (const key of lookupSessionKeys(input)) {
166
204
  const canonical = state.sessionAliases.get(key);
167
205
  if (canonical) {
@@ -172,14 +210,14 @@ export function resolveSessionKey(state: HookReplayBackendState, input: SessionL
172
210
  return input.sessionId ?? input.sessionKey ?? input.childSessionKey ?? input.runId;
173
211
  }
174
212
 
175
- /** Remember equivalent hook identifiers so later events attach to the same root span. */
213
+ /** Remember equivalent hook identifiers so later events attach to the same owner and root span. */
176
214
  export function rememberSessionAliases(
177
215
  state: HookReplayBackendState,
178
216
  session: SessionState,
179
217
  input: SessionLookupInput,
180
218
  ): void {
181
219
  for (const alias of aliasSessionKeys(input)) {
182
- state.sessionAliases.set(alias, session.sessionId);
220
+ state.sessionAliases.set(alias, session.ownerKey);
183
221
  }
184
222
  }
185
223
 
@@ -204,47 +242,102 @@ export function createHookReplayState(): HookReplayBackendState {
204
242
 
205
243
  /** Return an existing session or lazily create a root session scope for replay. */
206
244
  export function ensureSession(manager: SessionManager, input: EnsureSessionInput): SessionState | undefined {
207
- const key = resolveSessionKey(manager.state, input);
208
- if (!key) {
245
+ const resolvedInput = mergeEnsureSessionInput(input, manager.resolveSessionRootContext?.(input));
246
+ const ownerKey = resolveSessionOwnerKey(manager.state, resolvedInput);
247
+ if (!ownerKey) {
209
248
  manager.state.counters.skippedEvents += 1;
210
249
  manager.logBoundedWarn('missing-session-key', 'nemo-relay skipped replay because no session/run key was available');
211
250
  return undefined;
212
251
  }
213
252
 
214
- const existing = manager.state.sessions.get(key);
253
+ const existing = manager.state.sessions.get(ownerKey);
215
254
  if (existing) {
216
- rememberSessionAliases(manager.state, existing, input);
255
+ enrichSession(existing, resolvedInput);
256
+ rememberSessionAliases(manager.state, existing, resolvedInput);
257
+ if (!existing.rootHandle && resolvedInput.deferRootOpen !== true) {
258
+ materializeSessionRoot(manager, existing, resolvedInput);
259
+ }
217
260
  return existing;
218
261
  }
219
262
 
220
- const canonicalSessionId = input.sessionId ?? key;
221
- const aliased = manager.state.sessions.get(canonicalSessionId);
222
- if (aliased) {
223
- rememberSessionAliases(manager.state, aliased, input);
224
- return aliased;
225
- }
226
-
227
263
  const stack = manager.nf.createScopeStack();
228
264
  const session: SessionState = {
229
- sessionId: canonicalSessionId,
230
- source: input.source,
265
+ ownerKey,
266
+ sessionId: resolvedInput.sessionId ?? ownerKey,
267
+ source: resolvedInput.source,
231
268
  stack,
232
269
  };
233
270
 
234
- if (input.sessionKey !== undefined) {
235
- session.sessionKey = input.sessionKey;
271
+ const sessionKey = ownSessionKey(resolvedInput);
272
+ if (sessionKey !== undefined) {
273
+ session.sessionKey = sessionKey;
274
+ }
275
+ if (resolvedInput.agentId !== undefined) {
276
+ session.agentId = resolvedInput.agentId;
236
277
  }
237
- if (input.agentId !== undefined) {
278
+ if (resolvedInput.resumedFrom !== undefined) {
279
+ session.resumedFrom = resolvedInput.resumedFrom;
280
+ }
281
+ if (resolvedInput.scopeRole !== undefined) {
282
+ session.scopeRole = resolvedInput.scopeRole;
283
+ }
284
+
285
+ if (resolvedInput.deferRootOpen === true) {
286
+ session.pendingRootOpen = true;
287
+ if (resolvedInput.timestamp !== undefined) {
288
+ session.pendingRootTimestampMicros = resolvedInput.timestamp;
289
+ }
290
+ if (resolvedInput.runId !== undefined) {
291
+ session.pendingRootRunId = resolvedInput.runId;
292
+ }
293
+ session.pendingCapturedEmits = [];
294
+ } else {
295
+ materializeSessionRoot(manager, session, resolvedInput);
296
+ }
297
+ manager.state.sessions.set(session.ownerKey, session);
298
+ rememberSessionAliases(manager.state, session, resolvedInput);
299
+ return session;
300
+ }
301
+
302
+ /** Fill stable session identifiers from later hooks without clobbering established values. */
303
+ function enrichSession(session: SessionState, input: EnsureSessionInput): void {
304
+ const sessionKey = ownSessionKey(input);
305
+ if (session.sessionKey === undefined && sessionKey !== undefined) {
306
+ session.sessionKey = sessionKey;
307
+ }
308
+ if (session.agentId === undefined && input.agentId !== undefined) {
238
309
  session.agentId = input.agentId;
239
310
  }
240
- if (input.resumedFrom !== undefined) {
311
+ if (session.resumedFrom === undefined && input.resumedFrom !== undefined) {
241
312
  session.resumedFrom = input.resumedFrom;
242
313
  }
314
+ if (session.scopeRole === undefined && input.scopeRole !== undefined) {
315
+ session.scopeRole = input.scopeRole;
316
+ }
317
+ if (!session.rootHandle && input.sessionId !== undefined) {
318
+ session.sessionId = input.sessionId;
319
+ }
320
+ if (!session.rootHandle && session.source === 'lazy_session' && input.source === 'session_start') {
321
+ session.source = 'session_start';
322
+ }
323
+ if (!session.rootHandle && input.timestamp !== undefined) {
324
+ if (input.source === 'session_start' || session.pendingRootTimestampMicros === undefined) {
325
+ session.pendingRootTimestampMicros = input.timestamp;
326
+ }
327
+ }
328
+ if (!session.rootHandle && session.pendingRootRunId === undefined && input.runId !== undefined) {
329
+ session.pendingRootRunId = input.runId;
330
+ }
331
+ }
243
332
 
244
- openSessionRoot(manager, session, input);
245
- manager.state.sessions.set(session.sessionId, session);
246
- rememberSessionAliases(manager.state, session, input);
247
- return session;
333
+ /** Queue an emit callback until a deferred session root can open with honest lineage. */
334
+ export function queueCapturedEmit(session: SessionState, label: string, emit: () => void): boolean {
335
+ if (session.rootHandle || session.pendingRootOpen !== true) {
336
+ return false;
337
+ }
338
+
339
+ (session.pendingCapturedEmits ??= []).push({ label, emit });
340
+ return true;
248
341
  }
249
342
 
250
343
  /** Flush pending LLM output/timing state before the root session closes. */
@@ -261,6 +354,7 @@ export function closeSessionRoot(
261
354
  session: SessionState,
262
355
  summary: JsonRecord,
263
356
  rootOutput: JsonRecord = summary,
357
+ metadata?: JsonRecord | null,
264
358
  timestamp?: number,
265
359
  ): void {
266
360
  manager.emitCapturedUnderSession('session_end', session, () => {
@@ -268,7 +362,7 @@ export function closeSessionRoot(
268
362
  return;
269
363
  }
270
364
 
271
- manager.nf.event('openclaw.session_end', session.rootHandle, summary, null, timestamp ?? null);
365
+ manager.nf.event('openclaw.session_end', session.rootHandle, summary, metadata ?? null, timestamp ?? null);
272
366
  manager.state.counters.marksEmitted += 1;
273
367
  manager.nf.popScope(session.rootHandle, rootOutput, timestamp ?? null);
274
368
  delete session.rootHandle;
@@ -277,7 +371,7 @@ export function closeSessionRoot(
277
371
 
278
372
  /** Remove a closed session from active replay state. */
279
373
  export function deleteSession(state: HookReplayBackendState, session: SessionState): void {
280
- state.sessions.delete(session.sessionId);
374
+ state.sessions.delete(session.ownerKey);
281
375
  }
282
376
 
283
377
  /** Insert a correlation record while bounding retained entries per key. */
@@ -303,38 +397,69 @@ export function evictExpiredCorrelationRecords(state: HookReplayBackendState, no
303
397
  evictExpiredRecords(state.modelTimingsByLlmKey, nowMs, ttlMs);
304
398
  }
305
399
 
306
- /** Open the root NeMo Relay scope for one OpenClaw session and emit session_start. */
307
- function openSessionRoot(manager: SessionManager, session: SessionState, input: EnsureSessionInput): void {
400
+ /** Open a deferred or new root session scope and flush queued child emissions. */
401
+ export function materializeSessionRoot(manager: SessionManager, session: SessionState, input: EnsureSessionInput): void {
402
+ if (session.rootHandle) {
403
+ return;
404
+ }
405
+
406
+ enrichSession(session, input);
407
+
408
+ const timestampMicros = input.timestamp ?? session.pendingRootTimestampMicros ?? null;
409
+ const rootRunId = input.runId ?? session.pendingRootRunId;
410
+
308
411
  const data: JsonRecord = {
309
412
  sessionId: session.sessionId,
310
413
  source: session.source,
311
414
  ...(session.sessionKey === undefined ? {} : { sessionKey: session.sessionKey }),
312
415
  ...(session.agentId === undefined ? {} : { agentId: session.agentId }),
313
- ...(input.runId === undefined ? {} : { runId: input.runId }),
416
+ ...(rootRunId === undefined ? {} : { runId: rootRunId }),
314
417
  ...(session.resumedFrom === undefined ? {} : { resumedFrom: session.resumedFrom }),
315
418
  };
419
+ const metadata = toJsonRecord({
420
+ source: session.source === 'session_start' ? 'openclaw.session_start' : 'openclaw.lazy_session',
421
+ hook_event_name: session.source === 'session_start' ? 'session_start' : undefined,
422
+ sessionId: session.sessionId,
423
+ sessionKey: session.sessionKey,
424
+ agentId: session.agentId,
425
+ runId: rootRunId,
426
+ nemo_relay_scope_role: session.scopeRole,
427
+ });
316
428
 
317
- manager.emitCapturedUnderSession('session_start', session, () => {
429
+ const previousStack = manager.nf.currentScopeStack();
430
+ try {
431
+ manager.nf.setThreadScopeStack(session.stack);
318
432
  session.rootHandle = manager.nf.pushScope(
319
433
  'openclaw.session',
320
434
  agentScopeType(manager.nf),
321
- null,
435
+ input.parentHandle ?? null,
322
436
  null,
323
437
  data,
324
- null,
325
- null,
326
- input.timestamp ?? null,
438
+ metadata,
439
+ data,
440
+ timestampMicros,
327
441
  );
328
- manager.nf.event('openclaw.session_start', session.rootHandle, data, null, input.timestamp ?? null);
442
+ manager.nf.event('openclaw.session_start', session.rootHandle, data, metadata, timestampMicros);
329
443
  manager.state.counters.marksEmitted += 1;
330
- });
444
+ } finally {
445
+ manager.nf.setThreadScopeStack(previousStack);
446
+ }
447
+
448
+ delete session.pendingRootOpen;
449
+ delete session.pendingRootTimestampMicros;
450
+ delete session.pendingRootRunId;
451
+ const pendingEmits = session.pendingCapturedEmits ?? [];
452
+ delete session.pendingCapturedEmits;
453
+ for (const pending of pendingEmits) {
454
+ manager.emitCapturedUnderSession(pending.label, session, pending.emit);
455
+ }
331
456
  }
332
457
 
333
458
  /** Cancel timers that would otherwise replay late LLM outputs after session close. */
334
459
  function cancelPendingLlmOutputTimers(state: HookReplayBackendState, session: SessionState): void {
335
460
  for (const records of state.llmOutputsPendingInput.values()) {
336
461
  for (const record of records) {
337
- if (record.sessionKey === session.sessionId && record.timer) {
462
+ if (record.sessionOwnerKey === session.ownerKey && record.timer) {
338
463
  clearTimeout(record.timer);
339
464
  record.timer = undefined;
340
465
  }
@@ -344,22 +469,22 @@ function cancelPendingLlmOutputTimers(state: HookReplayBackendState, session: Se
344
469
 
345
470
  /** Remove all correlation records and aliases owned by a closed session. */
346
471
  function evictSessionCorrelationRecords(state: HookReplayBackendState, session: SessionState): void {
347
- evictFromRecordMap(state.llmInputs, session.sessionId);
348
- evictFromRecordMap(state.llmOutputsPendingInput, session.sessionId);
349
- evictFromRecordMap(state.modelCallsByCallId, session.sessionId);
350
- evictFromRecordMap(state.modelTimingsByLlmKey, session.sessionId);
472
+ evictFromRecordMap(state.llmInputs, session.ownerKey);
473
+ evictFromRecordMap(state.llmOutputsPendingInput, session.ownerKey);
474
+ evictFromRecordMap(state.modelCallsByCallId, session.ownerKey);
475
+ evictFromRecordMap(state.modelTimingsByLlmKey, session.ownerKey);
351
476
 
352
- for (const [alias, canonical] of state.sessionAliases) {
353
- if (canonical === session.sessionId || alias === session.sessionId) {
477
+ for (const [alias, ownerKey] of state.sessionAliases) {
478
+ if (ownerKey === session.ownerKey) {
354
479
  state.sessionAliases.delete(alias);
355
480
  }
356
481
  }
357
482
  }
358
483
 
359
484
  /** Drop records for one session from a single keyed correlation map. */
360
- function evictFromRecordMap<T extends { sessionKey: string }>(map: Map<string, T[]>, sessionKey: string): void {
485
+ function evictFromRecordMap<T extends { sessionOwnerKey: string }>(map: Map<string, T[]>, ownerKey: string): void {
361
486
  for (const [key, records] of map) {
362
- const retained = records.filter((record) => record.sessionKey !== sessionKey);
487
+ const retained = records.filter((record) => record.sessionOwnerKey !== ownerKey);
363
488
  if (retained.length === 0) {
364
489
  map.delete(key);
365
490
  } else {
@@ -22,12 +22,14 @@ export async function guardBeforeToolCall(
22
22
  event: PluginHookBeforeToolCallEvent,
23
23
  ctx: PluginHookToolContext,
24
24
  ): Promise<void> {
25
+ const observedAtMicros = nowMicros();
25
26
  const session = ensureSession(manager, {
26
27
  sessionId: ctx.sessionId,
27
28
  sessionKey: ctx.sessionKey,
28
29
  runId: event.runId ?? ctx.runId,
29
30
  agentId: ctx.agentId,
30
31
  source: 'lazy_session',
32
+ timestamp: observedAtMicros,
31
33
  });
32
34
  const args = toJsonValue(event.params ?? {});
33
35
 
@@ -50,12 +52,15 @@ export function replayAfterToolCall(
50
52
  event: PluginHookAfterToolCallEvent,
51
53
  ctx: PluginHookToolContext,
52
54
  ): void {
55
+ const endMicros = nowMicros();
56
+ const sessionTimestamp = startMicrosFromDuration(endMicros, event.durationMs) ?? endMicros;
53
57
  const session = ensureSession(manager, {
54
58
  sessionId: ctx.sessionId,
55
59
  sessionKey: ctx.sessionKey,
56
60
  runId: event.runId ?? ctx.runId,
57
61
  agentId: ctx.agentId,
58
62
  source: 'lazy_session',
63
+ timestamp: sessionTimestamp,
59
64
  });
60
65
 
61
66
  const blockedDetails = blockedToolDetails(event, { runId: event.runId ?? ctx.runId });
@@ -67,6 +72,16 @@ export function replayAfterToolCall(
67
72
  session,
68
73
  name: 'openclaw.tool_blocked',
69
74
  data: blockedDetails,
75
+ metadata: toJsonRecord({
76
+ source: 'openclaw.after_tool_call',
77
+ hook_event_name: 'after_tool_call',
78
+ sessionId: session.sessionId,
79
+ sessionKey: session.sessionKey,
80
+ agentId: session.agentId,
81
+ runId: event.runId ?? ctx.runId,
82
+ toolCallId: event.toolCallId ?? ctx.toolCallId,
83
+ }),
84
+ timestamp: endMicros,
70
85
  });
71
86
  });
72
87
  return;
@@ -76,12 +91,12 @@ export function replayAfterToolCall(
76
91
  return;
77
92
  }
78
93
 
79
- const endMicros = nowMicros();
80
94
  const metadata = toJsonRecord({
81
95
  source: 'openclaw.after_tool_call',
82
96
  runId: event.runId ?? ctx.runId,
83
- sessionId: ctx.sessionId,
84
- sessionKey: ctx.sessionKey,
97
+ sessionId: session.sessionId,
98
+ sessionKey: session.sessionKey,
99
+ agentId: session.agentId,
85
100
  toolCallId: event.toolCallId ?? ctx.toolCallId,
86
101
  durationMs: event.durationMs,
87
102
  });