nemo-fabric-adapters-pi 0.4.0-beta.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,16 +22,24 @@ The adapter supports:
22
22
  - Explicit local `.ts` or `.js` extension files contained by the NeMo Fabric
23
23
  workspace
24
24
  - Slash commands registered by those explicit extensions
25
+ - NeMo Relay 0.9 telemetry through a runtime-owned gateway and an explicitly
26
+ configured Relay Pi extension
27
+ - Live model-turn ATOF records for successful Relay redirects through the
28
+ default embedded NeMo Fabric collector. Startup `model_redirect` marks remain
29
+ in the configured Relay ATOF artifacts and are not included in per-invocation
30
+ `invoke_stream()` records
25
31
  - Ordered plain-text invocations with a `{ "response": "..." }` terminal
26
- output
32
+ output, Relay runtime details, and collected ATOF artifacts
27
33
 
28
34
  Ambient Pi settings, context files, packages, extensions, skills, prompts,
29
35
  themes, model files, credentials, and session files are disabled. Explicitly
30
- configured extensions are trusted code.
36
+ configured extensions are trusted code. The adapter raises the compaction
37
+ reserve toward the selected model's maximum output while retaining at least
38
+ half of the context window for input.
31
39
 
32
40
  ## Install the Adapter
33
41
 
34
- Pi 0.84.x requires Node.js 22.19.0 or newer.
42
+ Pi 0.86.x requires Node.js 22.19.0 or newer.
35
43
 
36
44
  ### Install for Consumers
37
45
 
@@ -40,7 +48,7 @@ then install the compatible Pi SDK harness version selected by that project:
40
48
 
41
49
  ```bash
42
50
  npm install nemo-fabric-adapters-pi
43
- npm install @earendil-works/pi-ai@^0.84.2 @earendil-works/pi-coding-agent@^0.84.2
51
+ npm install @earendil-works/pi-ai@^0.86.0 @earendil-works/pi-coding-agent@^0.86.0
44
52
  ```
45
53
 
46
54
  The adapter declares the Pi packages as optional peers. Installing the adapter
@@ -65,6 +73,27 @@ just build-typescript
65
73
  The full build installs its own dependencies, so you do not need to run
66
74
  `just install-typescript-pi` first.
67
75
 
76
+ ### Install NeMo Relay
77
+
78
+ Relay-enabled Pi runs require `nemo-relay>=0.9.0,<0.10.0` on `PATH`. Install it
79
+ separately from the npm adapter:
80
+
81
+ ```bash
82
+ pip install "nemo-relay-cli-bin>=0.9.0,<0.10.0"
83
+ ```
84
+
85
+ To select a specific Relay executable instead of relying on `PATH`, set
86
+ `FABRIC_NEMO_RELAY_COMMAND` to its absolute path:
87
+
88
+ ```bash
89
+ FABRIC_NEMO_RELAY_COMMAND="/absolute/path/to/nemo-relay" uv run python your_app.py
90
+ ```
91
+
92
+ The adapter does not bundle the Relay Pi extension. Obtain the
93
+ [`crates/cli/assets/pi-extension`](https://github.com/NVIDIA/NeMo-Relay/tree/0.9.0/crates/cli/assets/pi-extension)
94
+ directory from the Relay 0.9 release and configure its path as described in the
95
+ next section.
96
+
68
97
  ## Configure the Adapter
69
98
 
70
99
  The npm package includes its adapter descriptor as `pi.fabric-adapter.json`.
@@ -85,6 +114,95 @@ harness = HarnessConfig(adapter_id="nvidia.fabric.pi")
85
114
  For a source build, set `discovery.local_paths` to
86
115
  `adapters/typescript/pi/pi.fabric-adapter.json` instead.
87
116
 
117
+ ## Configure NeMo Relay
118
+
119
+ Enable Relay with the standard NeMo Fabric configuration and provide the Relay
120
+ Pi extension as an adapter setting:
121
+
122
+ ```python
123
+ config.runtime.artifacts = "./artifacts/pi"
124
+ config.enable_relay(output_dir="./artifacts/relay")
125
+ config.harness.settings["relay_extension_path"] = (
126
+ "/path/to/NeMo-Relay/crates/cli/assets/pi-extension"
127
+ )
128
+ ```
129
+
130
+ Relay requires `runtime.artifacts` so NeMo Fabric can create the runtime-owned
131
+ configuration passed to the adapter. The extension path can be absolute or
132
+ relative to `environment.workspace`. It can identify a JavaScript or TypeScript
133
+ file or a Pi extension package directory. Unlike user-configured Pi extensions,
134
+ the Relay extension does not need to remain inside `environment.workspace` when
135
+ an absolute path is used.
136
+
137
+ When the runtime starts, the adapter validates the Relay 0.9 CLI, writes an
138
+ explicit `plugins.toml`, starts a loopback gateway, and loads the extension into
139
+ the isolated Pi session. The result includes `relay_runtime` and
140
+ `relay_artifacts` in `output`. The gateway can produce ATOF, ATIF,
141
+ OpenTelemetry, and OpenInference output from the Relay observability
142
+ configuration.
143
+
144
+ The adapter supports one Relay-enabled Pi runtime per adapter process because
145
+ the Relay 0.9 extension receives its gateway and upstream configuration through
146
+ process environment variables. NeMo Fabric starts each runtime in a separate
147
+ adapter process, so concurrent NeMo Fabric runtimes remain isolated. Direct
148
+ embedders must likewise place concurrent Relay-enabled Pi runtimes in separate
149
+ processes.
150
+
151
+ Local ATIF trajectories are finalized only after the Pi session closes and are
152
+ therefore not included in `relay_artifacts`. The adapter does not wait for local
153
+ ATIF during invocation; it collects per-invocation artifacts such as ATOF and
154
+ returns the result. After runtime shutdown, retrieve the finalized file directly
155
+ from the ATIF output directory. For the configuration above and the default
156
+ filename template, it is written to
157
+ `./artifacts/relay/<runtime_id>/trajectory-<session_id>.atif.json`. Invocation
158
+ results do not prevent subsequent turns.
159
+
160
+ Session, turn, and tool telemetry does not depend on model redirection. Model
161
+ telemetry is available only when Relay supports the selected model API and the
162
+ gateway upstream matches the model endpoint. Relay records a skipped redirect
163
+ as a `model_redirect` mark with the reason in configured ATOF artifacts. Pi
164
+ emits its startup redirect marks before NeMo Fabric registers an invocation, so
165
+ they are not included in the per-invocation records from `invoke_stream()`.
166
+
167
+ Install the matching collector for the embedded streaming path:
168
+
169
+ ```bash
170
+ pip install "nemo-fabric[streaming]"
171
+ ```
172
+
173
+ Start the runtime with streaming enabled to consume live model-turn ATOF
174
+ records for successful Relay redirects in one Pi invocation:
175
+
176
+ ```python
177
+ from nemo_fabric import Fabric
178
+
179
+ async with await Fabric().start_runtime(config, streaming=True) as runtime:
180
+ stream = runtime.invoke_stream(input="Review the latest patch")
181
+ async for record in stream:
182
+ print(record)
183
+ result = await stream.result()
184
+ ```
185
+
186
+ The terminal `RunResult` remains separate from the ATOF records. Fully consume
187
+ each stream, or call `await stream.aclose()` if iteration stops early, before
188
+ starting another invocation; the same runtime can then alternate
189
+ `invoke_stream()` and `invoke()` calls. The embedded collector serializes both
190
+ methods behind one Pi invocation lease. Streaming capture begins at the first
191
+ Pi `turn_start` and closes at `agent_settled`. If Relay output is interrupted or
192
+ late, the collector waits for a bounded interval, then admits the next native
193
+ invocation and uses Pi's cumulative turn count to discard ambiguous delayed
194
+ records until a higher `turn_start` arrives. This can thin the ATOF stream but
195
+ does not block later invocations. Increase `completion_wait_timeout` from its
196
+ one-second default when Relay delivery can take longer. Use the default embedded
197
+ collector for Pi streaming. The Pi extension does not attach NeMo Fabric
198
+ request IDs, so
199
+ `start_runtime(..., streaming=True, launch_collector=False)` cannot correlate
200
+ its records through an externally managed collector.
201
+
202
+ This Relay-backed path runs the adapter's ordinary `invoke` operation. It is
203
+ independent of native OpenAI streaming, so the adapter descriptor's
204
+ `capabilities.streaming` value remains `false`.
205
+
88
206
  ## Custom Tool Modules
89
207
 
90
208
  The adapter accepts custom tools with `kind: "module"`. The `ref` is a
@@ -125,7 +243,23 @@ TypeScript packages, inspect the plan from the repository root:
125
243
 
126
244
  Refer to the
127
245
  [code-review example](../../../examples/code_review_agent/README.md) for the
128
- live NVIDIA-backed run command. Relay and MCP are not currently supported.
246
+ live NVIDIA-backed run command. For a Relay-enabled Pi run, pass the extension
247
+ path explicitly:
248
+
249
+ ```bash
250
+ .venv/bin/python -m examples.code_review_agent \
251
+ --variant pi \
252
+ --relay \
253
+ --stream \
254
+ --pi-relay-extension-path /path/to/NeMo-Relay/crates/cli/assets/pi-extension \
255
+ --input "Review calculator.py"
256
+ ```
257
+
258
+ The command collects per-invocation model-turn ATOF records for successful Relay
259
+ redirects, then prints one JSON document containing `atof_records` and the
260
+ separate terminal `result`. Redirect-decision marks remain in configured Relay
261
+ ATOF artifacts; Pi's startup marks are not included in `atof_records`. MCP is
262
+ not currently supported.
129
263
 
130
264
  ## Dependency Rationale
131
265
 
@@ -135,7 +269,7 @@ process; maintaining a second JSON-RPC translation was rejected for the bundled
135
269
  adapter. `@earendil-works/pi-ai` supplies Pi's model catalog and credential
136
270
  store, which the coding-agent SDK expects. Both packages are optional peer
137
271
  dependencies so deployments control the compatible harness version. Exact
138
- 0.84.2 development dependencies keep repository builds and tests reproducible.
272
+ 0.86.0 development dependencies keep repository builds and tests reproducible.
139
273
 
140
274
  `jiti` loads explicitly configured, trusted JavaScript and TypeScript tool
141
275
  modules. Native Node.js loading cannot execute TypeScript modules, while a
package/dist/pi-sdk.d.ts CHANGED
@@ -1,8 +1,17 @@
1
1
  import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
2
2
  import type { AgentToolDefinition } from "nemo-fabric-adapter-contract";
3
3
  import { type AdapterStartInput } from "nemo-fabric-adapters-common";
4
+ import { type PiRelayControllerFactory } from "./relay.js";
4
5
  import type { PiSessionFactory, PiSessionHandle } from "./runtime.js";
6
+ export declare function withCustomBaseUrl<T extends {
7
+ api: string;
8
+ baseUrl: string;
9
+ compat?: object;
10
+ }>(catalogModel: T, baseUrl: string | null | undefined, overrideBaseUrl?: boolean): T;
11
+ export declare function modelAwareCompactionReserveTokens(configuredReserveTokens: number, maxOutputTokens: number, contextWindow: number): number;
5
12
  export declare function resolveCustomTools(workspace: string, definitions: Record<string, AgentToolDefinition>): Promise<ToolDefinition[]>;
6
13
  export declare class PiSdkSessionFactory implements PiSessionFactory {
14
+ private readonly relayFactory;
15
+ constructor(relayFactory?: PiRelayControllerFactory);
7
16
  create(input: AdapterStartInput): Promise<PiSessionHandle>;
8
17
  }
package/dist/pi-sdk.js CHANGED
@@ -7,9 +7,32 @@ import { realpath, stat } from "node:fs/promises";
7
7
  import { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
8
8
  import { createJiti } from "jiti/static";
9
9
  import { LifecycleError } from "nemo-fabric-adapters-common";
10
+ import { PiRelayFactory, } from "./relay.js";
11
+ export function withCustomBaseUrl(catalogModel, baseUrl, overrideBaseUrl = true) {
12
+ if (!baseUrl) {
13
+ return catalogModel;
14
+ }
15
+ const model = overrideBaseUrl ? { ...catalogModel, baseUrl } : catalogModel;
16
+ if (catalogModel.api !== "openai-completions") {
17
+ return model;
18
+ }
19
+ return {
20
+ ...model,
21
+ // Generic OpenAI-compatible proxies may reject provider-specific
22
+ // reasoning_content fields when Pi replays an assistant tool call.
23
+ compat: { ...catalogModel.compat, requiresThinkingAsText: true },
24
+ };
25
+ }
26
+ export function modelAwareCompactionReserveTokens(configuredReserveTokens, maxOutputTokens, contextWindow) {
27
+ const reserveTokens = Math.max(configuredReserveTokens, maxOutputTokens);
28
+ if (contextWindow <= 0) {
29
+ return reserveTokens;
30
+ }
31
+ return Math.min(reserveTokens, Math.floor(contextWindow / 2));
32
+ }
10
33
  const PI_BUILTIN_TOOL_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]);
11
34
  const TOOL_MODULE_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"]);
12
- const PI_HARNESS_INSTALL_COMMAND = "npm install @earendil-works/pi-ai@^0.84.2 @earendil-works/pi-coding-agent@^0.84.2";
35
+ const PI_HARNESS_INSTALL_COMMAND = "npm install @earendil-works/pi-ai@^0.86.0 @earendil-works/pi-coding-agent@^0.86.0";
13
36
  function isMissingModuleError(error) {
14
37
  return (typeof error === "object" &&
15
38
  error !== null &&
@@ -163,11 +186,6 @@ export async function resolveCustomTools(workspace, definitions) {
163
186
  }
164
187
  return tools;
165
188
  }
166
- function extensionToolNames(resourceLoader) {
167
- return resourceLoader
168
- .getExtensions()
169
- .extensions.flatMap((extension) => Array.from(extension.tools.keys()));
170
- }
171
189
  function rejectToolCollisions(customTools, configuredExtensionTools) {
172
190
  const customNames = new Set(customTools.map((tool) => tool.name));
173
191
  const seenExtensionNames = new Set();
@@ -180,6 +198,19 @@ function rejectToolCollisions(customTools, configuredExtensionTools) {
180
198
  seenExtensionNames.add(name);
181
199
  }
182
200
  }
201
+ function relayExtensionLoadErrors(errors, extensions, relay, workspace) {
202
+ if (relay === undefined) {
203
+ return [];
204
+ }
205
+ const loaded = extensions.some((extension) => [extension.path, extension.resolvedPath].some((path) => path !== undefined && resolve(workspace, path) === relay.extensionPath));
206
+ if (loaded) {
207
+ return [];
208
+ }
209
+ return errors.filter((error) => {
210
+ const path = resolve(workspace, error.path);
211
+ return path === relay.extensionPath || containedBy(relay.extensionPath, path);
212
+ });
213
+ }
183
214
  async function resolveExtensionPaths(workspace, configured) {
184
215
  const resolved = [];
185
216
  for (const entry of configured) {
@@ -259,17 +290,33 @@ function unsupportedSessionAction(name) {
259
290
  throw new LifecycleError("pi_unsupported_session_operation", `Pi session operation ${name} is not supported`);
260
291
  }
261
292
  class PiSdkSessionHandle {
293
+ relay;
262
294
  session;
263
295
  state;
296
+ unsubscribeTurnCounter;
264
297
  stopped = false;
265
- constructor(session, state) {
298
+ cumulativeTurnCount = 0;
299
+ constructor(session, state, relay) {
266
300
  this.session = session;
267
301
  this.state = state;
302
+ this.relay = relay;
303
+ this.unsubscribeTurnCounter = this.session.subscribe((event) => {
304
+ if (event.type === "turn_start") {
305
+ this.cumulativeTurnCount += 1;
306
+ }
307
+ });
308
+ }
309
+ get turnCount() {
310
+ return this.cumulativeTurnCount;
268
311
  }
269
312
  async prompt(text) {
270
313
  let accepted = false;
314
+ let turnStarted = false;
271
315
  let finalAssistant;
272
316
  const unsubscribe = this.session.subscribe((event) => {
317
+ if (event.type === "turn_start") {
318
+ turnStarted = true;
319
+ }
273
320
  if (event.type === "message_end" && event.message.role === "assistant") {
274
321
  finalAssistant = event.message;
275
322
  }
@@ -288,6 +335,8 @@ class PiSdkSessionHandle {
288
335
  }
289
336
  return {
290
337
  accepted,
338
+ turnStarted,
339
+ turnCount: this.cumulativeTurnCount,
291
340
  text: finalAssistant === undefined ? undefined : promptText(finalAssistant),
292
341
  stopReason: finalAssistant?.stopReason,
293
342
  errorMessage: finalAssistant?.errorMessage,
@@ -312,6 +361,12 @@ class PiSdkSessionHandle {
312
361
  catch (error) {
313
362
  failure ??= error;
314
363
  }
364
+ try {
365
+ this.unsubscribeTurnCounter();
366
+ }
367
+ catch (error) {
368
+ failure ??= error;
369
+ }
315
370
  try {
316
371
  this.session.dispose();
317
372
  }
@@ -324,6 +379,10 @@ class PiSdkSessionHandle {
324
379
  }
325
380
  }
326
381
  export class PiSdkSessionFactory {
382
+ relayFactory;
383
+ constructor(relayFactory = new PiRelayFactory()) {
384
+ this.relayFactory = relayFactory;
385
+ }
327
386
  async create(input) {
328
387
  const systemInstruction = input.config.instructions?.system;
329
388
  if (systemInstruction?.mode === "append") {
@@ -359,38 +418,6 @@ export class PiSdkSessionFactory {
359
418
  const extensionPaths = await resolveExtensionPaths(workspace, harnessSettings(input.config).extensions);
360
419
  const skillPaths = await resolveSkillPaths(input.baseDir, input.config.skills?.paths ?? []);
361
420
  const customTools = await resolveCustomTools(workspace, input.config.tools?.definitions ?? {});
362
- const agentDir = join(workspace, ".fabric-pi");
363
- const resourceLoader = new pi.DefaultResourceLoader({
364
- cwd: workspace,
365
- agentDir,
366
- settingsManager: settings,
367
- additionalExtensionPaths: extensionPaths,
368
- additionalSkillPaths: skillPaths,
369
- noExtensions: true,
370
- noSkills: true,
371
- noPromptTemplates: true,
372
- noThemes: true,
373
- noContextFiles: true,
374
- systemPrompt: systemInstruction?.content,
375
- });
376
- await resourceLoader.reload();
377
- const extensionErrors = resourceLoader.getExtensions().errors;
378
- if (extensionErrors.length > 0) {
379
- throw new LifecycleError("pi_extension_load_failed", "One or more configured Pi extensions failed to load", {
380
- metadata: { count: extensionErrors.length },
381
- });
382
- }
383
- rejectToolCollisions(customTools, extensionToolNames(resourceLoader));
384
- const skillDiagnostics = resourceLoader.getSkills().diagnostics;
385
- const blockingSkillDiagnostics = skillDiagnostics.filter((diagnostic) => diagnostic.type === "error" || diagnostic.type === "collision");
386
- for (const diagnostic of skillDiagnostics.filter((entry) => entry.type === "warning")) {
387
- process.stderr.write(`Pi skill warning: ${diagnostic.message}\n`);
388
- }
389
- if (blockingSkillDiagnostics.length > 0) {
390
- throw new LifecycleError("pi_skill_load_failed", "One or more configured NeMo Fabric skills failed to load", {
391
- metadata: { count: blockingSkillDiagnostics.length },
392
- });
393
- }
394
421
  const credentials = new pi.InMemoryCredentialStore();
395
422
  const modelRuntime = await pi.ModelRuntime.create({
396
423
  credentials,
@@ -399,28 +426,97 @@ export class PiSdkSessionFactory {
399
426
  refreshOnCreate: false,
400
427
  });
401
428
  await modelRuntime.setRuntimeApiKey(selected.provider, apiKey);
429
+ const relayEnabled = input.runtimeContext.telemetry?.relay_enabled === true;
430
+ if (relayEnabled && selected.base_url) {
431
+ // Configure the provider before Relay loads so its provider-wide redirect
432
+ // sees a consistent catalog instead of one overlaid selected model.
433
+ modelRuntime.registerProvider(selected.provider, {
434
+ baseUrl: selected.base_url,
435
+ });
436
+ }
402
437
  const catalogModel = modelRuntime.getModel(selected.provider, selected.model);
403
438
  if (catalogModel === undefined) {
404
439
  throw new LifecycleError("pi_model_unknown", "The selected provider and model are not present in Pi's catalog");
405
440
  }
406
- const model = selected.base_url ? { ...catalogModel, baseUrl: selected.base_url } : catalogModel;
407
- const enabled = input.config.tools?.enabled;
408
- const blocked = input.config.tools?.blocked ?? [];
409
- const state = { shutdownRequested: false };
410
- const { session } = await pi.createAgentSession({
411
- cwd: workspace,
412
- agentDir,
413
- model,
414
- modelRuntime,
415
- resourceLoader,
416
- sessionManager: pi.SessionManager.inMemory(workspace),
417
- settingsManager: settings,
418
- customTools,
419
- tools: enabled === null ? undefined : enabled,
420
- excludeTools: blocked,
441
+ const model = withCustomBaseUrl(catalogModel, selected.base_url, !relayEnabled);
442
+ const compactionReserveTokens = modelAwareCompactionReserveTokens(settings.getCompactionReserveTokens(), model.maxTokens, model.contextWindow);
443
+ settings.applyOverrides({
444
+ compaction: {
445
+ reserveTokens: compactionReserveTokens,
446
+ },
421
447
  });
422
- const handle = new PiSdkSessionHandle(session, state);
448
+ let relay;
449
+ let handle;
423
450
  try {
451
+ relay = await this.relayFactory.start(input, {
452
+ api: model.api,
453
+ baseUrl: model.baseUrl,
454
+ });
455
+ if (relay !== undefined && !extensionPaths.includes(relay.extensionPath)) {
456
+ extensionPaths.push(relay.extensionPath);
457
+ }
458
+ const agentDir = join(workspace, ".fabric-pi");
459
+ const resourceLoader = new pi.DefaultResourceLoader({
460
+ cwd: workspace,
461
+ agentDir,
462
+ settingsManager: settings,
463
+ additionalExtensionPaths: extensionPaths,
464
+ additionalSkillPaths: skillPaths,
465
+ noExtensions: true,
466
+ noSkills: true,
467
+ noPromptTemplates: true,
468
+ noThemes: true,
469
+ noContextFiles: true,
470
+ systemPrompt: systemInstruction?.content,
471
+ });
472
+ await resourceLoader.reload();
473
+ const extensionResult = resourceLoader.getExtensions();
474
+ rejectToolCollisions(customTools, extensionResult.extensions.flatMap((extension) => [...extension.tools.keys()]));
475
+ const extensionErrors = extensionResult.errors;
476
+ const relayErrors = relayExtensionLoadErrors(extensionErrors, extensionResult.extensions, relay, workspace);
477
+ if (relayErrors.length > 0) {
478
+ throw new LifecycleError("pi_relay_extension_load_failed", "The configured NeMo Relay Pi extension failed to load; use the Pi extension from the NeMo Relay 0.9 release", {
479
+ metadata: {
480
+ count: relayErrors.length,
481
+ relay_error: relayErrors.map((error) => error.error).join("; "),
482
+ },
483
+ });
484
+ }
485
+ if (extensionErrors.length > 0) {
486
+ throw new LifecycleError("pi_extension_load_failed", "One or more configured Pi extensions failed to load or conflict", {
487
+ metadata: {
488
+ count: extensionErrors.length,
489
+ extension_error: extensionErrors.map((error) => error.error).join("; "),
490
+ extension_paths: extensionErrors.map((error) => error.path),
491
+ },
492
+ });
493
+ }
494
+ const skillDiagnostics = resourceLoader.getSkills().diagnostics;
495
+ const blockingSkillDiagnostics = skillDiagnostics.filter((diagnostic) => diagnostic.type === "error" || diagnostic.type === "collision");
496
+ for (const diagnostic of skillDiagnostics.filter((entry) => entry.type === "warning")) {
497
+ process.stderr.write(`Pi skill warning: ${diagnostic.message}\n`);
498
+ }
499
+ if (blockingSkillDiagnostics.length > 0) {
500
+ throw new LifecycleError("pi_skill_load_failed", "One or more configured NeMo Fabric skills failed to load", {
501
+ metadata: { count: blockingSkillDiagnostics.length },
502
+ });
503
+ }
504
+ const enabled = input.config.tools?.enabled;
505
+ const blocked = input.config.tools?.blocked ?? [];
506
+ const state = { shutdownRequested: false };
507
+ const { session } = await pi.createAgentSession({
508
+ cwd: workspace,
509
+ agentDir,
510
+ model,
511
+ modelRuntime,
512
+ resourceLoader,
513
+ sessionManager: pi.SessionManager.inMemory(workspace),
514
+ settingsManager: settings,
515
+ customTools,
516
+ tools: enabled === null ? undefined : enabled,
517
+ excludeTools: blocked,
518
+ });
519
+ handle = new PiSdkSessionHandle(session, state, relay);
424
520
  const blockedNames = new Set(blocked);
425
521
  const availableNames = new Set(session.getAllTools().map((tool) => tool.name));
426
522
  const missing = (enabled ?? []).filter((name) => !blockedNames.has(name) && !availableNames.has(name));
@@ -451,15 +547,17 @@ export class PiSdkSessionFactory {
451
547
  process.stderr.write("Pi extension handler failed\n");
452
548
  },
453
549
  });
550
+ // bindExtensions emits session_start; emitting it here would create a
551
+ // duplicate Relay session scope.
454
552
  return handle;
455
553
  }
456
554
  catch (error) {
457
- try {
458
- await handle.stop();
459
- }
460
- catch {
555
+ await handle?.stop().catch(() => {
461
556
  process.stderr.write("Pi session cleanup failed after adapter startup error\n");
462
- }
557
+ });
558
+ await relay?.stop().catch(() => {
559
+ process.stderr.write("NeMo Relay cleanup failed after Pi adapter startup error\n");
560
+ });
463
561
  throw error;
464
562
  }
465
563
  }
@@ -0,0 +1,35 @@
1
+ import type { Dirent } from "node:fs";
2
+ import type { AdapterStartInput } from "nemo-fabric-adapters-common";
3
+ export type RelayPluginConfig = Record<string, unknown>;
4
+ export interface RelayConfigPaths {
5
+ configPath: string;
6
+ pluginConfigPath: string;
7
+ }
8
+ export interface RelayArtifact {
9
+ kind: "atof" | "atif";
10
+ path: string;
11
+ }
12
+ export interface RelayAtifMatcher {
13
+ directory: string;
14
+ local: boolean;
15
+ pattern: RegExp;
16
+ recursive: boolean;
17
+ template: string;
18
+ }
19
+ type ReadDirectory = (directory: string, options: {
20
+ withFileTypes: true;
21
+ }) => Promise<Dirent[]>;
22
+ export declare function validateRelayObservabilityV3(pluginConfig: RelayPluginConfig): void;
23
+ export declare function normalizeRelayOutputDirs(pluginConfig: RelayPluginConfig, input: AdapterStartInput): Promise<void>;
24
+ export declare function loadRelayPluginConfig(input: AdapterStartInput): Promise<RelayPluginConfig>;
25
+ export declare function encodeToml(value: Record<string, unknown>): string;
26
+ export declare function writeRelayConfigs(pluginConfig: RelayPluginConfig): Promise<RelayConfigPaths>;
27
+ export declare function matchesRelayAtifPath(matcher: RelayAtifMatcher, path: string): boolean;
28
+ export declare function prepareRelayAtifMatchers(pluginConfig: RelayPluginConfig): Promise<RelayAtifMatcher[]>;
29
+ export declare function collectAtifArtifacts(matchers: RelayAtifMatcher[], options?: {
30
+ localOnly?: boolean;
31
+ readDirectory?: ReadDirectory;
32
+ strict?: boolean;
33
+ }): Promise<RelayArtifact[]>;
34
+ export declare function collectRelayArtifacts(pluginConfig: RelayPluginConfig, atifMatchers?: RelayAtifMatcher[]): Promise<RelayArtifact[]>;
35
+ export {};