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.
- package/README.md +262 -4
- package/dist/index.d.ts +8 -0
- package/dist/index.js +20 -0
- package/dist/openclaw.plugin.json +154 -0
- package/dist/src/config.d.ts +329 -0
- package/dist/src/config.js +185 -0
- package/dist/src/health.d.ts +48 -0
- package/dist/src/health.js +63 -0
- package/dist/src/hook-replay/correlation.d.ts +34 -0
- package/dist/src/hook-replay/correlation.js +40 -0
- package/dist/src/hook-replay/llm.d.ts +52 -0
- package/dist/src/hook-replay/llm.js +1060 -0
- package/dist/src/hook-replay/marks.d.ts +30 -0
- package/dist/src/hook-replay/marks.js +109 -0
- package/dist/src/hook-replay/session.d.ts +157 -0
- package/dist/src/hook-replay/session.js +204 -0
- package/dist/src/hook-replay/tool.d.ts +12 -0
- package/dist/src/hook-replay/tool.js +101 -0
- package/dist/src/hooks-backend.d.ts +94 -0
- package/dist/src/hooks-backend.js +321 -0
- package/dist/src/modules.d.ts +42 -0
- package/dist/src/modules.js +15 -0
- package/dist/src/openclaw-hook-types.d.ts +186 -0
- package/dist/src/openclaw-hook-types.js +3 -0
- package/dist/src/runtime-state.d.ts +66 -0
- package/dist/src/runtime-state.js +420 -0
- package/dist/src/types.d.ts +27 -0
- package/dist/src/types.js +3 -0
- package/index.ts +23 -0
- package/openclaw.plugin.json +154 -0
- package/package.json +62 -24
- package/src/config.ts +251 -0
- package/src/health.ts +116 -0
- package/src/hook-replay/correlation.ts +75 -0
- package/src/hook-replay/llm.ts +1302 -0
- package/src/hook-replay/marks.ts +141 -0
- package/src/hook-replay/session.ts +396 -0
- package/src/hook-replay/tool.ts +142 -0
- package/src/hooks-backend.ts +457 -0
- package/src/modules.ts +79 -0
- package/src/openclaw-hook-types.ts +211 -0
- package/src/runtime-state.ts +553 -0
- package/src/types.ts +34 -0
- package/index.d.ts +0 -2
- package/index.js +0 -15
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime lifecycle coordinator for the OpenClaw plugin.
|
|
3
|
+
*
|
|
4
|
+
* This module validates config, lazy-loads NeMo Relay Node bindings, registers
|
|
5
|
+
* OpenClaw service/lifecycle/gateway surfaces, and forwards hooks to the replay
|
|
6
|
+
* backend once runtime state is ready.
|
|
7
|
+
*/
|
|
8
|
+
import type { OpenClawPluginApi, PluginLogger, PluginRuntimeLifecycleRegistration } from 'openclaw/plugin-sdk/plugin-entry';
|
|
9
|
+
import { type HookReplayBackendStatus } from './health.js';
|
|
10
|
+
import type { PluginAgentToolCallMiddlewareContext } from './openclaw-hook-types.js';
|
|
11
|
+
import { type NemoRelayModuleLoader } from './modules.js';
|
|
12
|
+
import type { RuntimeStateOptions, StartContext } from './types.js';
|
|
13
|
+
type RuntimeCleanupContext = Parameters<NonNullable<PluginRuntimeLifecycleRegistration['cleanup']>>[0];
|
|
14
|
+
/** Owns one plugin runtime instance across OpenClaw service start/stop cycles. */
|
|
15
|
+
export declare class NemoRelayRuntimeState {
|
|
16
|
+
private readonly api;
|
|
17
|
+
private readonly config;
|
|
18
|
+
private readonly moduleLoader;
|
|
19
|
+
private loadPromise;
|
|
20
|
+
private startPromise;
|
|
21
|
+
private statusValue;
|
|
22
|
+
private modulesValue?;
|
|
23
|
+
private backendValue;
|
|
24
|
+
private initializedPluginHost;
|
|
25
|
+
private pluginHostOutputsHealthy;
|
|
26
|
+
private started;
|
|
27
|
+
private beforeExitListener?;
|
|
28
|
+
private unavailableLogged;
|
|
29
|
+
private missingStartContextLogged;
|
|
30
|
+
private lastStartContext?;
|
|
31
|
+
private lastCounters?;
|
|
32
|
+
constructor(options: RuntimeStateOptions);
|
|
33
|
+
/** Return the current coarse backend status. */
|
|
34
|
+
status(): HookReplayBackendStatus;
|
|
35
|
+
/** Build the operator-facing health payload served through the gateway method. */
|
|
36
|
+
health(): import("./health.js").NemoRelayHealthSnapshot;
|
|
37
|
+
/** Start NeMo Relay modules, generic plugins, and the hook replay backend. */
|
|
38
|
+
start(ctx: StartContext): Promise<void>;
|
|
39
|
+
/** Do the startup work behind a single-flight guard. */
|
|
40
|
+
private startInternal;
|
|
41
|
+
/** Stop the runtime because OpenClaw service or gateway shutdown is happening. */
|
|
42
|
+
stop(reason: string, logger?: PluginLogger): Promise<void>;
|
|
43
|
+
/** Apply conditional-execution guardrails before an OpenClaw tool call proceeds. */
|
|
44
|
+
guardToolCall(ctx: PluginAgentToolCallMiddlewareContext): Promise<void>;
|
|
45
|
+
/** Shared stop implementation that controls the final health status. */
|
|
46
|
+
private stopWithStatus;
|
|
47
|
+
/** Handle OpenClaw runtime lifecycle cleanup for either a session or the backend. */
|
|
48
|
+
cleanup(ctx: RuntimeCleanupContext): Promise<void>;
|
|
49
|
+
/** Return a backend for a hook, lazily starting from runtime context if needed. */
|
|
50
|
+
private backendForHook;
|
|
51
|
+
/** Run a synchronous hook against the backend with fail-open replay handling. */
|
|
52
|
+
private replayWithBackend;
|
|
53
|
+
/** Run an asynchronous hook against the backend with fail-open replay handling. */
|
|
54
|
+
private replayWithBackendAsync;
|
|
55
|
+
/** Register every OpenClaw hook used by the observability backend. */
|
|
56
|
+
registerHooks(): void;
|
|
57
|
+
/** Reconstruct enough service-start context for hooks that arrive before service start. */
|
|
58
|
+
private startContextFromRuntime;
|
|
59
|
+
/** Register a process beforeExit cleanup guard for local OpenClaw shutdown paths. */
|
|
60
|
+
private registerBeforeExit;
|
|
61
|
+
/** Remove the beforeExit listener once normal shutdown begins. */
|
|
62
|
+
private removeBeforeExitListener;
|
|
63
|
+
}
|
|
64
|
+
/** Register the NeMo Relay observability plugin with the OpenClaw plugin API. */
|
|
65
|
+
export declare function registerNemoRelayPlugin(api: OpenClawPluginApi, moduleLoader?: NemoRelayModuleLoader): void;
|
|
66
|
+
export {};
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { parseConfig } from './config.js';
|
|
4
|
+
import { createHealthSnapshot } from './health.js';
|
|
5
|
+
import { HookReplayBackend } from './hooks-backend.js';
|
|
6
|
+
import { defaultNemoRelayModuleLoader, } from './modules.js';
|
|
7
|
+
const SERVICE_ID = 'nemo-relay-observability';
|
|
8
|
+
const LIFECYCLE_ID = 'nemo-relay-observability-cleanup';
|
|
9
|
+
const STATUS_METHOD = 'nemoRelay.status';
|
|
10
|
+
/** Owns one plugin runtime instance across OpenClaw service start/stop cycles. */
|
|
11
|
+
export class NemoRelayRuntimeState {
|
|
12
|
+
api;
|
|
13
|
+
config;
|
|
14
|
+
moduleLoader;
|
|
15
|
+
loadPromise;
|
|
16
|
+
startPromise;
|
|
17
|
+
statusValue = { state: 'not_initialized' };
|
|
18
|
+
modulesValue;
|
|
19
|
+
backendValue;
|
|
20
|
+
initializedPluginHost = false;
|
|
21
|
+
pluginHostOutputsHealthy = false;
|
|
22
|
+
started = false;
|
|
23
|
+
beforeExitListener;
|
|
24
|
+
unavailableLogged = false;
|
|
25
|
+
missingStartContextLogged = false;
|
|
26
|
+
lastStartContext;
|
|
27
|
+
lastCounters;
|
|
28
|
+
constructor(options) {
|
|
29
|
+
this.api = options.api;
|
|
30
|
+
this.config = options.config;
|
|
31
|
+
this.moduleLoader = options.moduleLoader ?? defaultNemoRelayModuleLoader;
|
|
32
|
+
}
|
|
33
|
+
/** Return the current coarse backend status. */
|
|
34
|
+
status() {
|
|
35
|
+
return this.statusValue;
|
|
36
|
+
}
|
|
37
|
+
/** Build the operator-facing health payload served through the gateway method. */
|
|
38
|
+
health() {
|
|
39
|
+
const backendState = this.backendValue?.state();
|
|
40
|
+
return createHealthSnapshot({
|
|
41
|
+
status: this.statusValue,
|
|
42
|
+
initializedPluginHost: this.initializedPluginHost,
|
|
43
|
+
pluginHostOutputsHealthy: this.pluginHostOutputsHealthy,
|
|
44
|
+
config: this.config,
|
|
45
|
+
...(backendState === undefined
|
|
46
|
+
? this.lastCounters === undefined
|
|
47
|
+
? {}
|
|
48
|
+
: { counters: this.lastCounters }
|
|
49
|
+
: {
|
|
50
|
+
counters: backendState.counters,
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/** Start NeMo Relay modules, generic plugins, and the hook replay backend. */
|
|
55
|
+
async start(ctx) {
|
|
56
|
+
this.lastStartContext = copyStartContext(ctx);
|
|
57
|
+
this.missingStartContextLogged = false;
|
|
58
|
+
if (this.started || this.statusValue.state === 'ready' || this.statusValue.state === 'degraded') {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (this.startPromise) {
|
|
62
|
+
await this.startPromise;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
this.startPromise = this.startInternal(ctx);
|
|
66
|
+
try {
|
|
67
|
+
await this.startPromise;
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
this.startPromise = undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Do the startup work behind a single-flight guard. */
|
|
74
|
+
async startInternal(ctx) {
|
|
75
|
+
delete this.lastCounters;
|
|
76
|
+
this.initializedPluginHost = false;
|
|
77
|
+
this.pluginHostOutputsHealthy = false;
|
|
78
|
+
let modules;
|
|
79
|
+
try {
|
|
80
|
+
this.loadPromise ??= this.moduleLoader();
|
|
81
|
+
modules = await this.loadPromise;
|
|
82
|
+
this.modulesValue = modules;
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
this.loadPromise = undefined;
|
|
86
|
+
this.statusValue = { state: 'degraded', reason: `failed to load nemo-relay-node: ${toMessage(error)}` };
|
|
87
|
+
if (!this.unavailableLogged) {
|
|
88
|
+
ctx.logger.warn?.(this.statusValue.reason);
|
|
89
|
+
this.unavailableLogged = true;
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const hostConfig = this.config.plugins;
|
|
94
|
+
let degradedReason;
|
|
95
|
+
const validationReport = validatePluginHostConfig(modules, hostConfig, ctx.logger);
|
|
96
|
+
if (!validationReport.ok) {
|
|
97
|
+
degradedReason = validationReport.reason;
|
|
98
|
+
ctx.logger.warn?.(degradedReason);
|
|
99
|
+
}
|
|
100
|
+
else if (validationReport.report.diagnostics.some((diagnostic) => diagnostic.level === 'error')) {
|
|
101
|
+
degradedReason = 'NeMo Relay plugin host config validation failed';
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
if (validationReport.report.diagnostics.some((diagnostic) => diagnostic.level === 'warning') &&
|
|
105
|
+
degradedReason === undefined) {
|
|
106
|
+
degradedReason = 'NeMo Relay plugin host config validation produced warnings';
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const activationReport = await modules.pluginHost.initialize(hostConfig);
|
|
110
|
+
logDiagnostics(ctx.logger, activationReport.diagnostics);
|
|
111
|
+
this.initializedPluginHost = true;
|
|
112
|
+
const hasInitializationErrors = activationReport.diagnostics.some((diagnostic) => diagnostic.level === 'error');
|
|
113
|
+
this.pluginHostOutputsHealthy = !hasInitializationErrors;
|
|
114
|
+
if (hasInitializationErrors) {
|
|
115
|
+
degradedReason ??= 'NeMo Relay plugin host initialization reported errors';
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
degradedReason = `failed to initialize NeMo Relay plugin host: ${toMessage(error)}`;
|
|
120
|
+
ctx.logger.warn?.(degradedReason);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
this.backendValue = new HookReplayBackend({
|
|
124
|
+
nf: modules.nf,
|
|
125
|
+
config: this.config,
|
|
126
|
+
logger: ctx.logger,
|
|
127
|
+
agentVersion: ctx.agentVersion,
|
|
128
|
+
});
|
|
129
|
+
this.registerBeforeExit(ctx.logger);
|
|
130
|
+
this.started = true;
|
|
131
|
+
this.statusValue =
|
|
132
|
+
degradedReason === undefined ? { state: 'ready' } : { state: 'degraded', reason: degradedReason };
|
|
133
|
+
}
|
|
134
|
+
/** Stop the runtime because OpenClaw service or gateway shutdown is happening. */
|
|
135
|
+
async stop(reason, logger) {
|
|
136
|
+
await this.stopWithStatus(reason, logger, { state: 'stopped', reason });
|
|
137
|
+
}
|
|
138
|
+
/** Apply conditional-execution guardrails before an OpenClaw tool call proceeds. */
|
|
139
|
+
async guardToolCall(ctx) {
|
|
140
|
+
const backend = await this.backendForHook(ctx.workspaceDir);
|
|
141
|
+
if (!backend) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
await backend.onBeforeToolCall({
|
|
145
|
+
toolName: ctx.toolName,
|
|
146
|
+
params: ctx.params,
|
|
147
|
+
runId: ctx.runId,
|
|
148
|
+
toolCallId: ctx.toolCallId,
|
|
149
|
+
}, ctx);
|
|
150
|
+
}
|
|
151
|
+
/** Shared stop implementation that controls the final health status. */
|
|
152
|
+
async stopWithStatus(reason, logger, finalStatus) {
|
|
153
|
+
if (this.statusValue.state === 'stopped' ||
|
|
154
|
+
this.statusValue.state === 'disabled' ||
|
|
155
|
+
this.statusValue.state === 'stopping') {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (this.startPromise) {
|
|
159
|
+
await this.startPromise.catch((error) => {
|
|
160
|
+
const log = logger ?? this.api.logger;
|
|
161
|
+
log.warn?.(`failed to finish NeMo Relay startup before stop: ${toMessage(error)}`);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
this.statusValue = { state: 'stopping' };
|
|
165
|
+
const log = logger ?? this.api.logger;
|
|
166
|
+
this.removeBeforeExitListener();
|
|
167
|
+
try {
|
|
168
|
+
await this.backendValue?.drainForGatewayStop(reason);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
log.warn?.(`failed to stop NeMo Relay hook backend: ${toMessage(error)}`);
|
|
172
|
+
}
|
|
173
|
+
const backendState = this.backendValue?.state();
|
|
174
|
+
if (backendState) {
|
|
175
|
+
this.lastCounters = { ...backendState.counters };
|
|
176
|
+
}
|
|
177
|
+
this.backendValue = undefined;
|
|
178
|
+
if (this.initializedPluginHost && this.modulesValue) {
|
|
179
|
+
try {
|
|
180
|
+
this.modulesValue.pluginHost.clear();
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
log.warn?.(`failed to clear NeMo Relay plugin host: ${toMessage(error)}`);
|
|
184
|
+
}
|
|
185
|
+
this.initializedPluginHost = false;
|
|
186
|
+
this.pluginHostOutputsHealthy = false;
|
|
187
|
+
}
|
|
188
|
+
this.started = false;
|
|
189
|
+
this.statusValue = finalStatus;
|
|
190
|
+
}
|
|
191
|
+
/** Handle OpenClaw runtime lifecycle cleanup for either a session or the backend. */
|
|
192
|
+
async cleanup(ctx) {
|
|
193
|
+
if (ctx.sessionKey !== undefined || ctx.runId !== undefined) {
|
|
194
|
+
await this.backendValue?.cleanupSession({
|
|
195
|
+
reason: ctx.reason,
|
|
196
|
+
...(ctx.sessionKey === undefined ? {} : { sessionKey: ctx.sessionKey }),
|
|
197
|
+
...(ctx.runId === undefined ? {} : { runId: ctx.runId }),
|
|
198
|
+
});
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
await this.stopWithStatus(ctx.reason, this.api.logger, ctx.reason === 'restart'
|
|
202
|
+
? { state: 'not_initialized', reason: 'restart' }
|
|
203
|
+
: { state: 'stopped', reason: ctx.reason });
|
|
204
|
+
}
|
|
205
|
+
/** Return a backend for a hook, lazily starting from runtime context if needed. */
|
|
206
|
+
async backendForHook(workspaceDir) {
|
|
207
|
+
if (this.backendValue) {
|
|
208
|
+
return this.backendValue;
|
|
209
|
+
}
|
|
210
|
+
if (this.statusValue.state === 'disabled' || this.statusValue.state === 'stopping') {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
const startContext = this.lastStartContext ?? this.startContextFromRuntime(workspaceDir);
|
|
214
|
+
if (!startContext) {
|
|
215
|
+
if (!this.missingStartContextLogged) {
|
|
216
|
+
this.api.logger.warn?.('nemo-relay skipped hook replay because OpenClaw service start context is unavailable');
|
|
217
|
+
this.missingStartContextLogged = true;
|
|
218
|
+
}
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
await this.start(startContext);
|
|
222
|
+
return this.backendValue;
|
|
223
|
+
}
|
|
224
|
+
/** Run a synchronous hook against the backend with fail-open replay handling. */
|
|
225
|
+
async replayWithBackend(label, workspaceDir, emit) {
|
|
226
|
+
const backend = await this.backendForHook(workspaceDir);
|
|
227
|
+
if (!backend) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
backend.safeReplay(label, undefined, () => emit(backend));
|
|
231
|
+
}
|
|
232
|
+
/** Run an asynchronous hook against the backend with fail-open replay handling. */
|
|
233
|
+
async replayWithBackendAsync(label, workspaceDir, emit) {
|
|
234
|
+
const backend = await this.backendForHook(workspaceDir);
|
|
235
|
+
if (!backend) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
await backend.safeReplayAsync(label, undefined, () => emit(backend));
|
|
239
|
+
}
|
|
240
|
+
/** Register every OpenClaw hook used by the observability backend. */
|
|
241
|
+
registerHooks() {
|
|
242
|
+
this.api.on('gateway_start', async (event, ctx) => {
|
|
243
|
+
await this.replayWithBackend('gateway_start', ctx.workspaceDir, (backend) => backend.onGatewayStart(event, ctx));
|
|
244
|
+
});
|
|
245
|
+
this.api.on('gateway_stop', async (event) => {
|
|
246
|
+
await this.stop(event.reason ?? 'gateway_stop', this.api.logger);
|
|
247
|
+
});
|
|
248
|
+
this.api.on('session_start', async (event, ctx) => {
|
|
249
|
+
await this.replayWithBackend('session_start', undefined, (backend) => backend.onSessionStart(event, ctx));
|
|
250
|
+
});
|
|
251
|
+
this.api.on('session_end', async (event, ctx) => {
|
|
252
|
+
await this.replayWithBackendAsync('session_end', undefined, (backend) => backend.onSessionEnd(event, ctx));
|
|
253
|
+
});
|
|
254
|
+
this.api.on('llm_input', async (event, ctx) => {
|
|
255
|
+
await this.replayWithBackend('llm_input', ctx.workspaceDir, (backend) => backend.onLlmInput(event, ctx));
|
|
256
|
+
});
|
|
257
|
+
this.api.on('llm_output', async (event, ctx) => {
|
|
258
|
+
await this.replayWithBackend('llm_output', ctx.workspaceDir, (backend) => backend.onLlmOutput(event, ctx));
|
|
259
|
+
});
|
|
260
|
+
this.api.on('model_call_started', async (event, ctx) => {
|
|
261
|
+
await this.replayWithBackend('model_call_started', ctx.workspaceDir, (backend) => backend.onModelCallStarted(event, ctx));
|
|
262
|
+
});
|
|
263
|
+
this.api.on('model_call_ended', async (event, ctx) => {
|
|
264
|
+
await this.replayWithBackend('model_call_ended', ctx.workspaceDir, (backend) => backend.onModelCallEnded(event, ctx));
|
|
265
|
+
});
|
|
266
|
+
this.api.on('after_tool_call', async (event, ctx) => {
|
|
267
|
+
await this.replayWithBackend('after_tool_call', undefined, (backend) => backend.onAfterToolCall(event, ctx));
|
|
268
|
+
});
|
|
269
|
+
this.api.on('before_message_write', (event, ctx) => {
|
|
270
|
+
const backend = this.backendValue;
|
|
271
|
+
if (!backend) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
backend.safeReplay('before_message_write', undefined, () => backend.onBeforeMessageWrite(event, ctx));
|
|
275
|
+
});
|
|
276
|
+
this.api.on('agent_end', async (event, ctx) => {
|
|
277
|
+
await this.replayWithBackend('agent_end', ctx.workspaceDir, (backend) => backend.onAgentEnd(event, ctx));
|
|
278
|
+
});
|
|
279
|
+
this.api.on('before_agent_finalize', async (event, ctx) => {
|
|
280
|
+
await this.replayWithBackend('before_agent_finalize', ctx.workspaceDir, (backend) => backend.onBeforeAgentFinalize(event, ctx));
|
|
281
|
+
});
|
|
282
|
+
this.api.on('subagent_spawned', async (event, ctx) => {
|
|
283
|
+
await this.replayWithBackend('subagent_spawned', undefined, (backend) => backend.onSubagentSpawned(event, ctx));
|
|
284
|
+
});
|
|
285
|
+
this.api.on('subagent_ended', async (event, ctx) => {
|
|
286
|
+
await this.replayWithBackend('subagent_ended', undefined, (backend) => backend.onSubagentEnded(event, ctx));
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
/** Reconstruct enough service-start context for hooks that arrive before service start. */
|
|
290
|
+
startContextFromRuntime(workspaceDir) {
|
|
291
|
+
try {
|
|
292
|
+
const stateDir = this.api.runtime.state.resolveStateDir();
|
|
293
|
+
return {
|
|
294
|
+
stateDir,
|
|
295
|
+
logger: this.api.logger,
|
|
296
|
+
resolvePath: this.api.resolvePath,
|
|
297
|
+
agentVersion: this.api.version ?? 'unknown',
|
|
298
|
+
...(workspaceDir === undefined ? {} : { workspaceDir }),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
this.api.logger.warn?.(`nemo-relay could not resolve OpenClaw runtime state dir: ${toMessage(error)}`);
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/** Register a process beforeExit cleanup guard for local OpenClaw shutdown paths. */
|
|
307
|
+
registerBeforeExit(logger) {
|
|
308
|
+
if (this.beforeExitListener) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const listener = () => {
|
|
312
|
+
void this.stop('beforeExit', logger).catch((error) => {
|
|
313
|
+
logger.warn?.(`nemo-relay beforeExit cleanup failed: ${toMessage(error)}`);
|
|
314
|
+
});
|
|
315
|
+
};
|
|
316
|
+
process.on('beforeExit', listener);
|
|
317
|
+
this.beforeExitListener = listener;
|
|
318
|
+
}
|
|
319
|
+
/** Remove the beforeExit listener once normal shutdown begins. */
|
|
320
|
+
removeBeforeExitListener() {
|
|
321
|
+
if (!this.beforeExitListener) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
process.removeListener('beforeExit', this.beforeExitListener);
|
|
325
|
+
delete this.beforeExitListener;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/** Register the NeMo Relay observability plugin with the OpenClaw plugin API. */
|
|
329
|
+
export function registerNemoRelayPlugin(api, moduleLoader) {
|
|
330
|
+
if (api.registrationMode !== 'full') {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
let config;
|
|
334
|
+
try {
|
|
335
|
+
config = parseConfig(api.pluginConfig);
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
api.logger.warn?.(`nemo-relay observability disabled because plugin config is invalid: ${toMessage(error)}`);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (!config.enabled) {
|
|
342
|
+
api.logger.info?.('nemo-relay observability disabled by plugin config');
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const runtime = new NemoRelayRuntimeState(moduleLoader === undefined ? { api, config } : { api, config, moduleLoader });
|
|
346
|
+
api.registerService({
|
|
347
|
+
id: SERVICE_ID,
|
|
348
|
+
start: (ctx) => runtime.start({
|
|
349
|
+
stateDir: ctx.stateDir,
|
|
350
|
+
logger: ctx.logger,
|
|
351
|
+
resolvePath: api.resolvePath,
|
|
352
|
+
agentVersion: api.version ?? 'unknown',
|
|
353
|
+
...(ctx.workspaceDir === undefined ? {} : { workspaceDir: ctx.workspaceDir }),
|
|
354
|
+
}),
|
|
355
|
+
stop: (ctx) => runtime.stop('service_stop', ctx.logger),
|
|
356
|
+
});
|
|
357
|
+
api.registerRuntimeLifecycle({
|
|
358
|
+
id: LIFECYCLE_ID,
|
|
359
|
+
description: 'Clean up NeMo Relay OpenClaw observability plugin state',
|
|
360
|
+
cleanup: (ctx) => runtime.cleanup(ctx),
|
|
361
|
+
});
|
|
362
|
+
api.registerGatewayMethod?.(STATUS_METHOD, ({ respond }) => {
|
|
363
|
+
respond(true, runtime.health());
|
|
364
|
+
}, {
|
|
365
|
+
scope: 'operator.admin',
|
|
366
|
+
});
|
|
367
|
+
runtime.registerHooks();
|
|
368
|
+
registerToolCallMiddleware(api, runtime);
|
|
369
|
+
}
|
|
370
|
+
function registerToolCallMiddleware(api, runtime) {
|
|
371
|
+
const register = api.registerAgentToolCallMiddleware;
|
|
372
|
+
if (typeof register !== 'function') {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
register.call(api, async (ctx) => {
|
|
376
|
+
await runtime.guardToolCall(ctx);
|
|
377
|
+
return await ctx.execute(ctx.params);
|
|
378
|
+
}, { runtimes: ['pi'], priority: 100 });
|
|
379
|
+
}
|
|
380
|
+
/** Validate the NeMo Relay plugin-host config and log diagnostics. */
|
|
381
|
+
function validatePluginHostConfig(modules, config, logger) {
|
|
382
|
+
try {
|
|
383
|
+
const report = modules.pluginHost.validate(config);
|
|
384
|
+
logDiagnostics(logger, report.diagnostics);
|
|
385
|
+
return { ok: true, report };
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
return {
|
|
389
|
+
ok: false,
|
|
390
|
+
reason: `failed to validate NeMo Relay plugin host config: ${toMessage(error)}`,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/** Log plugin-host diagnostics at warning or info level based on severity. */
|
|
395
|
+
function logDiagnostics(logger, diagnostics) {
|
|
396
|
+
for (const diagnostic of diagnostics) {
|
|
397
|
+
const prefix = diagnostic.component ? `${diagnostic.component}: ` : '';
|
|
398
|
+
const message = `${prefix}${diagnostic.code}: ${diagnostic.message}`;
|
|
399
|
+
if (diagnostic.level === 'error') {
|
|
400
|
+
logger.warn?.(message);
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
logger.info?.(message);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/** Copy service-start context so later lazy hook startup cannot mutate it. */
|
|
408
|
+
function copyStartContext(ctx) {
|
|
409
|
+
return {
|
|
410
|
+
stateDir: ctx.stateDir,
|
|
411
|
+
logger: ctx.logger,
|
|
412
|
+
resolvePath: ctx.resolvePath,
|
|
413
|
+
agentVersion: ctx.agentVersion,
|
|
414
|
+
...(ctx.workspaceDir === undefined ? {} : { workspaceDir: ctx.workspaceDir }),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
/** Convert thrown values into stable log strings. */
|
|
418
|
+
function toMessage(error) {
|
|
419
|
+
return error instanceof Error ? error.message : String(error);
|
|
420
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared runtime option and context types.
|
|
3
|
+
*
|
|
4
|
+
* These types avoid importing OpenClaw implementation modules outside the public
|
|
5
|
+
* plugin SDK surface and keep runtime-state constructor signatures explicit.
|
|
6
|
+
*/
|
|
7
|
+
import type { OpenClawPluginApi, OpenClawPluginServiceContext } from 'openclaw/plugin-sdk/plugin-entry';
|
|
8
|
+
import type { NemoRelayHookBackendConfig } from './config.js';
|
|
9
|
+
import type { HookReplayBackendStatus } from './health.js';
|
|
10
|
+
import type { NemoRelayModuleLoader } from './modules.js';
|
|
11
|
+
export type RuntimeStateOptions = {
|
|
12
|
+
api: OpenClawPluginApi;
|
|
13
|
+
config: NemoRelayHookBackendConfig;
|
|
14
|
+
moduleLoader?: NemoRelayModuleLoader;
|
|
15
|
+
};
|
|
16
|
+
export type StartContext = {
|
|
17
|
+
stateDir: string;
|
|
18
|
+
workspaceDir?: string;
|
|
19
|
+
logger: OpenClawPluginServiceContext['logger'];
|
|
20
|
+
resolvePath: OpenClawPluginApi['resolvePath'];
|
|
21
|
+
agentVersion: string;
|
|
22
|
+
};
|
|
23
|
+
export type RuntimeStateSnapshot = {
|
|
24
|
+
status: HookReplayBackendStatus;
|
|
25
|
+
initializedPluginHost: boolean;
|
|
26
|
+
unavailableReason?: string;
|
|
27
|
+
};
|
package/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* OpenClaw plugin entry point.
|
|
6
|
+
*
|
|
7
|
+
* This file should stay small: it declares the public plugin metadata and hands
|
|
8
|
+
* registration to the runtime-state module, where lifecycle and hook wiring live.
|
|
9
|
+
*/
|
|
10
|
+
import { definePluginEntry, type OpenClawPluginApi } from 'openclaw/plugin-sdk/plugin-entry';
|
|
11
|
+
|
|
12
|
+
import { nemoRelayConfigSchema } from './src/config.js';
|
|
13
|
+
import { registerNemoRelayPlugin } from './src/runtime-state.js';
|
|
14
|
+
|
|
15
|
+
export default definePluginEntry({
|
|
16
|
+
id: 'nemo-relay',
|
|
17
|
+
name: 'NeMo Relay Observability',
|
|
18
|
+
description: 'ATIF, OpenInference, and OpenTelemetry telemetry through NeMo Relay',
|
|
19
|
+
configSchema: nemoRelayConfigSchema,
|
|
20
|
+
register(api: OpenClawPluginApi) {
|
|
21
|
+
registerNemoRelayPlugin(api);
|
|
22
|
+
},
|
|
23
|
+
});
|