nemo-relay-openclaw 0.0.0 → 0.3.0-beta.2

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.
Files changed (45) hide show
  1. package/README.md +262 -4
  2. package/dist/index.d.ts +8 -0
  3. package/dist/index.js +20 -0
  4. package/dist/openclaw.plugin.json +154 -0
  5. package/dist/src/config.d.ts +329 -0
  6. package/dist/src/config.js +185 -0
  7. package/dist/src/health.d.ts +48 -0
  8. package/dist/src/health.js +63 -0
  9. package/dist/src/hook-replay/correlation.d.ts +34 -0
  10. package/dist/src/hook-replay/correlation.js +40 -0
  11. package/dist/src/hook-replay/llm.d.ts +52 -0
  12. package/dist/src/hook-replay/llm.js +1060 -0
  13. package/dist/src/hook-replay/marks.d.ts +30 -0
  14. package/dist/src/hook-replay/marks.js +109 -0
  15. package/dist/src/hook-replay/session.d.ts +157 -0
  16. package/dist/src/hook-replay/session.js +204 -0
  17. package/dist/src/hook-replay/tool.d.ts +12 -0
  18. package/dist/src/hook-replay/tool.js +101 -0
  19. package/dist/src/hooks-backend.d.ts +94 -0
  20. package/dist/src/hooks-backend.js +321 -0
  21. package/dist/src/modules.d.ts +42 -0
  22. package/dist/src/modules.js +15 -0
  23. package/dist/src/openclaw-hook-types.d.ts +186 -0
  24. package/dist/src/openclaw-hook-types.js +3 -0
  25. package/dist/src/runtime-state.d.ts +66 -0
  26. package/dist/src/runtime-state.js +420 -0
  27. package/dist/src/types.d.ts +27 -0
  28. package/dist/src/types.js +3 -0
  29. package/index.ts +23 -0
  30. package/openclaw.plugin.json +154 -0
  31. package/package.json +62 -24
  32. package/src/config.ts +251 -0
  33. package/src/health.ts +116 -0
  34. package/src/hook-replay/correlation.ts +75 -0
  35. package/src/hook-replay/llm.ts +1302 -0
  36. package/src/hook-replay/marks.ts +141 -0
  37. package/src/hook-replay/session.ts +396 -0
  38. package/src/hook-replay/tool.ts +142 -0
  39. package/src/hooks-backend.ts +457 -0
  40. package/src/modules.ts +79 -0
  41. package/src/openclaw-hook-types.ts +211 -0
  42. package/src/runtime-state.ts +553 -0
  43. package/src/types.ts +34 -0
  44. package/index.d.ts +0 -2
  45. package/index.js +0 -15
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Shared event/mark emission and JSON normalization helpers.
3
+ *
4
+ * Hook payloads can contain undefined values, circular objects, errors, and
5
+ * prototype-sensitive keys. This module normalizes them before they cross the
6
+ * NeMo Relay NAPI boundary.
7
+ */
8
+ import type { PluginHookAfterToolCallEvent } from '../openclaw-hook-types.js';
9
+ import type { JsonObject as JsonRecord, JsonValue } from 'nemo-relay-node/typed';
10
+ import type { HookReplayBackendState, SessionState } from './session.js';
11
+ import type { NemoRelayRuntimeModule } from '../modules.js';
12
+ /** Emit a NeMo Relay event under an existing OpenClaw session root span. */
13
+ export declare function emitMark(params: {
14
+ nf: NemoRelayRuntimeModule;
15
+ state: HookReplayBackendState;
16
+ session: SessionState;
17
+ name: string;
18
+ data: JsonRecord;
19
+ timestamp?: number;
20
+ }): void;
21
+ /** Return a blocked-tool event payload when OpenClaw denied the tool call. */
22
+ export declare function blockedToolDetails(event: PluginHookAfterToolCallEvent, context?: {
23
+ runId?: string | undefined;
24
+ }): JsonRecord | undefined;
25
+ /** Convert an object to a JSON record, dropping undefined fields recursively. */
26
+ export declare function toJsonRecord(input: Record<string, unknown>): JsonRecord;
27
+ /** Convert arbitrary hook payload data into NAPI-safe JSON. */
28
+ export declare function toJsonValue(input: unknown): JsonValue;
29
+ /** Preserve useful error fields in telemetry without requiring Error instances. */
30
+ export declare function errorToJson(error: unknown): JsonRecord;
@@ -0,0 +1,109 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /** Emit a NeMo Relay event under an existing OpenClaw session root span. */
4
+ export function emitMark(params) {
5
+ if (!params.session.rootHandle) {
6
+ params.state.counters.skippedEvents += 1;
7
+ return;
8
+ }
9
+ params.nf.event(params.name, params.session.rootHandle, params.data, null, params.timestamp ?? null);
10
+ params.state.counters.marksEmitted += 1;
11
+ }
12
+ /** Return a blocked-tool event payload when OpenClaw denied the tool call. */
13
+ export function blockedToolDetails(event, context) {
14
+ const details = resultDetails(event.result);
15
+ if (details?.status !== 'blocked') {
16
+ return undefined;
17
+ }
18
+ return toJsonRecord({
19
+ toolName: event.toolName,
20
+ toolCallId: event.toolCallId,
21
+ runId: event.runId ?? context?.runId,
22
+ blocked: true,
23
+ deniedReason: typeof details.deniedReason === 'string' ? details.deniedReason : undefined,
24
+ durationMs: event.durationMs,
25
+ });
26
+ }
27
+ /** Convert an object to a JSON record, dropping undefined fields recursively. */
28
+ export function toJsonRecord(input) {
29
+ return stripUndefined(input, new WeakSet());
30
+ }
31
+ /** Convert arbitrary hook payload data into NAPI-safe JSON. */
32
+ export function toJsonValue(input) {
33
+ return normalizeJsonValue(input, new WeakSet());
34
+ }
35
+ /** Preserve useful error fields in telemetry without requiring Error instances. */
36
+ export function errorToJson(error) {
37
+ if (error instanceof Error) {
38
+ return toJsonRecord({
39
+ name: error.name,
40
+ message: error.message,
41
+ stack: error.stack,
42
+ });
43
+ }
44
+ if (isRecord(error)) {
45
+ return toJsonRecord(error);
46
+ }
47
+ return { message: String(error) };
48
+ }
49
+ /** Extract OpenClaw tool result details when the result uses that envelope. */
50
+ function resultDetails(result) {
51
+ if (!isRecord(result)) {
52
+ return undefined;
53
+ }
54
+ const details = result.details;
55
+ return isRecord(details) ? details : undefined;
56
+ }
57
+ /** Strip undefined properties and protect prototype-like keys during JSON conversion. */
58
+ function stripUndefined(input, seen) {
59
+ const output = {};
60
+ for (const [key, value] of Object.entries(input)) {
61
+ if (value !== undefined) {
62
+ const normalized = normalizeJsonValue(value, seen);
63
+ if (key === '__proto__') {
64
+ Object.defineProperty(output, key, {
65
+ configurable: true,
66
+ enumerable: true,
67
+ value: normalized,
68
+ writable: true,
69
+ });
70
+ }
71
+ else {
72
+ output[key] = normalized;
73
+ }
74
+ }
75
+ }
76
+ return output;
77
+ }
78
+ /** Normalize any hook value into JSON, replacing cycles and unsupported primitives. */
79
+ function normalizeJsonValue(value, seen) {
80
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') {
81
+ return value;
82
+ }
83
+ if (typeof value === 'number') {
84
+ return Number.isFinite(value) ? value : null;
85
+ }
86
+ if (Array.isArray(value)) {
87
+ if (seen.has(value)) {
88
+ return '[Circular]';
89
+ }
90
+ seen.add(value);
91
+ const out = value.map((item) => normalizeJsonValue(item, seen));
92
+ seen.delete(value);
93
+ return out;
94
+ }
95
+ if (isRecord(value)) {
96
+ if (seen.has(value)) {
97
+ return '[Circular]';
98
+ }
99
+ seen.add(value);
100
+ const out = stripUndefined(value, seen);
101
+ seen.delete(value);
102
+ return out;
103
+ }
104
+ return String(value);
105
+ }
106
+ /** Narrow unknown values to plain records for payload traversal. */
107
+ function isRecord(value) {
108
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
109
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Session identity and root-span management for hook replay.
3
+ *
4
+ * OpenClaw can reference the same conversation by session id, session key, run id,
5
+ * requester key, or child key depending on the hook. This module canonicalizes
6
+ * those identifiers and owns the root `openclaw.session` scope lifecycle.
7
+ */
8
+ import type { NemoRelayHookBackendConfig } from '../config.js';
9
+ import type { PluginHookAgentContext, PluginHookLlmOutputEvent, PluginHookModelCallEndedEvent } from '../openclaw-hook-types.js';
10
+ import type { PluginLogger } from 'openclaw/plugin-sdk/plugin-entry';
11
+ import type { JsonObject as JsonRecord } from 'nemo-relay-node/typed';
12
+ import type { NemoRelayRuntimeModule } from '../modules.js';
13
+ export type SessionLookupInput = {
14
+ sessionId?: string | undefined;
15
+ sessionKey?: string | undefined;
16
+ runId?: string | undefined;
17
+ childSessionKey?: string | undefined;
18
+ requesterSessionKey?: string | undefined;
19
+ };
20
+ export type EnsureSessionInput = SessionLookupInput & {
21
+ agentId?: string | undefined;
22
+ source: 'session_start' | 'lazy_session';
23
+ resumedFrom?: string | undefined;
24
+ timestamp?: number | undefined;
25
+ };
26
+ export type SessionState = {
27
+ sessionId: string;
28
+ sessionKey?: string;
29
+ agentId?: string;
30
+ source: 'session_start' | 'lazy_session';
31
+ resumedFrom?: string;
32
+ finalOutput?: JsonRecord;
33
+ trajectoryReplayedRuns?: Set<string>;
34
+ hookLlmOutputReplayCounts?: Map<string, number>;
35
+ agentRunInputSnapshots?: Map<string, {
36
+ historyMessageCount: number;
37
+ historyMessages: unknown[];
38
+ observedAtMs: number;
39
+ prompt: string;
40
+ }>;
41
+ messageWrites?: unknown[];
42
+ assistantMessageWrites?: AssistantMessageRecord[];
43
+ stack: ReturnType<NemoRelayRuntimeModule['createScopeStack']>;
44
+ rootHandle?: ReturnType<NemoRelayRuntimeModule['pushScope']>;
45
+ };
46
+ export type PendingLlmOutputRecord = {
47
+ sessionKey: string;
48
+ sessionId: string;
49
+ runId: string;
50
+ provider: string;
51
+ model: string;
52
+ event: PluginHookLlmOutputEvent;
53
+ ctx: PluginHookAgentContext;
54
+ observedAtMs: number;
55
+ timer?: ReturnType<typeof setTimeout> | undefined;
56
+ };
57
+ export type LlmInputRecord = {
58
+ sessionKey: string;
59
+ sessionId: string;
60
+ runId: string;
61
+ provider: string;
62
+ model: string;
63
+ prompt: string;
64
+ historyMessages: unknown[];
65
+ imagesCount: number;
66
+ observedAtMs: number;
67
+ systemPrompt?: string | undefined;
68
+ placeholderRequest?: boolean | undefined;
69
+ };
70
+ export type AssistantMessageRecord = {
71
+ sessionKey: string;
72
+ provider: string;
73
+ model: string;
74
+ assistantTexts: string[];
75
+ assistantToolCalls: unknown[];
76
+ historyMessages: unknown[];
77
+ prompt: string;
78
+ observedAtMs: number;
79
+ replayed: boolean;
80
+ usage?: unknown;
81
+ };
82
+ export type ModelCallRecord = {
83
+ sessionKey: string;
84
+ runId: string;
85
+ callId: string;
86
+ provider: string;
87
+ model: string;
88
+ consumed: boolean;
89
+ observedAtMs: number;
90
+ sessionId?: string | undefined;
91
+ api?: string | undefined;
92
+ transport?: string | undefined;
93
+ startedAtMs?: number | undefined;
94
+ endedAtMs?: number | undefined;
95
+ durationMs?: number | undefined;
96
+ outcome?: PluginHookModelCallEndedEvent['outcome'] | undefined;
97
+ errorCategory?: string | undefined;
98
+ failureKind?: PluginHookModelCallEndedEvent['failureKind'] | undefined;
99
+ requestPayloadBytes?: number | undefined;
100
+ responseStreamBytes?: number | undefined;
101
+ timeToFirstByteMs?: number | undefined;
102
+ upstreamRequestIdHash?: string | undefined;
103
+ ambiguous?: boolean | undefined;
104
+ };
105
+ export type HookReplayCounters = {
106
+ llmSpansReplayed: number;
107
+ toolSpansReplayed: number;
108
+ marksEmitted: number;
109
+ replayErrors: number;
110
+ skippedEvents: number;
111
+ };
112
+ export type HookReplayBackendState = {
113
+ sessions: Map<string, SessionState>;
114
+ sessionAliases: Map<string, string>;
115
+ llmInputs: Map<string, LlmInputRecord[]>;
116
+ llmOutputsPendingInput: Map<string, PendingLlmOutputRecord[]>;
117
+ modelCallsByCallId: Map<string, ModelCallRecord[]>;
118
+ modelTimingsByLlmKey: Map<string, ModelCallRecord[]>;
119
+ counters: HookReplayCounters;
120
+ };
121
+ export type SessionManager = {
122
+ nf: NemoRelayRuntimeModule;
123
+ config: NemoRelayHookBackendConfig;
124
+ logger: PluginLogger;
125
+ state: HookReplayBackendState;
126
+ agentVersion: string;
127
+ emitCapturedUnderSession: (label: string, session: SessionState, emit: () => void) => void;
128
+ replayPendingLlmOutputsForSession: (session: SessionState, options: {
129
+ allowPlaceholderRequest: boolean;
130
+ }) => void;
131
+ emitUnpairedModelCallTimingMarks: (session: SessionState) => void;
132
+ logBoundedWarn: (key: string, message: string) => void;
133
+ };
134
+ /** Return all keys that may identify an existing OpenClaw session. */
135
+ export declare function lookupSessionKeys(input: SessionLookupInput): string[];
136
+ /** Return keys that should alias to a canonical session once it is known. */
137
+ 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. */
141
+ export declare function rememberSessionAliases(state: HookReplayBackendState, session: SessionState, input: SessionLookupInput): void;
142
+ /** Create the mutable in-memory state used by the hook replay backend. */
143
+ export declare function createHookReplayState(): HookReplayBackendState;
144
+ /** Return an existing session or lazily create a root session scope for replay. */
145
+ export declare function ensureSession(manager: SessionManager, input: EnsureSessionInput): SessionState | undefined;
146
+ /** Flush pending LLM output/timing state before the root session closes. */
147
+ export declare function drainSession(manager: SessionManager, session: SessionState): void;
148
+ /** 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;
150
+ /** Remove a closed session from active replay state. */
151
+ export declare function deleteSession(state: HookReplayBackendState, session: SessionState): void;
152
+ /** Insert a correlation record while bounding retained entries per key. */
153
+ export declare function insertBoundedRecord<T>(map: Map<string, T[]>, key: string, record: T, maxRecordsPerKey: number): void;
154
+ /** Build a stable tuple key for session alias maps. */
155
+ export declare function tupleKey(parts: Array<string | undefined>): string;
156
+ /** Evict stale cross-hook correlation records across all replay maps. */
157
+ export declare function evictExpiredCorrelationRecords(state: HookReplayBackendState, nowMs: number, ttlMs: number): void;
@@ -0,0 +1,204 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { evictExpiredRecords, tupleKey as tupleKeyFromCorrelation } from './correlation.js';
4
+ /** Return all keys that may identify an existing OpenClaw session. */
5
+ export function lookupSessionKeys(input) {
6
+ return [input.sessionId, input.sessionKey, input.requesterSessionKey, input.childSessionKey, input.runId].filter((value) => typeof value === 'string' && value.length > 0);
7
+ }
8
+ /** Return keys that should alias to a canonical session once it is known. */
9
+ export function aliasSessionKeys(input) {
10
+ return [input.sessionId, input.sessionKey, input.requesterSessionKey, input.runId].filter((value) => typeof value === 'string' && value.length > 0);
11
+ }
12
+ /** Resolve a hook's session identity to the canonical session id used in replay state. */
13
+ export function resolveSessionKey(state, input) {
14
+ for (const key of lookupSessionKeys(input)) {
15
+ const canonical = state.sessionAliases.get(key);
16
+ if (canonical) {
17
+ return canonical;
18
+ }
19
+ }
20
+ return input.sessionId ?? input.sessionKey ?? input.childSessionKey ?? input.runId;
21
+ }
22
+ /** Remember equivalent hook identifiers so later events attach to the same root span. */
23
+ export function rememberSessionAliases(state, session, input) {
24
+ for (const alias of aliasSessionKeys(input)) {
25
+ state.sessionAliases.set(alias, session.sessionId);
26
+ }
27
+ }
28
+ /** Create the mutable in-memory state used by the hook replay backend. */
29
+ export function createHookReplayState() {
30
+ return {
31
+ sessions: new Map(),
32
+ sessionAliases: new Map(),
33
+ llmInputs: new Map(),
34
+ llmOutputsPendingInput: new Map(),
35
+ modelCallsByCallId: new Map(),
36
+ modelTimingsByLlmKey: new Map(),
37
+ counters: {
38
+ llmSpansReplayed: 0,
39
+ toolSpansReplayed: 0,
40
+ marksEmitted: 0,
41
+ replayErrors: 0,
42
+ skippedEvents: 0,
43
+ },
44
+ };
45
+ }
46
+ /** Return an existing session or lazily create a root session scope for replay. */
47
+ export function ensureSession(manager, input) {
48
+ const key = resolveSessionKey(manager.state, input);
49
+ if (!key) {
50
+ manager.state.counters.skippedEvents += 1;
51
+ manager.logBoundedWarn('missing-session-key', 'nemo-relay skipped replay because no session/run key was available');
52
+ return undefined;
53
+ }
54
+ const existing = manager.state.sessions.get(key);
55
+ if (existing) {
56
+ rememberSessionAliases(manager.state, existing, input);
57
+ return existing;
58
+ }
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
+ const stack = manager.nf.createScopeStack();
66
+ const session = {
67
+ sessionId: canonicalSessionId,
68
+ source: input.source,
69
+ stack,
70
+ };
71
+ if (input.sessionKey !== undefined) {
72
+ session.sessionKey = input.sessionKey;
73
+ }
74
+ if (input.agentId !== undefined) {
75
+ session.agentId = input.agentId;
76
+ }
77
+ if (input.resumedFrom !== undefined) {
78
+ session.resumedFrom = input.resumedFrom;
79
+ }
80
+ openSessionRoot(manager, session, input);
81
+ manager.state.sessions.set(session.sessionId, session);
82
+ rememberSessionAliases(manager.state, session, input);
83
+ return session;
84
+ }
85
+ /** Flush pending LLM output/timing state before the root session closes. */
86
+ export function drainSession(manager, session) {
87
+ cancelPendingLlmOutputTimers(manager.state, session);
88
+ manager.replayPendingLlmOutputsForSession(session, { allowPlaceholderRequest: true });
89
+ manager.emitUnpairedModelCallTimingMarks(session);
90
+ evictSessionCorrelationRecords(manager.state, session);
91
+ }
92
+ /** Close the root session scope with separate lifecycle summary and user-visible output. */
93
+ export function closeSessionRoot(manager, session, summary, rootOutput = summary, timestamp) {
94
+ manager.emitCapturedUnderSession('session_end', session, () => {
95
+ if (!session.rootHandle) {
96
+ return;
97
+ }
98
+ manager.nf.event('openclaw.session_end', session.rootHandle, summary, null, timestamp ?? null);
99
+ manager.state.counters.marksEmitted += 1;
100
+ manager.nf.popScope(session.rootHandle, rootOutput, timestamp ?? null);
101
+ delete session.rootHandle;
102
+ });
103
+ }
104
+ /** Remove a closed session from active replay state. */
105
+ export function deleteSession(state, session) {
106
+ state.sessions.delete(session.sessionId);
107
+ }
108
+ /** Insert a correlation record while bounding retained entries per key. */
109
+ export function insertBoundedRecord(map, key, record, maxRecordsPerKey) {
110
+ const records = map.get(key) ?? [];
111
+ records.push(record);
112
+ while (records.length > maxRecordsPerKey) {
113
+ records.shift();
114
+ }
115
+ map.set(key, records);
116
+ }
117
+ /** Build a stable tuple key for session alias maps. */
118
+ export function tupleKey(parts) {
119
+ return tupleKeyFromCorrelation(parts);
120
+ }
121
+ /** Evict stale cross-hook correlation records across all replay maps. */
122
+ export function evictExpiredCorrelationRecords(state, nowMs, ttlMs) {
123
+ evictExpiredRecords(state.llmInputs, nowMs, ttlMs);
124
+ evictExpiredPendingLlmOutputs(state.llmOutputsPendingInput, nowMs, ttlMs);
125
+ evictExpiredRecords(state.modelCallsByCallId, nowMs, ttlMs);
126
+ evictExpiredRecords(state.modelTimingsByLlmKey, nowMs, ttlMs);
127
+ }
128
+ /** Open the root NeMo Relay scope for one OpenClaw session and emit session_start. */
129
+ function openSessionRoot(manager, session, input) {
130
+ const data = {
131
+ sessionId: session.sessionId,
132
+ source: session.source,
133
+ ...(session.sessionKey === undefined ? {} : { sessionKey: session.sessionKey }),
134
+ ...(session.agentId === undefined ? {} : { agentId: session.agentId }),
135
+ ...(input.runId === undefined ? {} : { runId: input.runId }),
136
+ ...(session.resumedFrom === undefined ? {} : { resumedFrom: session.resumedFrom }),
137
+ };
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;
142
+ });
143
+ }
144
+ /** Cancel timers that would otherwise replay late LLM outputs after session close. */
145
+ function cancelPendingLlmOutputTimers(state, session) {
146
+ for (const records of state.llmOutputsPendingInput.values()) {
147
+ for (const record of records) {
148
+ if (record.sessionKey === session.sessionId && record.timer) {
149
+ clearTimeout(record.timer);
150
+ record.timer = undefined;
151
+ }
152
+ }
153
+ }
154
+ }
155
+ /** Remove all correlation records and aliases owned by a closed session. */
156
+ 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) {
163
+ state.sessionAliases.delete(alias);
164
+ }
165
+ }
166
+ }
167
+ /** Drop records for one session from a single keyed correlation map. */
168
+ function evictFromRecordMap(map, sessionKey) {
169
+ for (const [key, records] of map) {
170
+ const retained = records.filter((record) => record.sessionKey !== sessionKey);
171
+ if (retained.length === 0) {
172
+ map.delete(key);
173
+ }
174
+ else {
175
+ map.set(key, retained);
176
+ }
177
+ }
178
+ }
179
+ /** Evict pending LLM outputs and clear their grace timers when their TTL expires. */
180
+ function evictExpiredPendingLlmOutputs(map, nowMs, ttlMs) {
181
+ for (const [key, records] of map) {
182
+ const retained = [];
183
+ for (const record of records) {
184
+ if (nowMs - record.observedAtMs <= ttlMs) {
185
+ retained.push(record);
186
+ continue;
187
+ }
188
+ if (record.timer) {
189
+ clearTimeout(record.timer);
190
+ record.timer = undefined;
191
+ }
192
+ }
193
+ if (retained.length === 0) {
194
+ map.delete(key);
195
+ }
196
+ else {
197
+ map.set(key, retained);
198
+ }
199
+ }
200
+ }
201
+ /** Resolve the runtime's Agent scope enum while tolerating older Node bindings. */
202
+ function agentScopeType(nf) {
203
+ return (nf.ScopeType?.Agent ?? 0);
204
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Tool-call replay from OpenClaw hooks into NeMo Relay spans.
3
+ *
4
+ * Tool payloads can be large or sensitive, so this module applies capture policy
5
+ * before exporting arguments/results while keeping enough metadata for debugging.
6
+ */
7
+ import type { PluginHookAfterToolCallEvent, PluginHookBeforeToolCallEvent, PluginHookToolContext } from '../openclaw-hook-types.js';
8
+ import { type SessionManager } from './session.js';
9
+ /** Run NeMo Relay tool conditional-execution guardrails before OpenClaw executes a tool. */
10
+ export declare function guardBeforeToolCall(manager: SessionManager, event: PluginHookBeforeToolCallEvent, ctx: PluginHookToolContext): Promise<void>;
11
+ /** Convert one OpenClaw after_tool_call event into a NeMo Relay tool span or blocked-tool mark. */
12
+ export declare function replayAfterToolCall(manager: SessionManager, event: PluginHookAfterToolCallEvent, ctx: PluginHookToolContext): void;
@@ -0,0 +1,101 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { blockedToolDetails, emitMark, errorToJson, toJsonRecord, toJsonValue } from './marks.js';
4
+ import { ensureSession } from './session.js';
5
+ import { nowMicros, startMicrosFromDuration } from './correlation.js';
6
+ /** Run NeMo Relay tool conditional-execution guardrails before OpenClaw executes a tool. */
7
+ export async function guardBeforeToolCall(manager, event, ctx) {
8
+ const session = ensureSession(manager, {
9
+ sessionId: ctx.sessionId,
10
+ sessionKey: ctx.sessionKey,
11
+ runId: event.runId ?? ctx.runId,
12
+ agentId: ctx.agentId,
13
+ source: 'lazy_session',
14
+ });
15
+ const args = toJsonValue(event.params ?? {});
16
+ if (session) {
17
+ const previousStack = manager.nf.currentScopeStack();
18
+ try {
19
+ manager.nf.setThreadScopeStack(session.stack);
20
+ await manager.nf.toolConditionalExecution(event.toolName, args);
21
+ }
22
+ finally {
23
+ manager.nf.setThreadScopeStack(previousStack);
24
+ }
25
+ }
26
+ else {
27
+ await manager.nf.toolConditionalExecution(event.toolName, args);
28
+ }
29
+ }
30
+ /** Convert one OpenClaw after_tool_call event into a NeMo Relay tool span or blocked-tool mark. */
31
+ export function replayAfterToolCall(manager, event, ctx) {
32
+ const session = ensureSession(manager, {
33
+ sessionId: ctx.sessionId,
34
+ sessionKey: ctx.sessionKey,
35
+ runId: event.runId ?? ctx.runId,
36
+ agentId: ctx.agentId,
37
+ source: 'lazy_session',
38
+ });
39
+ const blockedDetails = blockedToolDetails(event, { runId: event.runId ?? ctx.runId });
40
+ if (session && blockedDetails) {
41
+ manager.emitCapturedUnderSession('openclaw.tool_blocked', session, () => {
42
+ emitMark({
43
+ nf: manager.nf,
44
+ state: manager.state,
45
+ session,
46
+ name: 'openclaw.tool_blocked',
47
+ data: blockedDetails,
48
+ });
49
+ });
50
+ return;
51
+ }
52
+ if (!session) {
53
+ return;
54
+ }
55
+ const endMicros = nowMicros();
56
+ const metadata = toJsonRecord({
57
+ source: 'openclaw.after_tool_call',
58
+ runId: event.runId ?? ctx.runId,
59
+ sessionId: ctx.sessionId,
60
+ sessionKey: ctx.sessionKey,
61
+ toolCallId: event.toolCallId ?? ctx.toolCallId,
62
+ durationMs: event.durationMs,
63
+ });
64
+ const argsPayload = toJsonValue(manager.config.capture.stripToolArgs
65
+ ? {
66
+ stripped: true,
67
+ argKeys: event.params && typeof event.params === 'object' && !Array.isArray(event.params)
68
+ ? Object.keys(event.params)
69
+ : undefined,
70
+ }
71
+ : (event.params ?? {}));
72
+ const endPayload = toJsonValue(manager.config.capture.stripToolResults
73
+ ? toolDisplayPayload(event, true)
74
+ : event.error
75
+ ? { ...toolDisplayPayload(event, false), error: errorToJson(event.error), result: event.result ?? null }
76
+ : { ...toolDisplayPayload(event, false), result: event.result ?? null });
77
+ manager.emitCapturedUnderSession('after_tool_call', session, () => {
78
+ const handle = manager.nf.toolCall(event.toolName, argsPayload, session.rootHandle, null, null, metadata, event.toolCallId ?? ctx.toolCallId ?? null, startMicrosFromDuration(endMicros, event.durationMs));
79
+ manager.nf.toolCallEnd(handle, endPayload, null, metadata, endMicros);
80
+ manager.state.counters.toolSpansReplayed += 1;
81
+ });
82
+ }
83
+ /** Build the compact default tool output shown in trace UIs. */
84
+ function toolDisplayPayload(event, stripped) {
85
+ const hasError = Boolean(event.error);
86
+ return {
87
+ content: `Tool ${event.toolName} ${hasError ? 'failed' : 'completed'}.`,
88
+ openclaw: {
89
+ toolName: event.toolName,
90
+ toolCallId: event.toolCallId,
91
+ durationMs: event.durationMs,
92
+ hasError,
93
+ stripped,
94
+ resultKeys: resultKeys(event.result),
95
+ },
96
+ };
97
+ }
98
+ /** Include result keys as a low-noise hint when full tool results are stripped. */
99
+ function resultKeys(result) {
100
+ return result && typeof result === 'object' && !Array.isArray(result) ? Object.keys(result) : undefined;
101
+ }