From 758eb004808b4d239daadae448e0610d90c90984 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Fri, 17 Jul 2026 15:31:00 -0700 Subject: [PATCH 01/34] feat(core): add persistent local adapter lifecycle Signed-off-by: Ajay Thorve --- adapters/common/README.md | 33 + .../nemo_fabric_adapters/common/lifecycle.py | 325 ++++++ crates/fabric-core/src/config.rs | 138 +++ crates/fabric-core/src/error.rs | 21 + crates/fabric-core/src/lib.rs | 17 +- crates/fabric-core/src/runtime.rs | 943 +++++++++++++++++- schemas/SCHEMA.md | 3 +- schemas/adapter-descriptor.schema.json | 37 + schemas/run-plan.schema.json | 37 + .../test_adapters_common_lifecycle.py | 275 +++++ tests/python/test_environment_handle.py | 13 +- 11 files changed, 1821 insertions(+), 21 deletions(-) create mode 100644 adapters/common/src/nemo_fabric_adapters/common/lifecycle.py create mode 100644 tests/adapters/test_adapters_common_lifecycle.py diff --git a/adapters/common/README.md b/adapters/common/README.md index 5f95d95a..d5fcbbc4 100644 --- a/adapters/common/README.md +++ b/adapters/common/README.md @@ -21,6 +21,39 @@ Alternately through the NeMo Fabric metapackage: pip install "nemo-fabric[adapters-common]" ``` +## Persistent Local Hosts + +Adapters that declare `runtime.local_host` in `fabric-adapter.json` implement +the versioned lifecycle contract with +`nemo_fabric_adapters.common.lifecycle`. Supply a factory that creates one +adapter-owned runtime with asynchronous `start`, `invoke`, and `stop` methods: + +```python +from nemo_fabric_adapters.common import lifecycle + + +class AdapterRuntime: + async def start(self, payload): + self.client = await connect_client(payload) + + async def invoke(self, payload): + return await self.client.run(payload["request"]["input"]) + + async def stop(self): + await self.client.close() + + +if lifecycle.is_lifecycle_host(): + lifecycle.serve(AdapterRuntime) +``` + +Fabric creates one factory instance per local host and serializes invocations +through it. The host keeps one event loop alive for the complete lifecycle so +SDK clients, compiled graphs, checkpointers, and harness databases can remain +live safely. Adapter stdout is reserved for the protocol; diagnostics are +redirected to stderr. A host crash is terminal for that runtime and never falls +back to per-invocation execution. + Refer to the [NeMo Fabric documentation](https://nvidia-nemo-fabric.docs.buildwithfern.com/nemo/fabric) for adapter and configuration guidance. Source code is available in the [NVIDIA NeMo Fabric repository](https://github.com/NVIDIA/nemo-fabric/). diff --git a/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py b/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py new file mode 100644 index 00000000..bfaf38d2 --- /dev/null +++ b/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py @@ -0,0 +1,325 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Versioned host protocol for persistent local adapter runtimes.""" + +from __future__ import annotations + +import asyncio +import json +import os +import sys +import traceback +from collections.abc import Callable +from collections.abc import Iterator +from collections.abc import Mapping +from collections.abc import Awaitable +from contextlib import contextmanager +from contextlib import redirect_stdout +from typing import Any +from typing import Protocol +from typing import TextIO + + +CONTRACT_VERSION = "fabric.adapter.lifecycle/v1alpha1" +CONTRACT_ENV = "FABRIC_ADAPTER_LIFECYCLE_CONTRACT" + + +class AdapterRuntime(Protocol): + """One adapter-owned runtime living for the complete host lifetime.""" + + async def start(self, payload: dict[str, Any]) -> None: + """Initialize runtime-owned SDK clients and resources.""" + + async def invoke(self, payload: dict[str, Any]) -> Any: + """Execute one invocation against the initialized runtime.""" + + async def stop(self) -> None: + """Release all resources owned by the runtime.""" + + +RuntimeFactory = Callable[[], AdapterRuntime] + + +class LifecycleError(Exception): + """Adapter-supplied lifecycle failure safe to return across the protocol.""" + + def __init__( + self, + code: str, + message: str, + *, + retryable: bool = False, + metadata: Mapping[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.code = code + self.message = message + self.retryable = retryable + self.metadata = dict(metadata or {}) + + +def is_lifecycle_host(environ: Mapping[str, str] = os.environ) -> bool: + """Return whether Fabric requested the persistent local-host protocol.""" + + return environ.get(CONTRACT_ENV) == CONTRACT_VERSION + + +def _error( + stage: str, + code: str, + message: str, + *, + retryable: bool = False, + metadata: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + error: dict[str, Any] = { + "stage": stage, + "code": code, + "message": message, + "retryable": retryable, + } + if metadata: + error["metadata"] = dict(metadata) + return error + + +def _response( + operation: str, + *, + output: Any = None, + error: dict[str, Any] | None = None, +) -> dict[str, Any]: + outcome = ( + {"status": "succeeded", "output": output} + if error is None + else {"status": "failed", "error": error} + ) + return { + "contract_version": CONTRACT_VERSION, + "operation": operation, + "outcome": outcome, + } + + +def _runtime_id(operation: str, payload: dict[str, Any]) -> str | None: + if operation in {"start", "invoke"}: + value = (payload.get("runtime_context") or {}).get("runtime_id") + else: + value = payload.get("runtime_id") + return value if isinstance(value, str) and value else None + + +@contextmanager +def _invocation_environment(payload: dict[str, Any]) -> Iterator[None]: + telemetry = (payload.get("runtime_context") or {}).get("telemetry") or {} + overlay = telemetry.get("env") if isinstance(telemetry, dict) else None + if not isinstance(overlay, dict) or any( + not isinstance(key, str) or not isinstance(value, str) + for key, value in overlay.items() + ): + overlay = {} + previous = {key: os.environ.get(key) for key in overlay} + os.environ.update(overlay) + try: + yield + finally: + for key, value in previous.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +async def _adapter_call(operation: str, call: Callable[[], Awaitable[Any]]) -> Any: + try: + # Protocol stdout is reserved for exactly one JSON response per line. + # Keep incidental adapter and library output as host diagnostics. + with redirect_stdout(sys.stderr): + return await call() + except LifecycleError: + raise + except Exception as error: + traceback.print_exc(file=sys.stderr) + raise LifecycleError( + f"lifecycle_adapter_{operation}_failed", + f"Adapter failed during lifecycle {operation}", + ) from error + + +def _failure_response(operation: str, error: LifecycleError) -> dict[str, Any]: + return _response( + operation, + error=_error( + operation, + error.code, + error.message, + retryable=error.retryable, + metadata=error.metadata, + ), + ) + + +async def _stop_after_eof(runtime: AdapterRuntime) -> None: + try: + await _adapter_call("stop", runtime.stop) + except LifecycleError: + traceback.print_exc(file=sys.stderr) + + +async def _serve( + runtime_factory: RuntimeFactory, + *, + input_stream: TextIO, + output_stream: TextIO, +) -> None: + runtime: AdapterRuntime | None = None + active_runtime_id: str | None = None + runtime_failed = False + try: + while True: + # Keep this event loop alive while idle. Persistent SDK clients such + # as ClaudeSDKClient own background tasks tied to this exact loop. + line = await asyncio.to_thread(input_stream.readline) + if not line: + break + + operation = "start" + should_stop = False + try: + message = json.loads(line) + if not isinstance(message, dict): + raise TypeError("lifecycle request must be a mapping") + raw_operation = message.get("operation") + operation = raw_operation if isinstance(raw_operation, str) else "start" + if operation not in {"start", "invoke", "stop"}: + raise LifecycleError( + "lifecycle_invalid_operation", + "Unknown lifecycle operation", + ) + if message.get("contract_version") != CONTRACT_VERSION: + raise LifecycleError( + "lifecycle_contract_mismatch", + f"Expected lifecycle contract {CONTRACT_VERSION}", + ) + payload = message.get("payload") + if not isinstance(payload, dict): + raise LifecycleError( + "lifecycle_invalid_payload", + "Lifecycle payload must be a mapping", + ) + message_runtime_id = _runtime_id(operation, payload) + if message_runtime_id is None: + raise LifecycleError( + "lifecycle_invalid_runtime", + "Lifecycle payload is missing a runtime ID", + ) + + if operation == "start": + if runtime is not None: + raise LifecycleError( + "lifecycle_already_started", + "Lifecycle host already owns a runtime", + ) + candidate = runtime_factory() + try: + await _adapter_call("start", lambda: candidate.start(payload)) + except LifecycleError: + await _stop_after_eof(candidate) + raise + runtime = candidate + active_runtime_id = message_runtime_id + runtime_failed = False + response = _response(operation) + else: + if runtime is None or active_runtime_id is None: + raise LifecycleError( + "lifecycle_not_started", + "Lifecycle host has not started a runtime", + ) + if message_runtime_id != active_runtime_id: + raise LifecycleError( + "lifecycle_runtime_mismatch", + "Lifecycle payload does not match the active runtime", + ) + if operation == "invoke": + if runtime_failed: + raise LifecycleError( + "lifecycle_runtime_failed", + "Lifecycle runtime cannot accept another invocation", + ) + with _invocation_environment(payload): + output = await _adapter_call( + "invoke", lambda: runtime.invoke(payload) + ) + response = _response(operation, output=output) + else: + try: + await _adapter_call("stop", runtime.stop) + finally: + runtime = None + active_runtime_id = None + runtime_failed = False + should_stop = True + response = _response(operation) + except LifecycleError as error: + if operation == "invoke" and runtime is not None: + runtime_failed = True + response = _failure_response(operation, error) + should_stop = should_stop or operation in {"start", "stop"} + except Exception as error: + traceback.print_exc(file=sys.stderr) + if operation == "invoke" and runtime is not None: + runtime_failed = True + response = _response( + operation, + error=_error( + operation, + "lifecycle_invalid_request", + "Invalid lifecycle request", + ), + ) + + try: + encoded = json.dumps(response, sort_keys=True) + except (TypeError, ValueError): + traceback.print_exc(file=sys.stderr) + if operation == "invoke" and runtime is not None: + runtime_failed = True + encoded = json.dumps( + _response( + operation, + error=_error( + operation, + "lifecycle_invalid_response", + "Adapter returned a non-JSON lifecycle response", + ), + ), + sort_keys=True, + ) + print(encoded, file=output_stream, flush=True) + if should_stop: + break + finally: + if runtime is not None: + await _stop_after_eof(runtime) + + +def serve( + runtime_factory: RuntimeFactory, + *, + input_stream: TextIO = sys.stdin, + output_stream: TextIO = sys.stdout, +) -> None: + """Serve ordered lifecycle requests for exactly one Fabric runtime.""" + + # Reserve process stdout for the protocol for the entire host lifetime, + # including SDK background tasks running while the host is idle. + with redirect_stdout(sys.stderr): + asyncio.run( + _serve( + runtime_factory, + input_stream=input_stream, + output_stream=output_stream, + ) + ) diff --git a/crates/fabric-core/src/config.rs b/crates/fabric-core/src/config.rs index 49b5592d..546bbf2f 100644 --- a/crates/fabric-core/src/config.rs +++ b/crates/fabric-core/src/config.rs @@ -15,6 +15,8 @@ use crate::error::{FabricError, Result}; /// Adapter descriptor contract version supported by this core. pub const ADAPTER_CONTRACT_VERSION: &str = "fabric.adapter/v1alpha1"; +/// Persistent local-host lifecycle contract version supported by this core. +pub const ADAPTER_LIFECYCLE_CONTRACT_VERSION: &str = "fabric.adapter.lifecycle/v1alpha1"; /// Versioned Fabric agent config. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] @@ -119,6 +121,9 @@ pub struct AdapterDescriptor { /// Telemetry support declared by this adapter. #[serde(default)] pub telemetry: AdapterTelemetrySupport, + /// Adapter-owned runtime hosting support. + #[serde(default, skip_serializing_if = "AdapterRuntimeSupport::is_empty")] + pub runtime: AdapterRuntimeSupport, /// Runtime lifecycle operations supported by this adapter. #[serde(default)] pub capabilities: RuntimeCapabilities, @@ -127,6 +132,34 @@ pub struct AdapterDescriptor { pub extensions: BTreeMap, } +/// Runtime hosting mechanisms implemented by an adapter. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct AdapterRuntimeSupport { + /// Versioned persistent local-host support, when implemented. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local_host: Option, + /// Additive adapter runtime-hosting fields. + #[serde(default, flatten)] + pub extensions: BTreeMap, +} + +impl AdapterRuntimeSupport { + fn is_empty(&self) -> bool { + self.local_host.is_none() && self.extensions.is_empty() + } +} + +/// Persistent local-host protocol implemented by an adapter. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct AdapterLocalHostSupport { + /// Adapter lifecycle protocol version spoken over the host transport. + #[schemars(length(min = 1))] + pub contract_version: String, + /// Additive persistent local-host fields. + #[serde(default, flatten)] + pub extensions: BTreeMap, +} + /// Where Fabric resolved an adapter descriptor from. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] @@ -1094,6 +1127,27 @@ fn validate_adapter_descriptor_shape(descriptor: &AdapterDescriptor, path: &Path if descriptor.harness.trim().is_empty() { return invalid_adapter_descriptor(path, "harness must not be empty"); } + if let Some(local_host) = descriptor.runtime.local_host.as_ref() { + if local_host.contract_version.trim().is_empty() { + return invalid_adapter_descriptor( + path, + "runtime.local_host.contract_version must not be empty", + ); + } + if local_host.contract_version != ADAPTER_LIFECYCLE_CONTRACT_VERSION { + return Err(FabricError::AdapterDescriptorUnsupported { + adapter_id: descriptor.adapter_id.clone(), + field: "runtime.local_host.contract_version", + value: local_host.contract_version.clone(), + }); + } + if descriptor.adapter_kind != AdapterKind::Python { + return invalid_adapter_descriptor( + path, + "runtime.local_host is currently supported only by python adapters", + ); + } + } Ok(()) } @@ -1712,4 +1766,88 @@ mod tests { assert_eq!(descriptor.contract_version, ADAPTER_CONTRACT_VERSION); assert_eq!(descriptor.adapter_kind, AdapterKind::Python); } + + #[test] + fn repository_adapters_declare_versioned_local_host() { + for harness in ["claude", "codex", "deepagents", "hermes"] { + let descriptor = load_adapter_descriptor( + repository_adapter_dir().join(format!("{harness}/fabric-adapter.json")), + ) + .unwrap_or_else(|error| panic!("{harness} adapter descriptor: {error}")); + + assert_eq!( + descriptor + .runtime + .local_host + .as_ref() + .map(|host| host.contract_version.as_str()), + Some(ADAPTER_LIFECYCLE_CONTRACT_VERSION), + "{harness} descriptor", + ); + } + } + + #[test] + fn rejects_unsupported_local_host_contract_version() { + let root = std::env::temp_dir().join(format!( + "fabric-invalid-local-host-contract-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("create temp root"); + let descriptor_path = root.join("fabric-adapter.json"); + std::fs::write( + &descriptor_path, + r#"{ + "contract_version": "fabric.adapter/v1alpha1", + "adapter_id": "acme.fabric.future-host", + "harness": "future-host", + "adapter_kind": "python", + "runtime": { + "local_host": {"contract_version": "fabric.adapter.lifecycle/v9"} + } +}"#, + ) + .expect("write adapter descriptor"); + + let error = load_adapter_descriptor(&descriptor_path).expect_err("invalid descriptor"); + assert!(matches!( + error, + FabricError::AdapterDescriptorUnsupported { + field, + value, + .. + } if field == "runtime.local_host.contract_version" + && value == "fabric.adapter.lifecycle/v9" + )); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn rejects_local_host_on_non_python_adapter() { + let descriptor: AdapterDescriptor = serde_json::from_value(serde_json::json!({ + "contract_version": ADAPTER_CONTRACT_VERSION, + "adapter_id": "acme.fabric.process-host", + "harness": "process-host", + "adapter_kind": "process", + "runtime": { + "local_host": { + "contract_version": ADAPTER_LIFECYCLE_CONTRACT_VERSION, + } + }, + })) + .expect("adapter descriptor"); + + let error = validate_adapter_descriptor_shape( + &descriptor, + Path::new("process-host/fabric-adapter.json"), + ) + .expect_err("process adapter must not declare a Python local host"); + assert!(matches!( + error, + FabricError::InvalidAdapterDescriptor { message, .. } + if message.contains("only by python adapters") + )); + } } diff --git a/crates/fabric-core/src/error.rs b/crates/fabric-core/src/error.rs index 41140959..c986173f 100644 --- a/crates/fabric-core/src/error.rs +++ b/crates/fabric-core/src/error.rs @@ -82,6 +82,27 @@ pub enum FabricError { /// Adapter kind. adapter_kind: AdapterKind, }, + /// A versioned persistent local-host lifecycle operation failed. + #[error( + "adapter lifecycle {operation} failed for runtime `{runtime_id}` ({code}): {message}{diagnostics_suffix}", + diagnostics_suffix = if diagnostics.is_empty() { + String::new() + } else { + format!("; diagnostics: {diagnostics}") + } + )] + AdapterLifecycleOperation { + /// Lifecycle operation that failed. + operation: &'static str, + /// Runtime whose host failed. + runtime_id: String, + /// Stable failure code. + code: String, + /// Human-readable failure message. + message: String, + /// Bounded adapter-host diagnostics. + diagnostics: String, + }, /// The selected harness cannot enforce the configured blocked-tools policy. #[error("harness `{harness}` cannot enforce configured blocked tools: {reason}")] UnsupportedToolsPolicy { diff --git a/crates/fabric-core/src/lib.rs b/crates/fabric-core/src/lib.rs index 2d55cc98..69a7af67 100644 --- a/crates/fabric-core/src/lib.rs +++ b/crates/fabric-core/src/lib.rs @@ -10,14 +10,15 @@ pub mod runtime; pub mod schema; pub use config::{ - ADAPTER_CONTRACT_VERSION, AdapterConfigSupport, AdapterDescriptor, AdapterDescriptorSource, - AdapterKind, AdapterRequirements, AdapterTelemetryProviderSupport, AdapterTelemetrySupport, - CapabilityPlan, ControlLocation, EnvironmentConfig, EnvironmentOwnership, EnvironmentPlan, - FabricConfig, HarnessConfig, McpConfig, McpExposure, McpServerPlan, MetadataConfig, - ModelConfig, ResolutionStrategy, ResolveContext, ResolvedAdapterDescriptor, RunPlan, - RuntimeCapabilities, RuntimeConfig, SkillConfig, TelemetryConfig, TelemetryPlan, - TelemetryProvider, TelemetryProviderConfig, load_adapter_descriptor, - resolve_run_plan_from_config, + ADAPTER_CONTRACT_VERSION, ADAPTER_LIFECYCLE_CONTRACT_VERSION, AdapterConfigSupport, + AdapterDescriptor, AdapterDescriptorSource, AdapterKind, AdapterLocalHostSupport, + AdapterRequirements, AdapterRuntimeSupport, AdapterTelemetryProviderSupport, + AdapterTelemetrySupport, CapabilityPlan, ControlLocation, EnvironmentConfig, + EnvironmentOwnership, EnvironmentPlan, FabricConfig, HarnessConfig, McpConfig, McpExposure, + McpServerPlan, MetadataConfig, ModelConfig, ResolutionStrategy, ResolveContext, + ResolvedAdapterDescriptor, RunPlan, RuntimeCapabilities, RuntimeConfig, SkillConfig, + TelemetryConfig, TelemetryPlan, TelemetryProvider, TelemetryProviderConfig, + load_adapter_descriptor, resolve_run_plan_from_config, }; pub use doctor::{DoctorCheck, DoctorReport, DoctorStatus, doctor_plan}; pub use error::{FabricError, Result}; diff --git a/crates/fabric-core/src/runtime.rs b/crates/fabric-core/src/runtime.rs index 8c07fd40..cb1502e4 100644 --- a/crates/fabric-core/src/runtime.rs +++ b/crates/fabric-core/src/runtime.rs @@ -5,27 +5,33 @@ use std::collections::BTreeMap; use std::ffi::OsString; -use std::io::{ErrorKind, Write}; +use std::fs::File; +use std::io::{BufRead, BufReader, ErrorKind, Write}; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; -#[cfg(test)] -use std::sync::Mutex; +use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; +use std::sync::{Arc, LazyLock, Mutex}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use crate::config::{ - AdapterKind, CapabilityKind, CapabilityPlan, CapabilityTarget, ControlLocation, - EnvironmentOwnership, FabricConfig, RunPlan, TelemetryPlan, + ADAPTER_LIFECYCLE_CONTRACT_VERSION, AdapterKind, CapabilityKind, CapabilityPlan, + CapabilityTarget, ControlLocation, EnvironmentOwnership, FabricConfig, RunPlan, TelemetryPlan, }; use crate::error::{FabricError, Result}; static NEXT_ID: AtomicU64 = AtomicU64::new(1); const ADAPTER_PYTHON_ENV: &str = "ADAPTER_PYTHON"; const VIRTUAL_ENV_ENV: &str = "VIRTUAL_ENV"; +const LOCAL_HOST_START_TIMEOUT: Duration = Duration::from_secs(90); +const LOCAL_HOST_STOP_TIMEOUT: Duration = Duration::from_secs(10); +const LOCAL_HOST_EXIT_GRACE: Duration = Duration::from_secs(2); +const LOCAL_HOST_DIAGNOSTIC_LIMIT: usize = 16 * 1024; #[cfg(not(windows))] const VENV_BIN_DIR: &str = "bin"; @@ -45,6 +51,8 @@ const DEFAULT_PYTHON: &str = "python3"; const DEFAULT_PYTHON: &str = "python.exe"; #[cfg(test)] static TEST_STOPPED_AGENTS: Mutex> = Mutex::new(Vec::new()); +static LOCAL_HOSTS: LazyLock>>>> = + LazyLock::new(|| Mutex::new(BTreeMap::new())); /// A request passed to a Fabric-managed harness runtime. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)] @@ -327,6 +335,105 @@ pub struct AdapterInvocation { pub telemetry_plan: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum AdapterLifecycleOperation { + Start, + Invoke, + Stop, +} + +impl AdapterLifecycleOperation { + fn as_str(self) -> &'static str { + match self { + Self::Start => "start", + Self::Invoke => "invoke", + Self::Stop => "stop", + } + } + + fn error_stage(self) -> ErrorStage { + match self { + Self::Start => ErrorStage::Start, + Self::Invoke => ErrorStage::Invoke, + Self::Stop => ErrorStage::Stop, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +struct AdapterLifecycleStart { + agent_name: String, + base_dir: PathBuf, + config: FabricConfig, + runtime_context: RuntimeContext, + capability_plan: CapabilityPlan, + #[serde(skip_serializing_if = "Option::is_none")] + telemetry_plan: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +struct AdapterLifecycleStop { + runtime_id: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "operation", content = "payload", rename_all = "snake_case")] +enum AdapterLifecycleRequestKind { + Start(AdapterLifecycleStart), + Invoke(AdapterInvocation), + Stop(AdapterLifecycleStop), +} + +impl AdapterLifecycleRequestKind { + fn operation(&self) -> AdapterLifecycleOperation { + match self { + Self::Start(_) => AdapterLifecycleOperation::Start, + Self::Invoke(_) => AdapterLifecycleOperation::Invoke, + Self::Stop(_) => AdapterLifecycleOperation::Stop, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +struct AdapterLifecycleRequest { + contract_version: String, + #[serde(flatten)] + request: AdapterLifecycleRequestKind, +} + +impl AdapterLifecycleRequest { + fn new(request: AdapterLifecycleRequestKind) -> Self { + Self { + contract_version: ADAPTER_LIFECYCLE_CONTRACT_VERSION.to_string(), + request, + } + } + + fn operation(&self) -> AdapterLifecycleOperation { + self.request.operation() + } +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +enum AdapterLifecycleOutcome { + Succeeded { + #[serde(default)] + output: Value, + }, + Failed { + error: ErrorInfo, + }, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +struct AdapterLifecycleResponse { + contract_version: String, + operation: AdapterLifecycleOperation, + outcome: AdapterLifecycleOutcome, +} + trait RuntimeAdapter { fn start(&self, plan: &RunPlan, environment: EnvironmentHandle) -> Result; fn invoke( @@ -340,6 +447,7 @@ trait RuntimeAdapter { struct ProcessAdapter; struct PythonAdapter; +struct LocalHostAdapter; #[derive(Debug, Clone)] struct RelayRuntimeConfig { @@ -347,6 +455,18 @@ struct RelayRuntimeConfig { env: BTreeMap, } +struct LocalAdapterHost { + child: Child, + stdin: ChildStdin, + responses: Receiver>, + command: String, + runtime_dir: PathBuf, + stderr_path: PathBuf, + stderr_offset: usize, + artifacts: ArtifactManifest, + relay_config: Option, +} + /// Invoke a Fabric run plan. pub fn run_plan(plan: &RunPlan, request: RunRequest) -> Result { let runtime = start_runtime(plan)?; @@ -430,6 +550,9 @@ pub fn prepare_environment(plan: &RunPlan) -> Result { pub fn start_runtime(plan: &RunPlan) -> Result { validate_blocked_tools_support(plan)?; let environment = prepare_environment(plan)?; + if uses_local_host(plan)? { + return LocalHostAdapter.start(plan, environment); + } match adapter_kind(plan) { AdapterKind::Process => ProcessAdapter.start(plan, environment), AdapterKind::Python => PythonAdapter.start(plan, environment), @@ -448,6 +571,9 @@ pub fn invoke_runtime( ) -> Result { validate_blocked_tools_support(plan)?; validate_runtime_handle(plan, runtime)?; + if uses_local_host(plan)? { + return LocalHostAdapter.invoke(plan, runtime, request); + } match adapter_kind(plan) { AdapterKind::Process => ProcessAdapter.invoke(plan, runtime, request), AdapterKind::Python => PythonAdapter.invoke(plan, runtime, request), @@ -473,6 +599,9 @@ fn validate_blocked_tools_support(plan: &RunPlan) -> Result<()> { /// Stop or detach from a harness runtime. pub fn stop_runtime(plan: &RunPlan, runtime: &RuntimeHandle) -> Result> { validate_runtime_handle(plan, runtime)?; + if uses_local_host(plan)? { + return LocalHostAdapter.stop(runtime); + } match runtime.adapter_kind { AdapterKind::Process => ProcessAdapter.stop(runtime), AdapterKind::Python => PythonAdapter.stop(runtime), @@ -483,6 +612,24 @@ pub fn stop_runtime(plan: &RunPlan, runtime: &RuntimeHandle) -> Result Result { + let local_host = plan + .adapter_descriptor + .as_ref() + .and_then(|adapter| adapter.descriptor.runtime.local_host.as_ref()); + let Some(local_host) = local_host else { + return Ok(false); + }; + if local_host.contract_version != ADAPTER_LIFECYCLE_CONTRACT_VERSION { + return Err(FabricError::AdapterDescriptorUnsupported { + adapter_id: adapter_id(plan).unwrap_or_else(|| harness(plan)), + field: "runtime.local_host.contract_version", + value: local_host.contract_version.clone(), + }); + } + Ok(true) +} + fn validate_runtime_handle(plan: &RunPlan, runtime: &RuntimeHandle) -> Result<()> { let expected_binding = runtime_binding(&runtime.runtime_id, plan, &runtime.environment)?; expect_runtime_field( @@ -756,6 +903,788 @@ impl RuntimeAdapter for PythonAdapter { } } +impl RuntimeAdapter for LocalHostAdapter { + fn start(&self, plan: &RunPlan, environment: EnvironmentHandle) -> Result { + if environment.provider != "local" { + return Err(FabricError::UnsupportedEnvironmentProvider { + provider: environment.provider, + adapter_kind: adapter_kind(plan), + }); + } + if adapter_kind(plan) != AdapterKind::Python { + return Err(FabricError::UnsupportedRuntimeAdapter { + harness: harness(plan), + adapter_kind: adapter_kind(plan), + }); + } + preflight_python_adapter(plan)?; + + let runtime_id = new_id("runtime"); + let runtime_binding = runtime_binding(&runtime_id, plan, &environment)?; + let runtime = RuntimeHandle { + runtime_id, + runtime_binding, + agent_name: plan.agent_name.clone(), + harness: harness(plan), + adapter_kind: adapter_kind(plan), + adapter_id: adapter_id(plan), + environment, + }; + + let start_request = RunRequest { + request_id: new_id("runtime-start-request"), + ..RunRequest::default() + }; + let start_invocation = InvocationHandle { + invocation_id: new_id("runtime-start"), + request_id: start_request.request_id.clone(), + runtime_id: runtime.runtime_id.clone(), + }; + let mut artifacts = artifact_manifest(plan)?; + let fabric_home = prepare_fabric_home(&artifacts, &runtime, &start_invocation)?; + let relay_config = prepare_relay_runtime_config( + plan, + &runtime, + &start_invocation, + &start_request, + &fabric_home, + &mut artifacts, + )?; + let AdapterInvocation { + agent_name, + base_dir, + config, + runtime_context, + capability_plan, + telemetry_plan, + .. + } = adapter_invocation( + plan, + &runtime, + &start_invocation, + &start_request, + &artifacts, + relay_config.as_ref(), + )?; + let request = AdapterLifecycleRequest::new(AdapterLifecycleRequestKind::Start( + AdapterLifecycleStart { + agent_name, + base_dir, + config, + runtime_context, + capability_plan, + telemetry_plan, + }, + )); + let mut host = spawn_local_host(plan, &runtime, artifacts, relay_config)?; + if let Err(error) = exchange_lifecycle_message( + &mut host, + &runtime.runtime_id, + &request, + Some(LOCAL_HOST_START_TIMEOUT), + ) { + let _ = terminate_local_host(&mut host); + let _ = remove_local_host_files(&host); + return Err(error); + } + + local_hosts().insert(runtime.runtime_id.clone(), Arc::new(Mutex::new(host))); + Ok(runtime) + } + + fn invoke( + &self, + plan: &RunPlan, + runtime: &RuntimeHandle, + request: RunRequest, + ) -> Result { + run_local_host_adapter(plan, runtime, request) + } + + fn stop(&self, runtime: &RuntimeHandle) -> Result> { + let Some(host) = local_hosts().remove(&runtime.runtime_id) else { + return Ok(vec![local_host_stop_event(runtime, true, false)]); + }; + let mut host = host.lock().unwrap_or_else(|error| error.into_inner()); + let request = + AdapterLifecycleRequest::new(AdapterLifecycleRequestKind::Stop(AdapterLifecycleStop { + runtime_id: runtime.runtime_id.clone(), + })); + let result = exchange_lifecycle_message( + &mut host, + &runtime.runtime_id, + &request, + Some(LOCAL_HOST_STOP_TIMEOUT), + ); + let termination = terminate_local_host(&mut host); + let diagnostics = local_host_diagnostics(&host); + let removal = remove_local_host_files(&host); + let host_crashed = matches!( + &result, + Err(FabricError::AdapterLifecycleOperation { code, .. }) + if code == "host_crashed" + ); + if !host_crashed { + result?; + } + termination.map_err(|source| { + lifecycle_error( + AdapterLifecycleOperation::Stop, + &runtime.runtime_id, + "host_termination_failed", + format!("persistent local adapter host could not be terminated: {source}"), + diagnostics, + ) + })?; + removal.map_err(|source| { + lifecycle_error( + AdapterLifecycleOperation::Stop, + &runtime.runtime_id, + "host_cleanup_failed", + format!("persistent local adapter host files could not be removed: {source}"), + "", + ) + })?; + + #[cfg(test)] + TEST_STOPPED_AGENTS + .lock() + .expect("stop tracker") + .push(runtime.agent_name.clone()); + Ok(vec![local_host_stop_event(runtime, false, host_crashed)]) + } +} + +fn run_local_host_adapter( + plan: &RunPlan, + runtime: &RuntimeHandle, + mut request: RunRequest, +) -> Result { + if request.request_id.is_empty() { + request.request_id = new_id("request"); + } + let invocation = InvocationHandle { + invocation_id: new_id("invocation"), + request_id: request.request_id.clone(), + runtime_id: runtime.runtime_id.clone(), + }; + let host = local_hosts() + .get(&runtime.runtime_id) + .cloned() + .ok_or_else(|| { + lifecycle_error( + AdapterLifecycleOperation::Invoke, + &runtime.runtime_id, + "host_unavailable", + "persistent local adapter host is not active", + "", + ) + })?; + + let ( + output, + stderr, + host_command, + host_pid, + mut artifacts, + relay_config, + fabric_home, + fabric_invocation, + ) = { + let mut host = host.lock().unwrap_or_else(|error| error.into_inner()); + let artifacts = host.artifacts.clone(); + let relay_config = host.relay_config.clone(); + let fabric_home = prepare_fabric_home(&artifacts, runtime, &invocation)?; + let adapter_invocation = adapter_invocation( + plan, + runtime, + &invocation, + &request, + &artifacts, + relay_config.as_ref(), + )?; + let adapter_payload = serde_json::to_string_pretty(&adapter_invocation) + .map_err(FabricError::SerializeJson)?; + let fabric_invocation = write_fabric_invocation(&fabric_home, &adapter_payload)?; + let lifecycle_request = + AdapterLifecycleRequest::new(AdapterLifecycleRequestKind::Invoke(adapter_invocation)); + let output = + exchange_lifecycle_message(&mut host, &runtime.runtime_id, &lifecycle_request, None)?; + let stderr = take_local_host_stderr(&mut host); + ( + output, + stderr, + host.command.clone(), + host.child.id(), + artifacts, + relay_config, + fabric_home, + fabric_invocation, + ) + }; + + let mut events = vec![event_with_metadata( + "runtime_start", + format!("started runtime {}", runtime.runtime_id), + BTreeMap::from([ + ( + "runtime_id".to_string(), + Value::String(runtime.runtime_id.clone()), + ), + ( + "environment_id".to_string(), + Value::String(runtime.environment.environment_id.clone()), + ), + ( + "environment_provider".to_string(), + Value::String(runtime.environment.provider.clone()), + ), + ("host_pid".to_string(), Value::from(host_pid)), + ]), + )]; + events.push(event_with_metadata( + "invocation_start", + format!("invoking persistent local host for {}", harness(plan)), + BTreeMap::from([ + ( + "runtime_id".to_string(), + Value::String(runtime.runtime_id.clone()), + ), + ( + "invocation_id".to_string(), + Value::String(invocation.invocation_id.clone()), + ), + ]), + )); + let (status, error) = adapter_output_status(&output); + events.push(event_with_metadata( + "invocation_end", + format!("persistent local host completed with status {status:?}"), + BTreeMap::from([ + ( + "runtime_id".to_string(), + Value::String(runtime.runtime_id.clone()), + ), + ( + "invocation_id".to_string(), + Value::String(invocation.invocation_id.clone()), + ), + ]), + )); + let mut stdout = serde_json::to_string(&output).map_err(FabricError::SerializeJson)?; + stdout.push('\n'); + write_artifact( + &mut artifacts, + &fabric_home, + "stdout", + "log", + "stdout.txt", + &stdout, + "text/plain", + )?; + if !stderr.is_empty() { + write_artifact( + &mut artifacts, + &fabric_home, + "stderr", + "log", + "stderr.txt", + &stderr, + "text/plain", + )?; + } + collect_workspace_artifacts(&mut artifacts, &fabric_home, runtime, &mut events)?; + promote_relay_artifacts_to_manifest(&output, &mut artifacts); + + let metadata = BTreeMap::from([ + ( + "adapter_runner".to_string(), + Value::String("persistent_local_host".to_string()), + ), + ("host_command".to_string(), Value::String(host_command)), + ("host_pid".to_string(), Value::from(host_pid)), + ( + "fabric_home".to_string(), + Value::String(fabric_home.to_string_lossy().into_owned()), + ), + ( + "fabric_invocation".to_string(), + Value::String(fabric_invocation.to_string_lossy().into_owned()), + ), + ( + "environment_provider".to_string(), + Value::String(runtime.environment.provider.clone()), + ), + ]); + Ok(RunResult { + agent_name: plan.agent_name.clone(), + harness: harness(plan), + adapter_kind: adapter_kind(plan), + adapter_id: adapter_id(plan), + runtime_id: invocation.runtime_id, + invocation_id: invocation.invocation_id, + request_id: request.request_id, + status, + output, + error, + artifacts, + telemetry: telemetry_ref(plan, relay_config.as_ref()), + events, + metadata, + }) +} + +fn adapter_output_status(output: &Value) -> (RunStatus, Option) { + let failed = output + .as_object() + .and_then(|output| output.get("failed")) + .and_then(Value::as_bool) + .unwrap_or(false); + if !failed { + return (RunStatus::Succeeded, None); + } + + let reported = output + .as_object() + .and_then(|output| output.get("error")) + .and_then(Value::as_object); + let code = reported + .and_then(|error| error.get("code")) + .and_then(Value::as_str) + .unwrap_or("adapter_reported_failure") + .to_string(); + let message = reported + .and_then(|error| error.get("message")) + .and_then(Value::as_str) + .unwrap_or("adapter reported an invocation failure") + .to_string(); + let retryable = reported + .and_then(|error| error.get("retryable")) + .and_then(Value::as_bool) + .unwrap_or(false); + let metadata = reported + .and_then(|error| error.get("metadata")) + .and_then(Value::as_object) + .map(|metadata| { + metadata + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect() + }) + .unwrap_or_default(); + ( + RunStatus::Failed, + Some(ErrorInfo { + stage: ErrorStage::Invoke, + code, + message, + retryable, + metadata, + }), + ) +} + +fn local_host_stop_event( + runtime: &RuntimeHandle, + already_stopped: bool, + host_crashed: bool, +) -> FabricEvent { + event_with_metadata( + "runtime_stop", + format!("stopped runtime {}", runtime.runtime_id), + BTreeMap::from([ + ( + "runtime_id".to_string(), + Value::String(runtime.runtime_id.clone()), + ), + ("already_stopped".to_string(), Value::Bool(already_stopped)), + ("host_crashed".to_string(), Value::Bool(host_crashed)), + ]), + ) +} + +fn local_hosts() -> std::sync::MutexGuard<'static, BTreeMap>>> { + LOCAL_HOSTS + .lock() + .unwrap_or_else(|error| error.into_inner()) +} + +fn spawn_local_host( + plan: &RunPlan, + runtime: &RuntimeHandle, + artifacts: ArtifactManifest, + relay_config: Option, +) -> Result { + let runtime_dir = std::env::temp_dir() + .join("nemo-fabric") + .join("hosts") + .join(&runtime.runtime_id); + std::fs::create_dir_all(&runtime_dir).map_err(|source| FabricError::Write { + path: runtime_dir.clone(), + source, + })?; + let stderr_path = runtime_dir.join("host.stderr.log"); + let stderr = File::create(&stderr_path).map_err(|source| FabricError::Write { + path: stderr_path.clone(), + source, + })?; + let (mut command, command_display) = match local_host_command(plan, runtime) { + Ok(command) => command, + Err(error) => { + let _ = std::fs::remove_dir_all(&runtime_dir); + return Err(error); + } + }; + command + .env( + "FABRIC_ADAPTER_LIFECYCLE_CONTRACT", + ADAPTER_LIFECYCLE_CONTRACT_VERSION, + ) + .env("FABRIC_RUNTIME_ID", &runtime.runtime_id) + .env("FABRIC_HOME", &runtime_dir) + .envs(relay_env(&relay_config)) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::from(stderr)); + if let Some(root) = artifacts.root.as_ref() { + command.env("FABRIC_ARTIFACTS", root); + } + let mut child = match command.spawn() { + Ok(child) => child, + Err(source) => { + let _ = std::fs::remove_dir_all(&runtime_dir); + return Err(FabricError::ProcessRunner { + command: command_display, + source, + }); + } + }; + let Some(stdin) = child.stdin.take() else { + let _ = child.kill(); + let _ = child.wait(); + let _ = std::fs::remove_dir_all(&runtime_dir); + return Err(lifecycle_error( + AdapterLifecycleOperation::Start, + &runtime.runtime_id, + "host_io", + "persistent local adapter host stdin was not available", + "", + )); + }; + let Some(stdout) = child.stdout.take() else { + let _ = child.kill(); + let _ = child.wait(); + let _ = std::fs::remove_dir_all(&runtime_dir); + return Err(lifecycle_error( + AdapterLifecycleOperation::Start, + &runtime.runtime_id, + "host_io", + "persistent local adapter host stdout was not available", + "", + )); + }; + let (sender, responses) = mpsc::channel(); + if let Err(source) = thread::Builder::new() + .name(format!("fabric-host-{}", runtime.runtime_id)) + .spawn(move || { + let mut stdout = BufReader::new(stdout); + loop { + let mut line = String::new(); + match stdout.read_line(&mut line) { + Ok(0) => break, + Ok(_) => { + while line.ends_with(['\n', '\r']) { + line.pop(); + } + if sender.send(Ok(line)).is_err() { + break; + } + } + Err(error) => { + let _ = sender.send(Err(error.to_string())); + break; + } + } + } + }) + { + let _ = child.kill(); + let _ = child.wait(); + let _ = std::fs::remove_dir_all(&runtime_dir); + return Err(FabricError::ProcessRunner { + command: command_display, + source, + }); + } + Ok(LocalAdapterHost { + child, + stdin, + responses, + command: command_display, + runtime_dir, + stderr_path, + stderr_offset: 0, + artifacts, + relay_config, + }) +} + +fn local_host_command(plan: &RunPlan, runtime: &RuntimeHandle) -> Result<(Command, String)> { + let settings = parse_python_settings(plan)?; + let python = resolve_python_command(&plan.base_dir, &settings).path; + let cwd = settings + .cwd + .as_ref() + .map(|path| resolve_path(&plan.base_dir, path)) + .or_else(|| runtime.environment.workspace.clone()) + .unwrap_or_else(|| plan.base_dir.clone()); + let mut command = Command::new(&python); + command + .arg("-m") + .arg(&settings.module) + .args(&settings.args) + .current_dir(cwd) + .envs(&settings.env); + Ok(( + command, + format!("{} -m {}", python.to_string_lossy(), settings.module), + )) +} + +fn exchange_lifecycle_message( + host: &mut LocalAdapterHost, + runtime_id: &str, + request: &AdapterLifecycleRequest, + timeout: Option, +) -> Result { + let operation = request.operation(); + if let Some(status) = host.child.try_wait().map_err(|source| { + lifecycle_error( + operation, + runtime_id, + "host_io", + format!("failed to inspect persistent local adapter host: {source}"), + local_host_diagnostics(host), + ) + })? { + return Err(lifecycle_error( + operation, + runtime_id, + "host_crashed", + format!( + "persistent local adapter host exited before {} ({status})", + operation.as_str() + ), + local_host_diagnostics(host), + )); + } + let mut message = serde_json::to_string(request).map_err(FabricError::SerializeJson)?; + message.push('\n'); + if let Err(source) = host + .stdin + .write_all(message.as_bytes()) + .and_then(|()| host.stdin.flush()) + { + let code = match host.child.try_wait() { + Ok(Some(_)) => "host_crashed", + _ => "host_io", + }; + return Err(lifecycle_error( + operation, + runtime_id, + code, + format!( + "failed to send {} to persistent local adapter host: {source}", + operation.as_str() + ), + local_host_diagnostics(host), + )); + } + + let line = match timeout { + Some(timeout) => match host.responses.recv_timeout(timeout) { + Ok(line) => line, + Err(RecvTimeoutError::Timeout) => { + return Err(lifecycle_error( + operation, + runtime_id, + "host_timeout", + format!( + "persistent local adapter host did not complete {} within {} ms", + operation.as_str(), + timeout.as_millis() + ), + local_host_diagnostics(host), + )); + } + Err(RecvTimeoutError::Disconnected) => { + return Err(lifecycle_error( + operation, + runtime_id, + "host_crashed", + format!( + "persistent local adapter host exited while processing {}", + operation.as_str() + ), + local_host_diagnostics(host), + )); + } + }, + None => host.responses.recv().map_err(|_| { + lifecycle_error( + operation, + runtime_id, + "host_crashed", + format!( + "persistent local adapter host exited while processing {}", + operation.as_str() + ), + local_host_diagnostics(host), + ) + })?, + } + .map_err(|message| { + lifecycle_error( + operation, + runtime_id, + "host_io", + message, + local_host_diagnostics(host), + ) + })?; + let response: AdapterLifecycleResponse = serde_json::from_str(&line).map_err(|source| { + lifecycle_error( + operation, + runtime_id, + "protocol_error", + format!("invalid lifecycle response: {source}"), + local_host_diagnostics(host), + ) + })?; + if response.contract_version != ADAPTER_LIFECYCLE_CONTRACT_VERSION { + return Err(lifecycle_error( + operation, + runtime_id, + "protocol_version_mismatch", + format!( + "expected lifecycle contract `{ADAPTER_LIFECYCLE_CONTRACT_VERSION}` but host returned `{}`", + response.contract_version + ), + local_host_diagnostics(host), + )); + } + if response.operation != operation { + return Err(lifecycle_error( + operation, + runtime_id, + "protocol_error", + format!( + "expected `{}` response but host returned `{}`", + operation.as_str(), + response.operation.as_str() + ), + local_host_diagnostics(host), + )); + } + match response.outcome { + AdapterLifecycleOutcome::Succeeded { output } => Ok(output), + AdapterLifecycleOutcome::Failed { error } => { + if error.stage != operation.error_stage() { + return Err(lifecycle_error( + operation, + runtime_id, + "protocol_error", + format!( + "{} failure reported the wrong lifecycle stage `{:?}`", + operation.as_str(), + error.stage + ), + local_host_diagnostics(host), + )); + } + let mut diagnostics = local_host_diagnostics(host); + if !error.metadata.is_empty() + && let Ok(metadata) = serde_json::to_string(&error.metadata) + { + if !diagnostics.is_empty() { + diagnostics.push('\n'); + } + diagnostics.push_str("adapter metadata: "); + diagnostics.push_str(&metadata); + } + Err(lifecycle_error( + operation, + runtime_id, + error.code, + error.message, + diagnostics, + )) + } + } +} + +fn lifecycle_error( + operation: AdapterLifecycleOperation, + runtime_id: &str, + code: impl Into, + message: impl Into, + diagnostics: impl Into, +) -> FabricError { + FabricError::AdapterLifecycleOperation { + operation: operation.as_str(), + runtime_id: runtime_id.to_string(), + code: code.into(), + message: message.into(), + diagnostics: diagnostics.into(), + } +} + +fn local_host_diagnostics(host: &LocalAdapterHost) -> String { + let Ok(bytes) = std::fs::read(&host.stderr_path) else { + return String::new(); + }; + let start = bytes.len().saturating_sub(LOCAL_HOST_DIAGNOSTIC_LIMIT); + String::from_utf8_lossy(&bytes[start..]).trim().to_string() +} + +fn take_local_host_stderr(host: &mut LocalAdapterHost) -> String { + let Ok(bytes) = std::fs::read(&host.stderr_path) else { + return String::new(); + }; + let start = host.stderr_offset.min(bytes.len()); + host.stderr_offset = bytes.len(); + String::from_utf8_lossy(&bytes[start..]).into_owned() +} + +fn terminate_local_host(host: &mut LocalAdapterHost) -> std::io::Result<()> { + let deadline = Instant::now() + LOCAL_HOST_EXIT_GRACE; + loop { + if host.child.try_wait()?.is_some() { + return Ok(()); + } + if Instant::now() >= deadline { + break; + } + thread::sleep(Duration::from_millis(10)); + } + + if let Err(source) = host.child.kill() + && host.child.try_wait()?.is_none() + { + return Err(source); + } + host.child.wait()?; + Ok(()) +} + +fn remove_local_host_files(host: &LocalAdapterHost) -> std::io::Result<()> { + match std::fs::remove_dir_all(&host.runtime_dir) { + Ok(()) => Ok(()), + Err(source) if source.kind() == ErrorKind::NotFound => Ok(()), + Err(source) => Err(source), + } +} + fn run_process_adapter( plan: &RunPlan, runtime: &RuntimeHandle, diff --git a/schemas/SCHEMA.md b/schemas/SCHEMA.md index bd534e92..34f67741 100644 --- a/schemas/SCHEMA.md +++ b/schemas/SCHEMA.md @@ -22,7 +22,8 @@ The core schema generator exports the current public typed contract. - `agent`: complete typed `FabricConfig`. - `adapter-descriptor`: minimal adapter descriptor consumed by Fabric. Each descriptor declares a `contract_version`; Fabric rejects descriptors for - unsupported adapter contracts during planning. + unsupported adapter contracts during planning. Adapters can also declare a + versioned persistent local-host lifecycle under `runtime.local_host`. - `run-plan`: executable plan containing the canonical typed config, absolute base directory, selected adapter, and derived execution metadata. diff --git a/schemas/adapter-descriptor.schema.json b/schemas/adapter-descriptor.schema.json index 6c93bfe1..a26b4bb2 100644 --- a/schemas/adapter-descriptor.schema.json +++ b/schemas/adapter-descriptor.schema.json @@ -46,6 +46,21 @@ } ] }, + "AdapterLocalHostSupport": { + "additionalProperties": true, + "description": "Persistent local-host protocol implemented by an adapter.", + "properties": { + "contract_version": { + "description": "Adapter lifecycle protocol version spoken over the host transport.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "contract_version" + ], + "type": "object" + }, "AdapterRequirements": { "additionalProperties": true, "description": "Adapter runtime requirements.", @@ -88,6 +103,24 @@ }, "type": "object" }, + "AdapterRuntimeSupport": { + "additionalProperties": true, + "description": "Runtime hosting mechanisms implemented by an adapter.", + "properties": { + "local_host": { + "anyOf": [ + { + "$ref": "#/$defs/AdapterLocalHostSupport" + }, + { + "type": "null" + } + ], + "description": "Versioned persistent local-host support, when implemented." + } + }, + "type": "object" + }, "AdapterTelemetryProviderSupport": { "additionalProperties": true, "description": "Telemetry capabilities for one adapter-supported provider.", @@ -203,6 +236,10 @@ "description": "Generic runner defaults consumed by the selected runtime adapter.", "type": "object" }, + "runtime": { + "$ref": "#/$defs/AdapterRuntimeSupport", + "description": "Adapter-owned runtime hosting support." + }, "telemetry": { "$ref": "#/$defs/AdapterTelemetrySupport", "default": {}, diff --git a/schemas/run-plan.schema.json b/schemas/run-plan.schema.json index 110290d0..f9e6f57f 100644 --- a/schemas/run-plan.schema.json +++ b/schemas/run-plan.schema.json @@ -69,6 +69,10 @@ "description": "Generic runner defaults consumed by the selected runtime adapter.", "type": "object" }, + "runtime": { + "$ref": "#/$defs/AdapterRuntimeSupport", + "description": "Adapter-owned runtime hosting support." + }, "telemetry": { "$ref": "#/$defs/AdapterTelemetrySupport", "default": {}, @@ -123,6 +127,21 @@ } ] }, + "AdapterLocalHostSupport": { + "additionalProperties": true, + "description": "Persistent local-host protocol implemented by an adapter.", + "properties": { + "contract_version": { + "description": "Adapter lifecycle protocol version spoken over the host transport.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "contract_version" + ], + "type": "object" + }, "AdapterRequirements": { "additionalProperties": true, "description": "Adapter runtime requirements.", @@ -165,6 +184,24 @@ }, "type": "object" }, + "AdapterRuntimeSupport": { + "additionalProperties": true, + "description": "Runtime hosting mechanisms implemented by an adapter.", + "properties": { + "local_host": { + "anyOf": [ + { + "$ref": "#/$defs/AdapterLocalHostSupport" + }, + { + "type": "null" + } + ], + "description": "Versioned persistent local-host support, when implemented." + } + }, + "type": "object" + }, "AdapterTelemetryProviderSupport": { "additionalProperties": true, "description": "Telemetry capabilities for one adapter-supported provider.", diff --git a/tests/adapters/test_adapters_common_lifecycle.py b/tests/adapters/test_adapters_common_lifecycle.py new file mode 100644 index 00000000..c14d5ee1 --- /dev/null +++ b/tests/adapters/test_adapters_common_lifecycle.py @@ -0,0 +1,275 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import asyncio +import io +import json +import os +from typing import Any + +from nemo_fabric_adapters.common import lifecycle + + +def _request(operation: str, payload: dict[str, Any]) -> dict[str, Any]: + return { + "contract_version": lifecycle.CONTRACT_VERSION, + "operation": operation, + "payload": payload, + } + + +def _streams(requests: list[dict[str, Any]]) -> tuple[io.StringIO, io.StringIO]: + input_stream = io.StringIO("".join(f"{json.dumps(item)}\n" for item in requests)) + return input_stream, io.StringIO() + + +def test_lifecycle_host_reuses_one_runtime_and_one_event_loop(): + runtime_id = "runtime-1" + input_stream, output_stream = _streams( + [ + _request( + "start", + {"runtime_context": {"runtime_id": runtime_id}}, + ), + _request( + "invoke", + { + "runtime_context": {"runtime_id": runtime_id}, + "request": {"input": "first"}, + }, + ), + _request( + "invoke", + { + "runtime_context": {"runtime_id": runtime_id}, + "request": {"input": "second"}, + }, + ), + _request("stop", {"runtime_id": runtime_id}), + ] + ) + instances = [] + + class Runtime: + def __init__(self): + self.loop_ids: list[int] = [] + self.invocations = 0 + instances.append(self) + + async def start(self, _payload): + self.loop_ids.append(id(asyncio.get_running_loop())) + + async def invoke(self, payload): + self.loop_ids.append(id(asyncio.get_running_loop())) + self.invocations += 1 + return { + "count": self.invocations, + "input": payload["request"]["input"], + } + + async def stop(self): + self.loop_ids.append(id(asyncio.get_running_loop())) + + lifecycle.serve(Runtime, input_stream=input_stream, output_stream=output_stream) + + responses = [json.loads(line) for line in output_stream.getvalue().splitlines()] + assert [item["operation"] for item in responses] == [ + "start", + "invoke", + "invoke", + "stop", + ] + assert all(item["outcome"]["status"] == "succeeded" for item in responses) + assert responses[1]["outcome"]["output"] == {"count": 1, "input": "first"} + assert responses[2]["outcome"]["output"] == {"count": 2, "input": "second"} + assert len(instances) == 1 + assert len(set(instances[0].loop_ids)) == 1 + + +def test_lifecycle_host_rejects_runtime_mismatch_without_invoking_adapter(): + input_stream, output_stream = _streams( + [ + _request("start", {"runtime_context": {"runtime_id": "runtime-1"}}), + _request( + "invoke", + { + "runtime_context": {"runtime_id": "runtime-2"}, + "request": {"input": "do not run"}, + }, + ), + _request("stop", {"runtime_id": "runtime-1"}), + ] + ) + + class Runtime: + async def start(self, _payload): + pass + + async def invoke(self, payload): + raise AssertionError(payload) + + async def stop(self): + pass + + lifecycle.serve(Runtime, input_stream=input_stream, output_stream=output_stream) + + responses = [json.loads(line) for line in output_stream.getvalue().splitlines()] + assert responses[1]["outcome"]["status"] == "failed" + assert responses[1]["outcome"]["error"]["code"] == "lifecycle_runtime_mismatch" + + +def test_lifecycle_host_keeps_adapter_stdout_out_of_protocol(capsys): + runtime_id = "runtime-1" + input_stream, output_stream = _streams( + [ + _request("start", {"runtime_context": {"runtime_id": runtime_id}}), + _request( + "invoke", + { + "runtime_context": {"runtime_id": runtime_id}, + "request": {"input": "hello"}, + }, + ), + _request("stop", {"runtime_id": runtime_id}), + ] + ) + + class Runtime: + async def start(self, _payload): + pass + + async def invoke(self, _payload): + print("adapter diagnostic") + return {"failed": False} + + async def stop(self): + pass + + lifecycle.serve(Runtime, input_stream=input_stream, output_stream=output_stream) + + assert "adapter diagnostic" not in output_stream.getvalue() + assert "adapter diagnostic" in capsys.readouterr().err + + +def test_lifecycle_host_scopes_invocation_telemetry_environment(monkeypatch): + runtime_id = "runtime-1" + variable = "FABRIC_TEST_LIFECYCLE_ENV" + monkeypatch.setenv(variable, "host-value") + input_stream, output_stream = _streams( + [ + _request("start", {"runtime_context": {"runtime_id": runtime_id}}), + _request( + "invoke", + { + "runtime_context": { + "runtime_id": runtime_id, + "telemetry": {"env": {variable: "invocation-value"}}, + }, + "request": {"input": "hello"}, + }, + ), + _request("stop", {"runtime_id": runtime_id}), + ] + ) + + class Runtime: + async def start(self, _payload): + pass + + async def invoke(self, _payload): + return {"value": os.environ[variable]} + + async def stop(self): + pass + + lifecycle.serve(Runtime, input_stream=input_stream, output_stream=output_stream) + + responses = [json.loads(line) for line in output_stream.getvalue().splitlines()] + assert responses[1]["outcome"]["output"] == {"value": "invocation-value"} + assert os.environ[variable] == "host-value" + + +def test_lifecycle_host_stops_runtime_when_fabric_closes_stdin(): + input_stream, output_stream = _streams( + [_request("start", {"runtime_context": {"runtime_id": "runtime-1"}})] + ) + stopped = [] + + class Runtime: + async def start(self, _payload): + pass + + async def invoke(self, _payload): + return None + + async def stop(self): + stopped.append(True) + + lifecycle.serve(Runtime, input_stream=input_stream, output_stream=output_stream) + + assert stopped == [True] + + +def test_lifecycle_host_rejects_invoke_after_adapter_failure(): + runtime_id = "runtime-1" + invoke_payload = { + "runtime_context": {"runtime_id": runtime_id}, + "request": {"input": "fail"}, + } + input_stream, output_stream = _streams( + [ + _request("start", {"runtime_context": {"runtime_id": runtime_id}}), + _request("invoke", invoke_payload), + _request("invoke", invoke_payload), + _request("stop", {"runtime_id": runtime_id}), + ] + ) + invocations = [] + + class Runtime: + async def start(self, _payload): + pass + + async def invoke(self, payload): + invocations.append(payload) + raise RuntimeError("adapter failed") + + async def stop(self): + pass + + lifecycle.serve(Runtime, input_stream=input_stream, output_stream=output_stream) + + responses = [json.loads(line) for line in output_stream.getvalue().splitlines()] + assert responses[1]["outcome"]["error"]["code"] == ( + "lifecycle_adapter_invoke_failed" + ) + assert responses[2]["outcome"]["error"]["code"] == "lifecycle_runtime_failed" + assert len(invocations) == 1 + + +def test_lifecycle_host_cleans_up_and_exits_after_start_failure(): + runtime_id = "runtime-1" + start = _request("start", {"runtime_context": {"runtime_id": runtime_id}}) + input_stream, output_stream = _streams([start, start]) + stopped = [] + + class Runtime: + async def start(self, _payload): + raise RuntimeError("start failed") + + async def invoke(self, _payload): + raise AssertionError("failed runtime must not be invoked") + + async def stop(self): + stopped.append(True) + + lifecycle.serve(Runtime, input_stream=input_stream, output_stream=output_stream) + + responses = [json.loads(line) for line in output_stream.getvalue().splitlines()] + assert len(responses) == 1 + assert responses[0]["outcome"]["error"]["code"] == ( + "lifecycle_adapter_start_failed" + ) + assert stopped == [True] diff --git a/tests/python/test_environment_handle.py b/tests/python/test_environment_handle.py index 22e6f9c5..a56fc46a 100644 --- a/tests/python/test_environment_handle.py +++ b/tests/python/test_environment_handle.py @@ -6,15 +6,18 @@ from __future__ import annotations import os +from pathlib import Path -from examples.code_review_agent import BASE_DIR, base_config +from examples.code_review_agent import base_config from nemo_fabric import Fabric -async def test_environment_handle(): +async def test_environment_handle(hermes_shim_agent_dir: Path): + config = base_config() + config.harness.adapter_id = "test.fabric.hermes_shim" runtime = await Fabric().start_runtime( - base_config(), - base_dir=BASE_DIR, + config, + base_dir=hermes_shim_agent_dir, ) try: workspace = runtime.handle["environment"]["workspace"] @@ -22,4 +25,4 @@ async def test_environment_handle(): await runtime.stop() assert os.path.isabs(workspace), f"workspace not absolute: {workspace}" - assert workspace == str((BASE_DIR / "repos" / "my-service").resolve()) + assert workspace == str((hermes_shim_agent_dir / "repos" / "my-service").resolve()) From 05cae8bcfd65cb20fdc6ae5c734f418d2167eeaa Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Fri, 17 Jul 2026 15:31:15 -0700 Subject: [PATCH 02/34] feat(adapters): persist Claude and Codex SDK runtimes Signed-off-by: Ajay Thorve --- adapters/claude/README.md | 39 +- adapters/claude/fabric-adapter.json | 5 + .../nemo_fabric_adapters/claude/adapter.py | 211 +++++++++- adapters/codex/README.md | 22 +- adapters/codex/fabric-adapter.json | 5 + .../src/nemo_fabric_adapters/codex/adapter.py | 387 ++++++++++++++---- .../common/relay_gateway.py | 6 +- .../adapters/claude/fabric-adapter.json | 5 + tests/adapters/test_claude_adapter.py | 143 +++++++ tests/adapters/test_codex_adapter.py | 106 +++-- tests/e2e/test_claude.py | 18 +- tests/e2e/test_codex.py | 4 + tests/fixtures/claude/mock-claude-cli.py | 1 - 13 files changed, 805 insertions(+), 147 deletions(-) diff --git a/adapters/claude/README.md b/adapters/claude/README.md index 77652142..fa1a797c 100644 --- a/adapters/claude/README.md +++ b/adapters/claude/README.md @@ -48,7 +48,7 @@ that Claude Code and the Claude Agent SDK consume. This includes and `ANTHROPIC_IDENTITY_TOKEN` or `ANTHROPIC_IDENTITY_TOKEN_FILE`. Fabric reads selected environment values and forwards them to the Claude runtime, but it does not persist or log them in configuration or artifacts. Authentication is -validated when Claude starts the invocation. +validated when the Claude runtime starts. Unset unused `ANTHROPIC_API_KEY` and `ANTHROPIC_AUTH_TOKEN` variables before using WIF. Anthropic credential resolution treats an empty variable as selected, @@ -71,11 +71,18 @@ for other supported installation methods. ## Execution Model -Each `invoke` starts a fresh adapter process. The adapter persists the terminal -Claude session ID under the Fabric artifact root, keyed by `runtime_id`, and -passes it as `ClaudeAgentOptions.resume` on the next invocation. One Fabric -runtime therefore maps to one Claude session even though no adapter process -stays resident. +The Claude adapter declares Fabric's versioned persistent local-host contract. +`Fabric.start_runtime(...)` launches one adapter host, creates one +`ClaudeSDKClient`, and connects it once. Every `Runtime.invoke(...)` reuses that +client and its event loop; `Runtime.stop()` disconnects the client and exits the +host. `Fabric.run(...)` uses the same lifecycle around one invocation. + +One Fabric runtime maps to one live Claude session. The adapter records the +terminal Claude session ID under the Fabric artifact root for correlation, but +does not silently recreate a crashed host or replay an invocation. Start a new +Fabric runtime when the host or SDK connection becomes unusable. Runtime +hosting is adapter-declared; consumers do not configure a runtime strategy in +`FabricConfig` or `harness.settings`. ## Configuration @@ -90,7 +97,7 @@ Configure portable capabilities through the normalized `FabricConfig` fields: - `mcp` configures stdio, HTTP, streamable HTTP, or SSE servers. For stdio, Fabric parses `url` as a command plus arguments. - `skills.paths` names skill directories that contain `SKILL.md`. The adapter - stages these directories as a local Claude plugin for the invocation. + stages these directories as a local Claude plugin for the runtime. Only Claude-specific controls belong in `harness.settings`: @@ -121,12 +128,12 @@ config.enable_relay( ) ``` -For each Relay-enabled invocation, Fabric starts one `nemo-relay` gateway, -waits for its health endpoint, and stops it after Claude succeeds, fails, times -out, or is canceled. Fabric passes the gateway URL to Claude Code through -`ANTHROPIC_BASE_URL` and `NEMO_RELAY_GATEWAY_URL`. It also stages an -invocation-scoped Claude plugin that forwards lifecycle hooks with -`nemo-relay hook-forward claude`. +For each Relay-enabled Claude runtime, Fabric starts one `nemo-relay` gateway, +waits for its health endpoint, and stops it with the runtime. Fabric passes the +gateway URL to the connected Claude Code process through `ANTHROPIC_BASE_URL` +and `NEMO_RELAY_GATEWAY_URL`. It also stages a runtime-scoped Claude plugin that +forwards lifecycle hooks with `nemo-relay hook-forward claude`. A one-shot +`Fabric.run(...)` naturally owns the gateway for only one invocation. The Fabric result includes `relay_runtime.gateway_config_path`, `relay_runtime.gateway_log_path`, and the collected `relay_artifacts`. Relay @@ -218,6 +225,6 @@ assert first.runtime_id == second.runtime_id assert first.output["session_id"] == second.output["session_id"] ``` -Resume requires the same workspace and Claude state directory on the same host. -The Fabric-to-Claude correlation record alone is insufficient if Claude's -underlying transcript store is removed. +The runtime must remain on the same local host for its lifetime. A persisted +Fabric-to-Claude correlation record is not an attach token and cannot recover a +stopped or crashed local host. diff --git a/adapters/claude/fabric-adapter.json b/adapters/claude/fabric-adapter.json index ed36b146..ee998f8c 100644 --- a/adapters/claude/fabric-adapter.json +++ b/adapters/claude/fabric-adapter.json @@ -17,5 +17,10 @@ "integration_modes": ["hooks", "gateway"] } } + }, + "runtime": { + "local_host": { + "contract_version": "fabric.adapter.lifecycle/v1alpha1" + } } } diff --git a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py index b76c0672..0e8b5923 100644 --- a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py +++ b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Run Claude Agent SDK through the Fabric adapter process contract.""" +"""Run Claude Agent SDK through the Fabric adapter contract.""" from __future__ import annotations @@ -21,6 +21,7 @@ from typing import Any from claude_agent_sdk import ClaudeAgentOptions +from claude_agent_sdk import ClaudeSDKClient from claude_agent_sdk import ClaudeSDKError from claude_agent_sdk import CLIConnectionError from claude_agent_sdk import CLIJSONDecodeError @@ -30,6 +31,7 @@ from claude_agent_sdk import ResultMessage from claude_agent_sdk import query from claude_agent_sdk._errors import MessageParseError +from nemo_fabric_adapters.common import lifecycle from nemo_fabric_adapters.common import relay_gateway from nemo_fabric_adapters.common import relay_hooks from nemo_fabric_adapters.common import utils as common_utils @@ -97,7 +99,7 @@ @dataclass(frozen=True) class ClaudeRelaySettings: - """Invocation-scoped Relay gateway and Claude plugin settings.""" + """Relay gateway and Claude plugin settings owned by one adapter run.""" gateway: relay_gateway.RelayGatewayLaunch plugin_config: dict[str, Any] @@ -415,7 +417,7 @@ def _stage_relay_plugin(plugin_path: Path, executable: Path) -> None: def prepare_claude_relay(payload: dict[str, Any]) -> ClaudeRelaySettings | None: - """Generate invocation-scoped Relay and Claude hook configuration.""" + """Generate Relay gateway and Claude hook configuration.""" if not common_utils.relay_enabled(payload): return None @@ -859,6 +861,206 @@ def _persist_result_session( return None +def _as_lifecycle_error(error: ClaudeAdapterError) -> lifecycle.LifecycleError: + return lifecycle.LifecycleError( + error.code, + error.message, + metadata=error.metadata, + ) + + +def _sdk_lifecycle_error(error: BaseException) -> lifecycle.LifecycleError: + output = sdk_failure(error) + reported = output["error"] + return lifecycle.LifecycleError( + reported["code"], + reported["message"], + retryable=reported["retryable"], + metadata=reported.get("metadata"), + ) + + +class ClaudeRuntime: + """One connected Claude SDK client owned by a Fabric runtime.""" + + def __init__(self) -> None: + self._fabric_runtime_id: str | None = None + self._claude_session_id: str | None = None + self._client: ClaudeSDKClient | None = None + self._relay: ClaudeRelaySettings | None = None + self._gateway_process: subprocess.Popen[Any] | None = None + self._unusable = False + + async def start(self, payload: dict[str, Any]) -> None: + if self._client is not None: + raise lifecycle.LifecycleError( + "claude_runtime_already_started", + "Claude runtime is already started", + ) + try: + fabric_runtime_id = runtime_id(payload) + claude_session_id = load_claude_session_id(payload, fabric_runtime_id) + relay = prepare_claude_relay(payload) + self._relay = relay + self._gateway_process = _start_relay_gateway(payload, relay) + options = build_options(payload, resume=claude_session_id, relay=relay) + client = ClaudeSDKClient(options) + await client.connect() + except ClaudeAdapterError as error: + self._cleanup_failed_start() + raise _as_lifecycle_error(error) from error + except ClaudeSDKError as error: + self._cleanup_failed_start() + raise _sdk_lifecycle_error(error) from error + except BaseException: + self._cleanup_failed_start() + raise + + self._fabric_runtime_id = fabric_runtime_id + self._claude_session_id = claude_session_id + self._client = client + + async def invoke(self, payload: dict[str, Any]) -> dict[str, Any]: + client = self._client + fabric_runtime_id = self._fabric_runtime_id + if client is None or fabric_runtime_id is None: + raise lifecycle.LifecycleError( + "claude_runtime_not_started", + "Claude runtime is not started", + ) + if runtime_id(payload) != fabric_runtime_id: + raise lifecycle.LifecycleError( + "claude_runtime_mismatch", + "Claude invocation does not match the connected runtime", + ) + if self._unusable: + return _failure( + "claude_runtime_unavailable", + "Claude runtime cannot accept another invocation after an SDK failure", + ) + + try: + prompt = request_prompt(payload) + invocation_timeout = timeout_seconds(payload) + except ClaudeAdapterError as error: + output = adapter_failure(error) + if self._relay is not None: + output = _relay_output(output, self._relay) + return output + + messages: list[Message] = [] + result: ResultMessage | None = None + try: + async with asyncio.timeout(invocation_timeout): + await client.query(prompt) + async for message in client.receive_response(): + if isinstance(message, ResultMessage): + result = message + else: + messages.append(message) + except (TimeoutError, ClaudeSDKError) as error: + self._unusable = True + await self._interrupt_failed_invocation() + output = sdk_failure(error) + except Exception: + # Claude Agent SDK 0.2.120 can yield an error ResultMessage and then + # raise a plain Exception while closing the response stream. + self._unusable = True + if result is None or not _result_failed(result): + raise + LOGGER.exception("Claude SDK stream raised after a failed terminal result") + output = self._normalize_invocation(payload, messages, result) + else: + if result is None: + self._unusable = True + output = _failure( + "claude_missing_result", "Claude returned no terminal result" + ) + else: + output = self._normalize_invocation(payload, messages, result) + + if self._relay is not None: + output = _relay_output(output, self._relay) + return output + + def _normalize_invocation( + self, + payload: dict[str, Any], + messages: list[Message], + result: ResultMessage, + ) -> dict[str, Any]: + try: + output = normalize_result(payload, messages, result) + if not output["failed"]: + persisted = _persist_result_session( + payload, + runtime_id(payload), + self._claude_session_id, + result, + ) + if persisted is not None: + self._unusable = True + return persisted + self._claude_session_id = result.session_id + return output + except ClaudeAdapterError as error: + self._unusable = True + return adapter_failure(error) + + async def stop(self) -> None: + client = self._client + self._client = None + self._fabric_runtime_id = None + self._claude_session_id = None + self._unusable = True + + disconnect_error: BaseException | None = None + try: + if client is not None: + await client.disconnect() + except BaseException as error: + if not isinstance(error, asyncio.CancelledError): + LOGGER.exception("Claude SDK client failed to disconnect") + disconnect_error = error + finally: + cleanup_error = _cleanup_relay(self._relay, self._gateway_process) + self._relay = None + self._gateway_process = None + if isinstance(disconnect_error, asyncio.CancelledError): + raise disconnect_error + if disconnect_error is not None: + if cleanup_error is not None: + LOGGER.error( + "Claude Relay cleanup also failed during disconnect: %s", + cleanup_error.code, + ) + raise lifecycle.LifecycleError( + "claude_disconnect_failed", + "Claude SDK client failed to disconnect", + ) from disconnect_error + if cleanup_error is not None: + raise _as_lifecycle_error(cleanup_error) + + def _cleanup_failed_start(self) -> None: + cleanup_error = _cleanup_relay(self._relay, self._gateway_process) + self._relay = None + self._gateway_process = None + if cleanup_error is not None: + LOGGER.error( + "Claude runtime cleanup after start failure also failed: %s", + cleanup_error.code, + ) + + async def _interrupt_failed_invocation(self) -> None: + if self._client is None: + return + try: + async with asyncio.timeout(5): + await self._client.interrupt() + except Exception: + LOGGER.exception("Claude SDK invocation could not be interrupted") + + async def run_claude(payload: dict[str, Any]) -> dict[str, Any]: fabric_runtime_id = runtime_id(payload) prior_session_id = load_claude_session_id(payload, fabric_runtime_id) @@ -926,6 +1128,9 @@ def run(payload: dict[str, Any]) -> dict[str, Any]: def main() -> None: + if lifecycle.is_lifecycle_host(os.environ): + lifecycle.serve(ClaudeRuntime) + return try: payload = common_utils.load_payload() except ( diff --git a/adapters/codex/README.md b/adapters/codex/README.md index e96c06b1..c29e0f69 100644 --- a/adapters/codex/README.md +++ b/adapters/codex/README.md @@ -69,11 +69,14 @@ to the explicit `base_dir`. Fabric passes the resolved path through ## Execution Model -Each Fabric invocation starts a fresh SDK client and closes its app-server -transport before returning. The first invocation creates a Codex thread and -persists its ID under the Fabric artifact root. Later invocations for the same -Fabric runtime resume that exact thread. Codex owns the transcript; Fabric owns -runtime-to-thread correlation, timeout, cancellation, and cleanup. +Each Fabric runtime starts one local adapter host, one `AsyncCodex` app-server +client, and one Codex thread. Ordered `Runtime.invoke(...)` calls reuse that +client and thread directly; the adapter closes the app-server transport during +`Runtime.stop()`. The first successful invocation also persists the thread ID +under the Fabric artifact root for the adapter's direct per-invocation +compatibility path. The persistent host does not resume the thread between +turns. Codex owns the transcript; Fabric owns runtime-to-thread correlation, +timeout, cancellation, and cleanup. The result includes the SDK's typed terminal response, turn status, token usage, timing, and completed thread items. It does not expose CLI commands, @@ -125,15 +128,16 @@ Codex state variables, the selected model's `api_key_env`, and explicit ## Relay Observability Enable Relay through Fabric's normalized telemetry configuration. For each -Relay-enabled invocation, Fabric: +Relay-enabled Fabric runtime, the adapter: 1. Resolves one external `nemo-relay` executable. -2. Generates invocation-scoped gateway and plugin configuration. +2. Generates runtime-scoped gateway and plugin configuration. 3. Starts and health-checks `nemo-relay --config ... --bind ...`. -4. Redirects the built-in OpenAI provider with request-scoped +4. Redirects the built-in OpenAI provider with runtime-scoped `openai_base_url` and passes Relay hooks through the Codex SDK's `config` argument. -5. Interrupts timed-out turns, closes the SDK runtime, and stops the gateway. +5. Reuses the SDK client and gateway across turns, interrupting a timed-out turn. +6. Closes the SDK runtime and stops the gateway during runtime shutdown. The SDK remains the Codex execution driver. Relay is a supervised sidecar and hook forwarder; the adapter never invokes a `nemo-relay codex` wrapper. The diff --git a/adapters/codex/fabric-adapter.json b/adapters/codex/fabric-adapter.json index 96f2d7dd..d3453bec 100644 --- a/adapters/codex/fabric-adapter.json +++ b/adapters/codex/fabric-adapter.json @@ -20,5 +20,10 @@ "outputs": ["otel"] } } + }, + "runtime": { + "local_host": { + "contract_version": "fabric.adapter.lifecycle/v1alpha1" + } } } diff --git a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py index 6f367192..ca771e7f 100644 --- a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py +++ b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py @@ -8,9 +8,11 @@ import asyncio import json +import logging import math import os import shlex +import subprocess from dataclasses import asdict, dataclass, is_dataclass from enum import Enum from hashlib import sha256 @@ -32,6 +34,7 @@ import nemo_fabric_adapters.common.relay_gateway as relay_gateway import nemo_fabric_adapters.common.relay_hooks as relay_hooks import nemo_fabric_adapters.common.utils as common_utils +from nemo_fabric_adapters.common import lifecycle DEFAULT_TIMEOUT_SECONDS = 1800.0 @@ -89,11 +92,12 @@ "model_name": "FabricConfig.models", "skills": "FabricConfig.skills", } +LOGGER = logging.getLogger(__name__) @dataclass(frozen=True) class CodexRelaySettings: - """Invocation-scoped Relay state consumed by the Codex SDK adapter.""" + """Runtime-scoped Relay state consumed by the Codex SDK adapter.""" gateway: relay_gateway.RelayGatewayLaunch plugin_config: dict[str, Any] @@ -557,9 +561,7 @@ def _json_value(value: Any, *, name: str) -> Any: return value -def _apply_config_overrides( - config: dict[str, Any], overrides: dict[str, Any] -) -> None: +def _apply_config_overrides(config: dict[str, Any], overrides: dict[str, Any]) -> None: for dotted_key, value in sorted(overrides.items()): if not isinstance(dotted_key, str): raise AdapterConfigError( @@ -581,9 +583,7 @@ def _apply_config_overrides( f"Codex config override {dotted_key!r} conflicts with {part!r}", ) target = existing - target[parts[-1]] = _json_value( - value, name=f"config_overrides.{dotted_key}" - ) + target[parts[-1]] = _json_value(value, name=f"config_overrides.{dotted_key}") def native_codex_telemetry_config(payload: dict[str, Any]) -> dict[str, Any]: @@ -778,12 +778,11 @@ def _output_schema(payload: dict[str, Any]) -> dict[str, Any] | None: return _mapping(_json_value(value, name="output_schema"), name="output_schema") -def validate_payload(payload: dict[str, Any]) -> str: - """Validate pure invocation inputs before starting SDK or Relay processes.""" +def validate_runtime_payload(payload: dict[str, Any]) -> str: + """Validate runtime-owned configuration before starting SDK or Relay processes.""" settings = _settings(payload) _validate_settings_boundary(settings) - request_prompt(payload) _native_skill_paths(payload) fabric_runtime_id = runtime_id(payload) resolve_cwd(payload) @@ -814,6 +813,14 @@ def validate_payload(payload: dict[str, Any]) -> str: return fabric_runtime_id +def validate_payload(payload: dict[str, Any]) -> str: + """Validate runtime configuration and invocation input.""" + + fabric_runtime_id = validate_runtime_payload(payload) + request_prompt(payload) + return fabric_runtime_id + + def _json_safe(value: Any) -> Any: if hasattr(value, "model_dump"): return _json_safe(value.model_dump(mode="json", by_alias=True)) @@ -892,7 +899,9 @@ def normalize_result( payload: dict[str, Any], *, thread_id: str, result: Any ) -> dict[str, Any]: status = _json_safe(result.status) - completed = result.status == TurnStatus.completed and result.final_response is not None + completed = ( + result.status == TurnStatus.completed and result.final_response is not None + ) error = None if not completed: message = ( @@ -940,6 +949,72 @@ async def _interrupt_turn(handle: Any) -> None: pass +def _thread_options( + payload: dict[str, Any], relay: CodexRelaySettings | None +) -> dict[str, Any]: + settings = _settings(payload) + return { + "approval_mode": approval_mode(payload), + "base_instructions": _optional_string(settings, "base_instructions"), + "config": thread_config(payload, relay) or None, + "cwd": str(resolve_cwd(payload)), + "developer_instructions": _optional_string(settings, "developer_instructions"), + "model": selected_model(payload), + "model_provider": selected_model_provider(payload), + "personality": _personality(payload), + "sandbox": sandbox(payload), + "service_tier": _optional_string(settings, "service_tier"), + } + + +async def _open_thread( + codex: AsyncCodex, + payload: dict[str, Any], + *, + prior_thread_id: str | None, + relay: CodexRelaySettings | None, +) -> Any: + settings = _settings(payload) + options = _thread_options(payload, relay) + if prior_thread_id is None: + return await codex.thread_start( + **options, + service_name=_optional_string(settings, "service_name"), + ) + + thread = await codex.thread_resume(prior_thread_id, **options) + if thread.id != prior_thread_id: + raise AdapterStateError( + "codex_thread_mismatch", + "Codex thread identity changed during resume", + ) + return thread + + +async def _invoke_thread( + payload: dict[str, Any], thread: Any +) -> tuple[dict[str, Any], bool]: + """Run one turn and report whether the connected SDK transport remains usable.""" + + handle = None + try: + async with asyncio.timeout(timeout_seconds(payload)): + handle = await thread.turn( + request_prompt(payload), + effort=_reasoning_effort(payload), + output_schema=_output_schema(payload), + ) + result = await handle.run() + return normalize_result(payload, thread_id=thread.id, result=result), True + except TimeoutError as error: + await _interrupt_turn(handle) + return sdk_failure(error), False + except CodexAdapterError: + raise + except (CodexError, RuntimeError, OSError) as error: + return sdk_failure(error), False + + async def invoke_codex_sdk( payload: dict[str, Any], *, @@ -948,13 +1023,9 @@ async def invoke_codex_sdk( ) -> tuple[dict[str, Any], str | None]: """Execute one SDK turn and always close the app-server transport.""" - settings = _settings(payload) - config = thread_config(payload, relay) skill_paths = _native_skill_paths(payload) - prompt = request_prompt(payload) client_config = sdk_config(payload, relay) codex: AsyncCodex | None = None - handle = None output: dict[str, Any] thread_id: str | None = None try: @@ -965,45 +1036,15 @@ async def invoke_codex_sdk( exist_ok=True, ) codex = AsyncCodex(config=client_config) - async with asyncio.timeout(timeout_seconds(payload)): - await _register_skill_roots(codex, skill_paths) - common = { - "approval_mode": approval_mode(payload), - "base_instructions": _optional_string(settings, "base_instructions"), - "config": config or None, - "cwd": str(resolve_cwd(payload)), - "developer_instructions": _optional_string( - settings, "developer_instructions" - ), - "model": selected_model(payload), - "model_provider": selected_model_provider(payload), - "personality": _personality(payload), - "sandbox": sandbox(payload), - "service_tier": _optional_string(settings, "service_tier"), - } - if prior_thread_id is None: - thread = await codex.thread_start( - **common, - service_name=_optional_string(settings, "service_name"), - ) - else: - thread = await codex.thread_resume(prior_thread_id, **common) - if thread.id != prior_thread_id: - raise AdapterStateError( - "codex_thread_mismatch", - "Codex thread identity changed during resume", - ) - thread_id = thread.id - handle = await thread.turn( - prompt, - effort=_reasoning_effort(payload), - output_schema=_output_schema(payload), - ) - result = await handle.run() - output = normalize_result(payload, thread_id=thread.id, result=result) - except TimeoutError as error: - await _interrupt_turn(handle) - output = sdk_failure(error) + await _register_skill_roots(codex, skill_paths) + thread = await _open_thread( + codex, + payload, + prior_thread_id=prior_thread_id, + relay=relay, + ) + thread_id = thread.id + output, _ = await _invoke_thread(payload, thread) except CodexAdapterError: raise except (CodexError, RuntimeError, OSError) as error: @@ -1019,9 +1060,7 @@ async def invoke_codex_sdk( return output, thread_id -def _relay_output( - output: dict[str, Any], relay: CodexRelaySettings -) -> dict[str, Any]: +def _relay_output(output: dict[str, Any], relay: CodexRelaySettings) -> dict[str, Any]: output["relay_runtime"] = { "enabled": True, "emitter": "codex-sdk/nemo-relay", @@ -1036,6 +1075,211 @@ def _relay_output( return output +def _start_relay_gateway( + payload: dict[str, Any], relay: CodexRelaySettings | None +) -> subprocess.Popen[Any] | None: + if relay is None: + return None + try: + return relay_gateway.start_relay_gateway( + launch=relay.gateway, cwd=resolve_cwd(payload) + ) + except relay_gateway.RelayGatewayError as error: + raise AdapterRelayError( + "codex_relay_start_failed", + "NeMo Relay gateway failed to start", + metadata={"gateway_log_path": str(relay.gateway.log_path)}, + ) from error + + +def _cleanup_relay( + relay: CodexRelaySettings | None, + process: subprocess.Popen[Any] | None, +) -> AdapterRelayError | None: + if process is None: + return None + try: + relay_gateway.stop_relay_gateway(process) + except relay_gateway.RelayGatewayError: + return AdapterRelayError( + "codex_relay_stop_failed", + "NeMo Relay gateway failed to stop", + metadata={ + "gateway_log_path": str(relay.gateway.log_path) + if relay is not None + else "" + }, + ) + return None + + +def _as_lifecycle_error(error: CodexAdapterError) -> lifecycle.LifecycleError: + return lifecycle.LifecycleError( + error.code, + error.message, + metadata=error.metadata, + ) + + +class CodexRuntime: + """One Codex app-server client and thread owned by a Fabric runtime.""" + + def __init__(self) -> None: + self._fabric_runtime_id: str | None = None + self._client: AsyncCodex | None = None + self._thread: Any = None + self._relay: CodexRelaySettings | None = None + self._gateway_process: subprocess.Popen[Any] | None = None + self._unusable = False + + async def start(self, payload: dict[str, Any]) -> None: + if self._client is not None: + raise lifecycle.LifecycleError( + "codex_runtime_already_started", + "Codex runtime is already started", + ) + + try: + fabric_runtime_id = validate_runtime_payload(payload) + prior_thread_id = load_thread_id(payload, fabric_runtime_id) + relay = prepare_codex_relay(payload) + self._relay = relay + self._gateway_process = _start_relay_gateway(payload, relay) + client_config = sdk_config(payload, relay) + if selected_model_provider(payload) == "nvidia": + await asyncio.to_thread( + Path(client_config.env["CODEX_HOME"]).mkdir, + parents=True, + exist_ok=True, + ) + client = AsyncCodex(config=client_config) + self._client = client + await _register_skill_roots(client, _native_skill_paths(payload)) + thread = await _open_thread( + client, + payload, + prior_thread_id=prior_thread_id, + relay=relay, + ) + except CodexAdapterError as error: + await self._cleanup_failed_start() + raise _as_lifecycle_error(error) from error + except (CodexError, RuntimeError, OSError) as error: + await self._cleanup_failed_start() + reported = sdk_failure(error)["error"] + raise lifecycle.LifecycleError( + reported["code"], + reported["message"], + retryable=reported["retryable"], + metadata=reported.get("metadata"), + ) from error + except BaseException: + await self._cleanup_failed_start() + raise + + self._fabric_runtime_id = fabric_runtime_id + self._thread = thread + + async def invoke(self, payload: dict[str, Any]) -> dict[str, Any]: + if ( + self._client is None + or self._thread is None + or self._fabric_runtime_id is None + ): + raise lifecycle.LifecycleError( + "codex_runtime_not_started", + "Codex runtime is not started", + ) + if runtime_id(payload) != self._fabric_runtime_id: + raise lifecycle.LifecycleError( + "codex_runtime_mismatch", + "Codex invocation does not match the connected runtime", + ) + if self._unusable: + output = _failure( + "codex_runtime_unavailable", + "Codex runtime cannot accept another invocation after an SDK failure", + ) + return _relay_output(output, self._relay) if self._relay else output + + try: + request_prompt(payload) + timeout_seconds(payload) + _reasoning_effort(payload) + _output_schema(payload) + output, usable = await _invoke_thread(payload, self._thread) + except CodexAdapterError as error: + output = adapter_failure(error) + usable = True + + self._unusable = not usable + if not output["failed"]: + try: + save_thread_id( + payload, + self._fabric_runtime_id, + self._thread.id, + ) + except CodexAdapterError as error: + self._unusable = True + output = adapter_failure(error) + if self._relay is not None: + output = _relay_output(output, self._relay) + return output + + async def stop(self) -> None: + client = self._client + self._client = None + self._thread = None + self._fabric_runtime_id = None + self._unusable = True + + close_error: BaseException | None = None + try: + if client is not None: + await client.close() + except BaseException as error: + if not isinstance(error, asyncio.CancelledError): + LOGGER.exception("Codex SDK client failed to close") + close_error = error + finally: + cleanup_error = _cleanup_relay(self._relay, self._gateway_process) + self._relay = None + self._gateway_process = None + + if isinstance(close_error, asyncio.CancelledError): + raise close_error + if close_error is not None: + if cleanup_error is not None: + LOGGER.error( + "Codex Relay cleanup also failed during close: %s", + cleanup_error.code, + ) + raise lifecycle.LifecycleError( + "codex_sdk_stop_failed", + "Codex SDK runtime failed to stop", + ) from close_error + if cleanup_error is not None: + raise _as_lifecycle_error(cleanup_error) + + async def _cleanup_failed_start(self) -> None: + client = self._client + self._client = None + if client is not None: + try: + await client.close() + except Exception: + LOGGER.exception("Codex SDK cleanup after start failure also failed") + cleanup_error = _cleanup_relay(self._relay, self._gateway_process) + self._relay = None + self._gateway_process = None + if cleanup_error is not None: + LOGGER.error( + "Codex Relay cleanup after start failure also failed: %s", + cleanup_error.code, + ) + + async def run_codex(payload: dict[str, Any]) -> dict[str, Any]: """Run one Fabric invocation with SDK-owned Codex execution.""" @@ -1045,36 +1289,14 @@ async def run_codex(payload: dict[str, Any]) -> dict[str, Any]: gateway_process = None cleanup_error: AdapterRelayError | None = None try: - if relay is not None: - try: - gateway_process = relay_gateway.start_relay_gateway( - launch=relay.gateway, cwd=resolve_cwd(payload) - ) - except relay_gateway.RelayGatewayError as error: - raise AdapterRelayError( - "codex_relay_start_failed", - "NeMo Relay gateway failed to start", - metadata={"gateway_log_path": str(relay.gateway.log_path)}, - ) from error + gateway_process = _start_relay_gateway(payload, relay) output, thread_id = await invoke_codex_sdk( payload, prior_thread_id=prior_thread_id, relay=relay ) if not output["failed"] and thread_id is not None: save_thread_id(payload, fabric_runtime_id, thread_id) finally: - if gateway_process is not None: - try: - relay_gateway.stop_relay_gateway(gateway_process) - except relay_gateway.RelayGatewayError: - cleanup_error = AdapterRelayError( - "codex_relay_stop_failed", - "NeMo Relay gateway failed to stop", - metadata={ - "gateway_log_path": str(relay.gateway.log_path) - if relay is not None - else "" - }, - ) + cleanup_error = _cleanup_relay(relay, gateway_process) if relay is not None: output = _relay_output(output, relay) @@ -1108,6 +1330,9 @@ def run(payload: dict[str, Any]) -> dict[str, Any]: def main() -> None: + if lifecycle.is_lifecycle_host(os.environ): + lifecycle.serve(CodexRuntime) + return try: payload = common_utils.load_payload() except Exception: diff --git a/adapters/common/src/nemo_fabric_adapters/common/relay_gateway.py b/adapters/common/src/nemo_fabric_adapters/common/relay_gateway.py index 3d7d1f71..a292fafb 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/relay_gateway.py +++ b/adapters/common/src/nemo_fabric_adapters/common/relay_gateway.py @@ -30,7 +30,7 @@ class RelayGatewayError(RuntimeError): @dataclass(frozen=True) class RelayGatewayLaunch: - """Complete invocation-scoped inputs for launching a Relay gateway.""" + """Complete inputs for launching one Relay gateway.""" executable: Path config_path: Path @@ -70,7 +70,7 @@ def find_available_tcp_port(host: str = "127.0.0.1") -> int: def relay_cli_contract(executable: Path) -> RelayCliContract: - """Resolve and validate the external Relay CLI contract once per invocation.""" + """Resolve and validate the external Relay CLI contract for one launch.""" try: completed = subprocess.run( @@ -150,7 +150,7 @@ def start_relay_gateway( launch: RelayGatewayLaunch, cwd: Path, ) -> subprocess.Popen[Any]: - """Launch and health-check one invocation-scoped Relay gateway.""" + """Launch and health-check one Relay gateway.""" if not launch.config_path.is_file(): raise RelayGatewayError("NeMo Relay gateway configuration was not generated") diff --git a/examples/harbor/swebench/adapters/claude/fabric-adapter.json b/examples/harbor/swebench/adapters/claude/fabric-adapter.json index ed36b146..ee998f8c 100644 --- a/examples/harbor/swebench/adapters/claude/fabric-adapter.json +++ b/examples/harbor/swebench/adapters/claude/fabric-adapter.json @@ -17,5 +17,10 @@ "integration_modes": ["hooks", "gateway"] } } + }, + "runtime": { + "local_host": { + "contract_version": "fabric.adapter.lifecycle/v1alpha1" + } } } diff --git a/tests/adapters/test_claude_adapter.py b/tests/adapters/test_claude_adapter.py index 22d388f6..190528fb 100644 --- a/tests/adapters/test_claude_adapter.py +++ b/tests/adapters/test_claude_adapter.py @@ -70,6 +70,11 @@ def test_claude_descriptor_is_narrow_and_versioned(): } } }, + "runtime": { + "local_host": { + "contract_version": adapter.lifecycle.CONTRACT_VERSION, + } + }, } @@ -559,6 +564,144 @@ async def query_result(*, prompt, options): assert os.environ["FABRIC_UNRELATED_SECRET"] == "do-not-forward" +async def test_claude_runtime_reuses_one_connected_sdk_client( + claude_payload, monkeypatch +): + clients = [] + + class FakeClient: + def __init__(self, options): + self.options = options + self.connect_count = 0 + self.disconnect_count = 0 + self.prompts = [] + clients.append(self) + + async def connect(self): + self.connect_count += 1 + + async def query(self, prompt): + self.prompts.append(prompt) + + async def receive_response(self): + yield AssistantMessage( + content=[TextBlock(text="done")], model="claude-test-model" + ) + yield ResultMessage( + subtype="success", + duration_ms=100, + duration_api_ms=80, + is_error=False, + num_turns=1, + session_id="claude-session", + total_cost_usd=0.02, + usage={"input_tokens": 1, "output_tokens": 1}, + result=f"done-{len(self.prompts)}", + ) + + async def disconnect(self): + self.disconnect_count += 1 + + async def interrupt(self): + raise AssertionError("successful invocation must not be interrupted") + + monkeypatch.setattr(adapter, "ClaudeSDKClient", FakeClient) + + runtime = adapter.ClaudeRuntime() + await runtime.start(claude_payload) + first = await runtime.invoke(claude_payload) + claude_payload["runtime_context"]["invocation_id"] = "invocation-2" + claude_payload["request"]["input"] = {"not": "text"} + invalid = await runtime.invoke(claude_payload) + claude_payload["runtime_context"]["invocation_id"] = "invocation-3" + claude_payload["request"]["input"] = "Inspect the tests" + second = await runtime.invoke(claude_payload) + await runtime.stop() + + assert len(clients) == 1 + assert clients[0].connect_count == 1 + assert clients[0].disconnect_count == 1 + assert clients[0].prompts == ["Inspect the patch", "Inspect the tests"] + assert clients[0].options.resume is None + assert first["response"] == "done-1" + assert second["response"] == "done-2" + assert invalid["error"]["code"] == "claude_invalid_request" + assert first["session_id"] == second["session_id"] == "claude-session" + + +async def test_claude_runtime_owns_one_relay_gateway_until_stop( + relay_payload, monkeypatch, tmp_path +): + relay = adapter.ClaudeRelaySettings( + gateway=adapter.relay_gateway.RelayGatewayLaunch( + executable=tmp_path / "nemo-relay", + config_path=tmp_path / "relay-config" / "config.toml", + bind="127.0.0.1:43210", + url="http://127.0.0.1:43210", + log_path=tmp_path / "relay-config" / "gateway.log", + ), + plugin_config={"version": 1, "components": []}, + plugin_path=tmp_path / "relay-plugin", + ) + relay.plugin_path.mkdir() + process = MagicMock() + mock_start = MagicMock(return_value=process) + mock_stop = MagicMock() + + class FakeClient: + def __init__(self, options): + self.options = options + + async def connect(self): + pass + + async def query(self, _prompt): + pass + + async def receive_response(self): + yield ResultMessage( + subtype="success", + duration_ms=10, + duration_api_ms=8, + is_error=False, + num_turns=1, + session_id="claude-session", + total_cost_usd=0.01, + usage={"input_tokens": 1, "output_tokens": 1}, + result="done", + ) + + async def disconnect(self): + pass + + async def interrupt(self): + raise AssertionError("successful invocation must not be interrupted") + + monkeypatch.setattr(adapter, "ClaudeSDKClient", FakeClient) + monkeypatch.setattr(adapter, "prepare_claude_relay", MagicMock(return_value=relay)) + monkeypatch.setattr(adapter.relay_gateway, "start_relay_gateway", mock_start) + monkeypatch.setattr(adapter.relay_gateway, "stop_relay_gateway", mock_stop) + + runtime = adapter.ClaudeRuntime() + await runtime.start(relay_payload) + first = await runtime.invoke(relay_payload) + relay_payload["runtime_context"]["invocation_id"] = "invocation-2" + second = await runtime.invoke(relay_payload) + + mock_start.assert_called_once_with( + launch=relay.gateway, + cwd=Path(relay_payload["runtime_context"]["environment"]["workspace"]), + ) + mock_stop.assert_not_called() + assert first["relay_runtime"]["gateway_url"] == relay.gateway.url + assert second["relay_runtime"]["gateway_url"] == relay.gateway.url + + await runtime.stop() + + mock_stop.assert_called_once_with(process) + assert not relay.plugin_path.exists() + + async def test_run_claude_supervises_relay_and_reports_artifacts( relay_payload, monkeypatch, tmp_path ): diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index 4b8913cc..34a0602e 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -128,9 +128,7 @@ async def thread_start(**_kwargs): async def thread_resume(thread_id, **_kwargs): resumed_thread_id = mock_codex.resume_thread_id or thread_id - mock_client.thread = mock_thread( - resumed_thread_id, mock_codex.next_result - ) + mock_client.thread = mock_thread(resumed_thread_id, mock_codex.next_result) return mock_client.thread mock_client.close.side_effect = close @@ -175,10 +173,7 @@ def test_sdk_oneshot_uses_native_thread_and_turn_contract( ) assert client.config.env["CODEX_HOME"] == str(tmp_path / "codex-home") assert client.config.env["CODEX_EXPLICIT"] == "forward-me" - assert ( - client.config.env["CODEX_INTERNAL_ORIGINATOR_OVERRIDE"] - == "codex_python_sdk" - ) + assert client.config.env["CODEX_INTERNAL_ORIGINATOR_OVERRIDE"] == "codex_python_sdk" assert client.config.env["FABRIC_UNRELATED_SECRET"] == "" start = client.thread_start.await_args.kwargs assert start["model"] == "gpt-5.4" @@ -364,9 +359,7 @@ def test_sdk_keeps_absolute_codex_runtime_path(codex_payload, tmp_path): assert config.codex_bin == str(codex_bin) -def test_runtime_resumes_sdk_thread_across_invocations( - codex_payload, mock_codex -): +def test_runtime_resumes_sdk_thread_across_invocations(codex_payload, mock_codex): first = adapter.run(codex_payload) codex_payload["runtime_context"]["invocation_id"] = "invocation-2" codex_payload["request"]["input"] = "Continue." @@ -387,6 +380,72 @@ def test_runtime_resumes_sdk_thread_across_invocations( } +async def test_persistent_runtime_reuses_one_client_and_thread( + codex_payload, mock_codex +): + start_payload = dict(codex_payload) + start_payload.pop("request") + runtime = adapter.CodexRuntime() + + await runtime.start(start_payload) + first = await runtime.invoke(codex_payload) + codex_payload["runtime_context"]["invocation_id"] = "invocation-2" + codex_payload["request"]["input"] = "Continue." + second = await runtime.invoke(codex_payload) + await runtime.stop() + + assert first["thread_id"] == second["thread_id"] == "thread-123" + assert len(mock_codex.instances) == 1 + client = mock_codex.instances[0] + client.thread_start.assert_awaited_once() + client.thread_resume.assert_not_awaited() + assert client.thread.turn.await_count == 2 + assert client.thread.turn.await_args_list[1].args[0] == "Continue." + client.close.assert_awaited_once() + + +async def test_persistent_runtime_owns_one_relay_gateway( + codex_payload, mock_codex, monkeypatch, tmp_path +): + codex_payload["telemetry_plan"] = { + "providers": ["relay"], + "relay_enabled": True, + } + gateway = adapter.relay_gateway.RelayGatewayLaunch( + executable=tmp_path / "nemo-relay", + config_path=tmp_path / "relay" / "config.toml", + bind="127.0.0.1:43210", + url="http://127.0.0.1:43210", + log_path=tmp_path / "relay" / "gateway.log", + ) + relay = adapter.CodexRelaySettings( + gateway=gateway, + plugin_config={"version": 1, "components": []}, + ) + process = MagicMock() + start_gateway = MagicMock(return_value=process) + stop_gateway = MagicMock() + monkeypatch.setattr(adapter, "prepare_codex_relay", MagicMock(return_value=relay)) + monkeypatch.setattr(adapter.relay_gateway, "start_relay_gateway", start_gateway) + monkeypatch.setattr(adapter.relay_gateway, "stop_relay_gateway", stop_gateway) + start_payload = dict(codex_payload) + start_payload.pop("request") + runtime = adapter.CodexRuntime() + + await runtime.start(start_payload) + await runtime.invoke(codex_payload) + codex_payload["runtime_context"]["invocation_id"] = "invocation-2" + await runtime.invoke(codex_payload) + await runtime.stop() + + assert len(mock_codex.instances) == 1 + start_gateway.assert_called_once_with( + launch=gateway, + cwd=Path(codex_payload["runtime_context"]["environment"]["workspace"]), + ) + stop_gateway.assert_called_once_with(process) + + def test_runtime_rejects_corrupt_thread_state(codex_payload): state_path = adapter.runtime_state_path(codex_payload, "runtime-1") state_path.parent.mkdir(parents=True) @@ -536,9 +595,7 @@ def test_nvidia_provider_requires_endpoint(codex_payload, mock_codex): mock_codex.assert_not_called() -def test_resume_rejects_changed_sdk_thread_identity( - codex_payload, mock_codex -): +def test_resume_rejects_changed_sdk_thread_identity(codex_payload, mock_codex): adapter.save_thread_id(codex_payload, "runtime-1", "thread-persisted") mock_codex.resume_thread_id = "thread-replaced" @@ -572,9 +629,7 @@ def test_relay_uses_gateway_and_request_scoped_sdk_config( start_gateway = MagicMock(return_value=process) stop_gateway = MagicMock() monkeypatch.setattr(adapter, "prepare_codex_relay", MagicMock(return_value=relay)) - monkeypatch.setattr( - adapter.relay_gateway, "start_relay_gateway", start_gateway - ) + monkeypatch.setattr(adapter.relay_gateway, "start_relay_gateway", start_gateway) monkeypatch.setattr(adapter.relay_gateway, "stop_relay_gateway", stop_gateway) os.environ["FABRIC_RELAY_CONFIG_PATH"] = str(tmp_path / "relay.json") @@ -655,9 +710,7 @@ def test_prepare_relay_reuses_one_resolved_executable( ) write = MagicMock(return_value=(config_path, plugin_path)) monkeypatch.setattr(adapter.relay_gateway, "resolve_relay_command", resolve) - monkeypatch.setattr( - adapter.relay_gateway, "relay_cli_contract", contract - ) + monkeypatch.setattr(adapter.relay_gateway, "relay_cli_contract", contract) monkeypatch.setattr(adapter.relay_gateway, "find_available_tcp_port", lambda: 43210) monkeypatch.setattr( adapter.common_utils, @@ -745,9 +798,7 @@ def test_native_sdk_controls_and_telemetry_are_request_scoped( "enabled": True, "endpoint": "http://localhost:4318/v1/traces", "transport": "http_binary", - "resource_attributes": { - "deployment.environment": "test" - }, + "resource_attributes": {"deployment.environment": "test"}, } }, } @@ -776,9 +827,7 @@ def test_native_sdk_controls_and_telemetry_are_request_scoped( assert turn["output_schema"]["required"] == ["summary"] -def test_timeout_interrupts_native_turn_and_closes_sdk( - codex_payload, mock_codex -): +def test_timeout_interrupts_native_turn_and_closes_sdk(codex_payload, mock_codex): mock_blocking_thread = mock_thread("thread-timeout") async def block(): @@ -856,6 +905,9 @@ def test_descriptor_has_no_codex_binary_requirement(): "skills", "telemetry", ] + assert descriptor["runtime"]["local_host"] == { + "contract_version": "fabric.adapter.lifecycle/v1alpha1" + } assert "requirements" not in descriptor @@ -939,9 +991,7 @@ def test_environment_rejects_non_string_runtime_telemetry_env( @pytest.mark.parametrize("telemetry", [[], "invalid"]) -def test_environment_rejects_non_mapping_runtime_telemetry( - codex_payload, telemetry -): +def test_environment_rejects_non_mapping_runtime_telemetry(codex_payload, telemetry): codex_payload["runtime_context"]["telemetry"] = telemetry with pytest.raises( diff --git a/tests/e2e/test_claude.py b/tests/e2e/test_claude.py index ee5e5eca..90ee5cfc 100644 --- a/tests/e2e/test_claude.py +++ b/tests/e2e/test_claude.py @@ -130,7 +130,7 @@ def fabric_config( return config -async def test_fabric_session_launches_fresh_processes_and_resumes(tmp_path): +async def test_fabric_session_reuses_persistent_claude_runtime(tmp_path): config = fabric_config(tmp_path, cli_path=MOCK_CLAUDE_CLI) async with await Fabric().start_runtime(config, base_dir=tmp_path) as runtime: @@ -146,14 +146,18 @@ async def test_fabric_session_launches_fresh_processes_and_resumes(tmp_path): assert first.output["usage"] == {"input_tokens": 1, "output_tokens": 2} assert first.output["cost_usd"] == 0.001 assert [event["type"] for event in first.output["events"]] == ["AssistantMessage"] - arguments = [json.loads(line) for line in (tmp_path / "claude-args.jsonl").read_text().splitlines()] - assert len(arguments) == 2 + assert first.metadata["adapter_runner"] == "persistent_local_host" + assert first.metadata["host_pid"] == second.metadata["host_pid"] + arguments = [ + json.loads(line) + for line in (tmp_path / "claude-args.jsonl").read_text().splitlines() + ] + assert len(arguments) == 1 assert "--resume" not in arguments[0] - assert arguments[1][arguments[1].index("--resume") + 1] == SESSION_ID assert all("--mcp-config" in args for args in arguments) assert all("--plugin-dir" in args for args in arguments) plugin_paths = [args[args.index("--plugin-dir") + 1] for args in arguments] - assert plugin_paths[0] == plugin_paths[1] + assert len(plugin_paths) == 1 assert not any(artifact.kind == "stderr" for artifact in second.artifacts.artifacts) @@ -236,7 +240,9 @@ async def test_live_claude_one_shot_and_session(tmp_path): assert one_shot.status == "succeeded" session_root = tmp_path / "session" - async with await fabric.start_runtime(fabric_config(session_root), base_dir=session_root) as session: + async with await fabric.start_runtime( + fabric_config(session_root), base_dir=session_root + ) as session: first = await session.invoke(input="Remember token FABRIC-CONTINUITY-7") second = await session.invoke( input="Reply only with the token I asked you to remember" diff --git a/tests/e2e/test_codex.py b/tests/e2e/test_codex.py index 8c4da773..1e780404 100644 --- a/tests/e2e/test_codex.py +++ b/tests/e2e/test_codex.py @@ -81,6 +81,8 @@ async def _run() -> None: assert first["status"] == second["status"] == "succeeded", results assert first["output"]["thread_id"] == second["output"]["thread_id"], results assert nonce in second["output"]["response"], second.to_mapping() + assert first["metadata"]["adapter_runner"] == "persistent_local_host", results + assert first["metadata"]["host_pid"] == second["metadata"]["host_pid"], results assert first["output"]["events"], first.to_mapping() assert second["output"]["usage"] is not None, second.to_mapping() @@ -120,6 +122,8 @@ async def _run_relay(relay_command: str) -> None: assert first["status"] == second["status"] == "succeeded", results assert first["output"]["thread_id"] == second["output"]["thread_id"], results assert nonce in second["output"]["response"], second.to_mapping() + assert first["metadata"]["adapter_runner"] == "persistent_local_host", results + assert first["metadata"]["host_pid"] == second["metadata"]["host_pid"], results for turn in (first, second): assert turn["output"]["relay_runtime"]["enabled"] is True, turn.to_mapping() assert {item["kind"] for item in turn["output"]["relay_artifacts"]} >= { diff --git a/tests/fixtures/claude/mock-claude-cli.py b/tests/fixtures/claude/mock-claude-cli.py index 164ca7f4..f453d5a6 100755 --- a/tests/fixtures/claude/mock-claude-cli.py +++ b/tests/fixtures/claude/mock-claude-cli.py @@ -77,4 +77,3 @@ ), flush=True, ) - break From 933c3fdef89248925bae793a15c2f62c9e1513bb Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Fri, 17 Jul 2026 15:31:26 -0700 Subject: [PATCH 03/34] feat(adapters): persist Deep Agents and Hermes runtimes Signed-off-by: Ajay Thorve --- adapters/deepagents/README.md | 22 +- adapters/deepagents/fabric-adapter.json | 5 + .../deepagents/adapter.py | 450 +++++++++++----- adapters/hermes/README.md | 13 + adapters/hermes/fabric-adapter.json | 5 + .../nemo_fabric_adapters/hermes/adapter.py | 482 ++++++++++++------ .../adapters/hermes/fabric-adapter.json | 5 + tests/adapters/test_deepagents.py | 187 +++++-- tests/adapters/test_hermes_adapter.py | 153 ++++-- tests/e2e/test_deepagents.py | 44 +- tests/e2e/test_hermes_e2e.py | 52 +- tests/e2e/test_hermes_runtime.py | 7 +- 12 files changed, 1032 insertions(+), 393 deletions(-) diff --git a/adapters/deepagents/README.md b/adapters/deepagents/README.md index 2077546e..b62158aa 100644 --- a/adapters/deepagents/README.md +++ b/adapters/deepagents/README.md @@ -93,20 +93,20 @@ normalized failure result rather than a raw traceback. ## Runtime Modes -A one-shot `run` streams the agent with `astream` (buffering `updates` events and -`values` snapshots) and returns the final agent message, buffered messages and -per-step events, usage, and the LangGraph thread ID in the normalized Fabric -result. Each one-shot run gets a fresh Fabric `runtime_id`, so `resumed` is -`false`. +A one-shot `run` starts a local adapter host, compiles one Deep Agents graph, +opens its async LangGraph checkpointer, invokes the graph once with `astream`, +and then closes the checkpointer and host. The result contains the final agent +message, buffered messages and per-step events, usage, and the LangGraph thread +ID. Each one-shot run gets a fresh Fabric `runtime_id`, so `resumed` is `false`. Multi-turn and resume are keyed by the Fabric `runtime_id`, which is stable across `invoke` calls in a started runtime (`start_runtime`) and fresh for each -one-shot run. On the first turn the adapter generates a LangGraph thread ID and -records it against the runtime; later turns of the same runtime reuse that thread -ID and a persistent LangGraph SQLite checkpointer to resume (`resumed` is `true`). -The checkpointer lives under `harness.settings.state_dir` (default the runtime -artifacts directory). Fabric owns the runtime-to-thread correlation record; -LangGraph owns the transcript. +one-shot run. The host compiles the graph and opens the checkpointer once during +runtime start. Every turn reuses both native objects and the same LangGraph +thread ID; later turns report `resumed` as `true`. The checkpointer lives under +`harness.settings.state_dir` (default the runtime artifacts directory) and is +closed during runtime stop. Fabric owns the runtime-to-thread correlation +record; LangGraph owns the transcript. The `deepagents_config()` builder in `examples/code_review_agent` is the SDK example. Run it from the CLI with diff --git a/adapters/deepagents/fabric-adapter.json b/adapters/deepagents/fabric-adapter.json index 78a09fe3..444c5924 100644 --- a/adapters/deepagents/fabric-adapter.json +++ b/adapters/deepagents/fabric-adapter.json @@ -20,5 +20,10 @@ "outputs": ["otel", "openinference"] } } + }, + "runtime": { + "local_host": { + "contract_version": "fabric.adapter.lifecycle/v1alpha1" + } } } diff --git a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py index 0adc94c2..cfae7983 100644 --- a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py +++ b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py @@ -25,6 +25,7 @@ from langchain.agents.middleware import AgentMiddleware from langchain_core.messages import ToolMessage +from nemo_fabric_adapters.common import lifecycle import nemo_fabric_adapters.common.utils as common_utils HARNESS = "deepagents" @@ -46,7 +47,15 @@ # bypass the normalized model config, MCP tool resolution, workspace confinement, # or tool gating). FABRIC_OWNED_AGENT_KEYS = frozenset( - {"model", "tools", "backend", "skills", "system_prompt", "middleware", "checkpointer"} + { + "model", + "tools", + "backend", + "skills", + "system_prompt", + "middleware", + "checkpointer", + } ) # Documented, JSON-serializable create_deep_agent options callers may forward # through harness.settings.deepagents. Executable objects (AgentMiddleware, BaseTool, @@ -102,13 +111,18 @@ def resolve_api_key_env(settings: dict[str, Any], model_config: dict[str, Any]) provider = (settings.get("provider") or model_config.get("provider") or "").lower() default = PROVIDER_DEFAULT_API_KEY_ENV.get(provider) if default is None: - raise AdapterConfigError(f"models.default.api_key_env is required for provider '{provider}'.") + raise AdapterConfigError( + f"models.default.api_key_env is required for provider '{provider}'." + ) return default def main() -> None: """Subprocess entrypoint used by the ``python -m`` process path.""" + if lifecycle.is_lifecycle_host(os.environ): + lifecycle.serve(DeepAgentsRuntime) + return output = run(common_utils.load_payload()) print(json.dumps(output, sort_keys=True)) if output.get("failed"): @@ -157,9 +171,13 @@ def selected_model_config(payload: dict[str, Any]) -> dict[str, Any]: return models.get(settings.get("model", "default"), {}) or {} -def resolve_base_url(settings: dict[str, Any], model_config: dict[str, Any]) -> str | None: +def resolve_base_url( + settings: dict[str, Any], model_config: dict[str, Any] +) -> str | None: base_url = ( - settings.get("base_url") or (model_config.get("settings") or {}).get("base_url") or model_config.get("base_url") + settings.get("base_url") + or (model_config.get("settings") or {}).get("base_url") + or model_config.get("base_url") ) if base_url: return base_url @@ -184,14 +202,18 @@ def build_chat_model(payload: dict[str, Any]) -> tuple[Any, str, str | None]: model_config = selected_model_config(payload) model_name = settings.get("model_name") or model_config.get("model") if not model_name: - raise RuntimeError("models.default.model is required for the Deep Agents adapter") + raise RuntimeError( + "models.default.model is required for the Deep Agents adapter" + ) api_key_env = resolve_api_key_env(settings, model_config) api_key = os.environ.get(api_key_env) if not api_key: raise RuntimeError(f"{api_key_env} is required for the Deep Agents adapter") - provider = (settings.get("provider") or model_config.get("provider") or "nvidia").lower() + provider = ( + settings.get("provider") or model_config.get("provider") or "nvidia" + ).lower() base_url = resolve_base_url(settings, model_config) temperature = settings.get("temperature", model_config.get("temperature")) @@ -204,7 +226,11 @@ def build_chat_model(payload: dict[str, Any]) -> tuple[Any, str, str | None]: kwargs["temperature"] = temperature if base_url: kwargs["base_url"] = base_url - return init_chat_model(**_supported_kwargs(init_chat_model, kwargs)), model_name, base_url + return ( + init_chat_model(**_supported_kwargs(init_chat_model, kwargs)), + model_name, + base_url, + ) from langchain_openai import ChatOpenAI @@ -220,7 +246,9 @@ def resolve_backend(payload: dict[str, Any]) -> Any: """Root the Deep Agents filesystem backend at the Fabric workspace, if set.""" environment = common_utils.environment_payload(payload) - workspace = environment.get("workspace") or common_utils.settings_payload(payload).get("workspace") + workspace = environment.get("workspace") or common_utils.settings_payload( + payload + ).get("workspace") if not workspace: return None root = Path(str(workspace)) @@ -287,7 +315,9 @@ def _mcp_connection(name: str, spec: dict[str, Any]) -> dict[str, Any]: # McpServerPlan carries the URL or command in ``url``; there is no ``command``. target = os.path.expandvars(str(spec.get("url") or "")).strip() if not target: - raise AdapterConfigError(f"MCP server '{name}' requires a url (or command in url).") + raise AdapterConfigError( + f"MCP server '{name}' requires a url (or command in url)." + ) if transport in ("stdio", "command", "process"): parts = shlex.split(target) if not parts: @@ -296,7 +326,9 @@ def _mcp_connection(name: str, spec: dict[str, Any]) -> dict[str, Any]: if transport in ("", "http", "streamable_http", "streamablehttp"): transport = "streamable_http" if transport not in VALID_MCP_TRANSPORTS: - raise AdapterConfigError(f"MCP server '{name}' has unsupported transport '{transport}'.") + raise AdapterConfigError( + f"MCP server '{name}' has unsupported transport '{transport}'." + ) return {"transport": transport, "url": target} @@ -333,7 +365,11 @@ def load_thread_id(payload: dict[str, Any], runtime_id: str) -> str | None: if not json_path.is_file(): return None value = json.loads(json_path.read_text(encoding="utf-8")) - if not isinstance(value, dict) or value.get("runtime_id") != runtime_id or not value.get("thread_id"): + if ( + not isinstance(value, dict) + or value.get("runtime_id") != runtime_id + or not value.get("thread_id") + ): raise RuntimeError(f"invalid Deep Agents runtime state in {json_path}") return str(value["thread_id"]) @@ -341,9 +377,14 @@ def load_thread_id(payload: dict[str, Any], runtime_id: str) -> str | None: def save_thread_id(payload: dict[str, Any], runtime_id: str, thread_id: str) -> None: json_path, _ = runtime_state_paths(payload, runtime_id) json_path.parent.mkdir(parents=True, exist_ok=True) - invocation_id = common_utils.runtime_context(payload).get("invocation_id") or "pending" + invocation_id = ( + common_utils.runtime_context(payload).get("invocation_id") or "pending" + ) tmp = json_path.with_suffix(f".{invocation_id}.tmp") - tmp.write_text(json.dumps({"runtime_id": runtime_id, "thread_id": thread_id}, indent=2), encoding="utf-8") + tmp.write_text( + json.dumps({"runtime_id": runtime_id, "thread_id": thread_id}, indent=2), + encoding="utf-8", + ) os.replace(tmp, json_path) @@ -373,7 +414,9 @@ async def close_checkpointer(checkpointer: Any) -> None: # --- invocation ------------------------------------------------------------ -async def build_agent_kwargs(payload: dict[str, Any], model: Any, settings: dict[str, Any]) -> dict[str, Any]: +async def build_agent_kwargs( + payload: dict[str, Any], model: Any, settings: dict[str, Any] +) -> dict[str, Any]: kwargs: dict[str, Any] = { "model": model, "tools": await resolve_tools(payload), @@ -426,7 +469,10 @@ def _validated_passthrough(extra: Any) -> dict[str, Any]: def _block_subagent(subagent: dict[str, Any], blocked: set[str]) -> dict[str, Any]: gated = dict(subagent) - gated["middleware"] = [*(gated.get("middleware") or []), blocked_tools_middleware(blocked)] + gated["middleware"] = [ + *(gated.get("middleware") or []), + blocked_tools_middleware(blocked), + ] return gated @@ -443,12 +489,18 @@ def _gated_subagents(subagents: Any, blocked: set[str]) -> list[dict[str, Any]]: gated: list[dict[str, Any]] = [] for subagent in configured: if not isinstance(subagent, dict): - raise AdapterConfigError("Deep Agents subagents must be mappings when tools.blocked is configured.") + raise AdapterConfigError( + "Deep Agents subagents must be mappings when tools.blocked is configured." + ) name = str(subagent.get("name") or "") if "graph_id" in subagent: - raise AdapterConfigError(f"tools.blocked cannot be enforced for remote Deep Agents subagent '{name}'.") + raise AdapterConfigError( + f"tools.blocked cannot be enforced for remote Deep Agents subagent '{name}'." + ) if "runnable" in subagent: - raise AdapterConfigError(f"tools.blocked cannot be enforced for precompiled Deep Agents subagent '{name}'.") + raise AdapterConfigError( + f"tools.blocked cannot be enforced for precompiled Deep Agents subagent '{name}'." + ) gated.append(_block_subagent(subagent, blocked)) if not any(subagent.get("name") == "general-purpose" for subagent in gated): @@ -458,118 +510,236 @@ def _gated_subagents(subagents: Any, blocked: set[str]) -> list[dict[str, Any]]: return gated -async def run_deepagents(payload: dict[str, Any]) -> dict[str, Any]: - settings = common_utils.settings_payload(payload) - request = payload.get("request") or {} - telemetry_providers = common_utils.telemetry_providers(payload) - relay_enabled = common_utils.relay_enabled(payload) - telemetry_provider = "relay" if relay_enabled else ("native" if "native" in telemetry_providers else "") - - user_message = request.get("input") or "" - if not isinstance(user_message, str): - user_message = json.dumps(user_message, sort_keys=True) - - runtime_id = common_utils.runtime_context(payload).get("runtime_id") - model_name: str | None = None - base_url: str | None = None - prior_thread_id: str | None = None - thread_id: str | None = None - observability: Observability | None = None - result_state: Any = None - events: list[dict[str, Any]] = [] - turn_messages: list[dict[str, Any]] = [] - error: str | None = None - checkpointer = None - try: - # Preflight, model construction, and resume-state load run inside the guarded - # scope so a misconfiguration (e.g. a missing credential) returns a normalized - # failure result rather than raising a raw traceback. - preflight_check(payload) - model, model_name, base_url = build_chat_model(payload) - prior_thread_id = load_thread_id(payload, runtime_id) if runtime_id else None - thread_id = (prior_thread_id or uuid.uuid4().hex) if runtime_id else None - observability = resolve_observability(payload, telemetry_provider, relay_enabled) - - agent_kwargs = await build_agent_kwargs(payload, model, settings) - # Acquire the async checkpointer inside the guarded scope so a setup failure - # (tools, backend, observability) can never leak the SQLite connection. - if runtime_id: - _, state_sqlite = runtime_state_paths(payload, runtime_id) - checkpointer = await open_checkpointer(state_sqlite) - agent_kwargs["checkpointer"] = checkpointer - - if observability is not None: - # Both the relay and native telemetry providers run through nemo_relay's - # plugin, so nemo-relay is required for either. It is imported lazily only - # here (import-time dependency neutrality); fail with an actionable message - # when the optional Relay extra is not installed rather than a raw - # ModuleNotFoundError from the imports below. - import importlib.util - - if importlib.util.find_spec("nemo_relay") is None: - raise _relay_dependency_error() - try: - api_config = common_utils.relay_api_plugin_config(observability.plugin_config) - from nemo_relay import ScopeType, plugin, scope - from nemo_relay.integrations.deepagents import ( - NemoRelayDeepAgentsCallbackHandler, - add_nemo_relay_integration, +class DeepAgentsRuntime: + """One compiled Deep Agents graph and checkpointer owned by a Fabric runtime.""" + + def __init__(self) -> None: + self._started = False + self._runtime_id: str | None = None + self._model_name: str | None = None + self._base_url: str | None = None + self._prior_thread_id: str | None = None + self._thread_id: str | None = None + self._completed_invocations = 0 + self._checkpointer: Any = None + self._agent: Any = None + self._observability: Observability | None = None + self._telemetry_provider = "" + self._relay_plugin: Any = None + self._relay_scope: Any = None + self._relay_scope_type: Any = None + self._relay_api_config: Any = None + self._callback_handler_type: Any = None + + async def start(self, payload: dict[str, Any]) -> None: + if self._started: + raise lifecycle.LifecycleError( + "deepagents_runtime_already_started", + "Deep Agents runtime is already started", + ) + + try: + preflight_check(payload) + settings = common_utils.settings_payload(payload) + runtime_id = common_utils.runtime_context(payload).get("runtime_id") + model, self._model_name, self._base_url = build_chat_model(payload) + self._runtime_id = runtime_id + self._prior_thread_id = ( + load_thread_id(payload, runtime_id) if runtime_id else None + ) + self._thread_id = ( + (self._prior_thread_id or uuid.uuid4().hex) if runtime_id else None + ) + + telemetry_providers = common_utils.telemetry_providers(payload) + relay_enabled = common_utils.relay_enabled(payload) + self._telemetry_provider = ( + "relay" + if relay_enabled + else "native" + if "native" in telemetry_providers + else "" + ) + self._observability = resolve_observability( + payload, + self._telemetry_provider, + relay_enabled, + ) + + agent_kwargs = await build_agent_kwargs(payload, model, settings) + if runtime_id: + _, state_sqlite = runtime_state_paths(payload, runtime_id) + self._checkpointer = await open_checkpointer(state_sqlite) + agent_kwargs["checkpointer"] = self._checkpointer + + if self._observability is not None: + agent_kwargs = self._configure_observability(agent_kwargs) + + from deepagents import create_deep_agent + + self._agent = create_deep_agent( + **_supported_kwargs(create_deep_agent, agent_kwargs) + ) + self._started = True + except BaseException: + await self.stop() + raise + + def _configure_observability(self, agent_kwargs: dict[str, Any]) -> dict[str, Any]: + import importlib.util + + if importlib.util.find_spec("nemo_relay") is None: + raise _relay_dependency_error() + try: + from nemo_relay import ScopeType, plugin, scope + from nemo_relay.integrations.deepagents import ( + NemoRelayDeepAgentsCallbackHandler, + add_nemo_relay_integration, + ) + except (ImportError, AttributeError) as exc: + raise _relay_dependency_error() from exc + + assert self._observability is not None + self._relay_api_config = common_utils.relay_api_plugin_config( + self._observability.plugin_config + ) + self._relay_plugin = plugin + self._relay_scope = scope + self._relay_scope_type = ScopeType + self._callback_handler_type = NemoRelayDeepAgentsCallbackHandler + return add_nemo_relay_integration(agent_kwargs) + + async def invoke(self, payload: dict[str, Any]) -> dict[str, Any]: + if not self._started or self._agent is None: + raise lifecycle.LifecycleError( + "deepagents_runtime_not_started", + "Deep Agents runtime is not started", + ) + runtime_id = common_utils.runtime_context(payload).get("runtime_id") + if runtime_id != self._runtime_id: + raise lifecycle.LifecycleError( + "deepagents_runtime_mismatch", + "Deep Agents invocation does not match the active runtime", + ) + + request = payload.get("request") or {} + user_message = request.get("input") or "" + if not isinstance(user_message, str): + user_message = json.dumps(user_message, sort_keys=True) + + result_state: Any = None + events: list[dict[str, Any]] = [] + turn_messages: list[dict[str, Any]] = [] + error: str | None = None + resumed = bool(self._prior_thread_id) or self._completed_invocations > 0 + try: + if self._observability is not None: + callback_handler = self._callback_handler_type() + async with self._relay_plugin.plugin(self._relay_api_config): + with self._relay_scope.scope( + "deepagents-request", self._relay_scope_type.Agent + ): + ( + result_state, + events, + turn_messages, + ) = await invoke_compiled_agent( + self._agent, + user_message, + self._thread_id, + callbacks=[callback_handler], + ) + else: + result_state, events, turn_messages = await invoke_compiled_agent( + self._agent, + user_message, + self._thread_id, ) - except (ImportError, AttributeError) as exc: - raise _relay_dependency_error() from exc - - # add_nemo_relay_integration injects the Deep Agents middleware (model/tool - # calls, skill/subagent config marks) into create_deep_agent's kwargs and the - # same middleware into in-process dictionary-style subagents. The callback - # handler captures LangGraph scopes and human-in-the-loop interrupt/resume - # marks. The "deepagents-request" Agent scope contains the top-level Fabric - # invocation. All three apply uniformly to one-shot, multi-turn, resumed, and - # subagent-enabled runs because they share this single invocation path. - wrapped = add_nemo_relay_integration(agent_kwargs) - callback_handler = NemoRelayDeepAgentsCallbackHandler() - async with plugin.plugin(api_config): - with scope.scope("deepagents-request", ScopeType.Agent): - result_state, events, turn_messages = await invoke_agent( - wrapped, user_message, thread_id, callbacks=[callback_handler] - ) - else: - result_state, events, turn_messages = await invoke_agent(agent_kwargs, user_message, thread_id) - except Exception as exc: # normalized adapter failure - error = f"{type(exc).__name__}: {exc}" - finally: - if checkpointer is not None: - await close_checkpointer(checkpointer) + except Exception as exc: # normalized adapter failure + error = f"{type(exc).__name__}: {exc}" + + if error is None and self._runtime_id and self._thread_id: + save_thread_id(payload, self._runtime_id, self._thread_id) + self._completed_invocations += 1 + + telemetry_runtime, relay_artifacts = self._telemetry_output() + return normalize_output( + model_name=self._model_name, + base_url=self._base_url, + runtime_id=self._runtime_id, + thread_id=self._thread_id, + resumed=resumed, + result_state=result_state, + events=events, + turn_messages=turn_messages, + error=error, + telemetry_runtime=telemetry_runtime, + relay_artifacts=relay_artifacts, + ) - if error is None and runtime_id and thread_id: - save_thread_id(payload, runtime_id, thread_id) + def failure_output( + self, payload: dict[str, Any], error: BaseException + ) -> dict[str, Any]: + telemetry_runtime, relay_artifacts = self._telemetry_output() + return normalize_output( + model_name=self._model_name, + base_url=self._base_url, + runtime_id=self._runtime_id + or common_utils.runtime_context(payload).get("runtime_id"), + thread_id=self._thread_id, + resumed=bool(self._prior_thread_id), + result_state=None, + events=[], + turn_messages=[], + error=f"{type(error).__name__}: {error}", + telemetry_runtime=telemetry_runtime, + relay_artifacts=relay_artifacts, + ) - telemetry_runtime: dict[str, Any] | None = None - relay_artifacts: list[dict[str, str]] | None = None - if observability is not None: + def _telemetry_output( + self, + ) -> tuple[dict[str, Any] | None, list[dict[str, str]] | None]: + if self._observability is None: + return None, None telemetry_runtime = { "enabled": True, - "provider": telemetry_provider, - "emitter": observability.emitter, + "provider": self._telemetry_provider, + "emitter": self._observability.emitter, } - if observability.collect_artifacts: - relay_artifacts = common_utils.collect_relay_artifacts(observability.plugin_config) - - return normalize_output( - model_name=model_name, - base_url=base_url, - runtime_id=runtime_id, - thread_id=thread_id, - resumed=bool(prior_thread_id), - result_state=result_state, - events=events, - turn_messages=turn_messages, - error=error, - telemetry_runtime=telemetry_runtime, - relay_artifacts=relay_artifacts, - ) + relay_artifacts = ( + common_utils.collect_relay_artifacts(self._observability.plugin_config) + if self._observability.collect_artifacts + else None + ) + return telemetry_runtime, relay_artifacts + async def stop(self) -> None: + checkpointer = self._checkpointer + self._checkpointer = None + self._agent = None + self._started = False + if checkpointer is not None: + await close_checkpointer(checkpointer) + + +async def run_deepagents(payload: dict[str, Any]) -> dict[str, Any]: + runtime = DeepAgentsRuntime() + try: + await runtime.start(payload) + except Exception as error: + output = runtime.failure_output(payload, error) + await runtime.stop() + return output + + try: + return await runtime.invoke(payload) + finally: + await runtime.stop() -def _apply_callbacks(config: dict[str, Any], callbacks: list[Any] | None) -> dict[str, Any]: + +def _apply_callbacks( + config: dict[str, Any], callbacks: list[Any] | None +) -> dict[str, Any]: """Append ``callbacks`` to a LangGraph run config, preserving any already set. The Relay callback handler is added after any consumer-provided callbacks so @@ -588,7 +758,26 @@ async def invoke_agent( thread_id: str | None, callbacks: list[Any] | None = None, ) -> tuple[Any, list[dict[str, Any]], list[dict[str, Any]]]: - """Run the agent; return the final state, per-step events, and this turn's messages. + """Create and run an agent for the per-invocation compatibility path.""" + + from deepagents import create_deep_agent + + agent = create_deep_agent(**_supported_kwargs(create_deep_agent, agent_kwargs)) + return await invoke_compiled_agent( + agent, + user_message, + thread_id, + callbacks=callbacks, + ) + + +async def invoke_compiled_agent( + agent: Any, + user_message: str, + thread_id: str | None, + callbacks: list[Any] | None = None, +) -> tuple[Any, list[dict[str, Any]], list[dict[str, Any]]]: + """Run a compiled agent and return state, events, and current-turn messages. On a resumed run the final ``values`` state also replays prior-turn messages, so usage/cost must be aggregated from the messages emitted *this* turn — the @@ -599,9 +788,6 @@ async def invoke_agent( dropped. """ - from deepagents import create_deep_agent - - agent = create_deep_agent(**_supported_kwargs(create_deep_agent, agent_kwargs)) inputs = {"messages": [{"role": "user", "content": user_message}]} config: dict[str, Any] = {} if thread_id: @@ -673,7 +859,9 @@ def resolve_observability( if telemetry_provider == "native": native_config = common_utils.native_telemetry_config(payload) if native_config.get("components"): - return Observability(native_config, "deepagents.observability/native", False) + return Observability( + native_config, "deepagents.observability/native", False + ) return None @@ -759,7 +947,9 @@ def _dedup_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: def _message_to_dict(message: Any) -> dict[str, Any]: - role = getattr(message, "type", None) or getattr(message, "role", None) or "assistant" + role = ( + getattr(message, "type", None) or getattr(message, "role", None) or "assistant" + ) content = getattr(message, "content", "") entry: dict[str, Any] = {"role": role, "content": content} message_id = getattr(message, "id", None) @@ -801,7 +991,9 @@ def _aggregate_usage(messages: list[dict[str, Any]]) -> dict[str, Any] | None: totals[target] = int(totals.get(target, 0)) + value # Cost is not part of LangChain's UsageMetadata; surface it only when a # model/provider reports it on the usage or response metadata. - candidate = usage.get("total_cost") or usage.get("cost") or _metadata_cost(message) + candidate = ( + usage.get("total_cost") or usage.get("cost") or _metadata_cost(message) + ) if isinstance(candidate, (int, float)) and not isinstance(candidate, bool): cost += float(candidate) has_cost = True diff --git a/adapters/hermes/README.md b/adapters/hermes/README.md index 4d560ba2..58ef1d2c 100644 --- a/adapters/hermes/README.md +++ b/adapters/hermes/README.md @@ -37,6 +37,19 @@ The adapter receives a normalized payload from Fabric and materializes Hermes-na `runtimes/` so invocations in one Fabric runtime share Hermes state without sharing config or the session database with another runtime. +## Execution Model + +Each Fabric runtime starts one local adapter host, constructs one Hermes +`AIAgent`, and opens one `SessionDB`. Ordered `Runtime.invoke(...)` calls reuse +those native objects and pass the prior turn's returned transcript back to +`run_conversation(...)`. Runtime stop calls the agent's idempotent `close()` +method, closes the session database, and releases the Relay plugin context when +enabled. + +Hermes Relay telemetry is finalized after each Fabric invocation so its ATOF +and ATIF artifacts are complete when that invocation returns. This telemetry +boundary does not recreate the `AIAgent` or `SessionDB`. + ## Maintaining The Adapter Keep `fabric-adapter.json` aligned with the Python implementation: diff --git a/adapters/hermes/fabric-adapter.json b/adapters/hermes/fabric-adapter.json index f2857470..93f6bcfe 100644 --- a/adapters/hermes/fabric-adapter.json +++ b/adapters/hermes/fabric-adapter.json @@ -32,5 +32,10 @@ ] } } + }, + "runtime": { + "local_host": { + "contract_version": "fabric.adapter.lifecycle/v1alpha1" + } } } diff --git a/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py b/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py index 1faf92ea..f8604ea7 100755 --- a/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py +++ b/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py @@ -13,6 +13,7 @@ import asyncio import inspect import json +import logging import os import sys from contextlib import redirect_stdout @@ -20,6 +21,7 @@ from pathlib import Path from typing import Any +from nemo_fabric_adapters.common import lifecycle import nemo_fabric_adapters.common.utils as common_utils # Default agent loop budget when harness.settings.max_iterations is unset. @@ -27,6 +29,7 @@ # as 1 silently starves multi-step tasks (they run out of budget before # answering while the trial still reports success). See FABRIC-85. DEFAULT_MAX_ITERATIONS: int = 90 +LOGGER = logging.getLogger(__name__) def validate_hermes_telemetry_provider(payload: dict[str, Any]) -> None: @@ -43,7 +46,9 @@ def disabled_toolsets(payload: dict[str, Any]) -> list[str]: ) -def build_hermes_config(payload: dict[str, Any], *, relay_enabled: bool = False) -> dict[str, Any]: +def build_hermes_config( + payload: dict[str, Any], *, relay_enabled: bool = False +) -> dict[str, Any]: settings = common_utils.settings_payload(payload) model_config = common_utils.selected_model_config(payload) native = common_utils.capability_plan(payload).get("native") or {} @@ -71,7 +76,9 @@ def build_hermes_config(payload: dict[str, Any], *, relay_enabled: bool = False) "terminal": common_utils.without_none( { "backend": settings.get("terminal_backend", "local"), - "cwd": str(environment.get("workspace") or settings.get("workspace") or "."), + "cwd": str( + environment.get("workspace") or settings.get("workspace") or "." + ), "timeout": settings.get("terminal_timeout", 60), } ), @@ -90,7 +97,9 @@ def build_hermes_config(payload: dict[str, Any], *, relay_enabled: bool = False) if "enabled_toolsets" in settings: config["platform_toolsets"] = { - settings.get("toolset_platform", "cli"): common_utils.normalize_list(settings.get("enabled_toolsets")) + settings.get("toolset_platform", "cli"): common_utils.normalize_list( + settings.get("enabled_toolsets") + ) } plugins = common_utils.normalize_list(settings.get("plugins_enabled")) @@ -148,6 +157,9 @@ def summarize_hermes_config(config: dict[str, Any]) -> dict[str, Any]: def main() -> None: + if lifecycle.is_lifecycle_host(os.environ): + lifecycle.serve(HermesRuntime) + return payload = json.load(sys.stdin) output = run(payload) print(json.dumps(output, sort_keys=True)) @@ -161,7 +173,9 @@ def run(payload: dict[str, Any]) -> dict[str, Any]: return asyncio.run(run_hermes(payload)) -def resolve_hermes_toolsets(settings: dict[str, Any], config: dict[str, Any]) -> list[str] | None: +def resolve_hermes_toolsets( + settings: dict[str, Any], config: dict[str, Any] +) -> list[str] | None: if "enabled_toolsets" in settings: return common_utils.normalize_list(settings.get("enabled_toolsets")) @@ -171,7 +185,9 @@ def resolve_hermes_toolsets(settings: dict[str, Any], config: dict[str, Any]) -> return sorted(_get_platform_tools(config, platform)) -def load_runtime_history(session_db: Any, session_id: str | None) -> list[dict[str, Any]] | None: +def load_runtime_history( + session_db: Any, session_id: str | None +) -> list[dict[str, Any]] | None: if not session_id: return None @@ -183,178 +199,320 @@ def load_runtime_history(session_db: Any, session_id: str | None) -> list[dict[s return None messages = session_db.get_messages_as_conversation(resolved_id) - messages = [message for message in messages if message.get("role") != "session_meta"] + messages = [ + message for message in messages if message.get("role") != "session_meta" + ] return messages or None -async def run_hermes(payload: dict[str, Any]) -> dict[str, Any]: - validate_hermes_telemetry_provider(payload) - settings = common_utils.settings_payload(payload) - request = common_utils.request_payload(payload) - model_config = common_utils.selected_model_config(payload) - hermes_home_base = Path(common_utils.base_dir(payload)).joinpath( - settings.get("hermes_home", "./artifacts/hermes-home") - ) - hermes_home = common_utils.runtime_state_directory(hermes_home_base, payload) - hermes_home.mkdir(parents=True, exist_ok=True) - os.environ["HOME"] = str(hermes_home) - os.environ["HERMES_HOME"] = str(hermes_home) - os.environ.setdefault("HERMES_YOLO_MODE", "1") - os.environ.setdefault("HERMES_ACCEPT_HOOKS", "1") - os.environ["HERMES_SESSION_SOURCE"] = "fabric" - os.environ.setdefault("TERMINAL_ENV", settings.get("terminal_backend", "local")) - os.environ.setdefault("TERMINAL_TIMEOUT", str(settings.get("terminal_timeout", 60))) - relay_enabled = common_utils.relay_enabled(payload) - - relay_plugin_config = None - if relay_enabled: - relay_plugin_config = common_utils.load_relay_plugin_config(payload) - - hermes_config_path, hermes_config = write_hermes_config( - payload, - hermes_home, - relay_enabled=relay_enabled, - ) +class HermesRuntime: + """One Hermes agent and session database owned by a Fabric runtime.""" + + def __init__(self) -> None: + self._started = False + self._runtime_id: str | None = None + self._settings: dict[str, Any] = {} + self._model_config: dict[str, Any] = {} + self._base_url: str | None = None + self._hermes_home: Path | None = None + self._hermes_config_path: Path | None = None + self._hermes_config: dict[str, Any] = {} + self._enabled_toolsets: list[str] | None = None + self._conversation_history: list[dict[str, Any]] | None = None + self._session_db: Any = None + self._agent: Any = None + self._invoke_hook: Any = None + self._relay_plugin_config: dict[str, Any] | None = None + self._relay_context: Any = None + self._relay_context_entered = False + self._relay_model_name = "unknown" + + async def start(self, payload: dict[str, Any]) -> None: + if self._started: + raise lifecycle.LifecycleError( + "hermes_runtime_already_started", + "Hermes runtime is already started", + ) + + try: + validate_hermes_telemetry_provider(payload) + self._settings = common_utils.settings_payload(payload) + self._model_config = common_utils.selected_model_config(payload) + self._runtime_id = common_utils.runtime_id(payload) + hermes_home_base = Path(common_utils.base_dir(payload)).joinpath( + self._settings.get("hermes_home", "./artifacts/hermes-home") + ) + self._hermes_home = common_utils.runtime_state_directory( + hermes_home_base, payload + ) + self._hermes_home.mkdir(parents=True, exist_ok=True) + os.environ["HOME"] = str(self._hermes_home) + os.environ["HERMES_HOME"] = str(self._hermes_home) + os.environ.setdefault("HERMES_YOLO_MODE", "1") + os.environ.setdefault("HERMES_ACCEPT_HOOKS", "1") + os.environ["HERMES_SESSION_SOURCE"] = "fabric" + os.environ.setdefault( + "TERMINAL_ENV", + self._settings.get("terminal_backend", "local"), + ) + os.environ.setdefault( + "TERMINAL_TIMEOUT", + str(self._settings.get("terminal_timeout", 60)), + ) - api_key_env = settings.get("api_key_env") or model_config.get("api_key_env") or "NVIDIA_API_KEY" - api_key = os.environ.get(api_key_env) - if not api_key: - raise RuntimeError(f"{api_key_env} is required for Hermes mode") + relay_enabled = common_utils.relay_enabled(payload) + if relay_enabled: + self._relay_plugin_config = common_utils.load_relay_plugin_config( + payload + ) + relay_api_config = common_utils.relay_api_plugin_config( + self._relay_plugin_config + ) + from nemo_relay import plugin - base_url = common_utils.get_base_url(settings, model_config) - user_message = request.get("input") or "" - if not isinstance(user_message, str): - user_message = json.dumps(user_message, sort_keys=True) - - hermes_kwargs = { - "payload": payload, - "settings": settings, - "model_config": model_config, - "base_url": base_url, - "api_key": api_key, - "user_message": user_message, - "relay_plugin_config": relay_plugin_config, - } + self._relay_context = plugin.plugin(relay_api_config) + await self._relay_context.__aenter__() + self._relay_context_entered = True - if relay_enabled: - relay_api_config = common_utils.relay_api_plugin_config(relay_plugin_config or {}) - from nemo_relay import plugin - - async with plugin.plugin(relay_api_config): - (result, enabled_toolsets, relay_artifacts, adapter_stdout) = _invoke_hermes(**hermes_kwargs) - else: - (result, enabled_toolsets, relay_artifacts, adapter_stdout) = _invoke_hermes(**hermes_kwargs) - - if relay_plugin_config is not None: - relay_artifacts = common_utils.collect_relay_artifacts(relay_plugin_config) - - response = result.get("response") or result.get("final_response") - messages = result.get("messages") or [] - output = { - "harness": "hermes", - "adapter": "python", - "mode": "hermes", - "model": model_config.get("model"), - "base_url": base_url, - "response": response, - "completed": bool(result.get("completed")), - "failed": bool(result.get("failed")), - "api_calls": result.get("api_calls"), - "messages": messages, - "message_count": len(messages), - "error": result.get("error"), - "adapter_stdout": adapter_stdout, - "hermes_home": str(hermes_home), - "hermes_config_path": str(hermes_config_path), - "hermes_native_config": summarize_hermes_config(hermes_config), - "enabled_toolsets": enabled_toolsets, - } - if relay_plugin_config is not None: - output["relay_runtime"] = { - "enabled": True, - "config_path": os.environ.get("FABRIC_RELAY_CONFIG_PATH"), - "emitter": "hermes.observability/nemo_relay", + self._hermes_config_path, self._hermes_config = write_hermes_config( + payload, + self._hermes_home, + relay_enabled=relay_enabled, + ) + api_key_env = ( + self._settings.get("api_key_env") + or self._model_config.get("api_key_env") + or "NVIDIA_API_KEY" + ) + api_key = os.environ.get(api_key_env) + if not api_key: + raise RuntimeError(f"{api_key_env} is required for Hermes mode") + self._base_url = common_utils.get_base_url( + self._settings, self._model_config + ) + self._relay_model_name = common_utils.relay_model_name(payload) + + from hermes_cli.config import load_config + from hermes_cli.plugins import discover_plugins + from hermes_cli.plugins import invoke_hook + from hermes_state import SessionDB + from run_agent import AIAgent + + with redirect_stdout(StringIO()): + discover_plugins(force=True) + loaded_hermes_config = load_config() + self._enabled_toolsets = resolve_hermes_toolsets( + self._settings, loaded_hermes_config + ) + self._session_db = SessionDB() + self._conversation_history = load_runtime_history( + self._session_db, self._runtime_id + ) + max_iterations = self._settings.get("max_iterations") + if max_iterations is None: + max_iterations = DEFAULT_MAX_ITERATIONS + self._agent = AIAgent( + **filter_supported_kwargs( + AIAgent, + base_url=self._base_url, + api_key=api_key, + provider=self._settings.get("provider") + or self._model_config.get("provider"), + model=self._settings.get("model_name") + or self._model_config.get("model", ""), + max_iterations=int(max_iterations), + enabled_toolsets=self._enabled_toolsets, + disabled_toolsets=disabled_toolsets(payload) or None, + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + save_trajectories=bool( + self._settings.get("save_trajectories", False) + ), + max_tokens=self._settings.get("max_tokens", 512), + temperature=self._settings.get( + "temperature", + self._model_config.get("temperature", 0.0), + ), + reasoning_config=self._settings.get( + "reasoning_config", {"effort": "none"} + ), + insert_reasoning=bool( + self._settings.get("insert_reasoning", False) + ), + platform="fabric", + session_id=self._runtime_id, + session_db=self._session_db, + ) + ) + self._invoke_hook = invoke_hook + self._started = True + except BaseException: + await self.stop() + raise + + async def invoke(self, payload: dict[str, Any]) -> dict[str, Any]: + if not self._started or self._agent is None: + raise lifecycle.LifecycleError( + "hermes_runtime_not_started", + "Hermes runtime is not started", + ) + if common_utils.runtime_id(payload) != self._runtime_id: + raise lifecycle.LifecycleError( + "hermes_runtime_mismatch", + "Hermes invocation does not match the active runtime", + ) + + request = common_utils.request_payload(payload) + user_message = request.get("input") or "" + if not isinstance(user_message, str): + user_message = json.dumps(user_message, sort_keys=True) + try: + result, adapter_stdout = _invoke_hermes_turn( + agent=self._agent, + settings=self._settings, + user_message=user_message, + conversation_history=self._conversation_history, + ) + finally: + # Hermes' Relay plugin materializes ATIF when its session-finalize + # hook runs. Finalize the telemetry session for each Fabric + # invocation while retaining the native AIAgent and SessionDB. + self._finalize_relay_session() + messages = result.get("messages") or [] + if isinstance(messages, list): + self._conversation_history = messages + + output = { + "harness": "hermes", + "adapter": "python", + "mode": "hermes", + "model": self._model_config.get("model"), + "base_url": self._base_url, + "response": result.get("response") or result.get("final_response"), + "completed": bool(result.get("completed")), + "failed": bool(result.get("failed")), + "api_calls": result.get("api_calls"), + "messages": messages, + "message_count": len(messages), + "error": result.get("error"), + "adapter_stdout": adapter_stdout, + "hermes_home": str(self._hermes_home), + "hermes_config_path": str(self._hermes_config_path), + "hermes_native_config": summarize_hermes_config(self._hermes_config), + "enabled_toolsets": self._enabled_toolsets, } - output["relay_artifacts"] = relay_artifacts - return output + if self._relay_plugin_config is not None: + output["relay_runtime"] = { + "enabled": True, + "config_path": os.environ.get("FABRIC_RELAY_CONFIG_PATH"), + "emitter": "hermes.observability/nemo_relay", + } + output["relay_artifacts"] = common_utils.collect_relay_artifacts( + self._relay_plugin_config + ) + return output + + def _finalize_relay_session(self) -> None: + if ( + self._relay_plugin_config is None + or self._agent is None + or self._invoke_hook is None + ): + return + self._invoke_hook( + "on_session_finalize", + session_id=getattr(self._agent, "session_id", ""), + model=getattr(self._agent, "model", None) or self._relay_model_name, + platform=getattr(self._agent, "platform", None) or "fabric", + ) + # Relay subscriber callbacks are queued. The long-lived plugin context + # does not flush them until runtime shutdown, but invocation results + # must include artifacts produced by this turn. + from nemo_relay import subscribers + + subscribers.flush() + + async def stop(self) -> None: + agent = self._agent + session_db = self._session_db + relay_context = self._relay_context + relay_context_entered = self._relay_context_entered + relay_plugin_config = self._relay_plugin_config + errors: list[BaseException] = [] + if relay_plugin_config is not None and agent is not None: + try: + self._finalize_relay_session() + except BaseException as error: + errors.append(error) + self._agent = None + self._session_db = None + self._relay_context = None + self._relay_context_entered = False + self._invoke_hook = None + self._relay_plugin_config = None + self._started = False + + if agent is not None: + try: + agent.close() + except BaseException as error: + errors.append(error) + if session_db is not None: + try: + session_db.close() + except BaseException as error: + errors.append(error) + if relay_context is not None and relay_context_entered: + try: + await relay_context.__aexit__(None, None, None) + except BaseException as error: + errors.append(error) + + if errors: + for error in errors: + if isinstance(error, asyncio.CancelledError): + raise error + LOGGER.error( + "Hermes runtime cleanup failed", + exc_info=(type(error), error, error.__traceback__), + ) + raise lifecycle.LifecycleError( + "hermes_runtime_stop_failed", + "Hermes runtime failed to stop cleanly", + ) from errors[0] -def _invoke_hermes( +async def run_hermes(payload: dict[str, Any]) -> dict[str, Any]: + runtime = HermesRuntime() + await runtime.start(payload) + try: + return await runtime.invoke(payload) + finally: + await runtime.stop() + + +def _invoke_hermes_turn( *, - payload: dict[str, Any], + agent: Any, settings: dict[str, Any], - model_config: dict[str, Any], - base_url: str | None, - api_key: str, user_message: str, - relay_plugin_config: dict[str, Any] | None, -) -> tuple[dict[str, Any], list[str] | None, list[dict[str, str]], str]: - from hermes_cli.config import load_config - from hermes_cli.plugins import discover_plugins - from hermes_cli.plugins import invoke_hook - from hermes_state import SessionDB - from run_agent import AIAgent - - relay_artifacts: list[dict[str, str]] = [] + conversation_history: list[dict[str, Any]] | None, +) -> tuple[dict[str, Any], str]: hermes_stdout = StringIO() with redirect_stdout(hermes_stdout): - discover_plugins(force=True) - loaded_hermes_config = load_config() - enabled_toolsets = resolve_hermes_toolsets(settings, loaded_hermes_config) - blocked_toolsets = disabled_toolsets(payload) - session_id = common_utils.runtime_id(payload) - session_db = SessionDB() - conversation_history = load_runtime_history(session_db, session_id) - # Treat an explicit null max_iterations like an unset one (avoid int(None)). - max_iterations = settings.get("max_iterations") - if max_iterations is None: - max_iterations = DEFAULT_MAX_ITERATIONS - agent = None - agent = AIAgent( - **filter_supported_kwargs( - AIAgent, - base_url=base_url, - api_key=api_key, - provider=settings.get("provider") or model_config.get("provider"), - model=settings.get("model_name") or model_config.get("model", ""), - max_iterations=int(max_iterations), - enabled_toolsets=enabled_toolsets, - disabled_toolsets=blocked_toolsets or None, - quiet_mode=True, - skip_context_files=True, - skip_memory=True, - save_trajectories=bool(settings.get("save_trajectories", False)), - max_tokens=settings.get("max_tokens", 512), - temperature=settings.get("temperature", model_config.get("temperature", 0.0)), - reasoning_config=settings.get("reasoning_config", {"effort": "none"}), - insert_reasoning=bool(settings.get("insert_reasoning", False)), - platform="fabric", - session_id=session_id, - session_db=session_db, - ) + conversation_kwargs = filter_supported_call_kwargs( + agent.run_conversation, + system_message=settings.get("system_prompt"), + conversation_history=conversation_history, + sync_honcho=False, + dont_review=True, ) - try: - conversation_kwargs = filter_supported_call_kwargs( - agent.run_conversation, - system_message=settings.get("system_prompt"), - conversation_history=conversation_history, - sync_honcho=False, - dont_review=True, - ) - result = agent.run_conversation( - user_message, - **conversation_kwargs, - ) - finally: - if relay_plugin_config is not None and agent is not None: - invoke_hook( - "on_session_finalize", - session_id=getattr(agent, "session_id", ""), - model=getattr(agent, "model", None) or common_utils.relay_model_name(payload), - platform=getattr(agent, "platform", None) or "fabric", - ) - - return result, enabled_toolsets, relay_artifacts, hermes_stdout.getvalue() + result = agent.run_conversation( + user_message, + **conversation_kwargs, + ) + return result, hermes_stdout.getvalue() def filter_supported_kwargs(callable_obj: Any, **kwargs: Any) -> dict[str, Any]: diff --git a/examples/harbor/swebench/adapters/hermes/fabric-adapter.json b/examples/harbor/swebench/adapters/hermes/fabric-adapter.json index f2857470..93f6bcfe 100644 --- a/examples/harbor/swebench/adapters/hermes/fabric-adapter.json +++ b/examples/harbor/swebench/adapters/hermes/fabric-adapter.json @@ -32,5 +32,10 @@ ] } } + }, + "runtime": { + "local_host": { + "contract_version": "fabric.adapter.lifecycle/v1alpha1" + } } } diff --git a/tests/adapters/test_deepagents.py b/tests/adapters/test_deepagents.py index bffa8c10..c5dddf9c 100644 --- a/tests/adapters/test_deepagents.py +++ b/tests/adapters/test_deepagents.py @@ -224,7 +224,9 @@ def use_real_langgraph_fixture(fake_sdks, monkeypatch): monkeypatch.delitem(sys.modules, name, raising=False) -async def test_oneshot_normalizes_response_usage_and_thread(tmp_path, make_payload, fake_sdks): +async def test_oneshot_normalizes_response_usage_and_thread( + tmp_path, make_payload, fake_sdks +): output = await adapter.run_deepagents(make_payload(tmp_path)) assert output["harness"] == "deepagents" @@ -232,7 +234,11 @@ async def test_oneshot_normalizes_response_usage_and_thread(tmp_path, make_paylo assert output["model"] == "nvidia/nemotron-3-nano-30b-a3b" assert output["response"] == "reply to hello" assert output["message_count"] == 2 - assert output["usage"] == {"prompt_tokens": 5, "completion_tokens": 7, "total_tokens": 12} + assert output["usage"] == { + "prompt_tokens": 5, + "completion_tokens": 7, + "total_tokens": 12, + } # streamed events are buffered assert output["events"] == [{"nodes": ["agent"]}] assert output["event_count"] == 1 @@ -260,7 +266,9 @@ async def test_missing_api_key_is_normalized(tmp_path, make_payload, monkeypatch assert "NVIDIA_API_KEY" in output["error"] -async def test_missing_deepagents_package_is_normalized(tmp_path, make_payload, monkeypatch): +async def test_missing_deepagents_package_is_normalized( + tmp_path, make_payload, monkeypatch +): # Preflight reports a clear, normalized error when the deepagents package is # absent. Force find_spec("deepagents") -> None so the test holds whether or # not the real package is installed in the environment. @@ -301,9 +309,17 @@ async def test_relay_telemetry_wraps_agent_and_reports_artifacts( tmp_path, make_payload, monkeypatch, fake_sdks, fake_relay ): artifacts = [{"kind": "atof", "path": str(tmp_path / "events.atof.jsonl")}] - monkeypatch.setattr(adapter.common_utils, "load_relay_plugin_config", lambda _p: {"version": 1, "components": []}) - monkeypatch.setattr(adapter.common_utils, "relay_api_plugin_config", lambda _c: object()) - monkeypatch.setattr(adapter.common_utils, "collect_relay_artifacts", lambda _c: artifacts) + monkeypatch.setattr( + adapter.common_utils, + "load_relay_plugin_config", + lambda _p: {"version": 1, "components": []}, + ) + monkeypatch.setattr( + adapter.common_utils, "relay_api_plugin_config", lambda _c: object() + ) + monkeypatch.setattr( + adapter.common_utils, "collect_relay_artifacts", lambda _c: artifacts + ) payload = make_payload(tmp_path) payload["telemetry_plan"] = { "providers": ["relay"], @@ -332,11 +348,17 @@ async def test_relay_telemetry_wraps_agent_and_reports_artifacts( assert fake_relay["scopes"] == [("deepagents-request", "agent")] # the Deep Agents callback handler is added to the LangGraph run config so # LangGraph scopes and human-in-the-loop interrupt/resume marks are captured - assert fake_relay["callback_handler"] in (fake_sdks["config"] or {}).get("callbacks", []) + assert fake_relay["callback_handler"] in (fake_sdks["config"] or {}).get( + "callbacks", [] + ) -async def test_native_telemetry_exports_without_artifacts(tmp_path, make_payload, monkeypatch, fake_sdks, fake_relay): - monkeypatch.setattr(adapter.common_utils, "relay_api_plugin_config", lambda _c: object()) +async def test_native_telemetry_exports_without_artifacts( + tmp_path, make_payload, monkeypatch, fake_sdks, fake_relay +): + monkeypatch.setattr( + adapter.common_utils, "relay_api_plugin_config", lambda _c: object() + ) payload = make_payload(tmp_path) payload["telemetry_plan"] = { @@ -378,10 +400,14 @@ async def test_native_telemetry_exports_without_artifacts(tmp_path, make_payload assert "relay-mw" in fake_sdks["create_kwargs"]["middleware"] # the scope + callback handler apply to any observability-enabled run, native included assert fake_relay["scopes"] == [("deepagents-request", "agent")] - assert fake_relay["callback_handler"] in (fake_sdks["config"] or {}).get("callbacks", []) + assert fake_relay["callback_handler"] in (fake_sdks["config"] or {}).get( + "callbacks", [] + ) -async def test_relay_disabled_adds_no_scope_or_callbacks(tmp_path, make_payload, fake_sdks): +async def test_relay_disabled_adds_no_scope_or_callbacks( + tmp_path, make_payload, fake_sdks +): # With telemetry disabled the invocation runs without a Relay scope, callback # handler, or middleware, preserving the Relay-neutral default behavior. output = await adapter.run_deepagents(make_payload(tmp_path)) @@ -392,7 +418,9 @@ async def test_relay_disabled_adds_no_scope_or_callbacks(tmp_path, make_payload, assert "relay-mw" not in (fake_sdks["create_kwargs"].get("middleware") or []) -async def test_missing_nemo_relay_with_native_telemetry_is_normalized(tmp_path, make_payload, monkeypatch): +async def test_missing_nemo_relay_with_native_telemetry_is_normalized( + tmp_path, make_payload, monkeypatch +): # Native telemetry also runs through the nemo_relay plugin, so a core-only # install configured with native telemetry must fail with the actionable # extra-install message rather than a raw ModuleNotFoundError -- even though @@ -417,7 +445,9 @@ def fake_find_spec( "relay_enabled": False, "native_config": { "version": 1, - "components": [{"kind": "observability", "enabled": True, "config": {"version": 1}}], + "components": [ + {"kind": "observability", "enabled": True, "config": {"version": 1}} + ], }, "adapter_outputs": [], } @@ -465,13 +495,17 @@ def test_apply_callbacks_preserves_existing_ahead_of_new(): def test_apply_callbacks_without_callbacks_leaves_config_untouched(): config = {"configurable": {"thread_id": "t"}} - assert adapter._apply_callbacks(config, None) == {"configurable": {"thread_id": "t"}} + assert adapter._apply_callbacks(config, None) == { + "configurable": {"thread_id": "t"} + } async def test_invoke_agent_wires_callbacks_into_run_config(fake_sdks): # invoke_agent threads the supplied callbacks into the LangGraph run config. agent_kwargs = {"model": object()} - await adapter.invoke_agent(agent_kwargs, "hello", "thread-1", callbacks=["cb-a", "cb-b"]) + await adapter.invoke_agent( + agent_kwargs, "hello", "thread-1", callbacks=["cb-a", "cb-b"] + ) config = fake_sdks["config"] assert config["configurable"]["thread_id"] == "thread-1" @@ -494,7 +528,9 @@ async def test_workspace_roots_filesystem_backend(tmp_path, make_payload, fake_s assert backend_kwargs["virtual_mode"] is True -async def test_checkpointer_closed_on_success_and_failure(tmp_path, make_payload, monkeypatch, fake_sdks): +async def test_checkpointer_closed_on_success_and_failure( + tmp_path, make_payload, monkeypatch, fake_sdks +): # The async checkpointer must be closed on both the success and error paths. await adapter.run_deepagents(make_payload(tmp_path)) assert fake_sdks["saver_exits"] == 1 @@ -510,7 +546,9 @@ def boom(**_kwargs): assert fake_sdks["saver_exits"] == 2 -async def test_mcp_servers_become_adapter_tools(tmp_path, make_payload, monkeypatch, fake_sdks): +async def test_mcp_servers_become_adapter_tools( + tmp_path, make_payload, monkeypatch, fake_sdks +): tool_read = MagicMock() tool_read.name = "read_file" tool_write = MagicMock() @@ -521,7 +559,11 @@ async def test_mcp_servers_become_adapter_tools(tmp_path, make_payload, monkeypa client_mod = types.ModuleType("langchain_mcp_adapters.client") client_mod.MultiServerMCPClient = mock_client_cls - monkeypatch.setitem(sys.modules, "langchain_mcp_adapters", types.ModuleType("langchain_mcp_adapters")) + monkeypatch.setitem( + sys.modules, + "langchain_mcp_adapters", + types.ModuleType("langchain_mcp_adapters"), + ) monkeypatch.setitem(sys.modules, "langchain_mcp_adapters.client", client_mod) payload = make_payload(tmp_path) @@ -558,7 +600,9 @@ async def handler(_request: types.SimpleNamespace) -> str: return "executed" def request(name: str) -> types.SimpleNamespace: - return types.SimpleNamespace(tool_call={"name": name, "id": "call-1", "args": {}}) + return types.SimpleNamespace( + tool_call={"name": name, "id": "call-1", "args": {}} + ) blocked = await middleware.awrap_tool_call(request("write_file"), handler) assert isinstance(blocked, ToolMessage) @@ -603,7 +647,9 @@ def build(**kwargs): assert output["thread_id"] -async def test_openai_provider_keeps_openai_endpoint(tmp_path, make_payload, monkeypatch, fake_sdks): +async def test_openai_provider_keeps_openai_endpoint( + tmp_path, make_payload, monkeypatch, fake_sdks +): monkeypatch.setenv("OPENAI_API_KEY", "sk-test") payload = make_payload(tmp_path) payload["config"]["models"]["default"] = { @@ -628,7 +674,9 @@ async def test_skill_paths_map_to_skills(tmp_path, make_payload, fake_sdks): assert fake_sdks["create_kwargs"]["skills"] == ["/skills/a", "/skills/b"] -async def test_cost_is_extracted_from_response_metadata(tmp_path, make_payload, monkeypatch): +async def test_cost_is_extracted_from_response_metadata( + tmp_path, make_payload, monkeypatch +): import deepagents message = { @@ -650,7 +698,9 @@ async def astream(inputs, config=None, *, stream_mode=None, subgraphs=False): assert output["usage"]["cost"] == 0.0025 -async def test_resumed_usage_counts_current_turn_only(tmp_path, make_payload, monkeypatch): +async def test_resumed_usage_counts_current_turn_only( + tmp_path, make_payload, monkeypatch +): # On a resumed run the final state replays the prior turn's messages; usage and # cost must reflect only the message emitted this turn, not the replayed one. import deepagents @@ -707,6 +757,35 @@ async def test_runtime_resume_reuses_thread_id(tmp_path, make_payload, fake_sdks assert second["thread_id"] == thread_id +async def test_persistent_runtime_reuses_compiled_agent_and_checkpointer( + tmp_path, make_payload, fake_sdks +): + import deepagents + + payload = make_payload(tmp_path, runtime_id="run-persistent") + start_payload = dict(payload) + start_payload.pop("request") + runtime = adapter.DeepAgentsRuntime() + + await runtime.start(start_payload) + first = await runtime.invoke(payload) + payload["runtime_context"]["invocation_id"] = "inv-2" + payload["request"]["input"] = "continue" + second = await runtime.invoke(payload) + + assert first["resumed"] is False + assert second["resumed"] is True + assert first["thread_id"] == second["thread_id"] + assert deepagents.create_deep_agent.call_count == 1 + assert fake_sdks["saver_exits"] == 0 + checkpointer = fake_sdks["create_kwargs"]["checkpointer"] + assert checkpointer is fake_sdks["checkpointer"] + + await runtime.stop() + + assert fake_sdks["saver_exits"] == 1 + + async def test_stream_requests_subgraphs(tmp_path, make_payload, fake_sdks): # Streaming must opt into subgraphs so delegated (subagent) steps are visible # for usage aggregation. @@ -727,16 +806,23 @@ async def test_subagents_are_gated_by_blocked_tools(tmp_path, make_payload): settings = payload["config"]["harness"]["settings"] create_kwargs = await adapter.build_agent_kwargs(payload, MagicMock(), settings) - assert create_kwargs["middleware"], "main agent blocked-tools middleware not attached" + assert create_kwargs["middleware"], ( + "main agent blocked-tools middleware not attached" + ) subagents = create_kwargs["subagents"] - assert [subagent["name"] for subagent in subagents] == ["general-purpose", "researcher"] + assert [subagent["name"] for subagent in subagents] == [ + "general-purpose", + "researcher", + ] assert all(subagent["middleware"] for subagent in subagents) async def handler(_request: types.SimpleNamespace) -> str: return "executed" def request(name: str) -> types.SimpleNamespace: - return types.SimpleNamespace(tool_call={"name": name, "id": "call-1", "args": {}}) + return types.SimpleNamespace( + tool_call={"name": name, "id": "call-1", "args": {}} + ) gates = [create_kwargs["middleware"][-1]] gates.extend(subagent["middleware"][-1] for subagent in subagents) @@ -745,7 +831,10 @@ def request(name: str) -> types.SimpleNamespace: blocked = await middleware.awrap_tool_call(request("write_file"), handler) assert isinstance(blocked, ToolMessage) assert blocked.status == "error" - assert await middleware.awrap_tool_call(request("read_file"), handler) == "executed" + assert ( + await middleware.awrap_tool_call(request("read_file"), handler) + == "executed" + ) @pytest.mark.usefixtures("use_real_langgraph") @@ -756,12 +845,18 @@ async def test_default_subagent_is_gated_by_blocked_tools(tmp_path, make_payload settings = payload["config"]["harness"]["settings"] create_kwargs = await adapter.build_agent_kwargs(payload, MagicMock(), settings) - assert [subagent["name"] for subagent in create_kwargs["subagents"]] == ["general-purpose"] + assert [subagent["name"] for subagent in create_kwargs["subagents"]] == [ + "general-purpose" + ] assert create_kwargs["subagents"][0]["middleware"] -@pytest.mark.parametrize("unsupported", [{"graph_id": "remote"}, {"runnable": "compiled"}]) -async def test_blocked_tools_reject_unenforceable_subagents(tmp_path, make_payload, unsupported): +@pytest.mark.parametrize( + "unsupported", [{"graph_id": "remote"}, {"runnable": "compiled"}] +) +async def test_blocked_tools_reject_unenforceable_subagents( + tmp_path, make_payload, unsupported +): payload = make_payload(tmp_path) payload["config"]["tools"] = {"blocked": ["write_file"]} payload["config"]["harness"]["settings"]["deepagents"] = { @@ -793,21 +888,29 @@ def test_gated_subagents_reject_invalid_configuration(subagents, message): assert str(error.value) == message -async def test_deepagents_passthrough_forwards_supported_options(tmp_path, make_payload, fake_sdks): +async def test_deepagents_passthrough_forwards_supported_options( + tmp_path, make_payload, fake_sdks +): # Documented JSON-serializable options reach create_deep_agent unchanged. payload = make_payload(tmp_path) - payload["config"]["harness"]["settings"]["deepagents"] = {"interrupt_on": {"write_file": True}} + payload["config"]["harness"]["settings"]["deepagents"] = { + "interrupt_on": {"write_file": True} + } await adapter.run_deepagents(payload) assert fake_sdks["create_kwargs"]["interrupt_on"] == {"write_file": True} -async def test_deepagents_passthrough_cannot_override_fabric_owned_keys(tmp_path, make_payload): +async def test_deepagents_passthrough_cannot_override_fabric_owned_keys( + tmp_path, make_payload +): # Overriding a Fabric-owned key (here backend) would defeat workspace confinement; # it must fail loudly rather than silently replacing the derived value. payload = make_payload(tmp_path) - payload["config"]["harness"]["settings"]["deepagents"] = {"backend": {"root_dir": "/etc"}} + payload["config"]["harness"]["settings"]["deepagents"] = { + "backend": {"root_dir": "/etc"} + } output = await adapter.run_deepagents(payload) @@ -875,7 +978,11 @@ async def test_bad_mcp_transport_is_normalized_failure(tmp_path, make_payload): # A misconfigured MCP server must fail loudly, not be silently dropped. payload = make_payload(tmp_path) payload["capability_plan"] = { - "native": {"mcp_servers": {"bad": {"transport": "carrier-pigeon", "url": "http://x/mcp"}}} + "native": { + "mcp_servers": { + "bad": {"transport": "carrier-pigeon", "url": "http://x/mcp"} + } + } } output = await adapter.run_deepagents(payload) @@ -886,7 +993,9 @@ async def test_bad_mcp_transport_is_normalized_failure(tmp_path, make_payload): async def test_empty_mcp_url_is_normalized_failure(tmp_path, make_payload): payload = make_payload(tmp_path) - payload["capability_plan"] = {"native": {"mcp_servers": {"bad": {"transport": "streamable_http", "url": ""}}}} + payload["capability_plan"] = { + "native": {"mcp_servers": {"bad": {"transport": "streamable_http", "url": ""}}} + } output = await adapter.run_deepagents(payload) @@ -894,7 +1003,9 @@ async def test_empty_mcp_url_is_normalized_failure(tmp_path, make_payload): assert "url" in output["error"] -async def test_unknown_provider_requires_api_key_env(tmp_path, make_payload, monkeypatch): +async def test_unknown_provider_requires_api_key_env( + tmp_path, make_payload, monkeypatch +): # An unknown provider with no explicit api_key_env must fail loudly rather than # defaulting to NVIDIA_API_KEY and sending the wrong key to the endpoint. monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-x") @@ -910,7 +1021,9 @@ async def test_unknown_provider_requires_api_key_env(tmp_path, make_payload, mon assert "api_key_env" in output["error"] -async def test_openai_provider_defaults_to_openai_key(tmp_path, make_payload, monkeypatch, fake_sdks): +async def test_openai_provider_defaults_to_openai_key( + tmp_path, make_payload, monkeypatch, fake_sdks +): # provider openai with no explicit api_key_env defaults to OPENAI_API_KEY, never # NVIDIA_API_KEY, and keeps ChatOpenAI's own endpoint. monkeypatch.delenv("NVIDIA_API_KEY", raising=False) diff --git a/tests/adapters/test_hermes_adapter.py b/tests/adapters/test_hermes_adapter.py index 0124ab8f..f1e20522 100644 --- a/tests/adapters/test_hermes_adapter.py +++ b/tests/adapters/test_hermes_adapter.py @@ -11,7 +11,7 @@ import os import sys from pathlib import Path -from types import ModuleType +from types import ModuleType, SimpleNamespace from unittest.mock import MagicMock import pytest @@ -41,17 +41,50 @@ def test_validate_hermes_telemetry_provider_accepts_relay( def test_validate_hermes_telemetry_provider_rejects_native(): payload = {"telemetry_plan": {"providers": ["native"], "relay_enabled": False}} - with pytest.raises(ValueError, match="only relay telemetry is supported for Hermes"): + with pytest.raises( + ValueError, match="only relay telemetry is supported for Hermes" + ): adapter.validate_hermes_telemetry_provider(payload) def test_validate_hermes_telemetry_provider_rejects_mixed_native_and_relay(): - payload = {"telemetry_plan": {"providers": ["relay", "native"], "relay_enabled": True}} + payload = { + "telemetry_plan": {"providers": ["relay", "native"], "relay_enabled": True} + } - with pytest.raises(ValueError, match="only relay telemetry is supported for Hermes"): + with pytest.raises( + ValueError, match="only relay telemetry is supported for Hermes" + ): adapter.validate_hermes_telemetry_provider(payload) +def test_finalize_relay_session_flushes_before_artifact_collection(monkeypatch): + calls: list[str] = [] + invoke_hook = MagicMock(side_effect=lambda *args, **kwargs: calls.append("hook")) + runtime = adapter.HermesRuntime() + runtime._relay_plugin_config = {"components": []} + runtime._agent = SimpleNamespace( + session_id="runtime-1", + model="test-model", + platform="fabric", + ) + runtime._invoke_hook = invoke_hook + + from nemo_relay import subscribers + + monkeypatch.setattr(subscribers, "flush", lambda: calls.append("flush")) + + runtime._finalize_relay_session() + + assert calls == ["hook", "flush"] + invoke_hook.assert_called_once_with( + "on_session_finalize", + session_id="runtime-1", + model="test-model", + platform="fabric", + ) + + def test_build_hermes_config_maps_fabric_config_to_hermes_config(): os.environ["MCP_URL"] = "http://localhost:9000/mcp" payload = { @@ -134,7 +167,9 @@ def test_default_max_iterations_matches_hermes_library_default(): # multi-step tasks while the trial still reports success. assert adapter.DEFAULT_MAX_ITERATIONS > 1 - hermes_default = inspect.signature(AIAgent.__init__).parameters["max_iterations"].default + hermes_default = ( + inspect.signature(AIAgent.__init__).parameters["max_iterations"].default + ) assert adapter.DEFAULT_MAX_ITERATIONS == hermes_default @@ -143,8 +178,10 @@ def test_build_hermes_config_omits_max_turns_when_max_iterations_unset(): # absent so Hermes applies its own default rather than a starving override. payload = { "config": { - "harness": {"settings": {}}, - "models": {"default": {"provider": "nvidia", "model": "nvidia/test-model"}}, + "harness": {"settings": {}}, + "models": { + "default": {"provider": "nvidia", "model": "nvidia/test-model"} + }, } } @@ -158,8 +195,10 @@ def test_build_hermes_config_omits_max_turns_when_max_iterations_null(): # omitted so Hermes applies its own default instead of a starving override. payload = { "config": { - "harness": {"settings": {"max_iterations": None}}, - "models": {"default": {"provider": "nvidia", "model": "nvidia/test-model"}}, + "harness": {"settings": {"max_iterations": None}}, + "models": { + "default": {"provider": "nvidia", "model": "nvidia/test-model"} + }, } } @@ -258,8 +297,12 @@ def test_hermes_config_variation_matrix_surfaces_supported_capabilities( } assert config["platform_toolsets"] == {"cli": ["git", "shell"]} assert config["plugins"]["enabled"] == ["observability/nemo_relay"] - assert observability["atof"]["output_directory"] == str(tmp_path / "relay" / "atof" / "runtime-matrix") - assert observability["atif"]["output_directory"] == str(tmp_path / "relay" / "atif" / "runtime-matrix") + assert observability["atof"]["output_directory"] == str( + tmp_path / "relay" / "atof" / "runtime-matrix" + ) + assert observability["atif"]["output_directory"] == str( + tmp_path / "relay" / "atif" / "runtime-matrix" + ) assert observability["atif"]["agent_name"] == "matrix-agent" assert observability["atif"]["model_name"] == "nvidia/review-model" @@ -267,8 +310,10 @@ def test_hermes_config_variation_matrix_surfaces_supported_capabilities( def test_write_hermes_config_writes_file(tmp_path: Path): payload = { "config": { - "harness": {"settings": {}}, - "models": {"default": {"provider": "nvidia", "model": "nvidia/test-model"}}, + "harness": {"settings": {}}, + "models": { + "default": {"provider": "nvidia", "model": "nvidia/test-model"} + }, } } @@ -343,11 +388,13 @@ def test_summarize_hermes_config(): async def test_hermes_rejects_native_telemetry(): payload = {"telemetry_plan": {"providers": ["native"], "relay_enabled": False}} - with pytest.raises(ValueError, match="only relay telemetry is supported for Hermes"): + with pytest.raises( + ValueError, match="only relay telemetry is supported for Hermes" + ): await adapter.run_hermes(payload) -async def test_fabric_runtime_id_drives_hermes_session_id_and_db_history( +async def test_persistent_runtime_reuses_hermes_agent_session_and_history( monkeypatch, tmp_path: Path, ): @@ -363,13 +410,31 @@ async def test_fabric_runtime_id_drives_hermes_session_id_and_db_history( mock_ai_agent.session_id = "runtime-fabric-123" mock_ai_agent.model = "test-model" mock_ai_agent.platform = "fabric" - mock_ai_agent.run_conversation.__signature__ = inspect.signature(AIAgent.run_conversation) - mock_ai_agent.run_conversation.return_value = { - "response": "ok", - "completed": True, - "failed": False, - "messages": [{"role": "assistant", "content": "ok"}], - } + mock_ai_agent.run_conversation.__signature__ = inspect.signature( + AIAgent.run_conversation + ) + first_messages = [ + *db_history, + {"role": "assistant", "content": "first response"}, + ] + second_messages = [ + *first_messages, + {"role": "assistant", "content": "second response"}, + ] + mock_ai_agent.run_conversation.side_effect = [ + { + "response": "first response", + "completed": True, + "failed": False, + "messages": first_messages, + }, + { + "response": "second response", + "completed": True, + "failed": False, + "messages": second_messages, + }, + ] mock_ai_agent_type = MagicMock(spec=AIAgent, return_value=mock_ai_agent) monkeypatch.setattr( mock_ai_agent_type.__init__.__func__, @@ -428,12 +493,25 @@ async def test_fabric_runtime_id_drives_hermes_session_id_and_db_history( "capability_plan": {"native": {}}, } - output = await adapter.run_hermes(payload) + start_payload = dict(payload) + start_payload.pop("request") + runtime = adapter.HermesRuntime() + + await runtime.start(start_payload) + first = await runtime.invoke(payload) + payload["runtime_context"]["invocation_id"] = "invocation-2" + payload["request"]["input"] = "continue" + second = await runtime.invoke(payload) + await runtime.stop() mock_session_db_type.assert_called_once_with() - mock_session_db.resolve_resume_session_id.assert_called_once_with("runtime-fabric-123") + mock_session_db.resolve_resume_session_id.assert_called_once_with( + "runtime-fabric-123" + ) mock_session_db.get_session.assert_called_once_with("runtime-resolved-456") - mock_session_db.get_messages_as_conversation.assert_called_once_with("runtime-resolved-456") + mock_session_db.get_messages_as_conversation.assert_called_once_with( + "runtime-resolved-456" + ) mock_ai_agent_type.assert_called_once_with( base_url=None, api_key="secret", @@ -452,12 +530,23 @@ async def test_fabric_runtime_id_drives_hermes_session_id_and_db_history( session_id="runtime-fabric-123", session_db=mock_session_db, ) - mock_ai_agent.run_conversation.assert_called_once_with( - "hello", - system_message="system", - conversation_history=db_history, - ) - assert "session_id" not in output - assert Path(output["hermes_home"]) == ( + assert mock_ai_agent.run_conversation.call_count == 2 + first_call, second_call = mock_ai_agent.run_conversation.call_args_list + assert first_call.args == ("hello",) + assert first_call.kwargs == { + "system_message": "system", + "conversation_history": db_history, + } + assert second_call.args == ("continue",) + assert second_call.kwargs == { + "system_message": "system", + "conversation_history": first_messages, + } + mock_ai_agent.close.assert_called_once_with() + mock_session_db.close.assert_called_once_with() + assert first["response"] == "first response" + assert second["response"] == "second response" + assert "session_id" not in second + assert Path(second["hermes_home"]) == ( tmp_path / "hermes-home" / "runtimes" / "runtime-fabric-123" ) diff --git a/tests/e2e/test_deepagents.py b/tests/e2e/test_deepagents.py index 964d5001..f9ddac16 100644 --- a/tests/e2e/test_deepagents.py +++ b/tests/e2e/test_deepagents.py @@ -16,12 +16,48 @@ import pytest +@pytest.mark.usefixtures("mock_nvidia_api_key") +async def test_deepagents_persistent_host_with_mock_model(api_server, tmp_path): + pytest.importorskip("deepagents") + from examples.code_review_agent import deepagents_config + from nemo_fabric import EnvironmentConfig, Fabric, RuntimeConfig + + config = deepagents_config() + config.harness.settings["base_url"] = f"{api_server}/v1" + config.harness.settings["workspace"] = str(tmp_path) + config.environment = EnvironmentConfig( + provider="local", + workspace=tmp_path, + artifacts=tmp_path / "artifacts", + ) + config.runtime = RuntimeConfig( + input_schema="chat", + output_schema="message", + artifacts=tmp_path / "artifacts", + ) + + async with await Fabric().start_runtime(config, base_dir=tmp_path) as runtime: + first = await runtime.invoke(input="first") + second = await runtime.invoke(input="second") + + results = (first.to_mapping(), second.to_mapping()) + assert first["status"] == second["status"] == "succeeded", results + assert first["metadata"]["adapter_runner"] == "persistent_local_host", results + assert first["metadata"]["host_pid"] == second["metadata"]["host_pid"], results + assert first["output"]["thread_id"] == second["output"]["thread_id"], results + assert first["output"]["resumed"] is False, results + assert second["output"]["resumed"] is True, results + assert "user_count=2" in second["output"]["response"], results + + @pytest.fixture(name="_require_integration") def _require_integration_fixture() -> None: if os.environ.get("RUN_FABRIC_DEEPAGENTS_INTEGRATION") != "1": pytest.skip("set RUN_FABRIC_DEEPAGENTS_INTEGRATION=1 to run") if importlib.util.find_spec("deepagents") is None: - pytest.fail("the deepagents package is required (pip install -e '.[deepagents]')") + pytest.fail( + "the deepagents package is required (pip install -e '.[deepagents]')" + ) if importlib.util.find_spec("nemo_fabric._native") is None: pytest.fail("the nemo_fabric native extension is required (pip install -e .)") if not os.environ.get("NVIDIA_API_KEY"): @@ -53,7 +89,9 @@ async def test_deepagents_multi_turn(): client = Fabric() nonce = f"fabric-{uuid.uuid4().hex[:8]}" - async with await client.start_runtime(deepagents_config(), base_dir=BASE_DIR) as runtime: + async with await client.start_runtime( + deepagents_config(), base_dir=BASE_DIR + ) as runtime: first = await runtime.invoke(input=f"Remember this value: {nonce}") second = await runtime.invoke( input="Reply with only the value I asked you to remember." @@ -63,6 +101,8 @@ async def test_deepagents_multi_turn(): assert first["status"] == second["status"] == "succeeded", results # one started runtime keeps a stable LangGraph thread across turns assert first["output"]["thread_id"] == second["output"]["thread_id"], results + assert first["metadata"]["adapter_runner"] == "persistent_local_host", results + assert first["metadata"]["host_pid"] == second["metadata"]["host_pid"], results # the first turn opens the runtime; the second resumes and recalls turn one assert first["output"]["resumed"] is False, results assert second["output"]["resumed"] is True, results diff --git a/tests/e2e/test_hermes_e2e.py b/tests/e2e/test_hermes_e2e.py index 2ef94a5e..12e2548c 100644 --- a/tests/e2e/test_hermes_e2e.py +++ b/tests/e2e/test_hermes_e2e.py @@ -21,12 +21,34 @@ pytestmark = pytest.mark.usefixtures("requires_hermes_agent") +@pytest.mark.usefixtures("mock_nvidia_api_key") +async def test_hermes_persistent_host_reuses_native_session( + code_review_agent_dir: Path, + api_server: str, +): + os.environ["ADAPTER_PYTHON"] = sys.executable + config = hermes_config() + config.harness.settings["base_url"] = f"{api_server}/v1" + + async with await Fabric().start_runtime( + config, base_dir=code_review_agent_dir + ) as runtime: + first = await runtime.invoke(input="first") + second = await runtime.invoke(input="second") + + results = (first.to_mapping(), second.to_mapping()) + assert first["status"] == second["status"] == "succeeded", results + assert first["metadata"]["adapter_runner"] == "persistent_local_host", results + assert first["metadata"]["host_pid"] == second["metadata"]["host_pid"], results + assert "user_count=2" in second["output"]["response"], results + + class TestHermesE2E: """End-to-end Hermes relay assertions.""" config_builder = staticmethod(hermes_config) adapter_kind = "python" - adapter_runner = "python" + adapter_runner = "persistent_local_host" output_adapter = "python" mode = "hermes" artifact_dir = "hermes" @@ -62,7 +84,6 @@ async def run_hermes_with_relay( ).resolve() self.relay_artifacts = self.output["relay_artifacts"] - async def test_artifacts(self): assert self.result["status"] == "succeeded" assert self.result["adapter_kind"] == self.adapter_kind @@ -79,9 +100,9 @@ async def test_artifacts(self): assert output["base_url"] == f"{self.api_server}/v1" assert output["error"] is None assert output["relay_runtime"]["enabled"] is True - assert output["relay_runtime"]["emitter"] == "hermes.observability/nemo_relay" + assert output["relay_runtime"]["emitter"] == "hermes.observability/nemo_relay" assert output["failed"] is False - + assert "echo user_count=" in output["response"] hermes_home = Path(output["hermes_home"]).resolve() @@ -105,8 +126,7 @@ async def test_artifacts(self): assert self.artifact_root.is_dir() artifact_by_name = { - artifact["name"]: artifact - for artifact in self.artifacts["artifacts"] + artifact["name"]: artifact for artifact in self.artifacts["artifacts"] } assert "relay_config" in artifact_by_name assert "stdout" in artifact_by_name @@ -114,12 +134,12 @@ async def test_artifacts(self): relay_config_path = Path(artifact_by_name["relay_config"]["path"]).resolve() assert relay_config_path.is_file() assert relay_config_path.is_relative_to(self.artifact_root) - + relay_config = json.loads(relay_config_path.read_text(encoding="utf-8")) assert relay_config["schema_version"] == "fabric.relay/v1alpha1" assert relay_config["relay"]["enabled"] is True assert relay_config["fabric"]["agent_name"] == "code-review-agent" - + async def test_atof_artifacts(self): kinds = {artifact["kind"] for artifact in self.relay_artifacts} assert "atof" in kinds @@ -131,13 +151,10 @@ async def test_atof_artifacts(self): ] assert atof_paths assert all(path.exists() for path in atof_paths) - assert all( - path.is_relative_to(self.relay_artifact_root) for path in atof_paths - ) + assert all(path.is_relative_to(self.relay_artifact_root) for path in atof_paths) atof_records = [ - json.loads(line) - for line in atof_paths[0].read_text().strip().splitlines() + json.loads(line) for line in atof_paths[0].read_text().strip().splitlines() ] expected_atof_fields = { "atof_version", @@ -156,17 +173,16 @@ async def test_atof_artifacts(self): assert actual_atof_fields.issuperset(expected_atof_fields) assert len(atof_records) == 7 - + assert all( record["metadata"]["model"] == "nvidia/nemotron-3-nano-30b-a3b" and record["metadata"]["platform"] == self.atof_platform for record in atof_records ) - + assert atof_records[-2]["name"] == "hermes.session.end" assert atof_records[-1]["scope_category"] == "end" - async def test_atif_artifacts(self): kinds = {artifact["kind"] for artifact in self.relay_artifacts} assert "atif" in kinds @@ -178,9 +194,7 @@ async def test_atif_artifacts(self): ] assert atif_paths assert all(path.exists() for path in atif_paths) - assert all( - path.is_relative_to(self.relay_artifact_root) for path in atif_paths - ) + assert all(path.is_relative_to(self.relay_artifact_root) for path in atif_paths) trajectory = json.loads(atif_paths[0].read_text()) assert trajectory["agent"]["name"] in {"code-review-agent", "Hermes Agent"} diff --git a/tests/e2e/test_hermes_runtime.py b/tests/e2e/test_hermes_runtime.py index 173fcfce..24772a0c 100644 --- a/tests/e2e/test_hermes_runtime.py +++ b/tests/e2e/test_hermes_runtime.py @@ -23,6 +23,7 @@ import pytest + async def test_hermes_runtime(): if os.environ.get("RUN_FABRIC_HERMES_INTEGRATION") != "1": pytest.skip("set RUN_FABRIC_HERMES_INTEGRATION=1 to run") @@ -67,7 +68,9 @@ async def _run() -> None: ) as runtime: assert runtime.status is RuntimeStatus.ACTIVE, runtime.status - r1 = await runtime.invoke(input="My name is Robin. Please remember it for later.") + r1 = await runtime.invoke( + input="My name is Robin. Please remember it for later." + ) assert r1["status"] == "succeeded", r1 after_turn1 = runtime.messages assert len(after_turn1) >= 2, after_turn1 @@ -75,6 +78,8 @@ async def _run() -> None: r2 = await runtime.invoke(input="What is my name? Reply with just the name.") assert r2["status"] == "succeeded", r2 assert r2["runtime_id"] == r1["runtime_id"], (r1, r2) + assert r1["metadata"]["adapter_runner"] == "persistent_local_host", (r1, r2) + assert r1["metadata"]["host_pid"] == r2["metadata"]["host_pid"], (r1, r2) # Hermes should return a transcript that includes the prior turn. assert len(runtime.messages) > len(after_turn1), runtime.messages # And the model must recall the name supplied in turn 1. From d1843a4043fe2818015e479507e9b94bae16cf4a Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Fri, 17 Jul 2026 15:31:36 -0700 Subject: [PATCH 04/34] docs: document persistent adapter runtime support Signed-off-by: Ajay Thorve --- README.md | 21 +++++++++++-- docs/sdk/python.mdx | 31 +++++++++++++++++-- skills/nemo-fabric-integrate/SKILL.md | 10 +++++- .../references/sdk-api-inventory.md | 5 +++ 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b279ef14..25f3e501 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,23 @@ under `examples/code_review_agent/artifacts/hermes/`. Its complete base config and clone-based variants live in `examples/code_review_agent/config.py`. +## Bundled Adapter Capability Matrix + +The adapter descriptors are the source of truth for normalized configuration, +telemetry, and runtime-hosting support. The bundled adapters currently expose +the following capabilities: + +| Adapter | Accepted Normalized Config | Telemetry Providers | Runtime Hosting | Remote Service | +| --- | --- | --- | --- | --- | +| [Claude](adapters/claude/README.md) | `models`, `tools`, `tools.blocked`, `mcp`, `skills`, `telemetry` | Relay | Persistent local host; one connected `ClaudeSDKClient` | Not implemented | +| [Codex](adapters/codex/README.md) | `models`, `telemetry` | Relay and native | Persistent local host; one `AsyncCodex` client and thread | Not implemented | +| [Deep Agents](adapters/deepagents/README.md) | `models`, `tools`, `tools.blocked`, `mcp`, `skills`, `telemetry` | Relay and native | Persistent local host; one compiled graph and checkpointer | Not implemented | +| [Hermes](adapters/hermes/README.md) | `models`, `tools`, `tools.blocked`, `mcp`, `skills`, `telemetry` | Relay | Persistent local host; one `AIAgent` and `SessionDB` | Not implemented | + +Consumers use the same `Fabric.start_runtime(...)` contract for all four +bundled adapters. Adapter hosting remains descriptor-owned; it is not selected +through public `FabricConfig` settings. + ## Claude Adapter Build the local wheels and install Fabric with the independent Claude adapter: @@ -154,8 +171,8 @@ python -m pip install --find-links dist "nemo-fabric[claude]" ``` Refer to the [Claude adapter guide](adapters/claude/README.md) for -typed configuration, normalized tools, MCP and skills, multi-turn resume, -authentication, and execution details. +typed configuration, normalized tools, MCP and skills, persistent multi-turn +runtimes, authentication, and execution details. ## Core Concepts diff --git a/docs/sdk/python.mdx b/docs/sdk/python.mdx index 8c7bfaa0..491b6b5f 100644 --- a/docs/sdk/python.mdx +++ b/docs/sdk/python.mdx @@ -115,6 +115,30 @@ A runtime is a logical execution boundary, not necessarily an operating-system process. An adapter may use an in-process SDK, a process, or shared service infrastructure while preserving isolated state for each NeMo Fabric runtime. +Runtime hosting is selected by the adapter descriptor, not by a public +`FabricConfig` setting. An adapter that declares the versioned persistent local +host contract starts one local host during `start_runtime(...)`, reuses its +adapter-owned native resources across ordered invocations, and releases them +during `stop()`. + +### Bundled Adapter Capability Matrix + +The following matrix summarizes the current bundled adapter descriptors. The +descriptor selected in `RunPlan` remains authoritative. Telemetry output names +use the descriptor contract values. + +| Adapter ID | Accepted Normalized Config | Declared Telemetry | Runtime Hosting and Native Resources | Remote Service | +| --- | --- | --- | --- | --- | +| `nvidia.fabric.claude` | `models`, `tools`, `tools.blocked`, `mcp`, `skills`, `telemetry` | Relay: `atif`, `otel`, `openinference`; hooks and gateway | Persistent local host; one connected `ClaudeSDKClient` | Not implemented | +| `nvidia.fabric.codex` | `models`, `telemetry` | Relay: `atif`, `otel`, `openinference`; hooks and gateway. Native: `otel` | Persistent local host; one `AsyncCodex` app-server client and thread | Not implemented | +| `nvidia.fabric.langchain.deepagents` | `models`, `tools`, `tools.blocked`, `mcp`, `skills`, `telemetry` | Relay: `atif`, `otel`, `openinference`. Native: `otel`, `openinference` | Persistent local host; one compiled graph and async LangGraph checkpointer | Not implemented | +| `nvidia.fabric.hermes` | `models`, `tools`, `tools.blocked`, `mcp`, `skills`, `telemetry` | Relay: `atif`, `otel`, `openinference` | Persistent local host; one `AIAgent` and `SessionDB` | Not implemented | + +Third-party adapters that have not implemented the contract continue to use +the per-invocation compatibility path. NeMo Fabric does not currently define a +remote-service adapter contract. A crashed persistent host is terminal for that +runtime; NeMo Fabric does not silently respawn it or replay a request. + Harness-native threads, sessions, and conversations remain adapter-owned state associated with the NeMo Fabric runtime. They are not additional NeMo Fabric lifecycle objects. @@ -266,9 +290,10 @@ async with await fabric.start_runtime( print(first.status, second.status) ``` -The adapter reuses its native state between calls. For example, the Codex -adapter uses one Codex thread for the runtime and maps each invocation to one -turn. That thread ID remains adapter-internal. +The adapter reuses its native state between calls. Codex maps the calls to turns +on one live thread, Deep Agents invokes one compiled graph and checkpointer, +Hermes reuses one agent and session database, and Claude keeps one connected SDK +client. Harness-native identifiers remain adapter-internal. ## Application-Owned Parallelism diff --git a/skills/nemo-fabric-integrate/SKILL.md b/skills/nemo-fabric-integrate/SKILL.md index 110d5a21..9a3da3e0 100644 --- a/skills/nemo-fabric-integrate/SKILL.md +++ b/skills/nemo-fabric-integrate/SKILL.md @@ -119,13 +119,21 @@ Pick the smallest lifecycle the consumer needs: runs the full start, invoke, and stop cycle and returns a `RunResult`. Pass `request=RunRequest(...)` instead of `input=...` when the invocation needs a caller-owned request ID or context (the two are mutually exclusive). -- **Stateful runtime** — ordered turns over one live harness. Start it with +- **Stateful runtime** — ordered turns over one logical harness lifecycle. Start it with `start_runtime(...)` and use the returned `Runtime` as an async context manager so cleanup runs on exit — shutdown is attempted, not guaranteed (`stop()` can raise `FabricRuntimeError`; see Consume Results And Handle Errors). A runtime accepts one active invocation at a time; overlapping calls raise `FabricStateError`. +The selected adapter owns the execution topology. The bundled Claude, Codex, +Deep Agents, and Hermes adapters retain their native client, graph/checkpointer, +or agent/database inside one local host for the full runtime. Third-party +compatibility adapters may reconstruct continuity from persisted harness state. +Consumers do not select that mechanism in `FabricConfig` and must not replay an +invocation after a runtime failure. Stop the failed runtime and explicitly start +a new one according to the application's retry policy. + The lifecycle fragment below shows both forms. It assumes the caller has already set `config = to_fabric_config(job)` and chosen `base`, as described in the configuration example above: diff --git a/skills/nemo-fabric-integrate/references/sdk-api-inventory.md b/skills/nemo-fabric-integrate/references/sdk-api-inventory.md index 99f6b1ad..65469a03 100644 --- a/skills/nemo-fabric-integrate/references/sdk-api-inventory.md +++ b/skills/nemo-fabric-integrate/references/sdk-api-inventory.md @@ -66,5 +66,10 @@ FabricConfig -> plan() -> RunPlan -> start_runtime() -> Runtime -> invoke() -> R - A runtime is a logical execution boundary, not necessarily an operating-system process. Harness-native threads, sessions, and conversations remain adapter-owned state associated with the runtime. +- Runtime hosting is adapter-declared, not consumer-configured. The bundled + Claude, Codex, Deep Agents, and Hermes adapters retain their native runtime + resources in one local host; third-party compatibility adapters may + reconstruct continuity on each invocation. A persistent-host crash is + terminal; Fabric does not silently respawn the host or replay the request. - The application owns scheduling, queues, retries, and how many runtimes to run. Fabric provides only the runtime contract. From 713a41da3d229fc4db70bd92514d74d84b23c439 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Fri, 17 Jul 2026 15:44:47 -0700 Subject: [PATCH 05/34] docs: expand adapter capability matrix Signed-off-by: Ajay Thorve --- README.md | 19 +++++++++++++------ docs/sdk/python.mdx | 21 +++++++++++++++------ 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 25f3e501..c7550097 100644 --- a/README.md +++ b/README.md @@ -150,12 +150,19 @@ The adapter descriptors are the source of truth for normalized configuration, telemetry, and runtime-hosting support. The bundled adapters currently expose the following capabilities: -| Adapter | Accepted Normalized Config | Telemetry Providers | Runtime Hosting | Remote Service | -| --- | --- | --- | --- | --- | -| [Claude](adapters/claude/README.md) | `models`, `tools`, `tools.blocked`, `mcp`, `skills`, `telemetry` | Relay | Persistent local host; one connected `ClaudeSDKClient` | Not implemented | -| [Codex](adapters/codex/README.md) | `models`, `telemetry` | Relay and native | Persistent local host; one `AsyncCodex` client and thread | Not implemented | -| [Deep Agents](adapters/deepagents/README.md) | `models`, `tools`, `tools.blocked`, `mcp`, `skills`, `telemetry` | Relay and native | Persistent local host; one compiled graph and checkpointer | Not implemented | -| [Hermes](adapters/hermes/README.md) | `models`, `tools`, `tools.blocked`, `mcp`, `skills`, `telemetry` | Relay | Persistent local host; one `AIAgent` and `SessionDB` | Not implemented | +| Adapter | Models | Tools / Blocked Tools | MCP | Skills | Subagents | Telemetry | Persistent Local Host | Remote Service | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| [Claude](adapters/claude/README.md) | Anthropic / Claude | `allowed_tools` adapter setting / normalized block list | Normalized | Normalized | Not exposed | Relay: ATIF, OTel, and OpenInference through hooks and gateway | Yes: `ClaudeSDKClient`, session, and optional Relay gateway | Not implemented | +| [Codex](adapters/codex/README.md) | OpenAI / Codex | Not normalized | Not normalized | Not normalized | Not exposed | Relay: ATIF, OTel, and OpenInference through hooks and gateway; native OTel | Yes: `AsyncCodex`, app server, thread, and optional Relay gateway | Not implemented | +| [Deep Agents](adapters/deepagents/README.md) | LangChain providers | Built-ins and MCP / normalized middleware block list | Normalized | Normalized | Constrained local delegation | Relay: ATIF, OTel, and OpenInference; native OTel and OpenInference | Yes: compiled graph and async checkpointer | Not implemented | +| [Hermes](adapters/hermes/README.md) | Normalized provider and base URL | Toolsets / normalized disabled toolsets | Normalized | Normalized | Not exposed | Relay: ATIF, OTel, and OpenInference | Yes: `AIAgent`, `SessionDB`, and Relay context | Not implemented | + +"Normalized" means the adapter accepts the corresponding `FabricConfig` +field. "Not normalized" does not mean that the underlying harness lacks the +feature; it means that Fabric does not expose a portable configuration surface +for it. Fabric currently normalizes a blocked-tool list, not a portable tool +definition catalog. Deep Agents subagents are limited to declarative local +subagents that inherit the parent agent's capabilities. Consumers use the same `Fabric.start_runtime(...)` contract for all four bundled adapters. Adapter hosting remains descriptor-owned; it is not selected diff --git a/docs/sdk/python.mdx b/docs/sdk/python.mdx index 491b6b5f..91524241 100644 --- a/docs/sdk/python.mdx +++ b/docs/sdk/python.mdx @@ -127,12 +127,21 @@ The following matrix summarizes the current bundled adapter descriptors. The descriptor selected in `RunPlan` remains authoritative. Telemetry output names use the descriptor contract values. -| Adapter ID | Accepted Normalized Config | Declared Telemetry | Runtime Hosting and Native Resources | Remote Service | -| --- | --- | --- | --- | --- | -| `nvidia.fabric.claude` | `models`, `tools`, `tools.blocked`, `mcp`, `skills`, `telemetry` | Relay: `atif`, `otel`, `openinference`; hooks and gateway | Persistent local host; one connected `ClaudeSDKClient` | Not implemented | -| `nvidia.fabric.codex` | `models`, `telemetry` | Relay: `atif`, `otel`, `openinference`; hooks and gateway. Native: `otel` | Persistent local host; one `AsyncCodex` app-server client and thread | Not implemented | -| `nvidia.fabric.langchain.deepagents` | `models`, `tools`, `tools.blocked`, `mcp`, `skills`, `telemetry` | Relay: `atif`, `otel`, `openinference`. Native: `otel`, `openinference` | Persistent local host; one compiled graph and async LangGraph checkpointer | Not implemented | -| `nvidia.fabric.hermes` | `models`, `tools`, `tools.blocked`, `mcp`, `skills`, `telemetry` | Relay: `atif`, `otel`, `openinference` | Persistent local host; one `AIAgent` and `SessionDB` | Not implemented | +| Adapter ID | Models | Tools / Blocked Tools | MCP | Skills | Subagents | Telemetry | Persistent Local Host | Remote Service | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `nvidia.fabric.claude` | Anthropic provider and Claude models | `allowed_tools` adapter setting / normalized `tools.blocked` | Normalized | Normalized | Not exposed | Relay: `atif`, `otel`, and `openinference` through hooks and gateway | Yes: connected `ClaudeSDKClient`, session, and optional Relay gateway | Not implemented | +| `nvidia.fabric.codex` | Built-in OpenAI provider and Codex models | Not normalized | Not normalized | Not normalized | Not exposed | Relay: `atif`, `otel`, and `openinference` through hooks and gateway; native `otel` | Yes: `AsyncCodex` app-server client, thread, and optional Relay gateway | Not implemented | +| `nvidia.fabric.langchain.deepagents` | NVIDIA, OpenAI, OpenAI-compatible, and other LangChain providers | Built-ins and MCP / normalized middleware block list | Normalized | Normalized | Constrained: declarative local subagents inherit parent capabilities | Relay: `atif`, `otel`, and `openinference`; native `otel` and `openinference` | Yes: compiled graph and async LangGraph checkpointer | Not implemented | +| `nvidia.fabric.hermes` | Normalized provider, model, and base URL | Toolsets / normalized disabled toolsets | Normalized | Normalized | Not exposed | Relay: `atif`, `otel`, and `openinference` | Yes: `AIAgent`, `SessionDB`, and Relay plugin context | Not implemented | + +"Normalized" means the adapter accepts the corresponding `FabricConfig` +field and maps it to the harness. "Not normalized" does not mean that the +underlying harness lacks the feature; it means that Fabric does not expose a +portable configuration surface for it. Fabric currently normalizes +`tools.blocked`, not a portable tool definition catalog. Deep Agents supports +only JSON-shaped, in-process subagent definitions through +`harness.settings.deepagents.subagents`; independently configured or remote +subagent capabilities are not exposed. Third-party adapters that have not implemented the contract continue to use the per-invocation compatibility path. NeMo Fabric does not currently define a From 679a2651a3b71f33b4745f9787778833d6eee64d Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Fri, 17 Jul 2026 16:12:56 -0700 Subject: [PATCH 06/34] fix(runtime): align persistent Relay lifecycle Signed-off-by: Ajay Thorve --- .../nemo_fabric_adapters/hermes/adapter.py | 6 +++ crates/fabric-core/src/runtime.rs | 51 ++++++++++++------- tests/adapters/test_hermes_adapter.py | 32 ++++++++++++ 3 files changed, 72 insertions(+), 17 deletions(-) diff --git a/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py b/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py index f8604ea7..4136d1a6 100755 --- a/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py +++ b/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py @@ -225,6 +225,7 @@ def __init__(self) -> None: self._relay_plugin_config: dict[str, Any] | None = None self._relay_context: Any = None self._relay_context_entered = False + self._relay_session_pending = False self._relay_model_name = "unknown" async def start(self, payload: dict[str, Any]) -> None: @@ -235,6 +236,7 @@ async def start(self, payload: dict[str, Any]) -> None: ) try: + self._relay_session_pending = False validate_hermes_telemetry_provider(payload) self._settings = common_utils.settings_payload(payload) self._model_config = common_utils.selected_model_config(payload) @@ -368,6 +370,7 @@ async def invoke(self, payload: dict[str, Any]) -> dict[str, Any]: if not isinstance(user_message, str): user_message = json.dumps(user_message, sort_keys=True) try: + self._relay_session_pending = self._relay_plugin_config is not None result, adapter_stdout = _invoke_hermes_turn( agent=self._agent, settings=self._settings, @@ -418,6 +421,7 @@ def _finalize_relay_session(self) -> None: self._relay_plugin_config is None or self._agent is None or self._invoke_hook is None + or not self._relay_session_pending ): return self._invoke_hook( @@ -426,6 +430,7 @@ def _finalize_relay_session(self) -> None: model=getattr(self._agent, "model", None) or self._relay_model_name, platform=getattr(self._agent, "platform", None) or "fabric", ) + self._relay_session_pending = False # Relay subscriber callbacks are queued. The long-lived plugin context # does not flush them until runtime shutdown, but invocation results # must include artifacts produced by this turn. @@ -449,6 +454,7 @@ async def stop(self) -> None: self._session_db = None self._relay_context = None self._relay_context_entered = False + self._relay_session_pending = False self._invoke_hook = None self._relay_plugin_config = None self._started = False diff --git a/crates/fabric-core/src/runtime.rs b/crates/fabric-core/src/runtime.rs index cb1502e4..7c344a35 100644 --- a/crates/fabric-core/src/runtime.rs +++ b/crates/fabric-core/src/runtime.rs @@ -455,6 +455,14 @@ struct RelayRuntimeConfig { env: BTreeMap, } +enum RelayConfigCorrelation<'a> { + Runtime, + Invocation { + invocation: &'a InvocationHandle, + request: &'a RunRequest, + }, +} + struct LocalAdapterHost { child: Child, stdin: ChildStdin, @@ -945,8 +953,7 @@ impl RuntimeAdapter for LocalHostAdapter { let relay_config = prepare_relay_runtime_config( plan, &runtime, - &start_invocation, - &start_request, + RelayConfigCorrelation::Runtime, &fabric_home, &mut artifacts, )?; @@ -1716,8 +1723,10 @@ fn run_process_adapter( let relay_config = prepare_relay_runtime_config( plan, runtime, - &invocation, - &request, + RelayConfigCorrelation::Invocation { + invocation: &invocation, + request: &request, + }, &fabric_home, &mut artifacts, )?; @@ -1938,8 +1947,10 @@ fn run_python_adapter( let relay_config = prepare_relay_runtime_config( plan, runtime, - &invocation, - &request, + RelayConfigCorrelation::Invocation { + invocation: &invocation, + request: &request, + }, &fabric_home, &mut artifacts, )?; @@ -2788,8 +2799,7 @@ fn untracked_workspace_patch(workspace: &Path) -> Result { fn prepare_relay_runtime_config( plan: &RunPlan, runtime: &RuntimeHandle, - invocation: &InvocationHandle, - request: &RunRequest, + correlation: RelayConfigCorrelation<'_>, artifact_directory: &Path, artifacts: &mut ArtifactManifest, ) -> Result> { @@ -2802,6 +2812,21 @@ fn prepare_relay_runtime_config( if artifacts.root.is_none() { return Ok(None); } + let mut fabric = serde_json::json!({ + "agent_name": plan.agent_name.clone(), + "harness": harness(plan), + "adapter_id": adapter_id(plan), + "runtime_id": runtime.runtime_id.clone(), + "adapter_outputs": telemetry.adapter_outputs.clone(), + }); + if let RelayConfigCorrelation::Invocation { + invocation, + request, + } = correlation + { + fabric["invocation_id"] = Value::String(invocation.invocation_id.clone()); + fabric["request_id"] = Value::String(request.request_id.clone()); + } let relay_config = serde_json::json!({ "schema_version": "fabric.relay/v1alpha1", "relay": { @@ -2816,15 +2841,7 @@ fn prepare_relay_runtime_config( .clone() .unwrap_or_else(|| Value::Object(Default::default())), }, - "fabric": { - "agent_name": plan.agent_name.clone(), - "harness": harness(plan), - "adapter_id": adapter_id(plan), - "runtime_id": runtime.runtime_id.clone(), - "invocation_id": invocation.invocation_id.clone(), - "request_id": request.request_id.clone(), - "adapter_outputs": telemetry.adapter_outputs.clone(), - } + "fabric": fabric, }); let contents = serde_json::to_string_pretty(&relay_config).map_err(FabricError::SerializeJson)?; diff --git a/tests/adapters/test_hermes_adapter.py b/tests/adapters/test_hermes_adapter.py index f1e20522..76be28a1 100644 --- a/tests/adapters/test_hermes_adapter.py +++ b/tests/adapters/test_hermes_adapter.py @@ -69,6 +69,7 @@ def test_finalize_relay_session_flushes_before_artifact_collection(monkeypatch): platform="fabric", ) runtime._invoke_hook = invoke_hook + runtime._relay_session_pending = True from nemo_relay import subscribers @@ -85,6 +86,37 @@ def test_finalize_relay_session_flushes_before_artifact_collection(monkeypatch): ) +async def test_stop_does_not_refinalize_completed_relay_turn(monkeypatch): + invoke_hook = MagicMock() + runtime = adapter.HermesRuntime() + runtime._started = True + runtime._relay_plugin_config = {"components": []} + runtime._agent = MagicMock( + session_id="runtime-1", + model="test-model", + platform="fabric", + ) + runtime._session_db = MagicMock() + runtime._invoke_hook = invoke_hook + runtime._relay_session_pending = True + + from nemo_relay import subscribers + + flush = MagicMock() + monkeypatch.setattr(subscribers, "flush", flush) + + runtime._finalize_relay_session() + await runtime.stop() + + invoke_hook.assert_called_once_with( + "on_session_finalize", + session_id="runtime-1", + model="test-model", + platform="fabric", + ) + flush.assert_called_once_with() + + def test_build_hermes_config_maps_fabric_config_to_hermes_config(): os.environ["MCP_URL"] = "http://localhost:9000/mcp" payload = { From 7e9ab4a0661083fac5377eea8a424d0516c57edf Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Fri, 17 Jul 2026 16:13:03 -0700 Subject: [PATCH 07/34] test: validate persistent adapter lifecycles Signed-off-by: Ajay Thorve --- README.md | 4 +- docs/sdk/python.mdx | 4 +- tests/adapters/test_deepagents.py | 57 ++++++++++++++++++++++++- tests/e2e/test_claude.py | 31 ++++++++++++++ tests/e2e/test_deepagents.py | 71 +++++++++++++++++++++++++++++++ tests/e2e/test_hermes_e2e.py | 37 ++++++++++++++++ tests/e2e/test_hermes_runtime.py | 29 +++++++++++-- 7 files changed, 224 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index c7550097..2c1df99d 100644 --- a/README.md +++ b/README.md @@ -154,8 +154,8 @@ the following capabilities: | --- | --- | --- | --- | --- | --- | --- | --- | --- | | [Claude](adapters/claude/README.md) | Anthropic / Claude | `allowed_tools` adapter setting / normalized block list | Normalized | Normalized | Not exposed | Relay: ATIF, OTel, and OpenInference through hooks and gateway | Yes: `ClaudeSDKClient`, session, and optional Relay gateway | Not implemented | | [Codex](adapters/codex/README.md) | OpenAI / Codex | Not normalized | Not normalized | Not normalized | Not exposed | Relay: ATIF, OTel, and OpenInference through hooks and gateway; native OTel | Yes: `AsyncCodex`, app server, thread, and optional Relay gateway | Not implemented | -| [Deep Agents](adapters/deepagents/README.md) | LangChain providers | Built-ins and MCP / normalized middleware block list | Normalized | Normalized | Constrained local delegation | Relay: ATIF, OTel, and OpenInference; native OTel and OpenInference | Yes: compiled graph and async checkpointer | Not implemented | -| [Hermes](adapters/hermes/README.md) | Normalized provider and base URL | Toolsets / normalized disabled toolsets | Normalized | Normalized | Not exposed | Relay: ATIF, OTel, and OpenInference | Yes: `AIAgent`, `SessionDB`, and Relay context | Not implemented | +| [Deep Agents](adapters/deepagents/README.md) | LangChain providers | Built-ins and MCP / normalized middleware block list | Normalized | Normalized | Constrained local delegation | Relay SDK: ATIF, OTel, and OpenInference; native OTel and OpenInference | Yes: compiled graph and async checkpointer | Not implemented | +| [Hermes](adapters/hermes/README.md) | Normalized provider and base URL | Toolsets / normalized disabled toolsets | Normalized | Normalized | Not exposed | Relay plugin: ATIF, OTel, and OpenInference | Yes: `AIAgent`, `SessionDB`, and Relay context | Not implemented | "Normalized" means the adapter accepts the corresponding `FabricConfig` field. "Not normalized" does not mean that the underlying harness lacks the diff --git a/docs/sdk/python.mdx b/docs/sdk/python.mdx index 91524241..7ec0d1ff 100644 --- a/docs/sdk/python.mdx +++ b/docs/sdk/python.mdx @@ -131,8 +131,8 @@ use the descriptor contract values. | --- | --- | --- | --- | --- | --- | --- | --- | --- | | `nvidia.fabric.claude` | Anthropic provider and Claude models | `allowed_tools` adapter setting / normalized `tools.blocked` | Normalized | Normalized | Not exposed | Relay: `atif`, `otel`, and `openinference` through hooks and gateway | Yes: connected `ClaudeSDKClient`, session, and optional Relay gateway | Not implemented | | `nvidia.fabric.codex` | Built-in OpenAI provider and Codex models | Not normalized | Not normalized | Not normalized | Not exposed | Relay: `atif`, `otel`, and `openinference` through hooks and gateway; native `otel` | Yes: `AsyncCodex` app-server client, thread, and optional Relay gateway | Not implemented | -| `nvidia.fabric.langchain.deepagents` | NVIDIA, OpenAI, OpenAI-compatible, and other LangChain providers | Built-ins and MCP / normalized middleware block list | Normalized | Normalized | Constrained: declarative local subagents inherit parent capabilities | Relay: `atif`, `otel`, and `openinference`; native `otel` and `openinference` | Yes: compiled graph and async LangGraph checkpointer | Not implemented | -| `nvidia.fabric.hermes` | Normalized provider, model, and base URL | Toolsets / normalized disabled toolsets | Normalized | Normalized | Not exposed | Relay: `atif`, `otel`, and `openinference` | Yes: `AIAgent`, `SessionDB`, and Relay plugin context | Not implemented | +| `nvidia.fabric.langchain.deepagents` | NVIDIA, OpenAI, OpenAI-compatible, and other LangChain providers | Built-ins and MCP / normalized middleware block list | Normalized | Normalized | Constrained: declarative local subagents inherit parent capabilities | Relay SDK: `atif`, `otel`, and `openinference`; native `otel` and `openinference` | Yes: compiled graph and async LangGraph checkpointer | Not implemented | +| `nvidia.fabric.hermes` | Normalized provider, model, and base URL | Toolsets / normalized disabled toolsets | Normalized | Normalized | Not exposed | Relay plugin: `atif`, `otel`, and `openinference` | Yes: `AIAgent`, `SessionDB`, and Relay plugin context | Not implemented | "Normalized" means the adapter accepts the corresponding `FabricConfig` field and maps it to the harness. "Not normalized" does not mean that the diff --git a/tests/adapters/test_deepagents.py b/tests/adapters/test_deepagents.py index c5dddf9c..7848f76d 100644 --- a/tests/adapters/test_deepagents.py +++ b/tests/adapters/test_deepagents.py @@ -162,12 +162,17 @@ def add_nemo_relay_integration(kwargs, **_): merged = dict(kwargs) merged["middleware"] = [*(merged.get("middleware") or []), "relay-mw"] calls["wrapped"] = True + calls["integration_adds"] = calls.get("integration_adds", 0) + 1 return merged @contextlib.asynccontextmanager async def plugin_ctx(_config): calls["plugin_open"] = True - yield + calls["plugin_enters"] = calls.get("plugin_enters", 0) + 1 + try: + yield + finally: + calls["plugin_exits"] = calls.get("plugin_exits", 0) + 1 class ScopeType: Agent = "agent" @@ -786,6 +791,56 @@ async def test_persistent_runtime_reuses_compiled_agent_and_checkpointer( assert fake_sdks["saver_exits"] == 1 +async def test_persistent_runtime_scopes_relay_per_invocation( + tmp_path, make_payload, monkeypatch, fake_sdks, fake_relay +): + artifacts = [{"kind": "atif", "path": str(tmp_path / "trajectory.json")}] + monkeypatch.setattr( + adapter.common_utils, + "load_relay_plugin_config", + lambda _payload: {"version": 1, "components": []}, + ) + monkeypatch.setattr( + adapter.common_utils, "relay_api_plugin_config", lambda _config: object() + ) + monkeypatch.setattr( + adapter.common_utils, + "collect_relay_artifacts", + lambda _config: artifacts, + ) + payload = make_payload(tmp_path, runtime_id="run-relay-persistent") + payload["telemetry_plan"] = { + "providers": ["relay"], + "relay_enabled": True, + "relay_project": None, + "relay_output_dir": None, + "relay_config": {}, + "native_config": None, + "adapter_outputs": ["atif"], + } + start_payload = dict(payload) + start_payload.pop("request") + runtime = adapter.DeepAgentsRuntime() + + await runtime.start(start_payload) + first = await runtime.invoke(payload) + payload["runtime_context"]["invocation_id"] = "inv-2" + payload["request"]["input"] = "continue" + second = await runtime.invoke(payload) + await runtime.stop() + + assert fake_relay["integration_adds"] == 1 + assert fake_relay["plugin_enters"] == 2 + assert fake_relay["plugin_exits"] == 2 + assert fake_relay["scopes"] == [ + ("deepagents-request", "agent"), + ("deepagents-request", "agent"), + ] + assert first["thread_id"] == second["thread_id"] + assert first["relay_artifacts"] == second["relay_artifacts"] == artifacts + assert fake_sdks["saver_exits"] == 1 + + async def test_stream_requests_subgraphs(tmp_path, make_payload, fake_sdks): # Streaming must opt into subgraphs so delegated (subagent) steps are visible # for usage aggregation. diff --git a/tests/e2e/test_claude.py b/tests/e2e/test_claude.py index 90ee5cfc..b36ce50b 100644 --- a/tests/e2e/test_claude.py +++ b/tests/e2e/test_claude.py @@ -273,3 +273,34 @@ async def test_live_claude_relay_one_shot(tmp_path): result.output, "FABRIC_CLAUDE_RELAY_OK", ) + + +@pytest.mark.skipif( + os.environ.get("RUN_FABRIC_CLAUDE_RELAY_INTEGRATION") != "1", + reason="set RUN_FABRIC_CLAUDE_RELAY_INTEGRATION=1 to run Claude with NeMo Relay", +) +async def test_live_claude_relay_session(tmp_path): + relay_command = os.environ.get("FABRIC_TEST_NEMO_RELAY_COMMAND") + config = fabric_config( + tmp_path, + relay=True, + nemo_relay_command=relay_command, + ) + + async with await Fabric().start_runtime(config, base_dir=tmp_path) as runtime: + first = await runtime.invoke(input="Remember token FABRIC-CLAUDE-RELAY-7") + second = await runtime.invoke( + input="Reply only with the token I asked you to remember" + ) + + results = (first.to_mapping(), second.to_mapping()) + assert first.status == second.status == "succeeded", results + assert first.output["session_id"] == second.output["session_id"], results + assert first.metadata["host_pid"] == second.metadata["host_pid"], results + assert "FABRIC-CLAUDE-RELAY-7" in second.output["response"], results + for turn in (first, second): + assert turn.telemetry[0].provider == "relay", turn.to_mapping() + assert {artifact["kind"] for artifact in turn.output["relay_artifacts"]} == { + "atof", + "atif", + }, turn.to_mapping() diff --git a/tests/e2e/test_deepagents.py b/tests/e2e/test_deepagents.py index f9ddac16..e33a9deb 100644 --- a/tests/e2e/test_deepagents.py +++ b/tests/e2e/test_deepagents.py @@ -50,6 +50,47 @@ async def test_deepagents_persistent_host_with_mock_model(api_server, tmp_path): assert "user_count=2" in second["output"]["response"], results +@pytest.mark.usefixtures("mock_nvidia_api_key", "nemo_relay") +async def test_deepagents_persistent_host_with_relay_and_mock_model( + api_server, tmp_path +): + pytest.importorskip("deepagents") + from examples.code_review_agent import deepagents_config, with_relay + from nemo_fabric import EnvironmentConfig, Fabric, RuntimeConfig + + config = with_relay(deepagents_config()) + config.harness.settings["base_url"] = f"{api_server}/v1" + config.harness.settings["workspace"] = str(tmp_path) + config.environment = EnvironmentConfig( + provider="local", + workspace=tmp_path, + artifacts=tmp_path / "artifacts", + ) + config.runtime = RuntimeConfig( + input_schema="chat", + output_schema="message", + artifacts=tmp_path / "artifacts", + ) + + async with await Fabric().start_runtime(config, base_dir=tmp_path) as runtime: + first = await runtime.invoke(input="first") + second = await runtime.invoke(input="second") + + results = (first.to_mapping(), second.to_mapping()) + assert first["status"] == second["status"] == "succeeded", results + assert first["metadata"]["host_pid"] == second["metadata"]["host_pid"], results + assert first["output"]["thread_id"] == second["output"]["thread_id"], results + assert first["output"]["resumed"] is False, results + assert second["output"]["resumed"] is True, results + assert "user_count=2" in second["output"]["response"], results + for turn in (first, second): + assert turn.telemetry[0].provider == "relay", turn.to_mapping() + assert {artifact["kind"] for artifact in turn["output"]["relay_artifacts"]} >= { + "atof", + "atif", + }, turn.to_mapping() + + @pytest.fixture(name="_require_integration") def _require_integration_fixture() -> None: if os.environ.get("RUN_FABRIC_DEEPAGENTS_INTEGRATION") != "1": @@ -109,6 +150,36 @@ async def test_deepagents_multi_turn(): assert nonce in second["output"]["response"], second.to_mapping() +@pytest.mark.usefixtures("_require_integration", "nemo_relay") +async def test_deepagents_multi_turn_with_relay(): + from examples.code_review_agent import BASE_DIR, deepagents_config, with_relay + from nemo_fabric import Fabric + + client = Fabric() + nonce = f"fabric-relay-{uuid.uuid4().hex[:8]}" + config = with_relay(deepagents_config()) + + async with await client.start_runtime(config, base_dir=BASE_DIR) as runtime: + first = await runtime.invoke(input=f"Remember this value: {nonce}") + second = await runtime.invoke( + input="Reply with only the value I asked you to remember." + ) + + results = (first.to_mapping(), second.to_mapping()) + assert first["status"] == second["status"] == "succeeded", results + assert first["output"]["thread_id"] == second["output"]["thread_id"], results + assert first["metadata"]["host_pid"] == second["metadata"]["host_pid"], results + assert first["output"]["resumed"] is False, results + assert second["output"]["resumed"] is True, results + assert nonce in second["output"]["response"], second.to_mapping() + for turn in (first, second): + assert turn.telemetry[0].provider == "relay", turn.to_mapping() + assert {artifact["kind"] for artifact in turn["output"]["relay_artifacts"]} >= { + "atof", + "atif", + }, turn.to_mapping() + + @pytest.mark.usefixtures("_require_integration") async def test_deepagents_subagent_delegation(): # Exercise the real delegated-subagent path end to end: a `task`-delegating run diff --git a/tests/e2e/test_hermes_e2e.py b/tests/e2e/test_hermes_e2e.py index 12e2548c..eb63e298 100644 --- a/tests/e2e/test_hermes_e2e.py +++ b/tests/e2e/test_hermes_e2e.py @@ -43,6 +43,43 @@ async def test_hermes_persistent_host_reuses_native_session( assert "user_count=2" in second["output"]["response"], results +@pytest.mark.usefixtures("mock_nvidia_api_key", "nemo_relay") +async def test_hermes_persistent_host_with_relay( + code_review_agent_dir: Path, + api_server: str, +): + os.environ["ADAPTER_PYTHON"] = sys.executable + config = with_relay(hermes_config()) + config.harness.settings["base_url"] = f"{api_server}/v1" + + async with await Fabric().start_runtime( + config, base_dir=code_review_agent_dir + ) as runtime: + first = await runtime.invoke(input="first") + second = await runtime.invoke(input="second") + + results = (first.to_mapping(), second.to_mapping()) + assert first["status"] == second["status"] == "succeeded", results + assert first["metadata"]["host_pid"] == second["metadata"]["host_pid"], results + assert "user_count=2" in second["output"]["response"], results + for turn in (first, second): + assert turn.telemetry[0].provider == "relay", turn.to_mapping() + assert {artifact["kind"] for artifact in turn["output"]["relay_artifacts"]} >= { + "atof", + "atif", + }, turn.to_mapping() + + atof_path = next( + Path(artifact["path"]) + for artifact in second["output"]["relay_artifacts"] + if artifact["kind"] == "atof" + ) + atof_records = [ + json.loads(line) for line in atof_path.read_text(encoding="utf-8").splitlines() + ] + assert sum(record["name"] == "hermes.session.end" for record in atof_records) == 2 + + class TestHermesE2E: """End-to-end Hermes relay assertions.""" diff --git a/tests/e2e/test_hermes_runtime.py b/tests/e2e/test_hermes_runtime.py index 24772a0c..4f0e57a0 100644 --- a/tests/e2e/test_hermes_runtime.py +++ b/tests/e2e/test_hermes_runtime.py @@ -25,6 +25,18 @@ async def test_hermes_runtime(): + _require_hermes_integration() + await _run(relay=False) + + +async def test_hermes_runtime_with_relay(): + _require_hermes_integration() + if importlib.util.find_spec("nemo_relay") is None: + pytest.fail("the nemo-relay Python package is required") + await _run(relay=True) + + +def _require_hermes_integration() -> None: if os.environ.get("RUN_FABRIC_HERMES_INTEGRATION") != "1": pytest.skip("set RUN_FABRIC_HERMES_INTEGRATION=1 to run") if not os.environ.get("NVIDIA_API_KEY"): @@ -55,15 +67,17 @@ async def test_hermes_runtime(): ) python_bin = Path(sys.executable).resolve().parent os.environ["PATH"] = f"{python_bin}{os.pathsep}{os.environ.get('PATH', '')}" - await _run() -async def _run() -> None: - from examples.code_review_agent import BASE_DIR, hermes_config +async def _run(*, relay: bool) -> None: + from examples.code_review_agent import BASE_DIR, hermes_config, with_relay from nemo_fabric import Fabric, RuntimeStatus + config = hermes_config() + if relay: + config = with_relay(config) async with await Fabric().start_runtime( - hermes_config(), + config, base_dir=BASE_DIR, ) as runtime: assert runtime.status is RuntimeStatus.ACTIVE, runtime.status @@ -85,5 +99,12 @@ async def _run() -> None: # And the model must recall the name supplied in turn 1. response = (r2["output"].get("response") or "").lower() assert "robin" in response, response + if relay: + for result in (r1, r2): + assert result.telemetry[0].provider == "relay", result.to_mapping() + assert { + artifact["kind"] + for artifact in result["output"]["relay_artifacts"] + } >= {"atof", "atif"}, result.to_mapping() assert runtime.status is RuntimeStatus.STOPPED, runtime.status From 8874a3b9c23bf77586c7f912f2b4fc2ed88b4267 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Fri, 17 Jul 2026 16:52:54 -0700 Subject: [PATCH 08/34] fix(runtime): harden persistent host failures Signed-off-by: Ajay Thorve --- .../nemo_fabric_adapters/common/lifecycle.py | 6 +- crates/fabric-core/src/runtime.rs | 154 +++++++++++------- .../test_adapters_common_lifecycle.py | 22 ++- 3 files changed, 118 insertions(+), 64 deletions(-) diff --git a/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py b/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py index bfaf38d2..5baaec6b 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py +++ b/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py @@ -263,7 +263,11 @@ async def _serve( should_stop = True response = _response(operation) except LifecycleError as error: - if operation == "invoke" and runtime is not None: + if ( + operation == "invoke" + and runtime is not None + and error.code == "lifecycle_adapter_invoke_failed" + ): runtime_failed = True response = _failure_response(operation, error) should_stop = should_stop or operation in {"start", "stop"} diff --git a/crates/fabric-core/src/runtime.rs b/crates/fabric-core/src/runtime.rs index 7c344a35..f09b0e7b 100644 --- a/crates/fabric-core/src/runtime.rs +++ b/crates/fabric-core/src/runtime.rs @@ -29,6 +29,9 @@ static NEXT_ID: AtomicU64 = AtomicU64::new(1); const ADAPTER_PYTHON_ENV: &str = "ADAPTER_PYTHON"; const VIRTUAL_ENV_ENV: &str = "VIRTUAL_ENV"; const LOCAL_HOST_START_TIMEOUT: Duration = Duration::from_secs(90); +// Protocol liveness backstop. Adapters remain responsible for normal request +// timeouts and should return a normalized response before this bound. +const LOCAL_HOST_INVOKE_TIMEOUT: Duration = Duration::from_secs(60 * 60); const LOCAL_HOST_STOP_TIMEOUT: Duration = Duration::from_secs(10); const LOCAL_HOST_EXIT_GRACE: Duration = Duration::from_secs(2); const LOCAL_HOST_DIAGNOSTIC_LIMIT: usize = 16 * 1024; @@ -988,7 +991,7 @@ impl RuntimeAdapter for LocalHostAdapter { &mut host, &runtime.runtime_id, &request, - Some(LOCAL_HOST_START_TIMEOUT), + LOCAL_HOST_START_TIMEOUT, ) { let _ = terminate_local_host(&mut host); let _ = remove_local_host_files(&host); @@ -1021,7 +1024,7 @@ impl RuntimeAdapter for LocalHostAdapter { &mut host, &runtime.runtime_id, &request, - Some(LOCAL_HOST_STOP_TIMEOUT), + LOCAL_HOST_STOP_TIMEOUT, ); let termination = terminate_local_host(&mut host); let diagnostics = local_host_diagnostics(&host); @@ -1063,9 +1066,18 @@ impl RuntimeAdapter for LocalHostAdapter { } fn run_local_host_adapter( + plan: &RunPlan, + runtime: &RuntimeHandle, + request: RunRequest, +) -> Result { + run_local_host_adapter_with_timeout(plan, runtime, request, LOCAL_HOST_INVOKE_TIMEOUT) +} + +fn run_local_host_adapter_with_timeout( plan: &RunPlan, runtime: &RuntimeHandle, mut request: RunRequest, + invoke_timeout: Duration, ) -> Result { if request.request_id.is_empty() { request.request_id = new_id("request"); @@ -1088,16 +1100,7 @@ fn run_local_host_adapter( ) })?; - let ( - output, - stderr, - host_command, - host_pid, - mut artifacts, - relay_config, - fabric_home, - fabric_invocation, - ) = { + let exchange_result = { let mut host = host.lock().unwrap_or_else(|error| error.into_inner()); let artifacts = host.artifacts.clone(); let relay_config = host.relay_config.clone(); @@ -1115,19 +1118,48 @@ fn run_local_host_adapter( let fabric_invocation = write_fabric_invocation(&fabric_home, &adapter_payload)?; let lifecycle_request = AdapterLifecycleRequest::new(AdapterLifecycleRequestKind::Invoke(adapter_invocation)); - let output = - exchange_lifecycle_message(&mut host, &runtime.runtime_id, &lifecycle_request, None)?; - let stderr = take_local_host_stderr(&mut host); - ( - output, - stderr, - host.command.clone(), - host.child.id(), - artifacts, - relay_config, - fabric_home, - fabric_invocation, - ) + match exchange_lifecycle_message( + &mut host, + &runtime.runtime_id, + &lifecycle_request, + invoke_timeout, + ) { + Ok(output) => { + let stderr = take_local_host_stderr(&mut host); + Ok(( + output, + stderr, + host.command.clone(), + host.child.id(), + artifacts, + relay_config, + fabric_home, + fabric_invocation, + )) + } + Err(error) => Err(error), + } + }; + let ( + output, + stderr, + host_command, + host_pid, + mut artifacts, + relay_config, + fabric_home, + fabric_invocation, + ) = match exchange_result { + Ok(result) => result, + Err(error) => { + if matches!( + &error, + FabricError::AdapterLifecycleOperation { code, .. } if code == "host_timeout" + ) { + invalidate_timed_out_local_host(&runtime.runtime_id, &host); + } + return Err(error); + } }; let mut events = vec![event_with_metadata( @@ -1241,6 +1273,24 @@ fn run_local_host_adapter( }) } +fn invalidate_timed_out_local_host(runtime_id: &str, expected_host: &Arc>) { + { + let mut hosts = local_hosts(); + if hosts + .get(runtime_id) + .is_some_and(|host| Arc::ptr_eq(host, expected_host)) + { + hosts.remove(runtime_id); + } + } + + let mut host = expected_host + .lock() + .unwrap_or_else(|error| error.into_inner()); + let _ = terminate_local_host(&mut host); + let _ = remove_local_host_files(&host); +} + fn adapter_output_status(output: &Value) -> (RunStatus, Option) { let failed = output .as_object() @@ -1462,7 +1512,7 @@ fn exchange_lifecycle_message( host: &mut LocalAdapterHost, runtime_id: &str, request: &AdapterLifecycleRequest, - timeout: Option, + timeout: Duration, ) -> Result { let operation = request.operation(); if let Some(status) = host.child.try_wait().map_err(|source| { @@ -1508,37 +1558,23 @@ fn exchange_lifecycle_message( )); } - let line = match timeout { - Some(timeout) => match host.responses.recv_timeout(timeout) { - Ok(line) => line, - Err(RecvTimeoutError::Timeout) => { - return Err(lifecycle_error( - operation, - runtime_id, - "host_timeout", - format!( - "persistent local adapter host did not complete {} within {} ms", - operation.as_str(), - timeout.as_millis() - ), - local_host_diagnostics(host), - )); - } - Err(RecvTimeoutError::Disconnected) => { - return Err(lifecycle_error( - operation, - runtime_id, - "host_crashed", - format!( - "persistent local adapter host exited while processing {}", - operation.as_str() - ), - local_host_diagnostics(host), - )); - } - }, - None => host.responses.recv().map_err(|_| { - lifecycle_error( + let line = match host.responses.recv_timeout(timeout) { + Ok(line) => line, + Err(RecvTimeoutError::Timeout) => { + return Err(lifecycle_error( + operation, + runtime_id, + "host_timeout", + format!( + "persistent local adapter host did not complete {} within {} ms", + operation.as_str(), + timeout.as_millis() + ), + local_host_diagnostics(host), + )); + } + Err(RecvTimeoutError::Disconnected) => { + return Err(lifecycle_error( operation, runtime_id, "host_crashed", @@ -1547,8 +1583,8 @@ fn exchange_lifecycle_message( operation.as_str() ), local_host_diagnostics(host), - ) - })?, + )); + } } .map_err(|message| { lifecycle_error( diff --git a/tests/adapters/test_adapters_common_lifecycle.py b/tests/adapters/test_adapters_common_lifecycle.py index c14d5ee1..5e0a4b2f 100644 --- a/tests/adapters/test_adapters_common_lifecycle.py +++ b/tests/adapters/test_adapters_common_lifecycle.py @@ -88,7 +88,7 @@ async def stop(self): assert len(set(instances[0].loop_ids)) == 1 -def test_lifecycle_host_rejects_runtime_mismatch_without_invoking_adapter(): +def test_lifecycle_host_rejects_runtime_mismatch_without_poisoning_runtime(): input_stream, output_stream = _streams( [ _request("start", {"runtime_context": {"runtime_id": "runtime-1"}}), @@ -99,16 +99,25 @@ def test_lifecycle_host_rejects_runtime_mismatch_without_invoking_adapter(): "request": {"input": "do not run"}, }, ), + _request( + "invoke", + { + "runtime_context": {"runtime_id": "runtime-1"}, + "request": {"input": "run"}, + }, + ), _request("stop", {"runtime_id": "runtime-1"}), ] ) + invocations = [] class Runtime: async def start(self, _payload): pass async def invoke(self, payload): - raise AssertionError(payload) + invocations.append(payload) + return {"input": payload["request"]["input"]} async def stop(self): pass @@ -118,6 +127,11 @@ async def stop(self): responses = [json.loads(line) for line in output_stream.getvalue().splitlines()] assert responses[1]["outcome"]["status"] == "failed" assert responses[1]["outcome"]["error"]["code"] == "lifecycle_runtime_mismatch" + assert responses[2]["outcome"] == { + "status": "succeeded", + "output": {"input": "run"}, + } + assert len(invocations) == 1 def test_lifecycle_host_keeps_adapter_stdout_out_of_protocol(capsys): @@ -153,10 +167,10 @@ async def stop(self): assert "adapter diagnostic" in capsys.readouterr().err -def test_lifecycle_host_scopes_invocation_telemetry_environment(monkeypatch): +def test_lifecycle_host_scopes_invocation_telemetry_environment(): runtime_id = "runtime-1" variable = "FABRIC_TEST_LIFECYCLE_ENV" - monkeypatch.setenv(variable, "host-value") + os.environ[variable] = "host-value" input_stream, output_stream = _streams( [ _request("start", {"runtime_context": {"runtime_id": runtime_id}}), From cab3dcc9cfaccd1952e7a75da4e55dc364752d77 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Fri, 17 Jul 2026 16:52:59 -0700 Subject: [PATCH 09/34] test(adapters): tighten persistent lifecycle coverage Signed-off-by: Ajay Thorve --- tests/adapters/test_claude_adapter.py | 8 ++++++-- tests/adapters/test_codex_adapter.py | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/adapters/test_claude_adapter.py b/tests/adapters/test_claude_adapter.py index 190528fb..acf55baf 100644 --- a/tests/adapters/test_claude_adapter.py +++ b/tests/adapters/test_claude_adapter.py @@ -607,8 +607,10 @@ async def interrupt(self): monkeypatch.setattr(adapter, "ClaudeSDKClient", FakeClient) + start_payload = dict(claude_payload) + start_payload.pop("request") runtime = adapter.ClaudeRuntime() - await runtime.start(claude_payload) + await runtime.start(start_payload) first = await runtime.invoke(claude_payload) claude_payload["runtime_context"]["invocation_id"] = "invocation-2" claude_payload["request"]["input"] = {"not": "text"} @@ -682,8 +684,10 @@ async def interrupt(self): monkeypatch.setattr(adapter.relay_gateway, "start_relay_gateway", mock_start) monkeypatch.setattr(adapter.relay_gateway, "stop_relay_gateway", mock_stop) + start_payload = dict(relay_payload) + start_payload.pop("request") runtime = adapter.ClaudeRuntime() - await runtime.start(relay_payload) + await runtime.start(start_payload) first = await runtime.invoke(relay_payload) relay_payload["runtime_context"]["invocation_id"] = "invocation-2" second = await runtime.invoke(relay_payload) diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index 34a0602e..908feaf6 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -436,6 +436,7 @@ async def test_persistent_runtime_owns_one_relay_gateway( await runtime.invoke(codex_payload) codex_payload["runtime_context"]["invocation_id"] = "invocation-2" await runtime.invoke(codex_payload) + stop_gateway.assert_not_called() await runtime.stop() assert len(mock_codex.instances) == 1 From 1144e76da386739db032b7de2b793fc9bfb7c278 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Fri, 17 Jul 2026 16:53:03 -0700 Subject: [PATCH 10/34] docs: align persistent lifecycle guidance Signed-off-by: Ajay Thorve --- adapters/common/README.md | 4 ++-- docs/integrations/claude.mdx | 11 ++++++----- docs/integrations/codex.mdx | 18 ++++++++++-------- docs/sdk/python.mdx | 7 ++++--- .../references/sdk-api-inventory.md | 5 +++-- 5 files changed, 25 insertions(+), 20 deletions(-) diff --git a/adapters/common/README.md b/adapters/common/README.md index d5fcbbc4..430ef2cd 100644 --- a/adapters/common/README.md +++ b/adapters/common/README.md @@ -51,8 +51,8 @@ Fabric creates one factory instance per local host and serializes invocations through it. The host keeps one event loop alive for the complete lifecycle so SDK clients, compiled graphs, checkpointers, and harness databases can remain live safely. Adapter stdout is reserved for the protocol; diagnostics are -redirected to stderr. A host crash is terminal for that runtime and never falls -back to per-invocation execution. +redirected to stderr. A host crash or protocol timeout is terminal for that +runtime and never falls back to per-invocation execution. Refer to the [NeMo Fabric documentation](https://nvidia-nemo-fabric.docs.buildwithfern.com/nemo/fabric) for adapter and configuration guidance. Source code is available in the diff --git a/docs/integrations/claude.mdx b/docs/integrations/claude.mdx index cabf0957..434a0695 100644 --- a/docs/integrations/claude.mdx +++ b/docs/integrations/claude.mdx @@ -85,11 +85,12 @@ variables take precedence over federation even when their value is empty. ## Use Authentication with Relay -NeMo Relay does not authenticate Claude. A Relay-enabled NeMo Fabric invocation -starts the gateway as a supervised sidecar and sets `ANTHROPIC_BASE_URL` for the -Claude runtime. Claude still resolves its credential through the selected mode, -and NeMo Fabric does not write authentication values to Relay configuration or -artifacts. +NeMo Relay does not authenticate Claude. A Relay-enabled NeMo Fabric runtime +starts one gateway as a supervised sidecar, sets `ANTHROPIC_BASE_URL` for the +Claude runtime, and reuses the gateway across ordered invocations. A one-shot +`Fabric.run(...)` owns the gateway for one invocation. Claude still resolves its +credential through the selected mode, and NeMo Fabric does not write +authentication values to Relay configuration or artifacts. NeMo Fabric supports the external NeMo Relay CLI from `0.6.0` up to, but not including, `0.7.0`. The Python package named `nemo-relay` does not install this diff --git a/docs/integrations/codex.mdx b/docs/integrations/codex.mdx index 158e75b9..aaa5f9a6 100644 --- a/docs/integrations/codex.mdx +++ b/docs/integrations/codex.mdx @@ -103,24 +103,26 @@ storage. Do not commit or copy it into NeMo Fabric configuration or artifacts. ## Use Authentication with Relay -NeMo Relay does not replace OpenAI authentication. A Relay-enabled invocation -starts the Relay gateway as a supervised sidecar and directs the Codex SDK's -built-in OpenAI provider through that gateway. The SDK still obtains credentials -from the selected Codex authentication mode. +NeMo Relay does not replace OpenAI authentication. A Relay-enabled NeMo Fabric +runtime starts one gateway as a supervised sidecar and directs the Codex SDK's +built-in OpenAI provider through that gateway. The runtime reuses the gateway +and SDK client across ordered invocations. A one-shot `Fabric.run(...)` owns the +gateway for one invocation. The SDK still obtains credentials from the selected +Codex authentication mode. Relay-enabled Codex runs require `models.default.provider` to be `openai`. The custom `nvidia` provider uses its configured NVIDIA Responses endpoint and does not support the built-in provider redirect that Relay requires. Use the `nvidia` provider without Relay. -NeMo Fabric supplies Relay configuration to the SDK for only the current request. It -does not copy the Codex credential store into Relay configuration or persist -credentials in Relay artifacts. +NeMo Fabric supplies runtime-scoped Relay configuration to the SDK. It does not +copy the Codex credential store into Relay configuration or persist credentials +in Relay artifacts. NeMo Fabric supports the external NeMo Relay CLI from `0.6.0` up to, but not including, `0.7.0`. The Python package named `nemo-relay` is a separate library dependency and does not install the CLI. NeMo Fabric owns sidecar supervision and -request-scoped SDK configuration. Relay owns gateway transport behavior, +runtime-scoped SDK configuration. Relay owns gateway transport behavior, including decoding `Content-Encoding` before it constructs managed LLM events. There is no NeMo Fabric compression setting. diff --git a/docs/sdk/python.mdx b/docs/sdk/python.mdx index 7ec0d1ff..6bae5d7b 100644 --- a/docs/sdk/python.mdx +++ b/docs/sdk/python.mdx @@ -118,8 +118,8 @@ infrastructure while preserving isolated state for each NeMo Fabric runtime. Runtime hosting is selected by the adapter descriptor, not by a public `FabricConfig` setting. An adapter that declares the versioned persistent local host contract starts one local host during `start_runtime(...)`, reuses its -adapter-owned native resources across ordered invocations, and releases them -during `stop()`. +adapter-owned native resources across ordered invocations, and attempts to +release them during `stop()`. ### Bundled Adapter Capability Matrix @@ -146,7 +146,8 @@ subagent capabilities are not exposed. Third-party adapters that have not implemented the contract continue to use the per-invocation compatibility path. NeMo Fabric does not currently define a remote-service adapter contract. A crashed persistent host is terminal for that -runtime; NeMo Fabric does not silently respawn it or replay a request. +runtime. The same applies when the host exceeds the protocol response timeout. +NeMo Fabric does not silently respawn the host or replay a request. Harness-native threads, sessions, and conversations remain adapter-owned state associated with the NeMo Fabric runtime. They are not additional NeMo Fabric lifecycle diff --git a/skills/nemo-fabric-integrate/references/sdk-api-inventory.md b/skills/nemo-fabric-integrate/references/sdk-api-inventory.md index 65469a03..a7bc713c 100644 --- a/skills/nemo-fabric-integrate/references/sdk-api-inventory.md +++ b/skills/nemo-fabric-integrate/references/sdk-api-inventory.md @@ -69,7 +69,8 @@ FabricConfig -> plan() -> RunPlan -> start_runtime() -> Runtime -> invoke() -> R - Runtime hosting is adapter-declared, not consumer-configured. The bundled Claude, Codex, Deep Agents, and Hermes adapters retain their native runtime resources in one local host; third-party compatibility adapters may - reconstruct continuity on each invocation. A persistent-host crash is - terminal; Fabric does not silently respawn the host or replay the request. + reconstruct continuity on each invocation. A persistent-host crash or + protocol timeout is terminal; Fabric does not silently respawn the host or + replay the request. - The application owns scheduling, queues, retries, and how many runtimes to run. Fabric provides only the runtime contract. From 0e9250a2a26dd91fdb2a33818879fc419666ee6c Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Fri, 17 Jul 2026 17:09:34 -0700 Subject: [PATCH 11/34] fix(hermes): retry failed relay flush Signed-off-by: Ajay Thorve --- .../nemo_fabric_adapters/hermes/adapter.py | 21 +++++--- tests/adapters/test_hermes_adapter.py | 48 ++++++++++++++++++- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py b/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py index 4136d1a6..7756d6ff 100755 --- a/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py +++ b/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py @@ -226,6 +226,7 @@ def __init__(self) -> None: self._relay_context: Any = None self._relay_context_entered = False self._relay_session_pending = False + self._relay_finalize_hook_invoked = False self._relay_model_name = "unknown" async def start(self, payload: dict[str, Any]) -> None: @@ -237,6 +238,7 @@ async def start(self, payload: dict[str, Any]) -> None: try: self._relay_session_pending = False + self._relay_finalize_hook_invoked = False validate_hermes_telemetry_provider(payload) self._settings = common_utils.settings_payload(payload) self._model_config = common_utils.selected_model_config(payload) @@ -371,6 +373,7 @@ async def invoke(self, payload: dict[str, Any]) -> dict[str, Any]: user_message = json.dumps(user_message, sort_keys=True) try: self._relay_session_pending = self._relay_plugin_config is not None + self._relay_finalize_hook_invoked = False result, adapter_stdout = _invoke_hermes_turn( agent=self._agent, settings=self._settings, @@ -424,19 +427,22 @@ def _finalize_relay_session(self) -> None: or not self._relay_session_pending ): return - self._invoke_hook( - "on_session_finalize", - session_id=getattr(self._agent, "session_id", ""), - model=getattr(self._agent, "model", None) or self._relay_model_name, - platform=getattr(self._agent, "platform", None) or "fabric", - ) - self._relay_session_pending = False + if not self._relay_finalize_hook_invoked: + self._invoke_hook( + "on_session_finalize", + session_id=getattr(self._agent, "session_id", ""), + model=getattr(self._agent, "model", None) or self._relay_model_name, + platform=getattr(self._agent, "platform", None) or "fabric", + ) + self._relay_finalize_hook_invoked = True # Relay subscriber callbacks are queued. The long-lived plugin context # does not flush them until runtime shutdown, but invocation results # must include artifacts produced by this turn. from nemo_relay import subscribers subscribers.flush() + self._relay_session_pending = False + self._relay_finalize_hook_invoked = False async def stop(self) -> None: agent = self._agent @@ -455,6 +461,7 @@ async def stop(self) -> None: self._relay_context = None self._relay_context_entered = False self._relay_session_pending = False + self._relay_finalize_hook_invoked = False self._invoke_hook = None self._relay_plugin_config = None self._started = False diff --git a/tests/adapters/test_hermes_adapter.py b/tests/adapters/test_hermes_adapter.py index 76be28a1..d8b969d8 100644 --- a/tests/adapters/test_hermes_adapter.py +++ b/tests/adapters/test_hermes_adapter.py @@ -91,12 +91,14 @@ async def test_stop_does_not_refinalize_completed_relay_turn(monkeypatch): runtime = adapter.HermesRuntime() runtime._started = True runtime._relay_plugin_config = {"components": []} - runtime._agent = MagicMock( + agent = MagicMock( session_id="runtime-1", model="test-model", platform="fabric", ) - runtime._session_db = MagicMock() + session_db = MagicMock() + runtime._agent = agent + runtime._session_db = session_db runtime._invoke_hook = invoke_hook runtime._relay_session_pending = True @@ -115,6 +117,48 @@ async def test_stop_does_not_refinalize_completed_relay_turn(monkeypatch): platform="fabric", ) flush.assert_called_once_with() + agent.close.assert_called_once_with() + session_db.close.assert_called_once_with() + assert runtime._started is False + assert runtime._relay_session_pending is False + assert runtime._relay_finalize_hook_invoked is False + + +async def test_stop_retries_failed_relay_flush_without_refinalizing(monkeypatch): + invoke_hook = MagicMock() + runtime = adapter.HermesRuntime() + runtime._started = True + runtime._relay_plugin_config = {"components": []} + runtime._agent = MagicMock( + session_id="runtime-1", + model="test-model", + platform="fabric", + ) + runtime._session_db = MagicMock() + runtime._invoke_hook = invoke_hook + runtime._relay_session_pending = True + + from nemo_relay import subscribers + + flush = MagicMock(side_effect=[RuntimeError("flush failed"), None]) + monkeypatch.setattr(subscribers, "flush", flush) + + with pytest.raises(RuntimeError, match="flush failed"): + runtime._finalize_relay_session() + + assert runtime._relay_session_pending is True + assert runtime._relay_finalize_hook_invoked is True + await runtime.stop() + + invoke_hook.assert_called_once_with( + "on_session_finalize", + session_id="runtime-1", + model="test-model", + platform="fabric", + ) + assert flush.call_count == 2 + assert runtime._relay_session_pending is False + assert runtime._relay_finalize_hook_invoked is False def test_build_hermes_config_maps_fabric_config_to_hermes_config(): From 9b2a2366f64ea18865c1560977575e7bb45665cc Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Fri, 17 Jul 2026 17:20:43 -0700 Subject: [PATCH 12/34] fix(runtime): serialize timed-out host invalidation Signed-off-by: Ajay Thorve --- crates/fabric-core/src/runtime.rs | 47 ++++++++++++++++--------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/crates/fabric-core/src/runtime.rs b/crates/fabric-core/src/runtime.rs index f09b0e7b..25306286 100644 --- a/crates/fabric-core/src/runtime.rs +++ b/crates/fabric-core/src/runtime.rs @@ -1101,9 +1101,9 @@ fn run_local_host_adapter_with_timeout( })?; let exchange_result = { - let mut host = host.lock().unwrap_or_else(|error| error.into_inner()); - let artifacts = host.artifacts.clone(); - let relay_config = host.relay_config.clone(); + let mut host_guard = host.lock().unwrap_or_else(|error| error.into_inner()); + let artifacts = host_guard.artifacts.clone(); + let relay_config = host_guard.relay_config.clone(); let fabric_home = prepare_fabric_home(&artifacts, runtime, &invocation)?; let adapter_invocation = adapter_invocation( plan, @@ -1119,25 +1119,33 @@ fn run_local_host_adapter_with_timeout( let lifecycle_request = AdapterLifecycleRequest::new(AdapterLifecycleRequestKind::Invoke(adapter_invocation)); match exchange_lifecycle_message( - &mut host, + &mut host_guard, &runtime.runtime_id, &lifecycle_request, invoke_timeout, ) { Ok(output) => { - let stderr = take_local_host_stderr(&mut host); + let stderr = take_local_host_stderr(&mut host_guard); Ok(( output, stderr, - host.command.clone(), - host.child.id(), + host_guard.command.clone(), + host_guard.child.id(), artifacts, relay_config, fabric_home, fabric_invocation, )) } - Err(error) => Err(error), + Err(error) => { + if matches!( + &error, + FabricError::AdapterLifecycleOperation { code, .. } if code == "host_timeout" + ) { + invalidate_timed_out_local_host(&runtime.runtime_id, &host, &mut host_guard); + } + Err(error) + } } }; let ( @@ -1151,15 +1159,7 @@ fn run_local_host_adapter_with_timeout( fabric_invocation, ) = match exchange_result { Ok(result) => result, - Err(error) => { - if matches!( - &error, - FabricError::AdapterLifecycleOperation { code, .. } if code == "host_timeout" - ) { - invalidate_timed_out_local_host(&runtime.runtime_id, &host); - } - return Err(error); - } + Err(error) => return Err(error), }; let mut events = vec![event_with_metadata( @@ -1273,7 +1273,11 @@ fn run_local_host_adapter_with_timeout( }) } -fn invalidate_timed_out_local_host(runtime_id: &str, expected_host: &Arc>) { +fn invalidate_timed_out_local_host( + runtime_id: &str, + expected_host: &Arc>, + host: &mut LocalAdapterHost, +) { { let mut hosts = local_hosts(); if hosts @@ -1284,11 +1288,8 @@ fn invalidate_timed_out_local_host(runtime_id: &str, expected_host: &Arc (RunStatus, Option) { From 0b33bd51ef0665640337c78360b7f4d026ecaf14 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Fri, 17 Jul 2026 17:20:47 -0700 Subject: [PATCH 13/34] docs: clarify compatibility adapter continuity Signed-off-by: Ajay Thorve --- skills/nemo-fabric-integrate/references/sdk-api-inventory.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/nemo-fabric-integrate/references/sdk-api-inventory.md b/skills/nemo-fabric-integrate/references/sdk-api-inventory.md index a7bc713c..819c8712 100644 --- a/skills/nemo-fabric-integrate/references/sdk-api-inventory.md +++ b/skills/nemo-fabric-integrate/references/sdk-api-inventory.md @@ -68,7 +68,7 @@ FabricConfig -> plan() -> RunPlan -> start_runtime() -> Runtime -> invoke() -> R adapter-owned state associated with the runtime. - Runtime hosting is adapter-declared, not consumer-configured. The bundled Claude, Codex, Deep Agents, and Hermes adapters retain their native runtime - resources in one local host; third-party compatibility adapters may + resources in one local host; third-party compatibility adapters can reconstruct continuity on each invocation. A persistent-host crash or protocol timeout is terminal; Fabric does not silently respawn the host or replay the request. From 00e5ca4420ae159f14d4b696c59d31f6e9e75dc2 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Mon, 20 Jul 2026 15:26:16 -0700 Subject: [PATCH 14/34] test(runtime): restore typed local-host protocol coverage Signed-off-by: Ajay Thorve --- crates/fabric-core/src/runtime.rs | 483 ++++++++++++++++++++++++++++++ 1 file changed, 483 insertions(+) diff --git a/crates/fabric-core/src/runtime.rs b/crates/fabric-core/src/runtime.rs index 25306286..c8bd9001 100644 --- a/crates/fabric-core/src/runtime.rs +++ b/crates/fabric-core/src/runtime.rs @@ -2992,3 +2992,486 @@ fn now_millis() -> u128 { .map(|duration| duration.as_millis()) .unwrap_or_default() } + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + use crate::config::{ResolveContext, resolve_run_plan_from_config}; + + fn local_host_plan(mode: &str) -> (PathBuf, RunPlan) { + local_host_plan_with_relay(mode, false) + } + + fn local_host_plan_with_relay(mode: &str, relay: bool) -> (PathBuf, RunPlan) { + let root = std::env::temp_dir().join(new_id("fabric-local-host-test")); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("adapters/local-host")).expect("create adapters dir"); + fs::write( + root.join("adapters/local-host/fabric-adapter.json"), + r#"{ + "contract_version": "fabric.adapter/v1alpha1", + "adapter_id": "acme.fabric.local-host", + "harness": "local-host-test", + "adapter_kind": "python", + "runner": {"module": "fake_host"}, + "telemetry": { + "providers": { + "relay": {"outputs": ["atif"]} + } + }, + "runtime": { + "local_host": { + "contract_version": "fabric.adapter.lifecycle/v1alpha1" + } + } +}"#, + ) + .expect("write adapter descriptor"); + fs::write( + root.join("fake_host.py"), + r#"import json +import os +import sys + +VERSION = "fabric.adapter.lifecycle/v1alpha1" +MODE = os.environ.get("FABRIC_FAKE_HOST_MODE", "success") +invocations = 0 + +def response(operation, *, output=None, error=None): + outcome = ( + {"status": "succeeded", "output": output} + if error is None + else {"status": "failed", "error": error} + ) + print(json.dumps({ + "contract_version": VERSION, + "operation": operation, + "outcome": outcome, + }), flush=True) + +def failure(stage, code, message): + return { + "stage": stage, + "code": code, + "message": message, + "retryable": False, + } + +for line in sys.stdin: + message = json.loads(line) + operation = message["operation"] + if operation == "start": + if MODE == "start_failure": + print("start diagnostic", file=sys.stderr, flush=True) + response("start", error=failure("start", "fake_start", "start rejected")) + sys.exit(16) + response("start") + if MODE == "crash_after_start": + print("host crashed intentionally", file=sys.stderr, flush=True) + sys.exit(17) + elif operation == "invoke": + invocations += 1 + if MODE == "invoke_stderr": + print(f"diagnostic-{invocations}", file=sys.stderr, flush=True) + if MODE == "invoke_timeout": + print("invoke accepted without response", file=sys.stderr, flush=True) + continue + if MODE == "invoke_failure": + response("invoke", error=failure("invoke", "fake_invoke", "invoke rejected")) + continue + invocation = message["payload"] + output = { + "host_pid": os.getpid(), + "invocation_count": invocations, + "input": invocation["request"]["input"], + "runtime_id": invocation["runtime_context"]["runtime_id"], + "invocation_id": invocation["runtime_context"]["invocation_id"], + "request_id": invocation["runtime_context"]["request_id"], + } + if MODE == "adapter_reported_failure": + output.update({ + "failed": True, + "error": { + "code": "fake_adapter_failure", + "message": "adapter rejected the invocation", + "retryable": True, + "metadata": {"source": "fake-host"}, + }, + }) + response("invoke", output=output) + elif operation == "stop": + if MODE == "stop_failure": + response("stop", error=failure("stop", "fake_stop", "stop rejected")) + sys.exit(18) + response("stop") + break +"#, + ) + .expect("write fake host"); + + let mut config_value = serde_json::json!({ + "schema_version": "fabric.agent/v1alpha1", + "metadata": {"name": "local-host-test-agent"}, + "harness": { + "adapter_id": "acme.fabric.local-host", + "resolution": "preinstalled", + "settings": { + "python": "python3", + "cwd": ".", + "env": {"FABRIC_FAKE_HOST_MODE": mode}, + }, + }, + "models": { + "default": { + "provider": "test", + "model": "test-model", + }, + }, + "runtime": { + "input_schema": "text", + "output_schema": "text", + "artifacts": "./artifacts", + }, + }); + if relay { + config_value["telemetry"] = serde_json::json!({ + "providers": {"relay": {}}, + }); + config_value["relay"] = serde_json::json!({ + "observability": {"atif": {"enabled": true}}, + }); + } + let config: FabricConfig = serde_json::from_value(config_value).expect("typed config"); + let plan = resolve_run_plan_from_config(config, ResolveContext::new(&root)) + .expect("resolve local-host plan"); + (root, plan) + } + + fn stopped_agents() -> Vec { + TEST_STOPPED_AGENTS.lock().expect("stop tracker").clone() + } + + #[test] + fn local_host_reuses_one_process_and_stops_idempotently() { + let (root, plan) = local_host_plan("success"); + let runtime = start_runtime(&plan).expect("start local host"); + + let first = + invoke_runtime(&plan, &runtime, RunRequest::text("first")).expect("first invocation"); + let second = + invoke_runtime(&plan, &runtime, RunRequest::text("second")).expect("second invocation"); + + assert_eq!(first.output["host_pid"], second.output["host_pid"]); + assert_eq!(first.output["invocation_count"], serde_json::json!(1)); + assert_eq!(second.output["invocation_count"], serde_json::json!(2)); + assert_eq!(first.output["input"], serde_json::json!("first")); + assert_eq!(second.output["input"], serde_json::json!("second")); + assert_eq!(first.metadata["host_pid"], second.metadata["host_pid"]); + assert_eq!( + first.metadata["adapter_runner"], + serde_json::json!("persistent_local_host") + ); + let stdout = first + .artifacts + .artifacts + .iter() + .find(|artifact| artifact.name == "stdout") + .expect("stdout artifact"); + let captured: Value = + serde_json::from_str(&fs::read_to_string(&stdout.path).expect("read stdout artifact")) + .expect("parse stdout artifact"); + assert_eq!(captured, first.output); + assert!( + first + .artifacts + .artifacts + .iter() + .all(|artifact| artifact.name != "stderr") + ); + + let first_stop = stop_runtime(&plan, &runtime).expect("first stop"); + let second_stop = stop_runtime(&plan, &runtime).expect("idempotent stop"); + assert_eq!(first_stop[0].metadata["already_stopped"], false); + assert_eq!(second_stop[0].metadata["already_stopped"], true); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn local_host_relay_config_is_runtime_scoped() { + let (root, plan) = local_host_plan_with_relay("success", true); + let runtime = start_runtime(&plan).expect("start local host"); + let host = local_hosts() + .get(&runtime.runtime_id) + .cloned() + .expect("active local host"); + let relay_config_path = host + .lock() + .expect("local host") + .relay_config + .as_ref() + .expect("Relay config") + .path + .clone(); + let relay_config: Value = serde_json::from_str( + &fs::read_to_string(relay_config_path).expect("read Relay config"), + ) + .expect("parse Relay config"); + + assert_eq!( + relay_config["fabric"]["runtime_id"], + serde_json::json!(runtime.runtime_id) + ); + assert!(relay_config["fabric"].get("invocation_id").is_none()); + assert!(relay_config["fabric"].get("request_id").is_none()); + + let result = + invoke_runtime(&plan, &runtime, RunRequest::text("first")).expect("invoke local host"); + assert_eq!(result.output["runtime_id"], result.runtime_id); + assert_eq!(result.output["invocation_id"], result.invocation_id); + assert_eq!(result.output["request_id"], result.request_id); + + stop_runtime(&plan, &runtime).expect("stop local host"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn local_host_start_failure_preserves_stage_and_diagnostics() { + let (root, plan) = local_host_plan("start_failure"); + + let error = start_runtime(&plan).expect_err("start must fail"); + let message = error.to_string(); + assert!(message.contains("lifecycle start"), "{message}"); + assert!(message.contains("fake_start"), "{message}"); + assert!(message.contains("start diagnostic"), "{message}"); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn local_host_invoke_timeout_evicts_and_terminates_host() { + let (root, plan) = local_host_plan("invoke_timeout"); + let runtime = start_runtime(&plan).expect("start local host"); + let host = local_hosts() + .get(&runtime.runtime_id) + .cloned() + .expect("active local host"); + let runtime_dir = host.lock().expect("local host").runtime_dir.clone(); + + let error = run_local_host_adapter_with_timeout( + &plan, + &runtime, + RunRequest::text("hang"), + Duration::from_millis(100), + ) + .expect_err("unresponsive host must time out"); + + assert!(matches!( + &error, + FabricError::AdapterLifecycleOperation { + code, + diagnostics, + .. + } if code == "host_timeout" && diagnostics.contains("invoke accepted without response") + )); + assert!(!local_hosts().contains_key(&runtime.runtime_id)); + assert!( + host.lock() + .expect("local host") + .child + .try_wait() + .expect("inspect timed-out host") + .is_some(), + "timed-out host process must be terminated" + ); + assert!(!runtime_dir.exists()); + + let retry = invoke_runtime(&plan, &runtime, RunRequest::text("retry")) + .expect_err("timed-out runtime must remain unavailable"); + assert!(matches!( + retry, + FabricError::AdapterLifecycleOperation { code, .. } if code == "host_unavailable" + )); + let stopped = stop_runtime(&plan, &runtime).expect("stop after timeout is idempotent"); + assert_eq!(stopped[0].metadata["already_stopped"], true); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn local_host_timeout_prevents_waiting_invocation_from_reusing_host() { + let (root, plan) = local_host_plan("invoke_timeout"); + let runtime = start_runtime(&plan).expect("start local host"); + let host = local_hosts() + .get(&runtime.runtime_id) + .cloned() + .expect("active local host"); + let stderr_path = host.lock().expect("local host").stderr_path.clone(); + + let first_plan = plan.clone(); + let first_runtime = runtime.clone(); + let first = thread::spawn(move || { + run_local_host_adapter_with_timeout( + &first_plan, + &first_runtime, + RunRequest::text("first"), + Duration::from_millis(500), + ) + }); + let accepted_deadline = Instant::now() + Duration::from_secs(2); + while !fs::read_to_string(&stderr_path) + .expect("read host stderr") + .contains("invoke accepted without response") + { + assert!( + Instant::now() < accepted_deadline, + "first invocation was not accepted" + ); + thread::sleep(Duration::from_millis(10)); + } + + let second_plan = plan.clone(); + let second_runtime = runtime.clone(); + let second = thread::spawn(move || { + run_local_host_adapter_with_timeout( + &second_plan, + &second_runtime, + RunRequest::text("second"), + Duration::from_millis(100), + ) + }); + // The registry, this test, and the first invocation own three host + // references. A fourth proves that the second invocation passed the + // registry lookup and is waiting for the host mutex. + let waiting_deadline = Instant::now() + Duration::from_secs(1); + while Arc::strong_count(&host) < 4 { + assert!( + Instant::now() < waiting_deadline, + "second invocation did not acquire the host reference" + ); + thread::sleep(Duration::from_millis(10)); + } + + let first_error = first + .join() + .expect("first invocation thread") + .expect_err("first invocation must time out"); + let second_error = second + .join() + .expect("second invocation thread") + .expect_err("waiting invocation must not reuse the timed-out host"); + + assert!(matches!( + first_error, + FabricError::AdapterLifecycleOperation { code, .. } if code == "host_timeout" + )); + assert!(matches!( + second_error, + FabricError::AdapterLifecycleOperation { code, .. } if code == "host_crashed" + )); + assert!(!local_hosts().contains_key(&runtime.runtime_id)); + let stopped = stop_runtime(&plan, &runtime).expect("stop after timeout is idempotent"); + assert_eq!(stopped[0].metadata["already_stopped"], true); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn local_host_captures_each_stderr_delta_once() { + let (root, plan) = local_host_plan("invoke_stderr"); + let runtime = start_runtime(&plan).expect("start local host"); + + let first = + invoke_runtime(&plan, &runtime, RunRequest::text("first")).expect("first invocation"); + let second = + invoke_runtime(&plan, &runtime, RunRequest::text("second")).expect("second invocation"); + let read_stderr = |result: &RunResult| { + let artifact = result + .artifacts + .artifacts + .iter() + .find(|artifact| artifact.name == "stderr") + .expect("stderr artifact"); + fs::read_to_string(&artifact.path).expect("read stderr artifact") + }; + + assert_eq!(read_stderr(&first), "diagnostic-1\n"); + assert_eq!(read_stderr(&second), "diagnostic-2\n"); + stop_runtime(&plan, &runtime).expect("stop local host"); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn run_plan_stops_local_host_after_invoke_failure() { + let (root, mut plan) = local_host_plan("invoke_failure"); + let agent_name = new_id("local-host-invoke-error-agent"); + plan.agent_name = agent_name.clone(); + plan.config.metadata.name = agent_name.clone(); + + let error = run_plan(&plan, RunRequest::text("fail")).expect_err("invoke must fail"); + assert!(error.to_string().contains("fake_invoke"), "{error}"); + assert!( + stopped_agents().contains(&agent_name), + "run_plan must stop the local host after invocation failure" + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn local_host_preserves_normalized_adapter_failure() { + let (root, plan) = local_host_plan("adapter_reported_failure"); + + let result = run_plan(&plan, RunRequest::text("fail")).expect("normalized result"); + + assert_eq!(result.status, RunStatus::Failed); + assert_eq!(result.output["failed"], true); + assert_eq!( + result.error, + Some(ErrorInfo { + stage: ErrorStage::Invoke, + code: "fake_adapter_failure".to_string(), + message: "adapter rejected the invocation".to_string(), + retryable: true, + metadata: BTreeMap::from([("source".to_string(), serde_json::json!("fake-host"))]), + }) + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn local_host_crash_never_falls_back_to_per_invocation_execution() { + let (root, plan) = local_host_plan("crash_after_start"); + let runtime = start_runtime(&plan).expect("start local host"); + + let first = invoke_runtime(&plan, &runtime, RunRequest::text("first")) + .expect_err("crashed host must reject invocation"); + let second = invoke_runtime(&plan, &runtime, RunRequest::text("second")) + .expect_err("dead runtime must remain unusable"); + assert!(first.to_string().contains("host_crashed"), "{first}"); + assert!(second.to_string().contains("host_crashed"), "{second}"); + + let stopped = stop_runtime(&plan, &runtime).expect("crashed host cleanup"); + assert_eq!(stopped[0].metadata["host_crashed"], true); + stop_runtime(&plan, &runtime).expect("cleanup remains idempotent"); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn local_host_stop_failure_still_releases_host() { + let (root, plan) = local_host_plan("stop_failure"); + let runtime = start_runtime(&plan).expect("start local host"); + + let error = stop_runtime(&plan, &runtime).expect_err("stop must fail"); + assert!(error.to_string().contains("fake_stop"), "{error}"); + let retry = stop_runtime(&plan, &runtime).expect("cleanup retry is idempotent"); + assert_eq!(retry[0].metadata["already_stopped"], true); + + let _ = fs::remove_dir_all(root); + } +} From f882a8f391874a7d014bf3025672b17c18413d24 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Mon, 20 Jul 2026 15:26:24 -0700 Subject: [PATCH 15/34] fix(cli): align persistent runtime integration Signed-off-by: Ajay Thorve --- .../assets/adapters/claude/fabric-adapter.json | 5 +++++ .../assets/adapters/codex/fabric-adapter.json | 5 +++++ .../adapters/deepagents/fabric-adapter.json | 5 +++++ .../assets/adapters/hermes/fabric-adapter.json | 5 +++++ crates/fabric-cli/src/app.rs | 17 ++++++++++++++++- tests/e2e/test_cli_scaffold.py | 5 ++++- 6 files changed, 40 insertions(+), 2 deletions(-) diff --git a/crates/fabric-cli/assets/adapters/claude/fabric-adapter.json b/crates/fabric-cli/assets/adapters/claude/fabric-adapter.json index ed36b146..ee998f8c 100644 --- a/crates/fabric-cli/assets/adapters/claude/fabric-adapter.json +++ b/crates/fabric-cli/assets/adapters/claude/fabric-adapter.json @@ -17,5 +17,10 @@ "integration_modes": ["hooks", "gateway"] } } + }, + "runtime": { + "local_host": { + "contract_version": "fabric.adapter.lifecycle/v1alpha1" + } } } diff --git a/crates/fabric-cli/assets/adapters/codex/fabric-adapter.json b/crates/fabric-cli/assets/adapters/codex/fabric-adapter.json index 96f2d7dd..d3453bec 100644 --- a/crates/fabric-cli/assets/adapters/codex/fabric-adapter.json +++ b/crates/fabric-cli/assets/adapters/codex/fabric-adapter.json @@ -20,5 +20,10 @@ "outputs": ["otel"] } } + }, + "runtime": { + "local_host": { + "contract_version": "fabric.adapter.lifecycle/v1alpha1" + } } } diff --git a/crates/fabric-cli/assets/adapters/deepagents/fabric-adapter.json b/crates/fabric-cli/assets/adapters/deepagents/fabric-adapter.json index 78a09fe3..444c5924 100644 --- a/crates/fabric-cli/assets/adapters/deepagents/fabric-adapter.json +++ b/crates/fabric-cli/assets/adapters/deepagents/fabric-adapter.json @@ -20,5 +20,10 @@ "outputs": ["otel", "openinference"] } } + }, + "runtime": { + "local_host": { + "contract_version": "fabric.adapter.lifecycle/v1alpha1" + } } } diff --git a/crates/fabric-cli/assets/adapters/hermes/fabric-adapter.json b/crates/fabric-cli/assets/adapters/hermes/fabric-adapter.json index f2857470..93f6bcfe 100644 --- a/crates/fabric-cli/assets/adapters/hermes/fabric-adapter.json +++ b/crates/fabric-cli/assets/adapters/hermes/fabric-adapter.json @@ -32,5 +32,10 @@ ] } } + }, + "runtime": { + "local_host": { + "contract_version": "fabric.adapter.lifecycle/v1alpha1" + } } } diff --git a/crates/fabric-cli/src/app.rs b/crates/fabric-cli/src/app.rs index 6093f356..93ab91eb 100644 --- a/crates/fabric-cli/src/app.rs +++ b/crates/fabric-cli/src/app.rs @@ -250,7 +250,22 @@ fn run(cli: Cli) -> Result<(), Box> { selected.config().clone(), ResolveContext::new(selected.base_dir()), )?; - let result = run_plan(&plan, RunRequest::text(input))?; + let result = match run_plan(&plan, RunRequest::text(input)) { + Ok(result) => result, + Err(error) => { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "status": "failed", + "error": { + "code": "runtime_error", + "message": "run failed before a normalized result was available", + }, + }))? + ); + return Err(error.into()); + } + }; println!("{}", serde_json::to_string_pretty(&result)?); require_successful_run(result.status)?; } diff --git a/tests/e2e/test_cli_scaffold.py b/tests/e2e/test_cli_scaffold.py index f36521ff..b7046e8f 100644 --- a/tests/e2e/test_cli_scaffold.py +++ b/tests/e2e/test_cli_scaffold.py @@ -108,4 +108,7 @@ def test_failed_cli_run_returns_nonzero_status(): ) assert result.returncode != 0 - assert json.loads(result.stdout)["status"] == "failed" + failure = json.loads(result.stdout) + assert failure["status"] == "failed" + assert failure["error"]["code"] == "runtime_error" + assert "adapter lifecycle start failed" in result.stderr From f53e30457b2b2829c8d6ae86f20b28f676415bd7 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Mon, 20 Jul 2026 15:26:28 -0700 Subject: [PATCH 16/34] test(codex): cover persistent normalized capabilities Signed-off-by: Ajay Thorve --- tests/adapters/test_codex_adapter.py | 43 ++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index 908feaf6..2a7f9c52 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -404,6 +404,49 @@ async def test_persistent_runtime_reuses_one_client_and_thread( client.close.assert_awaited_once() +async def test_persistent_runtime_registers_skills_once_and_maps_mcp( + codex_payload, mock_codex, tmp_path +): + skill = tmp_path / "skills" / "review" + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text( + "---\nname: review\ndescription: Test skill.\n---\n", + encoding="utf-8", + ) + codex_payload["capability_plan"] = { + "native": { + "skill_paths": ["skills/review"], + "mcp_servers": { + "review": { + "transport": "streamable-http", + "url": "https://mcp.example.test/review", + } + }, + } + } + start_payload = dict(codex_payload) + start_payload.pop("request") + runtime = adapter.CodexRuntime() + + await runtime.start(start_payload) + await runtime.invoke(codex_payload) + codex_payload["runtime_context"]["invocation_id"] = "invocation-2" + await runtime.invoke(codex_payload) + await runtime.stop() + + client = mock_codex.instances[0] + client.models.assert_awaited_once_with() + client._client.request.assert_awaited_once_with( + "skills/extraRoots/set", + {"extraRoots": [str(skill)]}, + response_model=adapter.SkillsExtraRootsSetResponse, + ) + assert client.thread_start.await_args.kwargs["config"]["mcp_servers"] == { + "review": {"url": "https://mcp.example.test/review"} + } + assert client.thread.turn.await_count == 2 + + async def test_persistent_runtime_owns_one_relay_gateway( codex_payload, mock_codex, monkeypatch, tmp_path ): From 4a58d136dbf4754eec76dc58f91173d0b884a65c Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Mon, 20 Jul 2026 15:26:37 -0700 Subject: [PATCH 17/34] docs: refresh persistent adapter capabilities Signed-off-by: Ajay Thorve --- README.md | 2 +- ...ant-adapter-lifecycle-contract-version.mdx | 14 +++ .../config/enum-adapterdescriptorsource.mdx | 2 +- .../config/enum-adapterkind.mdx | 2 +- .../config/enum-capabilitykind.mdx | 2 +- .../config/enum-capabilitytarget.mdx | 2 +- .../config/enum-controllocation.mdx | 2 +- .../config/enum-environmentownership.mdx | 2 +- .../config/enum-mcpexposure.mdx | 2 +- .../config/enum-relayatifstorageconfig.mdx | 2 +- .../enum-relayatofendpointfieldnamepolicy.mdx | 2 +- .../enum-relayatofendpointtransport.mdx | 2 +- .../config/enum-relayatofmode.mdx | 2 +- .../config/enum-relayotlptransport.mdx | 2 +- .../config/enum-relayunsupportedbehavior.mdx | 2 +- .../config/enum-resolutionstrategy.mdx | 2 +- .../config/enum-telemetryprovider.mdx | 2 +- .../config/fn-load-adapter-descriptor.mdx | 2 +- .../fn-resolve-run-plan-from-config.mdx | 2 +- .../nemo-fabric-core/config/index.mdx | 5 +- .../config/struct-adapterconfigsupport.mdx | 2 +- .../config/struct-adapterdescriptor.mdx | 8 +- .../config/struct-adapterlocalhostsupport.mdx | 98 ++++++++++++++++ .../config/struct-adapterrequirements.mdx | 2 +- .../config/struct-adapterruntimesupport.mdx | 106 ++++++++++++++++++ ...struct-adaptertelemetryprovidersupport.mdx | 2 +- .../config/struct-adaptertelemetrysupport.mdx | 2 +- .../config/struct-capabilityplan.mdx | 2 +- .../config/struct-capabilityroute.mdx | 2 +- .../config/struct-capabilitytargetplan.mdx | 2 +- .../config/struct-environmentconfig.mdx | 2 +- .../config/struct-environmentplan.mdx | 2 +- .../config/struct-fabricconfig.mdx | 2 +- .../config/struct-harnessconfig.mdx | 2 +- .../config/struct-mcpconfig.mdx | 2 +- .../config/struct-mcpserverconfig.mdx | 2 +- .../config/struct-mcpserverplan.mdx | 2 +- .../config/struct-metadataconfig.mdx | 2 +- .../config/struct-modelconfig.mdx | 2 +- .../config/struct-relayatifconfig.mdx | 2 +- .../config/struct-relayatofconfig.mdx | 2 +- .../config/struct-relayatofendpointconfig.mdx | 2 +- .../config/struct-relaycomponentconfig.mdx | 2 +- .../config/struct-relayconfig.mdx | 2 +- .../config/struct-relayconfigpolicy.mdx | 2 +- .../struct-relayobservabilityconfig.mdx | 2 +- .../config/struct-relayotlpconfig.mdx | 2 +- .../config/struct-resolvecontext.mdx | 2 +- .../struct-resolvedadapterdescriptor.mdx | 2 +- .../config/struct-runplan.mdx | 2 +- .../config/struct-runtimecapabilities.mdx | 2 +- .../config/struct-runtimeconfig.mdx | 2 +- .../config/struct-skillconfig.mdx | 2 +- .../config/struct-telemetryconfig.mdx | 2 +- .../config/struct-telemetryplan.mdx | 2 +- .../config/struct-telemetryproviderconfig.mdx | 2 +- .../config/struct-toolsconfig.mdx | 2 +- .../config/struct-toolsplan.mdx | 2 +- .../doctor/enum-doctorstatus.mdx | 2 +- .../doctor/fn-doctor-plan.mdx | 2 +- .../nemo-fabric-core/doctor/index.mdx | 2 +- .../doctor/struct-doctorcheck.mdx | 2 +- .../doctor/struct-doctorreport.mdx | 2 +- .../error/enum-fabricerror.mdx | 32 +++++- .../nemo-fabric-core/error/index.mdx | 2 +- .../nemo-fabric-core/error/type-result.mdx | 2 +- .../nemo-fabric-core/fn-version.mdx | 2 +- .../nemo-fabric-core/index.mdx | 3 + .../nemo-fabric-core/runtime/index.mdx | 2 +- .../nemo-fabric-core/schema/index.mdx | 2 +- docs/sdk/python.mdx | 2 +- 71 files changed, 325 insertions(+), 69 deletions(-) create mode 100644 docs/reference/api/rust-library-reference/nemo-fabric-core/config/constant-adapter-lifecycle-contract-version.mdx create mode 100644 docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterlocalhostsupport.mdx create mode 100644 docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterruntimesupport.mdx diff --git a/README.md b/README.md index 2c1df99d..0bfe72f0 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ the following capabilities: | Adapter | Models | Tools / Blocked Tools | MCP | Skills | Subagents | Telemetry | Persistent Local Host | Remote Service | | --- | --- | --- | --- | --- | --- | --- | --- | --- | | [Claude](adapters/claude/README.md) | Anthropic / Claude | `allowed_tools` adapter setting / normalized block list | Normalized | Normalized | Not exposed | Relay: ATIF, OTel, and OpenInference through hooks and gateway | Yes: `ClaudeSDKClient`, session, and optional Relay gateway | Not implemented | -| [Codex](adapters/codex/README.md) | OpenAI / Codex | Not normalized | Not normalized | Not normalized | Not exposed | Relay: ATIF, OTel, and OpenInference through hooks and gateway; native OTel | Yes: `AsyncCodex`, app server, thread, and optional Relay gateway | Not implemented | +| [Codex](adapters/codex/README.md) | OpenAI and NVIDIA Responses providers / Codex-compatible models | Not normalized | Normalized | Normalized | Not exposed | Relay: ATIF, OTel, and OpenInference through hooks and gateway; native OTel | Yes: `AsyncCodex`, app server, thread, and optional Relay gateway | Not implemented | | [Deep Agents](adapters/deepagents/README.md) | LangChain providers | Built-ins and MCP / normalized middleware block list | Normalized | Normalized | Constrained local delegation | Relay SDK: ATIF, OTel, and OpenInference; native OTel and OpenInference | Yes: compiled graph and async checkpointer | Not implemented | | [Hermes](adapters/hermes/README.md) | Normalized provider and base URL | Toolsets / normalized disabled toolsets | Normalized | Normalized | Not exposed | Relay plugin: ATIF, OTel, and OpenInference | Yes: `AIAgent`, `SessionDB`, and Relay context | Not implemented | diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/constant-adapter-lifecycle-contract-version.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/constant-adapter-lifecycle-contract-version.mdx new file mode 100644 index 00000000..b938a3a9 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/constant-adapter-lifecycle-contract-version.mdx @@ -0,0 +1,14 @@ +--- +title: "Constant ADAPTER_LIFECYCLE_CONTRACT_VERSION" +sidebar-title: "ADAPTER_LIFECYCLE_CONTRACT_VERSION" +description: "Persistent local-host lifecycle contract version supported by this core." +position: 2 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
str = \"fabric.adapter.lifecycle/v1alpha1\";"}} />
+ +Persistent local-host lifecycle contract version supported by this core. diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-adapterdescriptorsource.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-adapterdescriptorsource.mdx index 57ada140..3d35cda8 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-adapterdescriptorsource.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-adapterdescriptorsource.mdx @@ -2,7 +2,7 @@ title: "Enum Adapter Descriptor Source" sidebar-title: "AdapterDescriptorSource" description: "Where Fabric resolved an adapter descriptor from." -position: 4 +position: 5 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-adapterkind.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-adapterkind.mdx index c786a243..ff0fd98d 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-adapterkind.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-adapterkind.mdx @@ -2,7 +2,7 @@ title: "Enum Adapter Kind" sidebar-title: "AdapterKind" description: "Adapter implementation kind." -position: 5 +position: 6 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitykind.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitykind.mdx index b0d72b88..dc6679cc 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitykind.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitykind.mdx @@ -2,7 +2,7 @@ title: "Enum Capability Kind" sidebar-title: "CapabilityKind" description: "Capability kind." -position: 39 +position: 41 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitytarget.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitytarget.mdx index 2b82413a..bc7000dd 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitytarget.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitytarget.mdx @@ -2,7 +2,7 @@ title: "Enum Capability Target" sidebar-title: "CapabilityTarget" description: "Capability routing target." -position: 40 +position: 42 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-controllocation.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-controllocation.mdx index 84d27e31..3be7bc41 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-controllocation.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-controllocation.mdx @@ -2,7 +2,7 @@ title: "Enum Control Location" sidebar-title: "ControlLocation" description: "Where Fabric control code runs relative to the environment." -position: 10 +position: 13 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-environmentownership.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-environmentownership.mdx index 962a0afd..eb7988d1 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-environmentownership.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-environmentownership.mdx @@ -2,7 +2,7 @@ title: "Enum Environment Ownership" sidebar-title: "EnvironmentOwnership" description: "Whether Fabric owns the underlying environment resource." -position: 12 +position: 15 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-mcpexposure.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-mcpexposure.mdx index b0623603..24f22d62 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-mcpexposure.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-mcpexposure.mdx @@ -2,7 +2,7 @@ title: "Enum McpExposure" sidebar-title: "McpExposure" description: "MCP exposure strategy." -position: 17 +position: 20 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatifstorageconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatifstorageconfig.mdx index 1b2589f2..3e3bae70 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatifstorageconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatifstorageconfig.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atif Storage Config" sidebar-title: "RelayAtifStorageConfig" description: "Relay ATIF remote storage configuration." -position: 44 +position: 46 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofendpointfieldnamepolicy.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofendpointfieldnamepolicy.mdx index e500f6aa..ad00dee6 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofendpointfieldnamepolicy.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofendpointfieldnamepolicy.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atof Endpoint Field Name Policy" sidebar-title: "RelayAtofEndpointFieldNamePolicy" description: "Relay ATOF endpoint field-name policy." -position: 45 +position: 47 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofendpointtransport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofendpointtransport.mdx index 089169e4..f8277ee0 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofendpointtransport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofendpointtransport.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atof Endpoint Transport" sidebar-title: "RelayAtofEndpointTransport" description: "Relay ATOF endpoint transport." -position: 46 +position: 48 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofmode.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofmode.mdx index 010b8850..60977018 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofmode.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofmode.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atof Mode" sidebar-title: "RelayAtofMode" description: "Relay ATOF file mode." -position: 47 +position: 49 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayotlptransport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayotlptransport.mdx index fae226e2..1f585700 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayotlptransport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayotlptransport.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Otlp Transport" sidebar-title: "RelayOtlpTransport" description: "Relay OTLP transport." -position: 48 +position: 50 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayunsupportedbehavior.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayunsupportedbehavior.mdx index 34db2ad9..e8d549f6 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayunsupportedbehavior.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayunsupportedbehavior.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Unsupported Behavior" sidebar-title: "RelayUnsupportedBehavior" description: "Relay unsupported/unknown config handling." -position: 49 +position: 51 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-resolutionstrategy.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-resolutionstrategy.mdx index 86197cf3..2f06bcd9 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-resolutionstrategy.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-resolutionstrategy.mdx @@ -2,7 +2,7 @@ title: "Enum Resolution Strategy" sidebar-title: "ResolutionStrategy" description: "Adapter install or availability strategy." -position: 21 +position: 24 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-telemetryprovider.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-telemetryprovider.mdx index 9cab3af6..851ae882 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-telemetryprovider.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-telemetryprovider.mdx @@ -2,7 +2,7 @@ title: "Enum Telemetry Provider" sidebar-title: "TelemetryProvider" description: "Telemetry runtime provider." -position: 30 +position: 33 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-descriptor.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-descriptor.mdx index 93dff2ba..05880941 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-descriptor.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-descriptor.mdx @@ -2,7 +2,7 @@ title: "Function load_adapter_descriptor" sidebar-title: "load_adapter_descriptor" description: "Load an adapter descriptor from JSON package metadata." -position: 32 +position: 35 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-resolve-run-plan-from-config.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-resolve-run-plan-from-config.mdx index 830eb41f..14a4e484 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-resolve-run-plan-from-config.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-resolve-run-plan-from-config.mdx @@ -2,7 +2,7 @@ title: "Function resolve_run_plan_from_config" sidebar-title: "resolve_run_plan_from_config" description: "Resolve a typed Fabric config into a runnable plan." -position: 33 +position: 36 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx index 4de4cef4..478d0cc5 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx @@ -2,7 +2,7 @@ title: "Module config" sidebar-title: "config" description: "Fabric config models and loading helpers." -position: 65 +position: 68 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} @@ -15,7 +15,9 @@ Fabric config models and loading helpers. - [AdapterConfigSupport](/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterconfigsupport): Adapter config support. - [AdapterDescriptor](/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterdescriptor): Language-neutral adapter descriptor for a harness integration. +- [AdapterLocalHostSupport](/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterlocalhostsupport): Persistent local-host protocol implemented by an adapter. - [AdapterRequirements](/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterrequirements): Adapter runtime requirements. +- [AdapterRuntimeSupport](/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterruntimesupport): Runtime hosting mechanisms implemented by an adapter. - [AdapterTelemetryProviderSupport](/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adaptertelemetryprovidersupport): Telemetry capabilities for one adapter-supported provider. - [AdapterTelemetrySupport](/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adaptertelemetrysupport): Adapter telemetry support. - [CapabilityPlan](/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilityplan): Resolved capability configuration. @@ -71,6 +73,7 @@ Fabric config models and loading helpers. ## Constants - [ADAPTER_CONTRACT_VERSION](/reference/api/rust-library-reference/nemo-fabric-core/config/constant-adapter-contract-version): Adapter descriptor contract version supported by this core. +- [ADAPTER_LIFECYCLE_CONTRACT_VERSION](/reference/api/rust-library-reference/nemo-fabric-core/config/constant-adapter-lifecycle-contract-version): Persistent local-host lifecycle contract version supported by this core. ## Functions diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterconfigsupport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterconfigsupport.mdx index 4e4a85f0..d2d20b63 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterconfigsupport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterconfigsupport.mdx @@ -2,7 +2,7 @@ title: "Struct Adapter Config Support" sidebar-title: "AdapterConfigSupport" description: "Adapter config support." -position: 2 +position: 3 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterdescriptor.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterdescriptor.mdx index 2d1a3357..462202b0 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterdescriptor.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterdescriptor.mdx @@ -2,14 +2,14 @@ title: "Struct Adapter Descriptor" sidebar-title: "AdapterDescriptor" description: "Language-neutral adapter descriptor for a harness integration." -position: 3 +position: 4 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} Generated from `cargo doc --no-deps -p nemo-fabric-core`. -
String,\n    pub adapter_id: String,\n    pub harness: String,\n    pub adapter_kind: AdapterKind,\n    pub runner: Map<String, Value>,\n    pub requirements: AdapterRequirements,\n    pub config: AdapterConfigSupport,\n    pub telemetry: AdapterTelemetrySupport,\n    pub capabilities: RuntimeCapabilities,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
String,\n    pub adapter_id: String,\n    pub harness: String,\n    pub adapter_kind: AdapterKind,\n    pub runner: Map<String, Value>,\n    pub requirements: AdapterRequirements,\n    pub config: AdapterConfigSupport,\n    pub telemetry: AdapterTelemetrySupport,\n    pub runtime: AdapterRuntimeSupport,\n    pub capabilities: RuntimeCapabilities,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
Language-neutral adapter descriptor for a harness integration. @@ -47,6 +47,10 @@ Fabric config areas this adapter consumes or generates. Telemetry support declared by this adapter. +### `runtime: AdapterRuntimeSupport` + +Adapter-owned runtime hosting support. + ### `capabilities: RuntimeCapabilities` Runtime lifecycle operations supported by this adapter. diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterlocalhostsupport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterlocalhostsupport.mdx new file mode 100644 index 00000000..cb475a3c --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterlocalhostsupport.mdx @@ -0,0 +1,98 @@ +--- +title: "Struct Adapter Local Host Support" +sidebar-title: "AdapterLocalHostSupport" +description: "Persistent local-host protocol implemented by an adapter." +position: 7 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
String,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+ +Persistent local-host protocol implemented by an adapter. + +## Fields + +### `contract_version: String` + +Adapter lifecycle protocol version spoken over the host transport. + +### `extensions: BTreeMap` + +Additive persistent local-host fields. + +## Trait Implementations + +### `impl Clone for AdapterLocalHostSupport` + +
Clone for AdapterLocalHostSupport"}} />
+ +#### `clone` + +
clone(&self) -> AdapterLocalHostSupport"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for AdapterLocalHostSupport` + +
Debug for AdapterLocalHostSupport"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for AdapterLocalHostSupport` + +
Deserialize<'de> for AdapterLocalHostSupport"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for AdapterLocalHostSupport` + +
AdapterLocalHostSupport"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for AdapterLocalHostSupport` + +
PartialEq for AdapterLocalHostSupport"}} />
+ +#### `eq` + +
eq(&self, other: &AdapterLocalHostSupport) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for AdapterLocalHostSupport` + +
Serialize for AdapterLocalHostSupport"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl StructuralPartialEq for AdapterLocalHostSupport` + +
StructuralPartialEq for AdapterLocalHostSupport"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterrequirements.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterrequirements.mdx index 128e05df..115a6dc2 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterrequirements.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterrequirements.mdx @@ -2,7 +2,7 @@ title: "Struct Adapter Requirements" sidebar-title: "AdapterRequirements" description: "Adapter runtime requirements." -position: 6 +position: 8 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterruntimesupport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterruntimesupport.mdx new file mode 100644 index 00000000..e17e3d3e --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterruntimesupport.mdx @@ -0,0 +1,106 @@ +--- +title: "Struct Adapter Runtime Support" +sidebar-title: "AdapterRuntimeSupport" +description: "Runtime hosting mechanisms implemented by an adapter." +position: 9 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
Option<AdapterLocalHostSupport>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+ +Runtime hosting mechanisms implemented by an adapter. + +## Fields + +### `local_host: Option` + +Versioned persistent local-host support, when implemented. + +### `extensions: BTreeMap` + +Additive adapter runtime-hosting fields. + +## Trait Implementations + +### `impl Clone for AdapterRuntimeSupport` + +
Clone for AdapterRuntimeSupport"}} />
+ +#### `clone` + +
clone(&self) -> AdapterRuntimeSupport"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for AdapterRuntimeSupport` + +
Debug for AdapterRuntimeSupport"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl Default for AdapterRuntimeSupport` + +
Default for AdapterRuntimeSupport"}} />
+ +#### `default` + +
default() -> AdapterRuntimeSupport"}} />
+ +### `impl<'de> Deserialize<'de> for AdapterRuntimeSupport` + +
Deserialize<'de> for AdapterRuntimeSupport"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for AdapterRuntimeSupport` + +
AdapterRuntimeSupport"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for AdapterRuntimeSupport` + +
PartialEq for AdapterRuntimeSupport"}} />
+ +#### `eq` + +
eq(&self, other: &AdapterRuntimeSupport) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for AdapterRuntimeSupport` + +
Serialize for AdapterRuntimeSupport"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl StructuralPartialEq for AdapterRuntimeSupport` + +
StructuralPartialEq for AdapterRuntimeSupport"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adaptertelemetryprovidersupport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adaptertelemetryprovidersupport.mdx index 329db488..5f2e2463 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adaptertelemetryprovidersupport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adaptertelemetryprovidersupport.mdx @@ -2,7 +2,7 @@ title: "Struct Adapter Telemetry Provider Support" sidebar-title: "AdapterTelemetryProviderSupport" description: "Telemetry capabilities for one adapter-supported provider." -position: 7 +position: 10 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adaptertelemetrysupport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adaptertelemetrysupport.mdx index 455d860a..7912798e 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adaptertelemetrysupport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adaptertelemetrysupport.mdx @@ -2,7 +2,7 @@ title: "Struct Adapter Telemetry Support" sidebar-title: "AdapterTelemetrySupport" description: "Adapter telemetry support." -position: 8 +position: 11 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilityplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilityplan.mdx index 8e98b7c7..9a07cdda 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilityplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilityplan.mdx @@ -2,7 +2,7 @@ title: "Struct Capability Plan" sidebar-title: "CapabilityPlan" description: "Resolved capability configuration." -position: 9 +position: 12 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilityroute.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilityroute.mdx index f9bc76d8..6c31b087 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilityroute.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilityroute.mdx @@ -2,7 +2,7 @@ title: "Struct Capability Route" sidebar-title: "CapabilityRoute" description: "One capability routing decision." -position: 7 +position: 9 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilitytargetplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilitytargetplan.mdx index 73c0a0b9..1df0610f 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilitytargetplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilitytargetplan.mdx @@ -2,7 +2,7 @@ title: "Struct Capability Target Plan" sidebar-title: "CapabilityTargetPlan" description: "Capabilities routed to one target." -position: 8 +position: 10 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-environmentconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-environmentconfig.mdx index 97e9e0d1..74c1764e 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-environmentconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-environmentconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Environment Config" sidebar-title: "EnvironmentConfig" description: "Execution environment configuration." -position: 11 +position: 14 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-environmentplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-environmentplan.mdx index f0f664bc..bc223bf1 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-environmentplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-environmentplan.mdx @@ -2,7 +2,7 @@ title: "Struct Environment Plan" sidebar-title: "EnvironmentPlan" description: "Resolved environment plan." -position: 13 +position: 16 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-fabricconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-fabricconfig.mdx index dca40ab9..0c442fa7 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-fabricconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-fabricconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Fabric Config" sidebar-title: "FabricConfig" description: "Versioned Fabric agent config." -position: 14 +position: 17 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-harnessconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-harnessconfig.mdx index ce73c2a1..4a2f9994 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-harnessconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-harnessconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Harness Config" sidebar-title: "HarnessConfig" description: "Harness selection." -position: 15 +position: 18 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpconfig.mdx index 12e5c64c..72493283 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpconfig.mdx @@ -2,7 +2,7 @@ title: "Struct McpConfig" sidebar-title: "McpConfig" description: "MCP capability configuration." -position: 16 +position: 19 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpserverconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpserverconfig.mdx index a569574d..8e939ebd 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpserverconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpserverconfig.mdx @@ -2,7 +2,7 @@ title: "Struct McpServer Config" sidebar-title: "McpServerConfig" description: "MCP server configuration." -position: 14 +position: 16 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpserverplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpserverplan.mdx index 6902533b..04a6f83c 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpserverplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpserverplan.mdx @@ -2,7 +2,7 @@ title: "Struct McpServer Plan" sidebar-title: "McpServerPlan" description: "Resolved MCP server exposure." -position: 18 +position: 21 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-metadataconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-metadataconfig.mdx index c9bfdec0..da287e9d 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-metadataconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-metadataconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Metadata Config" sidebar-title: "MetadataConfig" description: "Human-readable metadata." -position: 19 +position: 22 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-modelconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-modelconfig.mdx index 92033d79..62c9107b 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-modelconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-modelconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Model Config" sidebar-title: "ModelConfig" description: "Model configuration." -position: 20 +position: 23 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatifconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatifconfig.mdx index 2beb9d72..085d3343 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatifconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatifconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Relay Atif Config" sidebar-title: "RelayAtifConfig" description: "Relay ATIF export configuration." -position: 18 +position: 20 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatofconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatofconfig.mdx index 0d91c211..6eefdbf7 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatofconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatofconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Relay Atof Config" sidebar-title: "RelayAtofConfig" description: "Relay ATOF export configuration." -position: 19 +position: 21 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatofendpointconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatofendpointconfig.mdx index 74f69842..46c89049 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatofendpointconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatofendpointconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Relay Atof Endpoint Config" sidebar-title: "RelayAtofEndpointConfig" description: "Relay ATOF endpoint configuration." -position: 20 +position: 22 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relaycomponentconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relaycomponentconfig.mdx index 44df6f4e..3677df6e 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relaycomponentconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relaycomponentconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Relay Component Config" sidebar-title: "RelayComponentConfig" description: "Generic NeMo Relay plugin component configuration." -position: 21 +position: 23 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayconfig.mdx index 852abee8..cac532d3 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Relay Config" sidebar-title: "RelayConfig" description: "NeMo Relay integration configuration." -position: 22 +position: 24 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayconfigpolicy.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayconfigpolicy.mdx index 66b07af7..ae463a19 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayconfigpolicy.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayconfigpolicy.mdx @@ -2,7 +2,7 @@ title: "Struct Relay Config Policy" sidebar-title: "RelayConfigPolicy" description: "Relay validation policy." -position: 23 +position: 25 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayobservabilityconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayobservabilityconfig.mdx index 1fba378f..4bbaef31 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayobservabilityconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayobservabilityconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Relay Observability Config" sidebar-title: "RelayObservabilityConfig" description: "NeMo Relay observability component configuration." -position: 24 +position: 26 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayotlpconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayotlpconfig.mdx index 0d9cfc97..56c4001e 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayotlpconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayotlpconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Relay Otlp Config" sidebar-title: "RelayOtlpConfig" description: "Relay OpenTelemetry/OpenInference export configuration." -position: 25 +position: 27 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvecontext.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvecontext.mdx index 1fd20085..6f6f213b 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvecontext.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvecontext.mdx @@ -2,7 +2,7 @@ title: "Struct Resolve Context" sidebar-title: "ResolveContext" description: "Source context used when resolving an in-memory Fabric config." -position: 22 +position: 25 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvedadapterdescriptor.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvedadapterdescriptor.mdx index c1891a95..0fa3450f 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvedadapterdescriptor.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvedadapterdescriptor.mdx @@ -2,7 +2,7 @@ title: "Struct Resolved Adapter Descriptor" sidebar-title: "ResolvedAdapterDescriptor" description: "Adapter descriptor selected for a run plan." -position: 23 +position: 26 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runplan.mdx index f042afb2..d6dda97d 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runplan.mdx @@ -2,7 +2,7 @@ title: "Struct RunPlan" sidebar-title: "RunPlan" description: "Resolved Fabric run plan." -position: 24 +position: 27 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimecapabilities.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimecapabilities.mdx index 7ef3c169..2f73a4d2 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimecapabilities.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimecapabilities.mdx @@ -2,7 +2,7 @@ title: "Struct Runtime Capabilities" sidebar-title: "RuntimeCapabilities" description: "Lifecycle behavior implemented by a resolved runtime path." -position: 25 +position: 28 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimeconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimeconfig.mdx index 86641ac9..8b51cafc 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimeconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimeconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Runtime Config" sidebar-title: "RuntimeConfig" description: "Runtime input/output contract." -position: 26 +position: 29 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-skillconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-skillconfig.mdx index 9572bbf7..c684959d 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-skillconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-skillconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Skill Config" sidebar-title: "SkillConfig" description: "Skill capability configuration." -position: 27 +position: 30 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryconfig.mdx index 5dd271a6..9338bbfb 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Telemetry Config" sidebar-title: "TelemetryConfig" description: "Telemetry configuration." -position: 28 +position: 31 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryplan.mdx index 04c79e8c..eee9a11e 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryplan.mdx @@ -2,7 +2,7 @@ title: "Struct Telemetry Plan" sidebar-title: "TelemetryPlan" description: "Resolved telemetry plan." -position: 29 +position: 32 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryproviderconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryproviderconfig.mdx index 21e9291b..67483880 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryproviderconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryproviderconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Telemetry Provider Config" sidebar-title: "TelemetryProviderConfig" description: "Provider-specific telemetry configuration." -position: 31 +position: 34 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsconfig.mdx index ccedcf91..4de361e4 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Tools Config" sidebar-title: "ToolsConfig" description: "Harness-neutral tool capability configuration." -position: 35 +position: 37 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsplan.mdx index 948ab6ec..40b50baf 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsplan.mdx @@ -2,7 +2,7 @@ title: "Struct Tools Plan" sidebar-title: "ToolsPlan" description: "Normalized tool policy for a run." -position: 36 +position: 38 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/enum-doctorstatus.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/enum-doctorstatus.mdx index 0d424ce7..50a55ecc 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/enum-doctorstatus.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/enum-doctorstatus.mdx @@ -2,7 +2,7 @@ title: "Enum Doctor Status" sidebar-title: "DoctorStatus" description: "Diagnostic status." -position: 36 +position: 39 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/fn-doctor-plan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/fn-doctor-plan.mdx index c2588703..8ddbe6d2 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/fn-doctor-plan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/fn-doctor-plan.mdx @@ -2,7 +2,7 @@ title: "Function doctor_plan" sidebar-title: "doctor_plan" description: "Inspect a resolved run plan without mutating the environment." -position: 37 +position: 40 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/index.mdx index 5dff6b0b..b3323536 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/index.mdx @@ -2,7 +2,7 @@ title: "Module doctor" sidebar-title: "doctor" description: "Plan diagnostics for Fabric." -position: 66 +position: 69 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorcheck.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorcheck.mdx index 75af4033..d3d3d019 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorcheck.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorcheck.mdx @@ -2,7 +2,7 @@ title: "Struct Doctor Check" sidebar-title: "DoctorCheck" description: "Diagnostic check result." -position: 34 +position: 37 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorreport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorreport.mdx index e7b635eb..5f71661d 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorreport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorreport.mdx @@ -2,7 +2,7 @@ title: "Struct Doctor Report" sidebar-title: "DoctorReport" description: "Diagnostic report for a resolved run plan." -position: 35 +position: 38 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/enum-fabricerror.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/enum-fabricerror.mdx index 4ff429c4..5ea78d87 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/enum-fabricerror.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/enum-fabricerror.mdx @@ -2,14 +2,14 @@ title: "Enum Fabric Error" sidebar-title: "FabricError" description: "Errors raised by Fabric config loading and validation." -position: 38 +position: 41 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} Generated from `cargo doc --no-deps -p nemo-fabric-core`. -
PathBuf,\n        source: Error,\n    },\n    PathNotFound(PathBuf),\n    UnknownAdapter {\n        adapter_id: String,\n        available: Vec<String>,\n    },\n    AdapterDescriptorMismatch {\n        path: PathBuf,\n        field: &'static str,\n        expected: String,\n        actual: String,\n    },\n    AdapterDescriptorUnsupported {\n        adapter_id: String,\n        field: &'static str,\n        value: String,\n    },\n    InvalidAdapterDescriptor {\n        path: PathBuf,\n        message: String,\n    },\n    UnknownSchema {\n        schema: String,\n        available: Vec<String>,\n    },\n    UnsupportedRuntimeAdapter {\n        harness: String,\n        adapter_kind: AdapterKind,\n    },\n    UnsupportedToolsPolicy {\n        harness: String,\n        reason: String,\n    },\n    RuntimeHandleMismatch {\n        field: &'static str,\n        expected: String,\n        actual: String,\n        runtime_id: String,\n    },\n    UnsupportedEnvironmentProvider {\n        provider: String,\n        adapter_kind: AdapterKind,\n    },\n    InvalidProcessSettings {\n        path: PathBuf,\n        source: Error,\n    },\n    InvalidPythonSettings {\n        path: PathBuf,\n        source: Error,\n    },\n    PythonInterpreterUnavailable {\n        path: PathBuf,\n        origin: String,\n        reason: String,\n    },\n    ProcessRunner {\n        command: String,\n        source: Error,\n    },\n    SerializeJson(Error),\n    Read {\n        path: PathBuf,\n        source: Error,\n    },\n    Write {\n        path: PathBuf,\n        source: Error,\n    },\n    ParseJson {\n        path: PathBuf,\n        source: Error,\n    },\n}"}} />
+
PathBuf,\n        source: Error,\n    },\n    PathNotFound(PathBuf),\n    UnknownAdapter {\n        adapter_id: String,\n        available: Vec<String>,\n    },\n    AdapterDescriptorMismatch {\n        path: PathBuf,\n        field: &'static str,\n        expected: String,\n        actual: String,\n    },\n    AdapterDescriptorUnsupported {\n        adapter_id: String,\n        field: &'static str,\n        value: String,\n    },\n    InvalidAdapterDescriptor {\n        path: PathBuf,\n        message: String,\n    },\n    UnknownSchema {\n        schema: String,\n        available: Vec<String>,\n    },\n    UnsupportedRuntimeAdapter {\n        harness: String,\n        adapter_kind: AdapterKind,\n    },\n    AdapterLifecycleOperation {\n        operation: &'static str,\n        runtime_id: String,\n        code: String,\n        message: String,\n        diagnostics: String,\n    },\n    UnsupportedToolsPolicy {\n        harness: String,\n        reason: String,\n    },\n    RuntimeHandleMismatch {\n        field: &'static str,\n        expected: String,\n        actual: String,\n        runtime_id: String,\n    },\n    UnsupportedEnvironmentProvider {\n        provider: String,\n        adapter_kind: AdapterKind,\n    },\n    InvalidProcessSettings {\n        path: PathBuf,\n        source: Error,\n    },\n    InvalidPythonSettings {\n        path: PathBuf,\n        source: Error,\n    },\n    PythonInterpreterUnavailable {\n        path: PathBuf,\n        origin: String,\n        reason: String,\n    },\n    ProcessRunner {\n        command: String,\n        source: Error,\n    },\n    SerializeJson(Error),\n    Read {\n        path: PathBuf,\n        source: Error,\n    },\n    Write {\n        path: PathBuf,\n        source: Error,\n    },\n    ParseJson {\n        path: PathBuf,\n        source: Error,\n    },\n}"}} />
Errors raised by Fabric config loading and validation. @@ -145,6 +145,34 @@ Harness type. Adapter kind. +### `AdapterLifecycleOperation` + +
+ +A versioned persistent local-host lifecycle operation failed. + +#### Fields + +### `operation: &'static str` + +Lifecycle operation that failed. + +### `runtime_id: String` + +Runtime whose host failed. + +### `code: String` + +Stable failure code. + +### `message: String` + +Human-readable failure message. + +### `diagnostics: String` + +Bounded adapter-host diagnostics. + ### `UnsupportedToolsPolicy`
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/index.mdx index bc0a4a45..d8ddd484 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/index.mdx @@ -2,7 +2,7 @@ title: "Module error" sidebar-title: "error" description: "Error types for Fabric core." -position: 67 +position: 70 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/type-result.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/type-result.mdx index 011b5b22..618862e5 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/type-result.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/type-result.mdx @@ -2,7 +2,7 @@ title: "Type Alias Result" sidebar-title: "Result" description: "Core Fabric result type." -position: 39 +position: 42 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/fn-version.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/fn-version.mdx index b9f5b49f..a3206a15 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/fn-version.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/fn-version.mdx @@ -2,7 +2,7 @@ title: "Function version" sidebar-title: "version" description: "Returns the crate version compiled into this build." -position: 70 +position: 73 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/index.mdx index 0f9030b1..0dabba3d 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/index.mdx @@ -14,11 +14,14 @@ Core config and runtime contract for NeMo Fabric. ## Re-exports - `pub use config::ADAPTER_CONTRACT_VERSION;` +- `pub use config::ADAPTER_LIFECYCLE_CONTRACT_VERSION;` - `pub use config::AdapterConfigSupport;` - `pub use config::AdapterDescriptor;` - `pub use config::AdapterDescriptorSource;` - `pub use config::AdapterKind;` +- `pub use config::AdapterLocalHostSupport;` - `pub use config::AdapterRequirements;` +- `pub use config::AdapterRuntimeSupport;` - `pub use config::AdapterTelemetryProviderSupport;` - `pub use config::AdapterTelemetrySupport;` - `pub use config::CapabilityPlan;` diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/index.mdx index 05f895e7..daca55de 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/index.mdx @@ -2,7 +2,7 @@ title: "Module runtime" sidebar-title: "runtime" description: "Runtime invocation helpers." -position: 68 +position: 71 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/index.mdx index a3475f92..42b12626 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/index.mdx @@ -2,7 +2,7 @@ title: "Module schema" sidebar-title: "schema" description: "JSON Schema generation for the public Fabric contract." -position: 69 +position: 72 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/sdk/python.mdx b/docs/sdk/python.mdx index 6bae5d7b..738130fd 100644 --- a/docs/sdk/python.mdx +++ b/docs/sdk/python.mdx @@ -130,7 +130,7 @@ use the descriptor contract values. | Adapter ID | Models | Tools / Blocked Tools | MCP | Skills | Subagents | Telemetry | Persistent Local Host | Remote Service | | --- | --- | --- | --- | --- | --- | --- | --- | --- | | `nvidia.fabric.claude` | Anthropic provider and Claude models | `allowed_tools` adapter setting / normalized `tools.blocked` | Normalized | Normalized | Not exposed | Relay: `atif`, `otel`, and `openinference` through hooks and gateway | Yes: connected `ClaudeSDKClient`, session, and optional Relay gateway | Not implemented | -| `nvidia.fabric.codex` | Built-in OpenAI provider and Codex models | Not normalized | Not normalized | Not normalized | Not exposed | Relay: `atif`, `otel`, and `openinference` through hooks and gateway; native `otel` | Yes: `AsyncCodex` app-server client, thread, and optional Relay gateway | Not implemented | +| `nvidia.fabric.codex` | Built-in OpenAI and custom NVIDIA Responses providers with Codex-compatible models | Not normalized | Normalized | Normalized | Not exposed | Relay: `atif`, `otel`, and `openinference` through hooks and gateway; native `otel` | Yes: `AsyncCodex` app-server client, thread, and optional Relay gateway | Not implemented | | `nvidia.fabric.langchain.deepagents` | NVIDIA, OpenAI, OpenAI-compatible, and other LangChain providers | Built-ins and MCP / normalized middleware block list | Normalized | Normalized | Constrained: declarative local subagents inherit parent capabilities | Relay SDK: `atif`, `otel`, and `openinference`; native `otel` and `openinference` | Yes: compiled graph and async LangGraph checkpointer | Not implemented | | `nvidia.fabric.hermes` | Normalized provider, model, and base URL | Toolsets / normalized disabled toolsets | Normalized | Normalized | Not exposed | Relay plugin: `atif`, `otel`, and `openinference` | Yes: `AIAgent`, `SessionDB`, and Relay plugin context | Not implemented | From 07f301e3376f030b19f3babfaf5d2db351d71c1f Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Mon, 20 Jul 2026 15:54:17 -0700 Subject: [PATCH 18/34] docs: clarify Codex MCP and skills support Signed-off-by: Ajay Thorve --- README.md | 2 +- docs/sdk/python.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0bfe72f0..057edbeb 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ the following capabilities: | Adapter | Models | Tools / Blocked Tools | MCP | Skills | Subagents | Telemetry | Persistent Local Host | Remote Service | | --- | --- | --- | --- | --- | --- | --- | --- | --- | | [Claude](adapters/claude/README.md) | Anthropic / Claude | `allowed_tools` adapter setting / normalized block list | Normalized | Normalized | Not exposed | Relay: ATIF, OTel, and OpenInference through hooks and gateway | Yes: `ClaudeSDKClient`, session, and optional Relay gateway | Not implemented | -| [Codex](adapters/codex/README.md) | OpenAI and NVIDIA Responses providers / Codex-compatible models | Not normalized | Normalized | Normalized | Not exposed | Relay: ATIF, OTel, and OpenInference through hooks and gateway; native OTel | Yes: `AsyncCodex`, app server, thread, and optional Relay gateway | Not implemented | +| [Codex](adapters/codex/README.md) | OpenAI and NVIDIA Responses providers / Codex-compatible models | Not normalized | Normalized: stdio, HTTP, and streamable HTTP | Normalized: `SKILL.md` directories | Not exposed | Relay: ATIF, OTel, and OpenInference through hooks and gateway; native OTel | Yes: `AsyncCodex`, app server, thread, and optional Relay gateway | Not implemented | | [Deep Agents](adapters/deepagents/README.md) | LangChain providers | Built-ins and MCP / normalized middleware block list | Normalized | Normalized | Constrained local delegation | Relay SDK: ATIF, OTel, and OpenInference; native OTel and OpenInference | Yes: compiled graph and async checkpointer | Not implemented | | [Hermes](adapters/hermes/README.md) | Normalized provider and base URL | Toolsets / normalized disabled toolsets | Normalized | Normalized | Not exposed | Relay plugin: ATIF, OTel, and OpenInference | Yes: `AIAgent`, `SessionDB`, and Relay context | Not implemented | diff --git a/docs/sdk/python.mdx b/docs/sdk/python.mdx index 738130fd..47b15d02 100644 --- a/docs/sdk/python.mdx +++ b/docs/sdk/python.mdx @@ -130,7 +130,7 @@ use the descriptor contract values. | Adapter ID | Models | Tools / Blocked Tools | MCP | Skills | Subagents | Telemetry | Persistent Local Host | Remote Service | | --- | --- | --- | --- | --- | --- | --- | --- | --- | | `nvidia.fabric.claude` | Anthropic provider and Claude models | `allowed_tools` adapter setting / normalized `tools.blocked` | Normalized | Normalized | Not exposed | Relay: `atif`, `otel`, and `openinference` through hooks and gateway | Yes: connected `ClaudeSDKClient`, session, and optional Relay gateway | Not implemented | -| `nvidia.fabric.codex` | Built-in OpenAI and custom NVIDIA Responses providers with Codex-compatible models | Not normalized | Normalized | Normalized | Not exposed | Relay: `atif`, `otel`, and `openinference` through hooks and gateway; native `otel` | Yes: `AsyncCodex` app-server client, thread, and optional Relay gateway | Not implemented | +| `nvidia.fabric.codex` | Built-in OpenAI and custom NVIDIA Responses providers with Codex-compatible models | Not normalized | Normalized: stdio, HTTP, and streamable HTTP | Normalized: `SKILL.md` directories | Not exposed | Relay: `atif`, `otel`, and `openinference` through hooks and gateway; native `otel` | Yes: `AsyncCodex` app-server client, thread, and optional Relay gateway | Not implemented | | `nvidia.fabric.langchain.deepagents` | NVIDIA, OpenAI, OpenAI-compatible, and other LangChain providers | Built-ins and MCP / normalized middleware block list | Normalized | Normalized | Constrained: declarative local subagents inherit parent capabilities | Relay SDK: `atif`, `otel`, and `openinference`; native `otel` and `openinference` | Yes: compiled graph and async LangGraph checkpointer | Not implemented | | `nvidia.fabric.hermes` | Normalized provider, model, and base URL | Toolsets / normalized disabled toolsets | Normalized | Normalized | Not exposed | Relay plugin: `atif`, `otel`, and `openinference` | Yes: `AIAgent`, `SessionDB`, and Relay plugin context | Not implemented | From aa4646ceba2644f520aa0fb52ce3e5fcbd52ad5d Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Mon, 20 Jul 2026 17:09:22 -0700 Subject: [PATCH 19/34] fix: harden persistent adapter lifecycle Signed-off-by: Ajay Thorve --- .../nemo_fabric_adapters/claude/adapter.py | 26 +- adapters/codex/README.md | 24 +- .../src/nemo_fabric_adapters/codex/adapter.py | 4 +- adapters/common/README.md | 5 +- .../nemo_fabric_adapters/common/lifecycle.py | 278 +++++++++++------- crates/fabric-core/src/runtime.rs | 5 +- docs/integrations/codex.mdx | 14 +- .../test_adapters_common_lifecycle.py | 21 +- tests/adapters/test_codex_adapter.py | 19 ++ tests/adapters/test_deepagents.py | 3 +- tests/e2e/test_cli_scaffold.py | 6 + 11 files changed, 272 insertions(+), 133 deletions(-) diff --git a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py index 0e8b5923..8186ece0 100644 --- a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py +++ b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py @@ -944,9 +944,26 @@ async def invoke(self, payload: dict[str, Any]) -> dict[str, Any]: invocation_timeout = timeout_seconds(payload) except ClaudeAdapterError as error: output = adapter_failure(error) - if self._relay is not None: - output = _relay_output(output, self._relay) - return output + else: + output = await self._run_query( + payload, + client, + prompt, + invocation_timeout, + ) + + if self._relay is not None: + output = _relay_output(output, self._relay) + return output + + async def _run_query( + self, + payload: dict[str, Any], + client: ClaudeSDKClient, + prompt: str, + invocation_timeout: float, + ) -> dict[str, Any]: + """Run one SDK query and normalize its terminal result.""" messages: list[Message] = [] result: ResultMessage | None = None @@ -978,9 +995,6 @@ async def invoke(self, payload: dict[str, Any]) -> dict[str, Any]: ) else: output = self._normalize_invocation(payload, messages, result) - - if self._relay is not None: - output = _relay_output(output, self._relay) return output def _normalize_invocation( diff --git a/adapters/codex/README.md b/adapters/codex/README.md index c29e0f69..56a8f9d1 100644 --- a/adapters/codex/README.md +++ b/adapters/codex/README.md @@ -49,11 +49,13 @@ the SDK runtime. The current real-agent acceptance path validates an existing Codex login; it does not yet claim a raw environment variable as a complete login flow. -When `models.default.provider` is `nvidia`, the adapter defines a request-scoped -Codex model provider for the configured NVIDIA Responses endpoint. It reads the -credential from `api_key_env` (default: `NVIDIA_API_KEY`) and isolates Codex -state under the Fabric artifact root, so the invocation does not depend on or -modify a user's Codex login. Set the endpoint in +When `models.default.provider` is `nvidia`, the adapter defines a Codex model +provider for the configured NVIDIA Responses endpoint. `Fabric.run(...)` owns +that provider for one invocation, while `Fabric.start_runtime(...)` fixes it for +the lifetime of the persistent runtime. The adapter reads the credential from +`api_key_env` (default: `NVIDIA_API_KEY`) and isolates Codex state under the +Fabric artifact root, so execution does not depend on or modify a user's Codex +login. Set the endpoint in `models.default.settings.base_url` or `NVIDIA_FRONTIER_BASE_URL`; the adapter does not assume a default frontier endpoint. @@ -90,7 +92,7 @@ Use normalized `FabricConfig` fields for portable configuration: provider and NVIDIA-hosted Responses-compatible models through the `nvidia` provider. - `environment.workspace` sets the working directory. -- `mcp` maps stdio, HTTP, and streamable HTTP servers into request-scoped Codex +- `mcp` maps stdio, HTTP, and streamable HTTP servers into the Codex thread's `mcp_servers` configuration. For stdio, Fabric parses `url` as a command plus arguments. - `skills.paths` names skill directories that contain `SKILL.md`. The adapter @@ -112,8 +114,8 @@ Codex-specific controls belong in `harness.settings`: - `personality`, `reasoning_effort`, `service_name`, and `service_tier` - `output_schema` for SDK-native structured output - `codex_bin` for an explicit Codex app-server runtime override -- `config_overrides` as dotted request-scoped Codex configuration keys, such as - Codex-only MCP timeout or required-server options +- `config_overrides` as dotted Codex configuration keys applied when the SDK + runtime starts, such as Codex-only MCP timeout or required-server options - `timeout_seconds`, defaulting to 1800 - `env` for variables explicitly forwarded to the Codex runtime - `nemo_relay_command` for the optional external Relay gateway executable @@ -121,6 +123,12 @@ Codex-specific controls belong in `harness.settings`: Set model selection through `models` and the working directory through `environment.workspace`. +For `Fabric.start_runtime(...)`, the model provider, MCP configuration, skill +roots, and `config_overrides` are fixed when the runtime starts and cannot vary +between `Runtime.invoke(...)` calls. Start a new runtime to change them. +`Fabric.run(...)` creates a fresh one-shot runtime, so the same settings are +scoped to that invocation. + The adapter filters the inherited environment. It retains portable OS and Codex state variables, the selected model's `api_key_env`, and explicit `settings.env` values while clearing unrelated parent-process secrets. diff --git a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py index ca771e7f..fc8a26f8 100644 --- a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py +++ b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py @@ -1054,9 +1054,7 @@ async def invoke_codex_sdk( try: await codex.close() except Exception: - output = _failure( - "codex_sdk_stop_failed", "Codex SDK runtime failed to stop" - ) + LOGGER.exception("Codex SDK client failed to close after invocation") return output, thread_id diff --git a/adapters/common/README.md b/adapters/common/README.md index 430ef2cd..ab283083 100644 --- a/adapters/common/README.md +++ b/adapters/common/README.md @@ -47,8 +47,9 @@ if lifecycle.is_lifecycle_host(): lifecycle.serve(AdapterRuntime) ``` -Fabric creates one factory instance per local host and serializes invocations -through it. The host keeps one event loop alive for the complete lifecycle so +Fabric calls the factory once per local host to create one runtime instance and +serializes invocations through that instance. The host keeps one event loop +alive for the complete lifecycle so SDK clients, compiled graphs, checkpointers, and harness databases can remain live safely. Adapter stdout is reserved for the protocol; diagnostics are redirected to stderr. A host crash or protocol timeout is terminal for that diff --git a/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py b/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py index 5baaec6b..9200577e 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py +++ b/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py @@ -16,6 +16,7 @@ from collections.abc import Awaitable from contextlib import contextmanager from contextlib import redirect_stdout +from dataclasses import dataclass from typing import Any from typing import Protocol from typing import TextIO @@ -59,6 +60,22 @@ def __init__( self.metadata = dict(metadata or {}) +class _AdapterCallError(LifecycleError): + """Failure raised while executing an adapter runtime method.""" + + +@dataclass +class _HostState: + runtime: AdapterRuntime | None = None + runtime_id: str | None = None + failed: bool = False + + def clear(self) -> None: + self.runtime = None + self.runtime_id = None + self.failed = False + + def is_lifecycle_host(environ: Mapping[str, str] = os.environ) -> bool: """Return whether Fabric requested the persistent local-host protocol.""" @@ -137,11 +154,16 @@ async def _adapter_call(operation: str, call: Callable[[], Awaitable[Any]]) -> A # Keep incidental adapter and library output as host diagnostics. with redirect_stdout(sys.stderr): return await call() - except LifecycleError: - raise + except LifecycleError as error: + raise _AdapterCallError( + error.code, + error.message, + retryable=error.retryable, + metadata=error.metadata, + ) from error except Exception as error: traceback.print_exc(file=sys.stderr) - raise LifecycleError( + raise _AdapterCallError( f"lifecycle_adapter_{operation}_failed", f"Adapter failed during lifecycle {operation}", ) from error @@ -167,15 +189,148 @@ async def _stop_after_eof(runtime: AdapterRuntime) -> None: traceback.print_exc(file=sys.stderr) +def _validated_request( + message: dict[str, Any], operation: str +) -> tuple[dict[str, Any], str]: + if operation not in {"start", "invoke", "stop"}: + raise LifecycleError( + "lifecycle_invalid_operation", + "Unknown lifecycle operation", + ) + if message.get("contract_version") != CONTRACT_VERSION: + raise LifecycleError( + "lifecycle_contract_mismatch", + f"Expected lifecycle contract {CONTRACT_VERSION}", + ) + payload = message.get("payload") + if not isinstance(payload, dict): + raise LifecycleError( + "lifecycle_invalid_payload", + "Lifecycle payload must be a mapping", + ) + message_runtime_id = _runtime_id(operation, payload) + if message_runtime_id is None: + raise LifecycleError( + "lifecycle_invalid_runtime", + "Lifecycle payload is missing a runtime ID", + ) + return payload, message_runtime_id + + +def _active_runtime(state: _HostState, message_runtime_id: str) -> AdapterRuntime: + if state.runtime is None or state.runtime_id is None: + raise LifecycleError( + "lifecycle_not_started", + "Lifecycle host has not started a runtime", + ) + if message_runtime_id != state.runtime_id: + raise LifecycleError( + "lifecycle_runtime_mismatch", + "Lifecycle payload does not match the active runtime", + ) + return state.runtime + + +async def _handle_start( + state: _HostState, + runtime_factory: RuntimeFactory, + payload: dict[str, Any], + message_runtime_id: str, +) -> dict[str, Any]: + if state.runtime is not None: + raise LifecycleError( + "lifecycle_already_started", + "Lifecycle host already owns a runtime", + ) + candidate = runtime_factory() + try: + await _adapter_call("start", lambda: candidate.start(payload)) + except LifecycleError: + await _stop_after_eof(candidate) + raise + state.runtime = candidate + state.runtime_id = message_runtime_id + state.failed = False + return _response("start") + + +async def _handle_invoke( + state: _HostState, + runtime: AdapterRuntime, + payload: dict[str, Any], +) -> dict[str, Any]: + if state.failed: + raise LifecycleError( + "lifecycle_runtime_failed", + "Lifecycle runtime cannot accept another invocation", + ) + with _invocation_environment(payload): + output = await _adapter_call("invoke", lambda: runtime.invoke(payload)) + return _response("invoke", output=output) + + +async def _handle_stop( + state: _HostState, + runtime: AdapterRuntime, +) -> dict[str, Any]: + try: + await _adapter_call("stop", runtime.stop) + finally: + state.clear() + return _response("stop") + + +async def _dispatch( + state: _HostState, + runtime_factory: RuntimeFactory, + operation: str, + payload: dict[str, Any], + message_runtime_id: str, +) -> dict[str, Any]: + if operation == "start": + return await _handle_start( + state, + runtime_factory, + payload, + message_runtime_id, + ) + runtime = _active_runtime(state, message_runtime_id) + if operation == "invoke": + return await _handle_invoke(state, runtime, payload) + return await _handle_stop(state, runtime) + + +def _encode_response( + state: _HostState, + operation: str, + response: dict[str, Any], +) -> str: + try: + return json.dumps(response, sort_keys=True) + except (TypeError, ValueError): + traceback.print_exc(file=sys.stderr) + if operation == "invoke" and state.runtime is not None: + state.failed = True + return json.dumps( + _response( + operation, + error=_error( + operation, + "lifecycle_invalid_response", + "Adapter returned a non-JSON lifecycle response", + ), + ), + sort_keys=True, + ) + + async def _serve( runtime_factory: RuntimeFactory, *, input_stream: TextIO, output_stream: TextIO, ) -> None: - runtime: AdapterRuntime | None = None - active_runtime_id: str | None = None - runtime_failed = False + state = _HostState() try: while True: # Keep this event loop alive while idle. Persistent SDK clients such @@ -192,89 +347,28 @@ async def _serve( raise TypeError("lifecycle request must be a mapping") raw_operation = message.get("operation") operation = raw_operation if isinstance(raw_operation, str) else "start" - if operation not in {"start", "invoke", "stop"}: - raise LifecycleError( - "lifecycle_invalid_operation", - "Unknown lifecycle operation", - ) - if message.get("contract_version") != CONTRACT_VERSION: - raise LifecycleError( - "lifecycle_contract_mismatch", - f"Expected lifecycle contract {CONTRACT_VERSION}", - ) - payload = message.get("payload") - if not isinstance(payload, dict): - raise LifecycleError( - "lifecycle_invalid_payload", - "Lifecycle payload must be a mapping", - ) - message_runtime_id = _runtime_id(operation, payload) - if message_runtime_id is None: - raise LifecycleError( - "lifecycle_invalid_runtime", - "Lifecycle payload is missing a runtime ID", - ) - - if operation == "start": - if runtime is not None: - raise LifecycleError( - "lifecycle_already_started", - "Lifecycle host already owns a runtime", - ) - candidate = runtime_factory() - try: - await _adapter_call("start", lambda: candidate.start(payload)) - except LifecycleError: - await _stop_after_eof(candidate) - raise - runtime = candidate - active_runtime_id = message_runtime_id - runtime_failed = False - response = _response(operation) - else: - if runtime is None or active_runtime_id is None: - raise LifecycleError( - "lifecycle_not_started", - "Lifecycle host has not started a runtime", - ) - if message_runtime_id != active_runtime_id: - raise LifecycleError( - "lifecycle_runtime_mismatch", - "Lifecycle payload does not match the active runtime", - ) - if operation == "invoke": - if runtime_failed: - raise LifecycleError( - "lifecycle_runtime_failed", - "Lifecycle runtime cannot accept another invocation", - ) - with _invocation_environment(payload): - output = await _adapter_call( - "invoke", lambda: runtime.invoke(payload) - ) - response = _response(operation, output=output) - else: - try: - await _adapter_call("stop", runtime.stop) - finally: - runtime = None - active_runtime_id = None - runtime_failed = False - should_stop = True - response = _response(operation) + payload, message_runtime_id = _validated_request(message, operation) + response = await _dispatch( + state, + runtime_factory, + operation, + payload, + message_runtime_id, + ) + should_stop = operation == "stop" except LifecycleError as error: if ( operation == "invoke" - and runtime is not None - and error.code == "lifecycle_adapter_invoke_failed" + and state.runtime is not None + and isinstance(error, _AdapterCallError) ): - runtime_failed = True + state.failed = True response = _failure_response(operation, error) should_stop = should_stop or operation in {"start", "stop"} except Exception as error: traceback.print_exc(file=sys.stderr) - if operation == "invoke" and runtime is not None: - runtime_failed = True + if operation == "invoke" and state.runtime is not None: + state.failed = True response = _response( operation, error=_error( @@ -284,29 +378,13 @@ async def _serve( ), ) - try: - encoded = json.dumps(response, sort_keys=True) - except (TypeError, ValueError): - traceback.print_exc(file=sys.stderr) - if operation == "invoke" and runtime is not None: - runtime_failed = True - encoded = json.dumps( - _response( - operation, - error=_error( - operation, - "lifecycle_invalid_response", - "Adapter returned a non-JSON lifecycle response", - ), - ), - sort_keys=True, - ) + encoded = _encode_response(state, operation, response) print(encoded, file=output_stream, flush=True) if should_stop: break finally: - if runtime is not None: - await _stop_after_eof(runtime) + if state.runtime is not None: + await _stop_after_eof(state.runtime) def serve( diff --git a/crates/fabric-core/src/runtime.rs b/crates/fabric-core/src/runtime.rs index c8bd9001..bb9cc619 100644 --- a/crates/fabric-core/src/runtime.rs +++ b/crates/fabric-core/src/runtime.rs @@ -1157,10 +1157,7 @@ fn run_local_host_adapter_with_timeout( relay_config, fabric_home, fabric_invocation, - ) = match exchange_result { - Ok(result) => result, - Err(error) => return Err(error), - }; + ) = exchange_result?; let mut events = vec![event_with_metadata( "runtime_start", diff --git a/docs/integrations/codex.mdx b/docs/integrations/codex.mdx index aaa5f9a6..14073d11 100644 --- a/docs/integrations/codex.mdx +++ b/docs/integrations/codex.mdx @@ -31,10 +31,16 @@ config.add_mcp_server( ``` The adapter maps stdio, HTTP, and streamable HTTP servers into the Codex -thread's request-scoped `mcp_servers` configuration. It resolves each skill -directory, verifies that it contains `SKILL.md`, and registers it as a -process-scoped Codex skill root. Codex uses its normal skill discovery behavior. -Invalid skill directories and MCP transports fail before the SDK runtime starts. +thread's `mcp_servers` configuration. It resolves each skill directory, verifies +that it contains `SKILL.md`, and registers it as a process-scoped Codex skill +root. Codex uses its normal skill discovery behavior. Invalid skill directories +and MCP transports fail before the SDK runtime starts. + +For `Fabric.start_runtime(...)`, the model provider, MCP configuration, skill +roots, and `harness.settings.config_overrides` are fixed when the runtime starts +and cannot vary between `Runtime.invoke(...)` calls. Start a new runtime to +change them. `Fabric.run(...)` creates a fresh one-shot runtime, so the same +settings are scoped to that invocation. The Codex adapter does not declare `tools.blocked` support. Codex can filter individual MCP server tools, but the pinned runtime does not provide one deny diff --git a/tests/adapters/test_adapters_common_lifecycle.py b/tests/adapters/test_adapters_common_lifecycle.py index 5e0a4b2f..b218f624 100644 --- a/tests/adapters/test_adapters_common_lifecycle.py +++ b/tests/adapters/test_adapters_common_lifecycle.py @@ -9,6 +9,7 @@ import os from typing import Any +import pytest from nemo_fabric_adapters.common import lifecycle @@ -226,7 +227,19 @@ async def stop(self): assert stopped == [True] -def test_lifecycle_host_rejects_invoke_after_adapter_failure(): +@pytest.mark.parametrize( + ("failure", "expected_code"), + [ + (RuntimeError("adapter failed"), "lifecycle_adapter_invoke_failed"), + ( + lifecycle.LifecycleError("adapter_known_failure", "Adapter failed"), + "adapter_known_failure", + ), + ], +) +def test_lifecycle_host_rejects_invoke_after_adapter_failure( + failure, expected_code +): runtime_id = "runtime-1" invoke_payload = { "runtime_context": {"runtime_id": runtime_id}, @@ -248,7 +261,7 @@ async def start(self, _payload): async def invoke(self, payload): invocations.append(payload) - raise RuntimeError("adapter failed") + raise failure async def stop(self): pass @@ -256,9 +269,7 @@ async def stop(self): lifecycle.serve(Runtime, input_stream=input_stream, output_stream=output_stream) responses = [json.loads(line) for line in output_stream.getvalue().splitlines()] - assert responses[1]["outcome"]["error"]["code"] == ( - "lifecycle_adapter_invoke_failed" - ) + assert responses[1]["outcome"]["error"]["code"] == expected_code assert responses[2]["outcome"]["error"]["code"] == "lifecycle_runtime_failed" assert len(invocations) == 1 diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index 2a7f9c52..469702ad 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -107,6 +107,7 @@ def mock_codex_fixture(monkeypatch): mock_codex.next_thread = None mock_codex.resume_thread_id = None mock_codex.skill_request = AsyncMock() + mock_codex.close_error = None def build_client(*, config): mock_client = MagicMock(spec=AsyncCodex) @@ -116,6 +117,8 @@ def build_client(*, config): mock_client._client = SimpleNamespace(request=mock_codex.skill_request) async def close(): + if mock_codex.close_error is not None: + raise mock_codex.close_error mock_client.closed = True async def thread_start(**_kwargs): @@ -190,6 +193,22 @@ def test_sdk_oneshot_uses_native_thread_and_turn_contract( client._client.request.assert_not_awaited() +def test_sdk_close_failure_preserves_completed_turn_and_thread_state( + codex_payload, mock_codex, caplog +): + mock_codex.close_error = RuntimeError("close failed") + + output = adapter.run(codex_payload) + + assert output["completed"] is True + assert output["failed"] is False + assert output["thread_id"] == "thread-123" + assert output["response"] == "done" + assert adapter.load_thread_id(codex_payload, "runtime-1") == "thread-123" + assert "Codex SDK client failed to close after invocation" in caplog.text + mock_codex.instances[0].close.assert_awaited_once_with() + + def test_sdk_maps_native_mcp_servers_into_thread_config(codex_payload, mock_codex): os.environ["FABRIC_TEST_MCP_URL"] = "https://mcp.example.test/mcp" codex_payload["capability_plan"] = { diff --git a/tests/adapters/test_deepagents.py b/tests/adapters/test_deepagents.py index 7848f76d..f2f08f74 100644 --- a/tests/adapters/test_deepagents.py +++ b/tests/adapters/test_deepagents.py @@ -464,8 +464,9 @@ def fake_find_spec( assert "[relay]" in output["error"] +@pytest.mark.usefixtures("fake_relay") async def test_incomplete_nemo_relay_install_is_normalized( - tmp_path, make_payload, monkeypatch, fake_relay + tmp_path, make_payload, monkeypatch ): monkeypatch.delitem(sys.modules, "nemo_relay.integrations.deepagents") payload = make_payload(tmp_path) diff --git a/tests/e2e/test_cli_scaffold.py b/tests/e2e/test_cli_scaffold.py index b7046e8f..dddc35e8 100644 --- a/tests/e2e/test_cli_scaffold.py +++ b/tests/e2e/test_cli_scaffold.py @@ -9,6 +9,7 @@ ROOT = Path(__file__).parents[2] +SUBPROCESS_TIMEOUT_SECONDS = 120 def generate_scaffold(destination: Path, language: str) -> None: @@ -31,6 +32,7 @@ def generate_scaffold(destination: Path, language: str) -> None: check=True, capture_output=True, text=True, + timeout=SUBPROCESS_TIMEOUT_SECONDS, ) @@ -43,6 +45,7 @@ def test_generated_python_scaffold_installs_editable(tmp_path: Path): check=True, capture_output=True, text=True, + timeout=SUBPROCESS_TIMEOUT_SECONDS, ) python = venv / ("Scripts/python.exe" if os.name == "nt" else "bin/python") environment = os.environ.copy() @@ -56,6 +59,7 @@ def test_generated_python_scaffold_installs_editable(tmp_path: Path): check=True, capture_output=True, text=True, + timeout=SUBPROCESS_TIMEOUT_SECONDS, ) @@ -80,6 +84,7 @@ def test_generated_rust_scaffold_builds(tmp_path: Path): check=True, capture_output=True, text=True, + timeout=SUBPROCESS_TIMEOUT_SECONDS, ) @@ -105,6 +110,7 @@ def test_failed_cli_run_returns_nonzero_status(): check=False, capture_output=True, text=True, + timeout=SUBPROCESS_TIMEOUT_SECONDS, ) assert result.returncode != 0 From 7829fa78234de93d68c1ea88c0ff8fc0432d8cda Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Mon, 20 Jul 2026 17:22:56 -0700 Subject: [PATCH 20/34] fix: surface Codex cleanup failures Signed-off-by: Ajay Thorve --- adapters/codex/src/nemo_fabric_adapters/codex/adapter.py | 5 +++++ tests/adapters/test_adapters_common_lifecycle.py | 2 +- tests/adapters/test_codex_adapter.py | 6 ++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py index fc8a26f8..acdaa441 100644 --- a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py +++ b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py @@ -1055,6 +1055,11 @@ async def invoke_codex_sdk( await codex.close() except Exception: LOGGER.exception("Codex SDK client failed to close after invocation") + output["cleanup_error"] = { + "code": "codex_sdk_stop_failed", + "message": "Codex SDK runtime failed to stop", + "retryable": False, + } return output, thread_id diff --git a/tests/adapters/test_adapters_common_lifecycle.py b/tests/adapters/test_adapters_common_lifecycle.py index b218f624..b93718e9 100644 --- a/tests/adapters/test_adapters_common_lifecycle.py +++ b/tests/adapters/test_adapters_common_lifecycle.py @@ -263,7 +263,7 @@ async def invoke(self, payload): invocations.append(payload) raise failure - async def stop(self): + async def stop(self) -> None: pass lifecycle.serve(Runtime, input_stream=input_stream, output_stream=output_stream) diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index 469702ad..ea7594e3 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -204,6 +204,12 @@ def test_sdk_close_failure_preserves_completed_turn_and_thread_state( assert output["failed"] is False assert output["thread_id"] == "thread-123" assert output["response"] == "done" + assert output["error"] is None + assert output["cleanup_error"] == { + "code": "codex_sdk_stop_failed", + "message": "Codex SDK runtime failed to stop", + "retryable": False, + } assert adapter.load_thread_id(codex_payload, "runtime-1") == "thread-123" assert "Codex SDK client failed to close after invocation" in caplog.text mock_codex.instances[0].close.assert_awaited_once_with() From ab056d7f4d20689d8737c71b2c34c5bc94ecb139 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Mon, 20 Jul 2026 17:29:48 -0700 Subject: [PATCH 21/34] fix: preserve Codex adapter errors during cleanup Signed-off-by: Ajay Thorve --- .../src/nemo_fabric_adapters/codex/adapter.py | 14 ++++++----- tests/adapters/test_codex_adapter.py | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py index acdaa441..2a6056b6 100644 --- a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py +++ b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py @@ -1026,7 +1026,7 @@ async def invoke_codex_sdk( skill_paths = _native_skill_paths(payload) client_config = sdk_config(payload, relay) codex: AsyncCodex | None = None - output: dict[str, Any] + output: dict[str, Any] | None = None thread_id: str | None = None try: if selected_model_provider(payload) == "nvidia": @@ -1055,11 +1055,13 @@ async def invoke_codex_sdk( await codex.close() except Exception: LOGGER.exception("Codex SDK client failed to close after invocation") - output["cleanup_error"] = { - "code": "codex_sdk_stop_failed", - "message": "Codex SDK runtime failed to stop", - "retryable": False, - } + if output is not None: + output["cleanup_error"] = { + "code": "codex_sdk_stop_failed", + "message": "Codex SDK runtime failed to stop", + "retryable": False, + } + assert output is not None return output, thread_id diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index ea7594e3..73a73500 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -215,6 +215,29 @@ def test_sdk_close_failure_preserves_completed_turn_and_thread_state( mock_codex.instances[0].close.assert_awaited_once_with() +def test_sdk_close_failure_does_not_mask_adapter_error( + codex_payload, mock_codex, monkeypatch, caplog +): + mock_codex.close_error = RuntimeError("close failed") + register_skills = AsyncMock( + side_effect=adapter.AdapterConfigError( + "codex_skill_registration_failed", + "Codex skill registration failed", + ) + ) + monkeypatch.setattr(adapter, "_register_skill_roots", register_skills) + + output = adapter.run(codex_payload) + + assert output["failed"] is True + assert output["error"]["code"] == "codex_skill_registration_failed" + assert output["error"]["message"] == "Codex skill registration failed" + assert "cleanup_error" not in output + assert "Codex SDK client failed to close after invocation" in caplog.text + register_skills.assert_awaited_once() + mock_codex.instances[0].close.assert_awaited_once_with() + + def test_sdk_maps_native_mcp_servers_into_thread_config(codex_payload, mock_codex): os.environ["FABRIC_TEST_MCP_URL"] = "https://mcp.example.test/mcp" codex_payload["capability_plan"] = { From 8dd24aef891f6fdd39b18383f94328c36428df99 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Tue, 21 Jul 2026 11:18:14 -0700 Subject: [PATCH 22/34] fix: remove local-host protocol versioning Signed-off-by: Ajay Thorve --- adapters/claude/README.md | 2 +- adapters/claude/fabric-adapter.json | 4 +- adapters/codex/README.md | 18 +++-- adapters/codex/fabric-adapter.json | 4 +- adapters/common/README.md | 7 +- .../nemo_fabric_adapters/common/lifecycle.py | 13 +-- adapters/deepagents/fabric-adapter.json | 4 +- adapters/hermes/fabric-adapter.json | 4 +- .../adapters/claude/fabric-adapter.json | 4 +- .../assets/adapters/codex/fabric-adapter.json | 4 +- .../adapters/deepagents/fabric-adapter.json | 4 +- .../adapters/hermes/fabric-adapter.json | 4 +- crates/fabric-core/src/config.rs | 79 +++---------------- crates/fabric-core/src/error.rs | 2 +- crates/fabric-core/src/lib.rs | 15 ++-- crates/fabric-core/src/runtime.rs | 63 +++++---------- docs/sdk/python.mdx | 8 +- .../adapters/claude/fabric-adapter.json | 4 +- .../adapters/hermes/fabric-adapter.json | 4 +- schemas/SCHEMA.md | 4 +- schemas/adapter-descriptor.schema.json | 12 +-- schemas/run-plan.schema.json | 12 +-- .../test_adapters_common_lifecycle.py | 7 +- tests/adapters/test_claude_adapter.py | 4 +- tests/adapters/test_codex_adapter.py | 4 +- 25 files changed, 87 insertions(+), 203 deletions(-) diff --git a/adapters/claude/README.md b/adapters/claude/README.md index fa1a797c..6469bfd4 100644 --- a/adapters/claude/README.md +++ b/adapters/claude/README.md @@ -71,7 +71,7 @@ for other supported installation methods. ## Execution Model -The Claude adapter declares Fabric's versioned persistent local-host contract. +The Claude adapter declares Fabric's persistent local-host wire protocol. `Fabric.start_runtime(...)` launches one adapter host, creates one `ClaudeSDKClient`, and connects it once. Every `Runtime.invoke(...)` reuses that client and its event loop; `Runtime.stop()` disconnects the client and exits the diff --git a/adapters/claude/fabric-adapter.json b/adapters/claude/fabric-adapter.json index ee998f8c..47ffc1fd 100644 --- a/adapters/claude/fabric-adapter.json +++ b/adapters/claude/fabric-adapter.json @@ -19,8 +19,6 @@ } }, "runtime": { - "local_host": { - "contract_version": "fabric.adapter.lifecycle/v1alpha1" - } + "local_host": {} } } diff --git a/adapters/codex/README.md b/adapters/codex/README.md index 56a8f9d1..7252a6ea 100644 --- a/adapters/codex/README.md +++ b/adapters/codex/README.md @@ -71,14 +71,16 @@ to the explicit `base_dir`. Fabric passes the resolved path through ## Execution Model -Each Fabric runtime starts one local adapter host, one `AsyncCodex` app-server -client, and one Codex thread. Ordered `Runtime.invoke(...)` calls reuse that -client and thread directly; the adapter closes the app-server transport during -`Runtime.stop()`. The first successful invocation also persists the thread ID -under the Fabric artifact root for the adapter's direct per-invocation -compatibility path. The persistent host does not resume the thread between -turns. Codex owns the transcript; Fabric owns runtime-to-thread correlation, -timeout, cancellation, and cleanup. +Each Fabric runtime currently starts one local adapter host and retains one +`AsyncCodex` client and one Codex thread. The Codex SDK starts and controls its +pinned local `codex app-server` subprocess over JSON-RPC. Ordered +`Runtime.invoke(...)` calls reuse that client and thread directly; the adapter +closes the SDK client and app-server transport during `Runtime.stop()`. The +first successful invocation also persists the thread ID under the Fabric +artifact root for the adapter's direct per-invocation compatibility path. The +persistent host does not resume the thread between turns. Codex owns the +transcript; Fabric owns runtime-to-thread correlation, timeout, cancellation, +and cleanup. The result includes the SDK's typed terminal response, turn status, token usage, timing, and completed thread items. It does not expose CLI commands, diff --git a/adapters/codex/fabric-adapter.json b/adapters/codex/fabric-adapter.json index d3453bec..119e7c04 100644 --- a/adapters/codex/fabric-adapter.json +++ b/adapters/codex/fabric-adapter.json @@ -22,8 +22,6 @@ } }, "runtime": { - "local_host": { - "contract_version": "fabric.adapter.lifecycle/v1alpha1" - } + "local_host": {} } } diff --git a/adapters/common/README.md b/adapters/common/README.md index ab283083..35cca5d9 100644 --- a/adapters/common/README.md +++ b/adapters/common/README.md @@ -24,10 +24,15 @@ pip install "nemo-fabric[adapters-common]" ## Persistent Local Hosts Adapters that declare `runtime.local_host` in `fabric-adapter.json` implement -the versioned lifecycle contract with +the local-host wire protocol with `nemo_fabric_adapters.common.lifecycle`. Supply a factory that creates one adapter-owned runtime with asynchronous `start`, `invoke`, and `stop` methods: +`runtime.local_host` is an unversioned capability marker, not a separate +adapter contract. The nesting is intentionally provisional while local hosting +is the only supported mode; a shared runtime protocol can replace it when +remote adapter hosting is introduced. + ```python from nemo_fabric_adapters.common import lifecycle diff --git a/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py b/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py index 9200577e..087bbd23 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py +++ b/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Versioned host protocol for persistent local adapter runtimes.""" +"""Wire protocol for persistent local adapter hosts.""" from __future__ import annotations @@ -22,8 +22,7 @@ from typing import TextIO -CONTRACT_VERSION = "fabric.adapter.lifecycle/v1alpha1" -CONTRACT_ENV = "FABRIC_ADAPTER_LIFECYCLE_CONTRACT" +LOCAL_HOST_ENV = "FABRIC_ADAPTER_LOCAL_HOST" class AdapterRuntime(Protocol): @@ -79,7 +78,7 @@ def clear(self) -> None: def is_lifecycle_host(environ: Mapping[str, str] = os.environ) -> bool: """Return whether Fabric requested the persistent local-host protocol.""" - return environ.get(CONTRACT_ENV) == CONTRACT_VERSION + return environ.get(LOCAL_HOST_ENV) == "1" def _error( @@ -113,7 +112,6 @@ def _response( else {"status": "failed", "error": error} ) return { - "contract_version": CONTRACT_VERSION, "operation": operation, "outcome": outcome, } @@ -197,11 +195,6 @@ def _validated_request( "lifecycle_invalid_operation", "Unknown lifecycle operation", ) - if message.get("contract_version") != CONTRACT_VERSION: - raise LifecycleError( - "lifecycle_contract_mismatch", - f"Expected lifecycle contract {CONTRACT_VERSION}", - ) payload = message.get("payload") if not isinstance(payload, dict): raise LifecycleError( diff --git a/adapters/deepagents/fabric-adapter.json b/adapters/deepagents/fabric-adapter.json index 444c5924..7435cf8f 100644 --- a/adapters/deepagents/fabric-adapter.json +++ b/adapters/deepagents/fabric-adapter.json @@ -22,8 +22,6 @@ } }, "runtime": { - "local_host": { - "contract_version": "fabric.adapter.lifecycle/v1alpha1" - } + "local_host": {} } } diff --git a/adapters/hermes/fabric-adapter.json b/adapters/hermes/fabric-adapter.json index 93f6bcfe..46d9fc7f 100644 --- a/adapters/hermes/fabric-adapter.json +++ b/adapters/hermes/fabric-adapter.json @@ -34,8 +34,6 @@ } }, "runtime": { - "local_host": { - "contract_version": "fabric.adapter.lifecycle/v1alpha1" - } + "local_host": {} } } diff --git a/crates/fabric-cli/assets/adapters/claude/fabric-adapter.json b/crates/fabric-cli/assets/adapters/claude/fabric-adapter.json index ee998f8c..47ffc1fd 100644 --- a/crates/fabric-cli/assets/adapters/claude/fabric-adapter.json +++ b/crates/fabric-cli/assets/adapters/claude/fabric-adapter.json @@ -19,8 +19,6 @@ } }, "runtime": { - "local_host": { - "contract_version": "fabric.adapter.lifecycle/v1alpha1" - } + "local_host": {} } } diff --git a/crates/fabric-cli/assets/adapters/codex/fabric-adapter.json b/crates/fabric-cli/assets/adapters/codex/fabric-adapter.json index d3453bec..119e7c04 100644 --- a/crates/fabric-cli/assets/adapters/codex/fabric-adapter.json +++ b/crates/fabric-cli/assets/adapters/codex/fabric-adapter.json @@ -22,8 +22,6 @@ } }, "runtime": { - "local_host": { - "contract_version": "fabric.adapter.lifecycle/v1alpha1" - } + "local_host": {} } } diff --git a/crates/fabric-cli/assets/adapters/deepagents/fabric-adapter.json b/crates/fabric-cli/assets/adapters/deepagents/fabric-adapter.json index 444c5924..7435cf8f 100644 --- a/crates/fabric-cli/assets/adapters/deepagents/fabric-adapter.json +++ b/crates/fabric-cli/assets/adapters/deepagents/fabric-adapter.json @@ -22,8 +22,6 @@ } }, "runtime": { - "local_host": { - "contract_version": "fabric.adapter.lifecycle/v1alpha1" - } + "local_host": {} } } diff --git a/crates/fabric-cli/assets/adapters/hermes/fabric-adapter.json b/crates/fabric-cli/assets/adapters/hermes/fabric-adapter.json index 93f6bcfe..46d9fc7f 100644 --- a/crates/fabric-cli/assets/adapters/hermes/fabric-adapter.json +++ b/crates/fabric-cli/assets/adapters/hermes/fabric-adapter.json @@ -34,8 +34,6 @@ } }, "runtime": { - "local_host": { - "contract_version": "fabric.adapter.lifecycle/v1alpha1" - } + "local_host": {} } } diff --git a/crates/fabric-core/src/config.rs b/crates/fabric-core/src/config.rs index 546bbf2f..5ce85934 100644 --- a/crates/fabric-core/src/config.rs +++ b/crates/fabric-core/src/config.rs @@ -15,8 +15,6 @@ use crate::error::{FabricError, Result}; /// Adapter descriptor contract version supported by this core. pub const ADAPTER_CONTRACT_VERSION: &str = "fabric.adapter/v1alpha1"; -/// Persistent local-host lifecycle contract version supported by this core. -pub const ADAPTER_LIFECYCLE_CONTRACT_VERSION: &str = "fabric.adapter.lifecycle/v1alpha1"; /// Versioned Fabric agent config. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] @@ -135,7 +133,10 @@ pub struct AdapterDescriptor { /// Runtime hosting mechanisms implemented by an adapter. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct AdapterRuntimeSupport { - /// Versioned persistent local-host support, when implemented. + /// Persistent local-host support, when implemented. + /// + /// This nesting is intentionally provisional until remote adapter hosting + /// introduces a shared runtime protocol. #[serde(default, skip_serializing_if = "Option::is_none")] pub local_host: Option, /// Additive adapter runtime-hosting fields. @@ -152,9 +153,6 @@ impl AdapterRuntimeSupport { /// Persistent local-host protocol implemented by an adapter. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct AdapterLocalHostSupport { - /// Adapter lifecycle protocol version spoken over the host transport. - #[schemars(length(min = 1))] - pub contract_version: String, /// Additive persistent local-host fields. #[serde(default, flatten)] pub extensions: BTreeMap, @@ -1127,20 +1125,7 @@ fn validate_adapter_descriptor_shape(descriptor: &AdapterDescriptor, path: &Path if descriptor.harness.trim().is_empty() { return invalid_adapter_descriptor(path, "harness must not be empty"); } - if let Some(local_host) = descriptor.runtime.local_host.as_ref() { - if local_host.contract_version.trim().is_empty() { - return invalid_adapter_descriptor( - path, - "runtime.local_host.contract_version must not be empty", - ); - } - if local_host.contract_version != ADAPTER_LIFECYCLE_CONTRACT_VERSION { - return Err(FabricError::AdapterDescriptorUnsupported { - adapter_id: descriptor.adapter_id.clone(), - field: "runtime.local_host.contract_version", - value: local_host.contract_version.clone(), - }); - } + if descriptor.runtime.local_host.is_some() { if descriptor.adapter_kind != AdapterKind::Python { return invalid_adapter_descriptor( path, @@ -1768,62 +1753,20 @@ mod tests { } #[test] - fn repository_adapters_declare_versioned_local_host() { + fn repository_adapters_declare_local_host() { for harness in ["claude", "codex", "deepagents", "hermes"] { let descriptor = load_adapter_descriptor( repository_adapter_dir().join(format!("{harness}/fabric-adapter.json")), ) .unwrap_or_else(|error| panic!("{harness} adapter descriptor: {error}")); - assert_eq!( - descriptor - .runtime - .local_host - .as_ref() - .map(|host| host.contract_version.as_str()), - Some(ADAPTER_LIFECYCLE_CONTRACT_VERSION), - "{harness} descriptor", + assert!( + descriptor.runtime.local_host.is_some(), + "{harness} descriptor" ); } } - #[test] - fn rejects_unsupported_local_host_contract_version() { - let root = std::env::temp_dir().join(format!( - "fabric-invalid-local-host-contract-test-{}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(&root).expect("create temp root"); - let descriptor_path = root.join("fabric-adapter.json"); - std::fs::write( - &descriptor_path, - r#"{ - "contract_version": "fabric.adapter/v1alpha1", - "adapter_id": "acme.fabric.future-host", - "harness": "future-host", - "adapter_kind": "python", - "runtime": { - "local_host": {"contract_version": "fabric.adapter.lifecycle/v9"} - } -}"#, - ) - .expect("write adapter descriptor"); - - let error = load_adapter_descriptor(&descriptor_path).expect_err("invalid descriptor"); - assert!(matches!( - error, - FabricError::AdapterDescriptorUnsupported { - field, - value, - .. - } if field == "runtime.local_host.contract_version" - && value == "fabric.adapter.lifecycle/v9" - )); - - let _ = std::fs::remove_dir_all(root); - } - #[test] fn rejects_local_host_on_non_python_adapter() { let descriptor: AdapterDescriptor = serde_json::from_value(serde_json::json!({ @@ -1832,9 +1775,7 @@ mod tests { "harness": "process-host", "adapter_kind": "process", "runtime": { - "local_host": { - "contract_version": ADAPTER_LIFECYCLE_CONTRACT_VERSION, - } + "local_host": {} }, })) .expect("adapter descriptor"); diff --git a/crates/fabric-core/src/error.rs b/crates/fabric-core/src/error.rs index c986173f..ce2de11b 100644 --- a/crates/fabric-core/src/error.rs +++ b/crates/fabric-core/src/error.rs @@ -82,7 +82,7 @@ pub enum FabricError { /// Adapter kind. adapter_kind: AdapterKind, }, - /// A versioned persistent local-host lifecycle operation failed. + /// A persistent local-host lifecycle operation failed. #[error( "adapter lifecycle {operation} failed for runtime `{runtime_id}` ({code}): {message}{diagnostics_suffix}", diagnostics_suffix = if diagnostics.is_empty() { diff --git a/crates/fabric-core/src/lib.rs b/crates/fabric-core/src/lib.rs index 69a7af67..f93f5356 100644 --- a/crates/fabric-core/src/lib.rs +++ b/crates/fabric-core/src/lib.rs @@ -10,14 +10,13 @@ pub mod runtime; pub mod schema; pub use config::{ - ADAPTER_CONTRACT_VERSION, ADAPTER_LIFECYCLE_CONTRACT_VERSION, AdapterConfigSupport, - AdapterDescriptor, AdapterDescriptorSource, AdapterKind, AdapterLocalHostSupport, - AdapterRequirements, AdapterRuntimeSupport, AdapterTelemetryProviderSupport, - AdapterTelemetrySupport, CapabilityPlan, ControlLocation, EnvironmentConfig, - EnvironmentOwnership, EnvironmentPlan, FabricConfig, HarnessConfig, McpConfig, McpExposure, - McpServerPlan, MetadataConfig, ModelConfig, ResolutionStrategy, ResolveContext, - ResolvedAdapterDescriptor, RunPlan, RuntimeCapabilities, RuntimeConfig, SkillConfig, - TelemetryConfig, TelemetryPlan, TelemetryProvider, TelemetryProviderConfig, + ADAPTER_CONTRACT_VERSION, AdapterConfigSupport, AdapterDescriptor, AdapterDescriptorSource, + AdapterKind, AdapterLocalHostSupport, AdapterRequirements, AdapterRuntimeSupport, + AdapterTelemetryProviderSupport, AdapterTelemetrySupport, CapabilityPlan, ControlLocation, + EnvironmentConfig, EnvironmentOwnership, EnvironmentPlan, FabricConfig, HarnessConfig, + McpConfig, McpExposure, McpServerPlan, MetadataConfig, ModelConfig, ResolutionStrategy, + ResolveContext, ResolvedAdapterDescriptor, RunPlan, RuntimeCapabilities, RuntimeConfig, + SkillConfig, TelemetryConfig, TelemetryPlan, TelemetryProvider, TelemetryProviderConfig, load_adapter_descriptor, resolve_run_plan_from_config, }; pub use doctor::{DoctorCheck, DoctorReport, DoctorStatus, doctor_plan}; diff --git a/crates/fabric-core/src/runtime.rs b/crates/fabric-core/src/runtime.rs index bb9cc619..c2a9ae32 100644 --- a/crates/fabric-core/src/runtime.rs +++ b/crates/fabric-core/src/runtime.rs @@ -20,8 +20,8 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use crate::config::{ - ADAPTER_LIFECYCLE_CONTRACT_VERSION, AdapterKind, CapabilityKind, CapabilityPlan, - CapabilityTarget, ControlLocation, EnvironmentOwnership, FabricConfig, RunPlan, TelemetryPlan, + AdapterKind, CapabilityKind, CapabilityPlan, CapabilityTarget, ControlLocation, + EnvironmentOwnership, FabricConfig, RunPlan, TelemetryPlan, }; use crate::error::{FabricError, Result}; @@ -400,17 +400,13 @@ impl AdapterLifecycleRequestKind { #[derive(Debug, Clone, PartialEq, Serialize)] struct AdapterLifecycleRequest { - contract_version: String, #[serde(flatten)] request: AdapterLifecycleRequestKind, } impl AdapterLifecycleRequest { fn new(request: AdapterLifecycleRequestKind) -> Self { - Self { - contract_version: ADAPTER_LIFECYCLE_CONTRACT_VERSION.to_string(), - request, - } + Self { request } } fn operation(&self) -> AdapterLifecycleOperation { @@ -432,7 +428,6 @@ enum AdapterLifecycleOutcome { #[derive(Debug, Clone, PartialEq, Deserialize)] struct AdapterLifecycleResponse { - contract_version: String, operation: AdapterLifecycleOperation, outcome: AdapterLifecycleOutcome, } @@ -624,21 +619,11 @@ pub fn stop_runtime(plan: &RunPlan, runtime: &RuntimeHandle) -> Result Result { - let local_host = plan + Ok(plan .adapter_descriptor .as_ref() - .and_then(|adapter| adapter.descriptor.runtime.local_host.as_ref()); - let Some(local_host) = local_host else { - return Ok(false); - }; - if local_host.contract_version != ADAPTER_LIFECYCLE_CONTRACT_VERSION { - return Err(FabricError::AdapterDescriptorUnsupported { - adapter_id: adapter_id(plan).unwrap_or_else(|| harness(plan)), - field: "runtime.local_host.contract_version", - value: local_host.contract_version.clone(), - }); - } - Ok(true) + .and_then(|adapter| adapter.descriptor.runtime.local_host.as_ref()) + .is_some()) } fn validate_runtime_handle(plan: &RunPlan, runtime: &RuntimeHandle) -> Result<()> { @@ -1391,10 +1376,7 @@ fn spawn_local_host( } }; command - .env( - "FABRIC_ADAPTER_LIFECYCLE_CONTRACT", - ADAPTER_LIFECYCLE_CONTRACT_VERSION, - ) + .env("FABRIC_ADAPTER_LOCAL_HOST", "1") .env("FABRIC_RUNTIME_ID", &runtime.runtime_id) .env("FABRIC_HOME", &runtime_dir) .envs(relay_env(&relay_config)) @@ -1602,18 +1584,6 @@ fn exchange_lifecycle_message( local_host_diagnostics(host), ) })?; - if response.contract_version != ADAPTER_LIFECYCLE_CONTRACT_VERSION { - return Err(lifecycle_error( - operation, - runtime_id, - "protocol_version_mismatch", - format!( - "expected lifecycle contract `{ADAPTER_LIFECYCLE_CONTRACT_VERSION}` but host returned `{}`", - response.contract_version - ), - local_host_diagnostics(host), - )); - } if response.operation != operation { return Err(lifecycle_error( operation, @@ -2997,6 +2967,19 @@ mod tests { use super::*; use crate::config::{ResolveContext, resolve_run_plan_from_config}; + #[test] + fn local_host_lifecycle_messages_are_unversioned() { + let request = + AdapterLifecycleRequest::new(AdapterLifecycleRequestKind::Stop(AdapterLifecycleStop { + runtime_id: "runtime-1".to_string(), + })); + let value = serde_json::to_value(request).expect("serialize lifecycle request"); + + assert_eq!(value["operation"], "stop"); + assert!(value.get("contract_version").is_none()); + assert!(value.get("protocol_version").is_none()); + } + fn local_host_plan(mode: &str) -> (PathBuf, RunPlan) { local_host_plan_with_relay(mode, false) } @@ -3019,9 +3002,7 @@ mod tests { } }, "runtime": { - "local_host": { - "contract_version": "fabric.adapter.lifecycle/v1alpha1" - } + "local_host": {} } }"#, ) @@ -3032,7 +3013,6 @@ mod tests { import os import sys -VERSION = "fabric.adapter.lifecycle/v1alpha1" MODE = os.environ.get("FABRIC_FAKE_HOST_MODE", "success") invocations = 0 @@ -3043,7 +3023,6 @@ def response(operation, *, output=None, error=None): else {"status": "failed", "error": error} ) print(json.dumps({ - "contract_version": VERSION, "operation": operation, "outcome": outcome, }), flush=True) diff --git a/docs/sdk/python.mdx b/docs/sdk/python.mdx index 47b15d02..12265c92 100644 --- a/docs/sdk/python.mdx +++ b/docs/sdk/python.mdx @@ -116,10 +116,12 @@ process. An adapter may use an in-process SDK, a process, or shared service infrastructure while preserving isolated state for each NeMo Fabric runtime. Runtime hosting is selected by the adapter descriptor, not by a public -`FabricConfig` setting. An adapter that declares the versioned persistent local -host contract starts one local host during `start_runtime(...)`, reuses its +`FabricConfig` setting. An adapter that declares the persistent local-host wire +protocol starts one local host during `start_runtime(...)`, reuses its adapter-owned native resources across ordered invocations, and attempts to -release them during `stop()`. +release them during `stop()`. `runtime.local_host` is an unversioned capability +marker whose nesting is intentionally provisional until remote hosting +requires a shared runtime protocol. ### Bundled Adapter Capability Matrix diff --git a/examples/harbor/swebench/adapters/claude/fabric-adapter.json b/examples/harbor/swebench/adapters/claude/fabric-adapter.json index ee998f8c..47ffc1fd 100644 --- a/examples/harbor/swebench/adapters/claude/fabric-adapter.json +++ b/examples/harbor/swebench/adapters/claude/fabric-adapter.json @@ -19,8 +19,6 @@ } }, "runtime": { - "local_host": { - "contract_version": "fabric.adapter.lifecycle/v1alpha1" - } + "local_host": {} } } diff --git a/examples/harbor/swebench/adapters/hermes/fabric-adapter.json b/examples/harbor/swebench/adapters/hermes/fabric-adapter.json index 93f6bcfe..46d9fc7f 100644 --- a/examples/harbor/swebench/adapters/hermes/fabric-adapter.json +++ b/examples/harbor/swebench/adapters/hermes/fabric-adapter.json @@ -34,8 +34,6 @@ } }, "runtime": { - "local_host": { - "contract_version": "fabric.adapter.lifecycle/v1alpha1" - } + "local_host": {} } } diff --git a/schemas/SCHEMA.md b/schemas/SCHEMA.md index 34f67741..ffbe59d2 100644 --- a/schemas/SCHEMA.md +++ b/schemas/SCHEMA.md @@ -23,7 +23,9 @@ The core schema generator exports the current public typed contract. - `adapter-descriptor`: minimal adapter descriptor consumed by Fabric. Each descriptor declares a `contract_version`; Fabric rejects descriptors for unsupported adapter contracts during planning. Adapters can also declare a - versioned persistent local-host lifecycle under `runtime.local_host`. + persistent local-host wire protocol under `runtime.local_host`. This field is + an unversioned capability marker, and its nesting is intentionally + provisional until remote hosting requires a shared runtime protocol. - `run-plan`: executable plan containing the canonical typed config, absolute base directory, selected adapter, and derived execution metadata. diff --git a/schemas/adapter-descriptor.schema.json b/schemas/adapter-descriptor.schema.json index a26b4bb2..32b153d4 100644 --- a/schemas/adapter-descriptor.schema.json +++ b/schemas/adapter-descriptor.schema.json @@ -49,16 +49,6 @@ "AdapterLocalHostSupport": { "additionalProperties": true, "description": "Persistent local-host protocol implemented by an adapter.", - "properties": { - "contract_version": { - "description": "Adapter lifecycle protocol version spoken over the host transport.", - "minLength": 1, - "type": "string" - } - }, - "required": [ - "contract_version" - ], "type": "object" }, "AdapterRequirements": { @@ -116,7 +106,7 @@ "type": "null" } ], - "description": "Versioned persistent local-host support, when implemented." + "description": "Persistent local-host support, when implemented.\n\nThis nesting is intentionally provisional until remote adapter hosting\nintroduces a shared runtime protocol." } }, "type": "object" diff --git a/schemas/run-plan.schema.json b/schemas/run-plan.schema.json index f9e6f57f..3c904d4c 100644 --- a/schemas/run-plan.schema.json +++ b/schemas/run-plan.schema.json @@ -130,16 +130,6 @@ "AdapterLocalHostSupport": { "additionalProperties": true, "description": "Persistent local-host protocol implemented by an adapter.", - "properties": { - "contract_version": { - "description": "Adapter lifecycle protocol version spoken over the host transport.", - "minLength": 1, - "type": "string" - } - }, - "required": [ - "contract_version" - ], "type": "object" }, "AdapterRequirements": { @@ -197,7 +187,7 @@ "type": "null" } ], - "description": "Versioned persistent local-host support, when implemented." + "description": "Persistent local-host support, when implemented.\n\nThis nesting is intentionally provisional until remote adapter hosting\nintroduces a shared runtime protocol." } }, "type": "object" diff --git a/tests/adapters/test_adapters_common_lifecycle.py b/tests/adapters/test_adapters_common_lifecycle.py index b93718e9..be473acb 100644 --- a/tests/adapters/test_adapters_common_lifecycle.py +++ b/tests/adapters/test_adapters_common_lifecycle.py @@ -15,7 +15,6 @@ def _request(operation: str, payload: dict[str, Any]) -> dict[str, Any]: return { - "contract_version": lifecycle.CONTRACT_VERSION, "operation": operation, "payload": payload, } @@ -26,6 +25,11 @@ def _streams(requests: list[dict[str, Any]]) -> tuple[io.StringIO, io.StringIO]: return input_stream, io.StringIO() +def test_lifecycle_host_uses_unversioned_marker(): + assert lifecycle.is_lifecycle_host({lifecycle.LOCAL_HOST_ENV: "1"}) + assert not lifecycle.is_lifecycle_host({lifecycle.LOCAL_HOST_ENV: "0"}) + + def test_lifecycle_host_reuses_one_runtime_and_one_event_loop(): runtime_id = "runtime-1" input_stream, output_stream = _streams( @@ -83,6 +87,7 @@ async def stop(self): "stop", ] assert all(item["outcome"]["status"] == "succeeded" for item in responses) + assert all(set(item) == {"operation", "outcome"} for item in responses) assert responses[1]["outcome"]["output"] == {"count": 1, "input": "first"} assert responses[2]["outcome"]["output"] == {"count": 2, "input": "second"} assert len(instances) == 1 diff --git a/tests/adapters/test_claude_adapter.py b/tests/adapters/test_claude_adapter.py index acf55baf..d2ef87d8 100644 --- a/tests/adapters/test_claude_adapter.py +++ b/tests/adapters/test_claude_adapter.py @@ -71,9 +71,7 @@ def test_claude_descriptor_is_narrow_and_versioned(): } }, "runtime": { - "local_host": { - "contract_version": adapter.lifecycle.CONTRACT_VERSION, - } + "local_host": {} }, } diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index 73a73500..8558e1b8 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -997,9 +997,7 @@ def test_descriptor_has_no_codex_binary_requirement(): "skills", "telemetry", ] - assert descriptor["runtime"]["local_host"] == { - "contract_version": "fabric.adapter.lifecycle/v1alpha1" - } + assert descriptor["runtime"]["local_host"] == {} assert "requirements" not in descriptor From f1b6d6f3103bb6d68b3b23074409e376e0aef193 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Tue, 21 Jul 2026 11:18:24 -0700 Subject: [PATCH 23/34] docs: regenerate local-host API reference Signed-off-by: Ajay Thorve --- ...constant-adapter-lifecycle-contract-version.mdx | 14 -------------- .../config/enum-adapterdescriptorsource.mdx | 2 +- .../nemo-fabric-core/config/enum-adapterkind.mdx | 2 +- .../config/enum-controllocation.mdx | 2 +- .../config/enum-environmentownership.mdx | 2 +- .../nemo-fabric-core/config/enum-mcpexposure.mdx | 2 +- .../config/enum-resolutionstrategy.mdx | 2 +- .../config/enum-telemetryprovider.mdx | 2 +- .../config/fn-load-adapter-descriptor.mdx | 2 +- .../config/fn-resolve-run-plan-from-config.mdx | 2 +- .../nemo-fabric-core/config/index.mdx | 3 +-- .../config/struct-adapterconfigsupport.mdx | 2 +- .../config/struct-adapterdescriptor.mdx | 2 +- .../config/struct-adapterlocalhostsupport.mdx | 8 ++------ .../config/struct-adapterrequirements.mdx | 2 +- .../config/struct-adapterruntimesupport.mdx | 6 ++++-- .../struct-adaptertelemetryprovidersupport.mdx | 2 +- .../config/struct-adaptertelemetrysupport.mdx | 2 +- .../config/struct-capabilityplan.mdx | 2 +- .../config/struct-environmentconfig.mdx | 2 +- .../config/struct-environmentplan.mdx | 2 +- .../config/struct-fabricconfig.mdx | 2 +- .../config/struct-harnessconfig.mdx | 2 +- .../nemo-fabric-core/config/struct-mcpconfig.mdx | 2 +- .../config/struct-mcpserverplan.mdx | 2 +- .../config/struct-metadataconfig.mdx | 2 +- .../nemo-fabric-core/config/struct-modelconfig.mdx | 2 +- .../config/struct-resolvecontext.mdx | 2 +- .../config/struct-resolvedadapterdescriptor.mdx | 2 +- .../nemo-fabric-core/config/struct-runplan.mdx | 2 +- .../config/struct-runtimecapabilities.mdx | 2 +- .../config/struct-runtimeconfig.mdx | 2 +- .../nemo-fabric-core/config/struct-skillconfig.mdx | 2 +- .../config/struct-telemetryconfig.mdx | 2 +- .../config/struct-telemetryplan.mdx | 2 +- .../config/struct-telemetryproviderconfig.mdx | 2 +- .../nemo-fabric-core/doctor/enum-doctorstatus.mdx | 2 +- .../nemo-fabric-core/doctor/fn-doctor-plan.mdx | 2 +- .../nemo-fabric-core/doctor/index.mdx | 2 +- .../nemo-fabric-core/doctor/struct-doctorcheck.mdx | 2 +- .../doctor/struct-doctorreport.mdx | 2 +- .../nemo-fabric-core/error/enum-fabricerror.mdx | 4 ++-- .../nemo-fabric-core/error/index.mdx | 2 +- .../nemo-fabric-core/error/type-result.mdx | 2 +- .../nemo-fabric-core/fn-version.mdx | 2 +- .../nemo-fabric-core/index.mdx | 1 - .../nemo-fabric-core/runtime/index.mdx | 2 +- .../nemo-fabric-core/schema/index.mdx | 2 +- 48 files changed, 51 insertions(+), 69 deletions(-) delete mode 100644 docs/reference/api/rust-library-reference/nemo-fabric-core/config/constant-adapter-lifecycle-contract-version.mdx diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/constant-adapter-lifecycle-contract-version.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/constant-adapter-lifecycle-contract-version.mdx deleted file mode 100644 index b938a3a9..00000000 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/constant-adapter-lifecycle-contract-version.mdx +++ /dev/null @@ -1,14 +0,0 @@ ---- -title: "Constant ADAPTER_LIFECYCLE_CONTRACT_VERSION" -sidebar-title: "ADAPTER_LIFECYCLE_CONTRACT_VERSION" -description: "Persistent local-host lifecycle contract version supported by this core." -position: 2 ---- -{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -SPDX-License-Identifier: Apache-2.0 */} - -Generated from `cargo doc --no-deps -p nemo-fabric-core`. - -
str = \"fabric.adapter.lifecycle/v1alpha1\";"}} />
- -Persistent local-host lifecycle contract version supported by this core. diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-adapterdescriptorsource.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-adapterdescriptorsource.mdx index 3d35cda8..57ada140 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-adapterdescriptorsource.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-adapterdescriptorsource.mdx @@ -2,7 +2,7 @@ title: "Enum Adapter Descriptor Source" sidebar-title: "AdapterDescriptorSource" description: "Where Fabric resolved an adapter descriptor from." -position: 5 +position: 4 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-adapterkind.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-adapterkind.mdx index ff0fd98d..c786a243 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-adapterkind.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-adapterkind.mdx @@ -2,7 +2,7 @@ title: "Enum Adapter Kind" sidebar-title: "AdapterKind" description: "Adapter implementation kind." -position: 6 +position: 5 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-controllocation.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-controllocation.mdx index 3be7bc41..46088e42 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-controllocation.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-controllocation.mdx @@ -2,7 +2,7 @@ title: "Enum Control Location" sidebar-title: "ControlLocation" description: "Where Fabric control code runs relative to the environment." -position: 13 +position: 12 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-environmentownership.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-environmentownership.mdx index eb7988d1..92fe3c8e 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-environmentownership.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-environmentownership.mdx @@ -2,7 +2,7 @@ title: "Enum Environment Ownership" sidebar-title: "EnvironmentOwnership" description: "Whether Fabric owns the underlying environment resource." -position: 15 +position: 14 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-mcpexposure.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-mcpexposure.mdx index 24f22d62..dce2481c 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-mcpexposure.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-mcpexposure.mdx @@ -2,7 +2,7 @@ title: "Enum McpExposure" sidebar-title: "McpExposure" description: "MCP exposure strategy." -position: 20 +position: 19 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-resolutionstrategy.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-resolutionstrategy.mdx index 2f06bcd9..1a861d09 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-resolutionstrategy.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-resolutionstrategy.mdx @@ -2,7 +2,7 @@ title: "Enum Resolution Strategy" sidebar-title: "ResolutionStrategy" description: "Adapter install or availability strategy." -position: 24 +position: 23 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-telemetryprovider.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-telemetryprovider.mdx index 851ae882..a8055df7 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-telemetryprovider.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-telemetryprovider.mdx @@ -2,7 +2,7 @@ title: "Enum Telemetry Provider" sidebar-title: "TelemetryProvider" description: "Telemetry runtime provider." -position: 33 +position: 32 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-descriptor.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-descriptor.mdx index 05880941..b9e8fdbe 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-descriptor.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-descriptor.mdx @@ -2,7 +2,7 @@ title: "Function load_adapter_descriptor" sidebar-title: "load_adapter_descriptor" description: "Load an adapter descriptor from JSON package metadata." -position: 35 +position: 34 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-resolve-run-plan-from-config.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-resolve-run-plan-from-config.mdx index 14a4e484..3412e2fe 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-resolve-run-plan-from-config.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-resolve-run-plan-from-config.mdx @@ -2,7 +2,7 @@ title: "Function resolve_run_plan_from_config" sidebar-title: "resolve_run_plan_from_config" description: "Resolve a typed Fabric config into a runnable plan." -position: 36 +position: 35 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx index 478d0cc5..3961f02e 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx @@ -2,7 +2,7 @@ title: "Module config" sidebar-title: "config" description: "Fabric config models and loading helpers." -position: 68 +position: 67 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} @@ -73,7 +73,6 @@ Fabric config models and loading helpers. ## Constants - [ADAPTER_CONTRACT_VERSION](/reference/api/rust-library-reference/nemo-fabric-core/config/constant-adapter-contract-version): Adapter descriptor contract version supported by this core. -- [ADAPTER_LIFECYCLE_CONTRACT_VERSION](/reference/api/rust-library-reference/nemo-fabric-core/config/constant-adapter-lifecycle-contract-version): Persistent local-host lifecycle contract version supported by this core. ## Functions diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterconfigsupport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterconfigsupport.mdx index d2d20b63..4e4a85f0 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterconfigsupport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterconfigsupport.mdx @@ -2,7 +2,7 @@ title: "Struct Adapter Config Support" sidebar-title: "AdapterConfigSupport" description: "Adapter config support." -position: 3 +position: 2 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterdescriptor.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterdescriptor.mdx index 462202b0..097e6c76 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterdescriptor.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterdescriptor.mdx @@ -2,7 +2,7 @@ title: "Struct Adapter Descriptor" sidebar-title: "AdapterDescriptor" description: "Language-neutral adapter descriptor for a harness integration." -position: 4 +position: 3 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterlocalhostsupport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterlocalhostsupport.mdx index cb475a3c..567be919 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterlocalhostsupport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterlocalhostsupport.mdx @@ -2,23 +2,19 @@ title: "Struct Adapter Local Host Support" sidebar-title: "AdapterLocalHostSupport" description: "Persistent local-host protocol implemented by an adapter." -position: 7 +position: 6 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} Generated from `cargo doc --no-deps -p nemo-fabric-core`. -
String,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
BTreeMap<String, Value>,\n}"}} />
Persistent local-host protocol implemented by an adapter. ## Fields -### `contract_version: String` - -Adapter lifecycle protocol version spoken over the host transport. - ### `extensions: BTreeMap` Additive persistent local-host fields. diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterrequirements.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterrequirements.mdx index 115a6dc2..a462ccfc 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterrequirements.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterrequirements.mdx @@ -2,7 +2,7 @@ title: "Struct Adapter Requirements" sidebar-title: "AdapterRequirements" description: "Adapter runtime requirements." -position: 8 +position: 7 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterruntimesupport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterruntimesupport.mdx index e17e3d3e..4d213d37 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterruntimesupport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterruntimesupport.mdx @@ -2,7 +2,7 @@ title: "Struct Adapter Runtime Support" sidebar-title: "AdapterRuntimeSupport" description: "Runtime hosting mechanisms implemented by an adapter." -position: 9 +position: 8 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} @@ -17,7 +17,9 @@ Runtime hosting mechanisms implemented by an adapter. ### `local_host: Option` -Versioned persistent local-host support, when implemented. +Persistent local-host support, when implemented. + +This nesting is intentionally provisional until remote adapter hosting introduces a shared runtime protocol. ### `extensions: BTreeMap` diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adaptertelemetryprovidersupport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adaptertelemetryprovidersupport.mdx index 5f2e2463..8a081daf 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adaptertelemetryprovidersupport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adaptertelemetryprovidersupport.mdx @@ -2,7 +2,7 @@ title: "Struct Adapter Telemetry Provider Support" sidebar-title: "AdapterTelemetryProviderSupport" description: "Telemetry capabilities for one adapter-supported provider." -position: 10 +position: 9 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adaptertelemetrysupport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adaptertelemetrysupport.mdx index 7912798e..90334dbf 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adaptertelemetrysupport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adaptertelemetrysupport.mdx @@ -2,7 +2,7 @@ title: "Struct Adapter Telemetry Support" sidebar-title: "AdapterTelemetrySupport" description: "Adapter telemetry support." -position: 11 +position: 10 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilityplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilityplan.mdx index 9a07cdda..554dd4d2 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilityplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilityplan.mdx @@ -2,7 +2,7 @@ title: "Struct Capability Plan" sidebar-title: "CapabilityPlan" description: "Resolved capability configuration." -position: 12 +position: 11 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-environmentconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-environmentconfig.mdx index 74c1764e..9354813c 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-environmentconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-environmentconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Environment Config" sidebar-title: "EnvironmentConfig" description: "Execution environment configuration." -position: 14 +position: 13 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-environmentplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-environmentplan.mdx index bc223bf1..1e371238 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-environmentplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-environmentplan.mdx @@ -2,7 +2,7 @@ title: "Struct Environment Plan" sidebar-title: "EnvironmentPlan" description: "Resolved environment plan." -position: 16 +position: 15 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-fabricconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-fabricconfig.mdx index 0c442fa7..8300c019 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-fabricconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-fabricconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Fabric Config" sidebar-title: "FabricConfig" description: "Versioned Fabric agent config." -position: 17 +position: 16 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-harnessconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-harnessconfig.mdx index 4a2f9994..11186975 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-harnessconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-harnessconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Harness Config" sidebar-title: "HarnessConfig" description: "Harness selection." -position: 18 +position: 17 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpconfig.mdx index 72493283..33a7d5e5 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpconfig.mdx @@ -2,7 +2,7 @@ title: "Struct McpConfig" sidebar-title: "McpConfig" description: "MCP capability configuration." -position: 19 +position: 18 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpserverplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpserverplan.mdx index 04a6f83c..c51dbefb 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpserverplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpserverplan.mdx @@ -2,7 +2,7 @@ title: "Struct McpServer Plan" sidebar-title: "McpServerPlan" description: "Resolved MCP server exposure." -position: 21 +position: 20 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-metadataconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-metadataconfig.mdx index da287e9d..40ba1d63 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-metadataconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-metadataconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Metadata Config" sidebar-title: "MetadataConfig" description: "Human-readable metadata." -position: 22 +position: 21 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-modelconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-modelconfig.mdx index 62c9107b..43673736 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-modelconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-modelconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Model Config" sidebar-title: "ModelConfig" description: "Model configuration." -position: 23 +position: 22 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvecontext.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvecontext.mdx index 6f6f213b..1bcba569 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvecontext.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvecontext.mdx @@ -2,7 +2,7 @@ title: "Struct Resolve Context" sidebar-title: "ResolveContext" description: "Source context used when resolving an in-memory Fabric config." -position: 25 +position: 24 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvedadapterdescriptor.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvedadapterdescriptor.mdx index 0fa3450f..87a0b145 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvedadapterdescriptor.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvedadapterdescriptor.mdx @@ -2,7 +2,7 @@ title: "Struct Resolved Adapter Descriptor" sidebar-title: "ResolvedAdapterDescriptor" description: "Adapter descriptor selected for a run plan." -position: 26 +position: 25 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runplan.mdx index d6dda97d..4d5b18cb 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runplan.mdx @@ -2,7 +2,7 @@ title: "Struct RunPlan" sidebar-title: "RunPlan" description: "Resolved Fabric run plan." -position: 27 +position: 26 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimecapabilities.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimecapabilities.mdx index 2f73a4d2..14aeae6a 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimecapabilities.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimecapabilities.mdx @@ -2,7 +2,7 @@ title: "Struct Runtime Capabilities" sidebar-title: "RuntimeCapabilities" description: "Lifecycle behavior implemented by a resolved runtime path." -position: 28 +position: 27 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimeconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimeconfig.mdx index 8b51cafc..82078f3a 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimeconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimeconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Runtime Config" sidebar-title: "RuntimeConfig" description: "Runtime input/output contract." -position: 29 +position: 28 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-skillconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-skillconfig.mdx index c684959d..1ae59d5a 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-skillconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-skillconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Skill Config" sidebar-title: "SkillConfig" description: "Skill capability configuration." -position: 30 +position: 29 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryconfig.mdx index 9338bbfb..03c2572f 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Telemetry Config" sidebar-title: "TelemetryConfig" description: "Telemetry configuration." -position: 31 +position: 30 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryplan.mdx index eee9a11e..81806dcf 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryplan.mdx @@ -2,7 +2,7 @@ title: "Struct Telemetry Plan" sidebar-title: "TelemetryPlan" description: "Resolved telemetry plan." -position: 32 +position: 31 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryproviderconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryproviderconfig.mdx index 67483880..ed3c2581 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryproviderconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryproviderconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Telemetry Provider Config" sidebar-title: "TelemetryProviderConfig" description: "Provider-specific telemetry configuration." -position: 34 +position: 33 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/enum-doctorstatus.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/enum-doctorstatus.mdx index 50a55ecc..b6539931 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/enum-doctorstatus.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/enum-doctorstatus.mdx @@ -2,7 +2,7 @@ title: "Enum Doctor Status" sidebar-title: "DoctorStatus" description: "Diagnostic status." -position: 39 +position: 38 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/fn-doctor-plan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/fn-doctor-plan.mdx index 8ddbe6d2..2b6be88b 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/fn-doctor-plan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/fn-doctor-plan.mdx @@ -2,7 +2,7 @@ title: "Function doctor_plan" sidebar-title: "doctor_plan" description: "Inspect a resolved run plan without mutating the environment." -position: 40 +position: 39 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/index.mdx index b3323536..d7d6d886 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/index.mdx @@ -2,7 +2,7 @@ title: "Module doctor" sidebar-title: "doctor" description: "Plan diagnostics for Fabric." -position: 69 +position: 68 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorcheck.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorcheck.mdx index d3d3d019..21ce5e5f 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorcheck.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorcheck.mdx @@ -2,7 +2,7 @@ title: "Struct Doctor Check" sidebar-title: "DoctorCheck" description: "Diagnostic check result." -position: 37 +position: 36 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorreport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorreport.mdx index 5f71661d..69993805 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorreport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorreport.mdx @@ -2,7 +2,7 @@ title: "Struct Doctor Report" sidebar-title: "DoctorReport" description: "Diagnostic report for a resolved run plan." -position: 38 +position: 37 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/enum-fabricerror.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/enum-fabricerror.mdx index 5ea78d87..3e65a64f 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/enum-fabricerror.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/enum-fabricerror.mdx @@ -2,7 +2,7 @@ title: "Enum Fabric Error" sidebar-title: "FabricError" description: "Errors raised by Fabric config loading and validation." -position: 41 +position: 40 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} @@ -149,7 +149,7 @@ Adapter kind.
-A versioned persistent local-host lifecycle operation failed. +A persistent local-host lifecycle operation failed. #### Fields diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/index.mdx index d8ddd484..f385e5e5 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/index.mdx @@ -2,7 +2,7 @@ title: "Module error" sidebar-title: "error" description: "Error types for Fabric core." -position: 70 +position: 69 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/type-result.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/type-result.mdx index 618862e5..343077c9 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/type-result.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/type-result.mdx @@ -2,7 +2,7 @@ title: "Type Alias Result" sidebar-title: "Result" description: "Core Fabric result type." -position: 42 +position: 41 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/fn-version.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/fn-version.mdx index a3206a15..a9cf553d 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/fn-version.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/fn-version.mdx @@ -2,7 +2,7 @@ title: "Function version" sidebar-title: "version" description: "Returns the crate version compiled into this build." -position: 73 +position: 72 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/index.mdx index 0dabba3d..0ecdd040 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/index.mdx @@ -14,7 +14,6 @@ Core config and runtime contract for NeMo Fabric. ## Re-exports - `pub use config::ADAPTER_CONTRACT_VERSION;` -- `pub use config::ADAPTER_LIFECYCLE_CONTRACT_VERSION;` - `pub use config::AdapterConfigSupport;` - `pub use config::AdapterDescriptor;` - `pub use config::AdapterDescriptorSource;` diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/index.mdx index daca55de..7a830121 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/index.mdx @@ -2,7 +2,7 @@ title: "Module runtime" sidebar-title: "runtime" description: "Runtime invocation helpers." -position: 71 +position: 70 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/index.mdx index 42b12626..8b052b82 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/index.mdx @@ -2,7 +2,7 @@ title: "Module schema" sidebar-title: "schema" description: "JSON Schema generation for the public Fabric contract." -position: 72 +position: 71 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} From c3cda2cb6dcf52728f6e000d0d8f38e2a2f66280 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Tue, 21 Jul 2026 13:49:16 -0700 Subject: [PATCH 24/34] feat(runtime): make local adapter hosting persistent-only Signed-off-by: Ajay Thorve --- .../nemo_fabric_adapters/claude/adapter.py | 21 +- .../src/nemo_fabric_adapters/codex/adapter.py | 19 +- .../nemo_fabric_adapters/common/lifecycle.py | 28 +- .../deepagents/adapter.py | 19 +- .../nemo_fabric_adapters/hermes/adapter.py | 14 +- .../adapters/scripted/fabric-adapter.json | 6 +- .../assets/adapters/scripted/run.py | 50 +- crates/fabric-core/src/config.rs | 102 +- crates/fabric-core/src/runtime.rs | 912 +++-------- crates/fabric-core/src/schema.rs | 2 +- .../adapters/scripted/fabric-adapter.json | 6 +- .../fabric/adapters/scripted/run.py | 68 +- schemas/adapter-invocation.schema.json | 1382 +---------------- .../test_adapters_common_lifecycle.py | 56 +- tests/adapters/test_claude_adapter.py | 16 +- tests/adapters/test_codex_adapter.py | 9 + tests/adapters/test_deepagents.py | 9 + tests/adapters/test_hermes_adapter.py | 9 + .../adapters/hermes-shim/fabric-adapter.json | 3 + .../hermes_shim/adapter.py | 21 +- tests/python/test_native_sdk.py | 2 +- tests/python/test_typed_config.py | 2 +- 22 files changed, 545 insertions(+), 2211 deletions(-) diff --git a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py index 8186ece0..4d57f712 100644 --- a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py +++ b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py @@ -1129,7 +1129,7 @@ async def run_claude(payload: dict[str, Any]) -> dict[str, Any]: def run(payload: dict[str, Any]) -> dict[str, Any]: - """Run one Fabric invocation.""" + """Run one isolated adapter invocation for direct library tests.""" try: return asyncio.run(run_claude(payload)) @@ -1142,22 +1142,9 @@ def run(payload: dict[str, Any]) -> dict[str, Any]: def main() -> None: - if lifecycle.is_lifecycle_host(os.environ): - lifecycle.serve(ClaudeRuntime) - return - try: - payload = common_utils.load_payload() - except ( - Exception - ): # Malformed invocation input must still satisfy the process contract. - output = _failure( - "claude_adapter_internal_error", "Claude adapter failed unexpectedly" - ) - else: - output = run(payload) - print(json.dumps(output, sort_keys=True)) - if output.get("failed"): - raise SystemExit(2) + """Serve the persistent local-host lifecycle protocol.""" + + lifecycle.serve(ClaudeRuntime) if __name__ == "__main__": diff --git a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py index 2a6056b6..dd88e10f 100644 --- a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py +++ b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py @@ -1322,7 +1322,7 @@ async def run_codex(payload: dict[str, Any]) -> dict[str, Any]: def run(payload: dict[str, Any]) -> dict[str, Any]: - """Run one Fabric invocation from the synchronous adapter boundary.""" + """Run one isolated adapter invocation for direct library tests.""" try: return asyncio.run(run_codex(payload)) @@ -1335,20 +1335,9 @@ def run(payload: dict[str, Any]) -> dict[str, Any]: def main() -> None: - if lifecycle.is_lifecycle_host(os.environ): - lifecycle.serve(CodexRuntime) - return - try: - payload = common_utils.load_payload() - except Exception: - output = _failure( - "codex_adapter_internal_error", "Codex adapter failed unexpectedly" - ) - else: - output = run(payload) - print(json.dumps(output, sort_keys=True)) - if output.get("failed"): - raise SystemExit(2) + """Serve the persistent local-host lifecycle protocol.""" + + lifecycle.serve(CodexRuntime) if __name__ == "__main__": diff --git a/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py b/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py index 087bbd23..f5f2b153 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py +++ b/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py @@ -6,14 +6,15 @@ from __future__ import annotations import asyncio +import copy import json import os import sys import traceback +from collections.abc import Awaitable from collections.abc import Callable from collections.abc import Iterator from collections.abc import Mapping -from collections.abc import Awaitable from contextlib import contextmanager from contextlib import redirect_stdout from dataclasses import dataclass @@ -22,9 +23,6 @@ from typing import TextIO -LOCAL_HOST_ENV = "FABRIC_ADAPTER_LOCAL_HOST" - - class AdapterRuntime(Protocol): """One adapter-owned runtime living for the complete host lifetime.""" @@ -67,20 +65,16 @@ class _AdapterCallError(LifecycleError): class _HostState: runtime: AdapterRuntime | None = None runtime_id: str | None = None + start_payload: dict[str, Any] | None = None failed: bool = False def clear(self) -> None: self.runtime = None self.runtime_id = None + self.start_payload = None self.failed = False -def is_lifecycle_host(environ: Mapping[str, str] = os.environ) -> bool: - """Return whether Fabric requested the persistent local-host protocol.""" - - return environ.get(LOCAL_HOST_ENV) == "1" - - def _error( stage: str, code: str, @@ -236,6 +230,7 @@ async def _handle_start( "Lifecycle host already owns a runtime", ) candidate = runtime_factory() + retained_payload = copy.deepcopy(payload) try: await _adapter_call("start", lambda: candidate.start(payload)) except LifecycleError: @@ -243,6 +238,7 @@ async def _handle_start( raise state.runtime = candidate state.runtime_id = message_runtime_id + state.start_payload = retained_payload state.failed = False return _response("start") @@ -257,8 +253,18 @@ async def _handle_invoke( "lifecycle_runtime_failed", "Lifecycle runtime cannot accept another invocation", ) + if state.start_payload is None: + raise LifecycleError( + "lifecycle_not_started", + "Lifecycle host has no retained start payload", + ) + invocation_payload = copy.deepcopy(state.start_payload) + invocation_payload["runtime_context"] = payload.get("runtime_context") + invocation_payload["request"] = payload.get("request") with _invocation_environment(payload): - output = await _adapter_call("invoke", lambda: runtime.invoke(payload)) + output = await _adapter_call( + "invoke", lambda: runtime.invoke(invocation_payload) + ) return _response("invoke", output=output) diff --git a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py index cfae7983..75334f4a 100644 --- a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py +++ b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py @@ -5,9 +5,8 @@ """LangChain Deep Agents adapter for Fabric. Maps Fabric's normalized invocation onto the ``deepagents`` SDK and returns a -normalized Fabric result. Supports one-shot, multi-turn, and resumed execution -via a persistent LangGraph checkpointer keyed by the Fabric session id, so -conversation state survives across separate adapter invocations. +normalized Fabric result. Supports single-run, multi-turn, and resumed execution +via a persistent LangGraph checkpointer keyed by the Fabric session id. """ from __future__ import annotations @@ -118,19 +117,13 @@ def resolve_api_key_env(settings: dict[str, Any], model_config: dict[str, Any]) def main() -> None: - """Subprocess entrypoint used by the ``python -m`` process path.""" + """Serve the persistent local-host lifecycle protocol.""" - if lifecycle.is_lifecycle_host(os.environ): - lifecycle.serve(DeepAgentsRuntime) - return - output = run(common_utils.load_payload()) - print(json.dumps(output, sort_keys=True)) - if output.get("failed"): - raise SystemExit(2) + lifecycle.serve(DeepAgentsRuntime) def run(payload: dict[str, Any]) -> dict[str, Any]: - """Fabric adapter entrypoint used by the ``python -m`` and native SDK paths.""" + """Run one isolated adapter invocation for direct library tests.""" import asyncio @@ -758,7 +751,7 @@ async def invoke_agent( thread_id: str | None, callbacks: list[Any] | None = None, ) -> tuple[Any, list[dict[str, Any]], list[dict[str, Any]]]: - """Create and run an agent for the per-invocation compatibility path.""" + """Create and run an isolated agent for direct library tests.""" from deepagents import create_deep_agent diff --git a/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py b/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py index 7756d6ff..1dd14fb0 100755 --- a/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py +++ b/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py @@ -15,7 +15,6 @@ import json import logging import os -import sys from contextlib import redirect_stdout from io import StringIO from pathlib import Path @@ -157,18 +156,13 @@ def summarize_hermes_config(config: dict[str, Any]) -> dict[str, Any]: def main() -> None: - if lifecycle.is_lifecycle_host(os.environ): - lifecycle.serve(HermesRuntime) - return - payload = json.load(sys.stdin) - output = run(payload) - print(json.dumps(output, sort_keys=True)) - if output.get("failed"): - raise SystemExit(2) + """Serve the persistent local-host lifecycle protocol.""" + + lifecycle.serve(HermesRuntime) def run(payload: dict[str, Any]) -> dict[str, Any]: - """Fabric adapter entrypoint used by script and native SDK runtime calls.""" + """Run one isolated adapter invocation for direct library tests.""" return asyncio.run(run_hermes(payload)) diff --git a/crates/fabric-cli/assets/adapters/scripted/fabric-adapter.json b/crates/fabric-cli/assets/adapters/scripted/fabric-adapter.json index 298b1e4e..57c214cf 100644 --- a/crates/fabric-cli/assets/adapters/scripted/fabric-adapter.json +++ b/crates/fabric-cli/assets/adapters/scripted/fabric-adapter.json @@ -5,7 +5,9 @@ "adapter_kind": "process", "runner": { "command": "python3", - "script": "run.py", - "stdin_payload": "fabric_request" + "script": "run.py" + }, + "runtime": { + "local_host": {} } } diff --git a/crates/fabric-cli/assets/adapters/scripted/run.py b/crates/fabric-cli/assets/adapters/scripted/run.py index 1f0f3cce..eb8782d1 100644 --- a/crates/fabric-cli/assets/adapters/scripted/run.py +++ b/crates/fabric-cli/assets/adapters/scripted/run.py @@ -6,15 +6,47 @@ import json import sys +from typing import Any -payload = json.load(sys.stdin) -request = payload["request"] -print( - json.dumps( - { - "response": request.get("input"), - "request_id": request["request_id"], - } +def response(operation: str, *, output: Any = None) -> None: + print( + json.dumps( + { + "operation": operation, + "outcome": {"status": "succeeded", "output": output}, + } + ), + flush=True, ) -) + + +def main() -> None: + runtime_id = None + for line in sys.stdin: + message = json.loads(line) + operation = message["operation"] + payload = message["payload"] + if operation == "start": + runtime_id = payload["runtime_context"]["runtime_id"] + response("start") + elif operation == "invoke": + if payload["runtime_context"]["runtime_id"] != runtime_id: + raise RuntimeError("invoke does not match the active runtime") + request = payload["request"] + response( + "invoke", + output={ + "response": request.get("input"), + "request_id": request["request_id"], + }, + ) + elif operation == "stop": + if payload["runtime_id"] != runtime_id: + raise RuntimeError("stop does not match the active runtime") + response("stop") + break + + +if __name__ == "__main__": + main() diff --git a/crates/fabric-core/src/config.rs b/crates/fabric-core/src/config.rs index 5ce85934..388ca91a 100644 --- a/crates/fabric-core/src/config.rs +++ b/crates/fabric-core/src/config.rs @@ -1125,12 +1125,22 @@ fn validate_adapter_descriptor_shape(descriptor: &AdapterDescriptor, path: &Path if descriptor.harness.trim().is_empty() { return invalid_adapter_descriptor(path, "harness must not be empty"); } - if descriptor.runtime.local_host.is_some() { - if descriptor.adapter_kind != AdapterKind::Python { - return invalid_adapter_descriptor( - path, - "runtime.local_host is currently supported only by python adapters", - ); + match descriptor.adapter_kind { + AdapterKind::Process | AdapterKind::Python => { + if descriptor.runtime.local_host.is_none() { + return invalid_adapter_descriptor( + path, + "local process and python adapters must declare runtime.local_host", + ); + } + } + AdapterKind::Http | AdapterKind::NativePlugin => { + if descriptor.runtime.local_host.is_some() { + return invalid_adapter_descriptor( + path, + "runtime.local_host is supported only by process and python adapters", + ); + } } } Ok(()) @@ -1158,7 +1168,7 @@ fn resolve_runtime_capabilities( matches!( descriptor.adapter_kind, AdapterKind::Process | AdapterKind::Python - ) + ) && descriptor.runtime.local_host.is_some() }); let descriptor_capabilities = descriptor .map(|descriptor| descriptor.capabilities.clone()) @@ -1754,21 +1764,31 @@ mod tests { #[test] fn repository_adapters_declare_local_host() { - for harness in ["claude", "codex", "deepagents", "hermes"] { - let descriptor = load_adapter_descriptor( - repository_adapter_dir().join(format!("{harness}/fabric-adapter.json")), - ) - .unwrap_or_else(|error| panic!("{harness} adapter descriptor: {error}")); - - assert!( - descriptor.runtime.local_host.is_some(), - "{harness} descriptor" - ); + let repository_root = repository_root(); + for relative_path in [ + "adapters/claude/fabric-adapter.json", + "adapters/codex/fabric-adapter.json", + "adapters/deepagents/fabric-adapter.json", + "adapters/hermes/fabric-adapter.json", + "crates/fabric-cli/assets/adapters/claude/fabric-adapter.json", + "crates/fabric-cli/assets/adapters/codex/fabric-adapter.json", + "crates/fabric-cli/assets/adapters/deepagents/fabric-adapter.json", + "crates/fabric-cli/assets/adapters/hermes/fabric-adapter.json", + "crates/fabric-cli/assets/adapters/scripted/fabric-adapter.json", + "examples/harbor/calculator/task/environment/fabric/adapters/scripted/fabric-adapter.json", + "examples/harbor/swebench/adapters/claude/fabric-adapter.json", + "examples/harbor/swebench/adapters/hermes/fabric-adapter.json", + "tests/fixtures/hermes-shim-agent/adapters/hermes-shim/fabric-adapter.json", + ] { + let descriptor = load_adapter_descriptor(repository_root.join(relative_path)) + .unwrap_or_else(|error| panic!("{relative_path}: {error}")); + + assert!(descriptor.runtime.local_host.is_some(), "{relative_path}"); } } #[test] - fn rejects_local_host_on_non_python_adapter() { + fn accepts_local_host_on_process_adapter() { let descriptor: AdapterDescriptor = serde_json::from_value(serde_json::json!({ "contract_version": ADAPTER_CONTRACT_VERSION, "adapter_id": "acme.fabric.process-host", @@ -1780,15 +1800,55 @@ mod tests { })) .expect("adapter descriptor"); - let error = validate_adapter_descriptor_shape( + validate_adapter_descriptor_shape( &descriptor, Path::new("process-host/fabric-adapter.json"), ) - .expect_err("process adapter must not declare a Python local host"); + .expect("process adapter may implement the persistent local-host protocol"); + } + + #[test] + fn rejects_local_adapter_without_local_host() { + let descriptor: AdapterDescriptor = serde_json::from_value(serde_json::json!({ + "contract_version": ADAPTER_CONTRACT_VERSION, + "adapter_id": "acme.fabric.python-host", + "harness": "python-host", + "adapter_kind": "python", + })) + .expect("adapter descriptor"); + + let error = validate_adapter_descriptor_shape( + &descriptor, + Path::new("python-host/fabric-adapter.json"), + ) + .expect_err("python adapter must declare a persistent local host"); + assert!(matches!( + error, + FabricError::InvalidAdapterDescriptor { message, .. } + if message.contains("must declare runtime.local_host") + )); + } + + #[test] + fn rejects_local_host_on_remote_adapter() { + let descriptor: AdapterDescriptor = serde_json::from_value(serde_json::json!({ + "contract_version": ADAPTER_CONTRACT_VERSION, + "adapter_id": "acme.fabric.http-host", + "harness": "http-host", + "adapter_kind": "http", + "runtime": {"local_host": {}}, + })) + .expect("adapter descriptor"); + + let error = validate_adapter_descriptor_shape( + &descriptor, + Path::new("http-host/fabric-adapter.json"), + ) + .expect_err("http adapter must not declare a local host"); assert!(matches!( error, FabricError::InvalidAdapterDescriptor { message, .. } - if message.contains("only by python adapters") + if message.contains("only by process and python adapters") )); } } diff --git a/crates/fabric-core/src/runtime.rs b/crates/fabric-core/src/runtime.rs index c2a9ae32..bce2746b 100644 --- a/crates/fabric-core/src/runtime.rs +++ b/crates/fabric-core/src/runtime.rs @@ -317,25 +317,13 @@ pub struct RuntimeTelemetryContext { pub metadata: BTreeMap, } -/// Adapter-facing invocation payload. +/// One invocation against an initialized adapter runtime. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct AdapterInvocation { - /// Stable agent name. - pub agent_name: String, - /// Absolute base directory used to resolve relative Fabric paths. - pub base_dir: PathBuf, - /// Complete typed Fabric config. - pub config: FabricConfig, - /// Per-runtime/per-invocation execution context. + /// Per-runtime and per-invocation context generated by Fabric. pub runtime_context: RuntimeContext, - /// Per-invocation request. + /// Typed caller request for this invocation. pub request: RunRequest, - /// Derived capability routing plan for the selected adapter. - #[serde(default)] - pub capability_plan: CapabilityPlan, - /// Derived telemetry plan for the selected adapter. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub telemetry_plan: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -383,8 +371,8 @@ struct AdapterLifecycleStop { #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(tag = "operation", content = "payload", rename_all = "snake_case")] enum AdapterLifecycleRequestKind { - Start(AdapterLifecycleStart), - Invoke(AdapterInvocation), + Start(Box), + Invoke(Box), Stop(AdapterLifecycleStop), } @@ -443,8 +431,6 @@ trait RuntimeAdapter { fn stop(&self, runtime: &RuntimeHandle) -> Result>; } -struct ProcessAdapter; -struct PythonAdapter; struct LocalHostAdapter; #[derive(Debug, Clone)] @@ -453,14 +439,6 @@ struct RelayRuntimeConfig { env: BTreeMap, } -enum RelayConfigCorrelation<'a> { - Runtime, - Invocation { - invocation: &'a InvocationHandle, - request: &'a RunRequest, - }, -} - struct LocalAdapterHost { child: Child, stdin: ChildStdin, @@ -559,14 +537,10 @@ pub fn start_runtime(plan: &RunPlan) -> Result { if uses_local_host(plan)? { return LocalHostAdapter.start(plan, environment); } - match adapter_kind(plan) { - AdapterKind::Process => ProcessAdapter.start(plan, environment), - AdapterKind::Python => PythonAdapter.start(plan, environment), - adapter_kind => Err(FabricError::UnsupportedRuntimeAdapter { - harness: harness(plan), - adapter_kind, - }), - } + Err(FabricError::UnsupportedRuntimeAdapter { + harness: harness(plan), + adapter_kind: adapter_kind(plan), + }) } /// Invoke a started harness runtime. @@ -580,14 +554,10 @@ pub fn invoke_runtime( if uses_local_host(plan)? { return LocalHostAdapter.invoke(plan, runtime, request); } - match adapter_kind(plan) { - AdapterKind::Process => ProcessAdapter.invoke(plan, runtime, request), - AdapterKind::Python => PythonAdapter.invoke(plan, runtime, request), - adapter_kind => Err(FabricError::UnsupportedRuntimeAdapter { - harness: harness(plan), - adapter_kind, - }), - } + Err(FabricError::UnsupportedRuntimeAdapter { + harness: harness(plan), + adapter_kind: adapter_kind(plan), + }) } fn validate_blocked_tools_support(plan: &RunPlan) -> Result<()> { @@ -608,14 +578,10 @@ pub fn stop_runtime(plan: &RunPlan, runtime: &RuntimeHandle) -> Result ProcessAdapter.stop(runtime), - AdapterKind::Python => PythonAdapter.stop(runtime), - adapter_kind => Err(FabricError::UnsupportedRuntimeAdapter { - harness: runtime.harness.clone(), - adapter_kind, - }), - } + Err(FabricError::UnsupportedRuntimeAdapter { + harness: runtime.harness.clone(), + adapter_kind: runtime.adapter_kind, + }) } fn uses_local_host(plan: &RunPlan) -> Result { @@ -736,16 +702,6 @@ struct ProcessAdapterSettings { cwd: Option, #[serde(default)] env: BTreeMap, - #[serde(default)] - stdin_payload: ProcessStdinPayload, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -enum ProcessStdinPayload { - #[default] - Input, - FabricRequest, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -804,101 +760,6 @@ impl PythonSource { } } -impl RuntimeAdapter for ProcessAdapter { - fn start(&self, plan: &RunPlan, environment: EnvironmentHandle) -> Result { - if environment.provider != "local" { - return Err(FabricError::UnsupportedEnvironmentProvider { - provider: environment.provider, - adapter_kind: AdapterKind::Process, - }); - } - let runtime_id = new_id("runtime"); - let runtime_binding = runtime_binding(&runtime_id, plan, &environment)?; - Ok(RuntimeHandle { - runtime_id, - runtime_binding, - agent_name: plan.agent_name.clone(), - harness: harness(plan), - adapter_kind: adapter_kind(plan), - adapter_id: adapter_id(plan), - environment, - }) - } - - fn invoke( - &self, - plan: &RunPlan, - runtime: &RuntimeHandle, - request: RunRequest, - ) -> Result { - run_process_adapter(plan, runtime, request) - } - - fn stop(&self, runtime: &RuntimeHandle) -> Result> { - #[cfg(test)] - TEST_STOPPED_AGENTS - .lock() - .expect("stop tracker") - .push(runtime.agent_name.clone()); - Ok(vec![event_with_metadata( - "runtime_stop", - format!("stopped runtime {}", runtime.runtime_id), - BTreeMap::from([( - "runtime_id".to_string(), - Value::String(runtime.runtime_id.clone()), - )]), - )]) - } -} - -impl RuntimeAdapter for PythonAdapter { - fn start(&self, plan: &RunPlan, environment: EnvironmentHandle) -> Result { - if environment.provider != "local" { - return Err(FabricError::UnsupportedEnvironmentProvider { - provider: environment.provider, - adapter_kind: AdapterKind::Python, - }); - } - preflight_python_adapter(plan)?; - let runtime_id = new_id("runtime"); - let runtime_binding = runtime_binding(&runtime_id, plan, &environment)?; - Ok(RuntimeHandle { - runtime_id, - runtime_binding, - agent_name: plan.agent_name.clone(), - harness: harness(plan), - adapter_kind: adapter_kind(plan), - adapter_id: adapter_id(plan), - environment, - }) - } - - fn invoke( - &self, - plan: &RunPlan, - runtime: &RuntimeHandle, - request: RunRequest, - ) -> Result { - run_python_adapter(plan, runtime, request) - } - - fn stop(&self, runtime: &RuntimeHandle) -> Result> { - #[cfg(test)] - TEST_STOPPED_AGENTS - .lock() - .expect("stop tracker") - .push(runtime.agent_name.clone()); - Ok(vec![event_with_metadata( - "runtime_stop", - format!("stopped runtime {}", runtime.runtime_id), - BTreeMap::from([( - "runtime_id".to_string(), - Value::String(runtime.runtime_id.clone()), - )]), - )]) - } -} - impl RuntimeAdapter for LocalHostAdapter { fn start(&self, plan: &RunPlan, environment: EnvironmentHandle) -> Result { if environment.provider != "local" { @@ -907,13 +768,18 @@ impl RuntimeAdapter for LocalHostAdapter { adapter_kind: adapter_kind(plan), }); } - if adapter_kind(plan) != AdapterKind::Python { + if !matches!( + adapter_kind(plan), + AdapterKind::Process | AdapterKind::Python + ) { return Err(FabricError::UnsupportedRuntimeAdapter { harness: harness(plan), adapter_kind: adapter_kind(plan), }); } - preflight_python_adapter(plan)?; + if adapter_kind(plan) == AdapterKind::Python { + preflight_python_adapter(plan)?; + } let runtime_id = new_id("runtime"); let runtime_binding = runtime_binding(&runtime_id, plan, &environment)?; @@ -927,50 +793,24 @@ impl RuntimeAdapter for LocalHostAdapter { environment, }; - let start_request = RunRequest { - request_id: new_id("runtime-start-request"), - ..RunRequest::default() - }; let start_invocation = InvocationHandle { invocation_id: new_id("runtime-start"), - request_id: start_request.request_id.clone(), + request_id: new_id("runtime-start-request"), runtime_id: runtime.runtime_id.clone(), }; let mut artifacts = artifact_manifest(plan)?; let fabric_home = prepare_fabric_home(&artifacts, &runtime, &start_invocation)?; - let relay_config = prepare_relay_runtime_config( - plan, - &runtime, - RelayConfigCorrelation::Runtime, - &fabric_home, - &mut artifacts, - )?; - let AdapterInvocation { - agent_name, - base_dir, - config, - runtime_context, - capability_plan, - telemetry_plan, - .. - } = adapter_invocation( + let relay_config = + prepare_relay_runtime_config(plan, &runtime, &fabric_home, &mut artifacts)?; + let start = adapter_lifecycle_start( plan, &runtime, &start_invocation, - &start_request, &artifacts, relay_config.as_ref(), )?; - let request = AdapterLifecycleRequest::new(AdapterLifecycleRequestKind::Start( - AdapterLifecycleStart { - agent_name, - base_dir, - config, - runtime_context, - capability_plan, - telemetry_plan, - }, - )); + let request = + AdapterLifecycleRequest::new(AdapterLifecycleRequestKind::Start(Box::new(start))); let mut host = spawn_local_host(plan, &runtime, artifacts, relay_config)?; if let Err(error) = exchange_lifecycle_message( &mut host, @@ -1101,8 +941,9 @@ fn run_local_host_adapter_with_timeout( let adapter_payload = serde_json::to_string_pretty(&adapter_invocation) .map_err(FabricError::SerializeJson)?; let fabric_invocation = write_fabric_invocation(&fabric_home, &adapter_payload)?; - let lifecycle_request = - AdapterLifecycleRequest::new(AdapterLifecycleRequestKind::Invoke(adapter_invocation)); + let lifecycle_request = AdapterLifecycleRequest::new(AdapterLifecycleRequestKind::Invoke( + Box::new(adapter_invocation), + )); match exchange_lifecycle_message( &mut host_guard, &runtime.runtime_id, @@ -1376,7 +1217,6 @@ fn spawn_local_host( } }; command - .env("FABRIC_ADAPTER_LOCAL_HOST", "1") .env("FABRIC_RUNTIME_ID", &runtime.runtime_id) .env("FABRIC_HOME", &runtime_dir) .envs(relay_env(&relay_config)) @@ -1467,6 +1307,45 @@ fn spawn_local_host( } fn local_host_command(plan: &RunPlan, runtime: &RuntimeHandle) -> Result<(Command, String)> { + match adapter_kind(plan) { + AdapterKind::Process => process_local_host_command(plan, runtime), + AdapterKind::Python => python_local_host_command(plan, runtime), + adapter_kind => Err(FabricError::UnsupportedRuntimeAdapter { + harness: harness(plan), + adapter_kind, + }), + } +} + +fn process_local_host_command( + plan: &RunPlan, + runtime: &RuntimeHandle, +) -> Result<(Command, String)> { + let settings = parse_process_settings(plan)?; + let command_path = resolve_command_path( + adapter_setting_root(plan, "command"), + Path::new(&settings.command), + ); + let command_args = process_command_args(plan, &settings); + let cwd = settings + .cwd + .as_ref() + .map(|path| resolve_path(&plan.base_dir, path)) + .or_else(|| runtime.environment.workspace.clone()) + .unwrap_or_else(|| plan.base_dir.clone()); + let mut command = Command::new(&command_path); + command + .args(&command_args) + .current_dir(cwd) + .envs(&settings.env); + let display = std::iter::once(command_path.to_string_lossy().into_owned()) + .chain(command_args) + .collect::>() + .join(" "); + Ok((command, display)) +} + +fn python_local_host_command(plan: &RunPlan, runtime: &RuntimeHandle) -> Result<(Command, String)> { let settings = parse_python_settings(plan)?; let python = resolve_python_command(&plan.base_dir, &settings).path; let cwd = settings @@ -1696,510 +1575,40 @@ fn remove_local_host_files(host: &LocalAdapterHost) -> std::io::Result<()> { } } -fn run_process_adapter( - plan: &RunPlan, - runtime: &RuntimeHandle, - mut request: RunRequest, -) -> Result { - if request.request_id.is_empty() { - request.request_id = new_id("request"); - } - let invocation = InvocationHandle { - invocation_id: new_id("invocation"), - request_id: request.request_id.clone(), - runtime_id: runtime.runtime_id.clone(), - }; - let settings = parse_process_settings(plan)?; - let command_path = resolve_command_path( - adapter_setting_root(plan, "command"), - Path::new(&settings.command), - ); - let command_display = command_path.to_string_lossy().into_owned(); - let command_args = process_command_args(plan, &settings); - let cwd = settings - .cwd +fn adapter_id(plan: &RunPlan) -> Option { + plan.adapter_descriptor .as_ref() - .map(|path| resolve_path(&plan.base_dir, path)) - .or_else(|| runtime.environment.workspace.clone()) - .unwrap_or_else(|| plan.base_dir.clone()); - let mut artifacts = artifact_manifest(plan)?; - let fabric_home = prepare_fabric_home(&artifacts, runtime, &invocation)?; - let relay_config = prepare_relay_runtime_config( - plan, - runtime, - RelayConfigCorrelation::Invocation { - invocation: &invocation, - request: &request, - }, - &fabric_home, - &mut artifacts, - )?; - let adapter_payload = fabric_adapter_payload( - plan, - runtime, - &invocation, - &request, - &artifacts, - relay_config.as_ref(), - )?; - let fabric_invocation = write_fabric_invocation(&fabric_home, &adapter_payload)?; + .map(|adapter| adapter.descriptor.adapter_id.clone()) + .or_else(|| Some(plan.config.harness.adapter_id.clone())) +} - let mut command = Command::new(&command_path); - command - .args(&command_args) - .current_dir(&cwd) - .envs(&settings.env) - .envs(relay_env(&relay_config)) - .env("FABRIC_HOME", &fabric_home) - .env("FABRIC_INVOCATION", &fabric_invocation) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - if let Some(root) = artifacts.root.as_ref() { - command.env("FABRIC_ARTIFACTS", root); - } +fn harness(plan: &RunPlan) -> String { + plan.adapter_descriptor + .as_ref() + .map(|adapter| adapter.descriptor.harness.clone()) + .unwrap_or_else(|| "unknown".to_string()) +} - let mut events = vec![event_with_metadata( - "runtime_start", - format!("started runtime {}", runtime.runtime_id), - BTreeMap::from([ - ( - "runtime_id".to_string(), - Value::String(runtime.runtime_id.clone()), - ), - ( - "environment_id".to_string(), - Value::String(runtime.environment.environment_id.clone()), - ), - ( - "environment_provider".to_string(), - Value::String(runtime.environment.provider.clone()), - ), - ]), - )]; - events.push(event_with_metadata( - "invocation_start", - format!("starting process adapter for {}", harness(plan)), - BTreeMap::from([ - ( - "runtime_id".to_string(), - Value::String(runtime.runtime_id.clone()), - ), - ( - "invocation_id".to_string(), - Value::String(invocation.invocation_id.clone()), - ), - ]), - )); - let mut child = command - .spawn() - .map_err(|source| FabricError::ProcessRunner { - command: command_display.clone(), - source, - })?; +fn adapter_kind(plan: &RunPlan) -> AdapterKind { + plan.adapter_descriptor + .as_ref() + .map(|adapter| adapter.descriptor.adapter_kind) + .unwrap_or(AdapterKind::Process) +} - let stdin_payload = match settings.stdin_payload { - ProcessStdinPayload::Input => value_to_stdin(&request.input)?, - ProcessStdinPayload::FabricRequest => adapter_payload, - }; - if !stdin_payload.is_empty() { - if let Some(mut stdin) = child.stdin.take() { - write_child_stdin(&mut stdin, &stdin_payload, &command_display)?; - } +fn adapter_kind_name(adapter_kind: AdapterKind) -> String { + match adapter_kind { + AdapterKind::Process => "process", + AdapterKind::Http => "http", + AdapterKind::Python => "python", + AdapterKind::NativePlugin => "native_plugin", } + .to_string() +} - let output = child - .wait_with_output() - .map_err(|source| FabricError::ProcessRunner { - command: command_display.clone(), - source, - })?; - - let exit_code = output.status.code(); - let status = if output.status.success() { - RunStatus::Succeeded - } else { - RunStatus::Failed - }; - events.push(event_with_metadata( - "invocation_end", - format!("process adapter completed with status {:?}", status), - BTreeMap::from([ - ( - "runtime_id".to_string(), - Value::String(runtime.runtime_id.clone()), - ), - ( - "invocation_id".to_string(), - Value::String(invocation.invocation_id.clone()), - ), - ]), - )); - - let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); - let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); - if !stdout.is_empty() { - write_artifact( - &mut artifacts, - &fabric_home, - "stdout", - "log", - "stdout.txt", - &stdout, - "text/plain", - )?; - } - if !stderr.is_empty() { - write_artifact( - &mut artifacts, - &fabric_home, - "stderr", - "log", - "stderr.txt", - &stderr, - "text/plain", - )?; - } - collect_workspace_artifacts(&mut artifacts, &fabric_home, runtime, &mut events)?; - - let mut metadata = BTreeMap::new(); - metadata.insert( - "adapter_runner".to_string(), - Value::String("process".to_string()), - ); - metadata.insert("command".to_string(), Value::String(command_display)); - metadata.insert( - "args".to_string(), - Value::Array(command_args.into_iter().map(Value::String).collect()), - ); - metadata.insert( - "fabric_home".to_string(), - Value::String(fabric_home.to_string_lossy().into_owned()), - ); - metadata.insert( - "fabric_invocation".to_string(), - Value::String(fabric_invocation.to_string_lossy().into_owned()), - ); - if let Some(exit_code) = exit_code { - metadata.insert("exit_code".to_string(), Value::from(exit_code)); - } - metadata.insert( - "cwd".to_string(), - Value::String(cwd.to_string_lossy().into_owned()), - ); - metadata.insert( - "environment_provider".to_string(), - Value::String(runtime.environment.provider.clone()), - ); - - let error = if status == RunStatus::Failed { - Some(adapter_exit_error( - "process_exit_nonzero", - "process exited with non-zero status", - &stderr, - &metadata, - )) - } else { - None - }; - - let parsed_output = parse_stdout_output(&stdout); - promote_relay_artifacts_to_manifest(&parsed_output, &mut artifacts); - - Ok(RunResult { - agent_name: plan.agent_name.clone(), - harness: harness(plan), - adapter_kind: adapter_kind(plan), - adapter_id: adapter_id(plan), - runtime_id: invocation.runtime_id, - invocation_id: invocation.invocation_id, - request_id: request.request_id, - status, - output: parsed_output, - error, - artifacts, - telemetry: telemetry_ref(plan, relay_config.as_ref()), - events, - metadata, - }) -} - -fn run_python_adapter( - plan: &RunPlan, - runtime: &RuntimeHandle, - mut request: RunRequest, -) -> Result { - if request.request_id.is_empty() { - request.request_id = new_id("request"); - } - let invocation = InvocationHandle { - invocation_id: new_id("invocation"), - request_id: request.request_id.clone(), - runtime_id: runtime.runtime_id.clone(), - }; - let settings = parse_python_settings(plan)?; - let cwd = settings - .cwd - .as_ref() - .map(|path| resolve_path(&plan.base_dir, path)) - .or_else(|| runtime.environment.workspace.clone()) - .unwrap_or_else(|| plan.base_dir.clone()); - - let python = resolve_python_command(&plan.base_dir, &settings).path; - let mut artifacts = artifact_manifest(plan)?; - let fabric_home = prepare_fabric_home(&artifacts, runtime, &invocation)?; - let relay_config = prepare_relay_runtime_config( - plan, - runtime, - RelayConfigCorrelation::Invocation { - invocation: &invocation, - request: &request, - }, - &fabric_home, - &mut artifacts, - )?; - - let mut command = Command::new(&python); - command - .arg("-m") - .arg(&settings.module) - .args(&settings.args) - .current_dir(&cwd) - .envs(&settings.env) - .envs(relay_env(&relay_config)) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - let mut events = vec![event_with_metadata( - "runtime_start", - format!("started runtime {}", runtime.runtime_id), - BTreeMap::from([ - ( - "runtime_id".to_string(), - Value::String(runtime.runtime_id.clone()), - ), - ( - "environment_id".to_string(), - Value::String(runtime.environment.environment_id.clone()), - ), - ( - "environment_provider".to_string(), - Value::String(runtime.environment.provider.clone()), - ), - ]), - )]; - events.push(event_with_metadata( - "invocation_start", - format!("starting python adapter for {}", harness(plan)), - BTreeMap::from([ - ( - "runtime_id".to_string(), - Value::String(runtime.runtime_id.clone()), - ), - ( - "invocation_id".to_string(), - Value::String(invocation.invocation_id.clone()), - ), - ("module".to_string(), Value::String(settings.module.clone())), - ]), - )); - - let mut child = command - .spawn() - .map_err(|source| FabricError::ProcessRunner { - command: python.to_string_lossy().into_owned(), - source, - })?; - - let stdin_payload = fabric_adapter_payload( - plan, - runtime, - &invocation, - &request, - &artifacts, - relay_config.as_ref(), - )?; - if let Some(mut stdin) = child.stdin.take() { - write_child_stdin(&mut stdin, &stdin_payload, &python.to_string_lossy())?; - } - - let output = child - .wait_with_output() - .map_err(|source| FabricError::ProcessRunner { - command: python.to_string_lossy().into_owned(), - source, - })?; - - let exit_code = output.status.code(); - let status = if output.status.success() { - RunStatus::Succeeded - } else { - RunStatus::Failed - }; - events.push(event_with_metadata( - "invocation_end", - format!("python adapter completed with status {:?}", status), - BTreeMap::from([ - ( - "runtime_id".to_string(), - Value::String(runtime.runtime_id.clone()), - ), - ( - "invocation_id".to_string(), - Value::String(invocation.invocation_id.clone()), - ), - ]), - )); - - let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); - let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); - if !stdout.is_empty() { - write_artifact( - &mut artifacts, - &fabric_home, - "stdout", - "log", - "stdout.txt", - &stdout, - "text/plain", - )?; - } - if !stderr.is_empty() { - write_artifact( - &mut artifacts, - &fabric_home, - "stderr", - "log", - "stderr.txt", - &stderr, - "text/plain", - )?; - } - collect_workspace_artifacts(&mut artifacts, &fabric_home, runtime, &mut events)?; - - let mut metadata = BTreeMap::new(); - metadata.insert( - "adapter_runner".to_string(), - Value::String("python".to_string()), - ); - metadata.insert( - "python".to_string(), - Value::String(python.to_string_lossy().into_owned()), - ); - metadata.insert("module".to_string(), Value::String(settings.module)); - metadata.insert( - "args".to_string(), - Value::Array(settings.args.into_iter().map(Value::String).collect()), - ); - if let Some(exit_code) = exit_code { - metadata.insert("exit_code".to_string(), Value::from(exit_code)); - } - metadata.insert( - "cwd".to_string(), - Value::String(cwd.to_string_lossy().into_owned()), - ); - metadata.insert( - "environment_provider".to_string(), - Value::String(runtime.environment.provider.clone()), - ); - - let error = if status == RunStatus::Failed { - Some(adapter_exit_error( - "python_adapter_exit_nonzero", - "python adapter exited with non-zero status", - &stderr, - &metadata, - )) - } else { - None - }; - - let parsed_output = parse_stdout_output(&stdout); - promote_relay_artifacts_to_manifest(&parsed_output, &mut artifacts); - - Ok(RunResult { - agent_name: plan.agent_name.clone(), - harness: harness(plan), - adapter_kind: adapter_kind(plan), - adapter_id: adapter_id(plan), - runtime_id: invocation.runtime_id, - invocation_id: invocation.invocation_id, - request_id: request.request_id, - status, - output: parsed_output, - error, - artifacts, - telemetry: telemetry_ref(plan, relay_config.as_ref()), - events, - metadata, - }) -} - -fn adapter_id(plan: &RunPlan) -> Option { - plan.adapter_descriptor - .as_ref() - .map(|adapter| adapter.descriptor.adapter_id.clone()) - .or_else(|| Some(plan.config.harness.adapter_id.clone())) -} - -fn write_child_stdin(stdin: &mut impl Write, payload: &str, command: &str) -> Result<()> { - match stdin.write_all(payload.as_bytes()) { - Ok(()) => Ok(()), - Err(source) if source.kind() == ErrorKind::BrokenPipe => Ok(()), - Err(source) => Err(FabricError::ProcessRunner { - command: command.to_string(), - source, - }), - } -} - -fn harness(plan: &RunPlan) -> String { - plan.adapter_descriptor - .as_ref() - .map(|adapter| adapter.descriptor.harness.clone()) - .unwrap_or_else(|| "unknown".to_string()) -} - -fn adapter_kind(plan: &RunPlan) -> AdapterKind { - plan.adapter_descriptor - .as_ref() - .map(|adapter| adapter.descriptor.adapter_kind) - .unwrap_or(AdapterKind::Process) -} - -fn adapter_kind_name(adapter_kind: AdapterKind) -> String { - match adapter_kind { - AdapterKind::Process => "process", - AdapterKind::Http => "http", - AdapterKind::Python => "python", - AdapterKind::NativePlugin => "native_plugin", - } - .to_string() -} - -fn optional_runtime_value(value: Option<&str>) -> String { - value.unwrap_or("").to_string() -} - -fn adapter_exit_error( - code: &str, - default_message: &str, - stderr: &str, - metadata: &BTreeMap, -) -> ErrorInfo { - ErrorInfo { - stage: ErrorStage::Invoke, - code: code.to_string(), - message: if stderr.is_empty() { - default_message.to_string() - } else { - stderr.to_string() - }, - retryable: false, - metadata: metadata.clone(), - } -} +fn optional_runtime_value(value: Option<&str>) -> String { + value.unwrap_or("").to_string() +} fn parse_python_settings(plan: &RunPlan) -> Result { let value = Value::Object(merged_adapter_settings(plan)); @@ -2237,23 +1646,27 @@ fn adapter_setting_root<'a>(plan: &'a RunPlan, key: &str) -> &'a Path { .unwrap_or(&plan.base_dir) } -fn fabric_adapter_payload( +fn adapter_lifecycle_start( plan: &RunPlan, runtime: &RuntimeHandle, invocation: &InvocationHandle, - request: &RunRequest, artifacts: &ArtifactManifest, relay_config: Option<&RelayRuntimeConfig>, -) -> Result { - serde_json::to_string_pretty(&adapter_invocation( - plan, - runtime, - invocation, - request, - artifacts, - relay_config, - )?) - .map_err(FabricError::SerializeJson) +) -> Result { + Ok(AdapterLifecycleStart { + agent_name: plan.agent_name.clone(), + base_dir: absolute_path(plan.base_dir.clone())?, + config: plan.config.clone(), + runtime_context: adapter_runtime_context( + plan, + runtime, + invocation, + artifacts, + relay_config, + ), + capability_plan: plan.capability_plan.clone(), + telemetry_plan: plan.telemetry_plan.clone(), + }) } fn adapter_invocation( @@ -2265,23 +1678,34 @@ fn adapter_invocation( relay_config: Option<&RelayRuntimeConfig>, ) -> Result { Ok(AdapterInvocation { - agent_name: plan.agent_name.clone(), - base_dir: absolute_path(plan.base_dir.clone())?, - config: plan.config.clone(), - runtime_context: RuntimeContext { - runtime_id: runtime.runtime_id.clone(), - invocation_id: invocation.invocation_id.clone(), - request_id: request.request_id.clone(), - environment: runtime.environment.clone(), - artifacts: artifacts.clone(), - telemetry: runtime_telemetry_context(plan, relay_config), - }, + runtime_context: adapter_runtime_context( + plan, + runtime, + invocation, + artifacts, + relay_config, + ), request: request.clone(), - capability_plan: plan.capability_plan.clone(), - telemetry_plan: plan.telemetry_plan.clone(), }) } +fn adapter_runtime_context( + plan: &RunPlan, + runtime: &RuntimeHandle, + invocation: &InvocationHandle, + artifacts: &ArtifactManifest, + relay_config: Option<&RelayRuntimeConfig>, +) -> RuntimeContext { + RuntimeContext { + runtime_id: runtime.runtime_id.clone(), + invocation_id: invocation.invocation_id.clone(), + request_id: invocation.request_id.clone(), + environment: runtime.environment.clone(), + artifacts: artifacts.clone(), + telemetry: runtime_telemetry_context(plan, relay_config), + } +} + fn runtime_telemetry_context( plan: &RunPlan, relay_config: Option<&RelayRuntimeConfig>, @@ -2481,10 +1905,6 @@ fn absolute_path(path: PathBuf) -> Result { Ok(cwd.join(path)) } -fn parse_stdout_output(stdout: &str) -> Value { - serde_json::from_str(stdout).unwrap_or_else(|_| Value::String(stdout.to_string())) -} - #[derive(Debug, Default, Deserialize)] struct RelayArtifactOutput { #[serde(default)] @@ -2586,14 +2006,6 @@ fn process_command_args(plan: &RunPlan, settings: &ProcessAdapterSettings) -> Ve args } -fn value_to_stdin(value: &Value) -> Result { - match value { - Value::Null => Ok(String::new()), - Value::String(text) => Ok(text.clone()), - value => serde_json::to_string_pretty(value).map_err(FabricError::SerializeJson), - } -} - fn artifact_manifest(plan: &RunPlan) -> Result { let root = plan .config @@ -2803,7 +2215,6 @@ fn untracked_workspace_patch(workspace: &Path) -> Result { fn prepare_relay_runtime_config( plan: &RunPlan, runtime: &RuntimeHandle, - correlation: RelayConfigCorrelation<'_>, artifact_directory: &Path, artifacts: &mut ArtifactManifest, ) -> Result> { @@ -2816,21 +2227,13 @@ fn prepare_relay_runtime_config( if artifacts.root.is_none() { return Ok(None); } - let mut fabric = serde_json::json!({ + let fabric = serde_json::json!({ "agent_name": plan.agent_name.clone(), "harness": harness(plan), "adapter_id": adapter_id(plan), "runtime_id": runtime.runtime_id.clone(), "adapter_outputs": telemetry.adapter_outputs.clone(), }); - if let RelayConfigCorrelation::Invocation { - invocation, - request, - } = correlation - { - fabric["invocation_id"] = Value::String(invocation.invocation_id.clone()); - fabric["request_id"] = Value::String(request.request_id.clone()); - } let relay_config = serde_json::json!({ "schema_version": "fabric.relay/v1alpha1", "relay": { @@ -3058,6 +2461,11 @@ for line in sys.stdin: response("invoke", error=failure("invoke", "fake_invoke", "invoke rejected")) continue invocation = message["payload"] + if set(invocation) != {"runtime_context", "request"}: + response("invoke", error=failure( + "invoke", "fake_invoke_shape", "invoke payload contains runtime config" + )) + continue output = { "host_pid": os.getpid(), "invocation_count": invocations, @@ -3175,6 +2583,34 @@ for line in sys.stdin: let _ = fs::remove_dir_all(root); } + #[test] + fn process_adapter_uses_the_same_persistent_local_host_protocol() { + let (root, mut plan) = local_host_plan("success"); + let descriptor = plan + .adapter_descriptor + .as_mut() + .expect("resolved adapter descriptor"); + descriptor.descriptor.adapter_kind = AdapterKind::Process; + descriptor.descriptor.runner = serde_json::from_value(serde_json::json!({ + "command": "python3", + "script": "../../fake_host.py", + })) + .expect("process runner settings"); + + let runtime = start_runtime(&plan).expect("start process host"); + let first = + invoke_runtime(&plan, &runtime, RunRequest::text("first")).expect("first invocation"); + let second = + invoke_runtime(&plan, &runtime, RunRequest::text("second")).expect("second invocation"); + stop_runtime(&plan, &runtime).expect("stop process host"); + + assert_eq!(first.output["host_pid"], second.output["host_pid"]); + assert_eq!(first.output["invocation_count"], 1); + assert_eq!(second.output["invocation_count"], 2); + + let _ = fs::remove_dir_all(root); + } + #[test] fn local_host_relay_config_is_runtime_scoped() { let (root, plan) = local_host_plan_with_relay("success", true); diff --git a/crates/fabric-core/src/schema.rs b/crates/fabric-core/src/schema.rs index f248466f..3ad59955 100644 --- a/crates/fabric-core/src/schema.rs +++ b/crates/fabric-core/src/schema.rs @@ -25,7 +25,7 @@ pub enum SchemaName { AdapterDescriptor, /// Resolved run plan schema. RunPlan, - /// Adapter-facing invocation payload schema. + /// Initialized-runtime invocation payload schema. AdapterInvocation, /// Runtime context schema. RuntimeContext, diff --git a/examples/harbor/calculator/task/environment/fabric/adapters/scripted/fabric-adapter.json b/examples/harbor/calculator/task/environment/fabric/adapters/scripted/fabric-adapter.json index e456718d..b0412d43 100644 --- a/examples/harbor/calculator/task/environment/fabric/adapters/scripted/fabric-adapter.json +++ b/examples/harbor/calculator/task/environment/fabric/adapters/scripted/fabric-adapter.json @@ -5,7 +5,9 @@ "adapter_kind": "process", "runner": { "command": "python3", - "script": "run.py", - "stdin_payload": "fabric_request" + "script": "run.py" + }, + "runtime": { + "local_host": {} } } diff --git a/examples/harbor/calculator/task/environment/fabric/adapters/scripted/run.py b/examples/harbor/calculator/task/environment/fabric/adapters/scripted/run.py index 7c6bc74c..82102e78 100644 --- a/examples/harbor/calculator/task/environment/fabric/adapters/scripted/run.py +++ b/examples/harbor/calculator/task/environment/fabric/adapters/scripted/run.py @@ -8,22 +8,56 @@ import json import sys from pathlib import Path +from typing import Any -payload = json.load(sys.stdin) -calculator = Path("/app/calculator.py") -calculator.write_text( - "def add(a, b):\n" - " return a + b\n\n\n" - "def multiply(a, b):\n" - " return a * b\n", - encoding="utf-8", -) -print( - json.dumps( - { - "harness": "scripted", - "response": "Fixed multiply(a, b) in /app/calculator.py", - "request_id": payload["request"]["request_id"], - } + +def response(operation: str, *, output: Any = None) -> None: + print( + json.dumps( + { + "operation": operation, + "outcome": {"status": "succeeded", "output": output}, + } + ), + flush=True, + ) + + +def invoke(payload: dict[str, Any]) -> dict[str, Any]: + calculator = Path("/app/calculator.py") + calculator.write_text( + "def add(a, b):\n" + " return a + b\n\n\n" + "def multiply(a, b):\n" + " return a * b\n", + encoding="utf-8", ) -) + return { + "harness": "scripted", + "response": "Fixed multiply(a, b) in /app/calculator.py", + "request_id": payload["request"]["request_id"], + } + + +def main() -> None: + runtime_id = None + for line in sys.stdin: + message = json.loads(line) + operation = message["operation"] + payload = message["payload"] + if operation == "start": + runtime_id = payload["runtime_context"]["runtime_id"] + response("start") + elif operation == "invoke": + if payload["runtime_context"]["runtime_id"] != runtime_id: + raise RuntimeError("invoke does not match the active runtime") + response("invoke", output=invoke(payload)) + elif operation == "stop": + if payload["runtime_id"] != runtime_id: + raise RuntimeError("stop does not match the active runtime") + response("stop") + break + + +if __name__ == "__main__": + main() diff --git a/schemas/adapter-invocation.schema.json b/schemas/adapter-invocation.schema.json index 44162719..0401cc8b 100644 --- a/schemas/adapter-invocation.schema.json +++ b/schemas/adapter-invocation.schema.json @@ -50,1167 +50,84 @@ ], "type": "object" }, - "CapabilityKind": { - "description": "Capability kind.", - "oneOf": [ - { - "const": "tools", - "description": "Tool config.", - "type": "string" - }, - { - "const": "skills", - "description": "Skill paths.", - "type": "string" - }, - { - "const": "mcp", - "description": "MCP server.", - "type": "string" - } - ] - }, - "CapabilityPlan": { - "description": "Resolved capability configuration.", - "properties": { - "managed": { - "$ref": "#/$defs/CapabilityTargetPlan", - "default": { - "tools_configured": false - }, - "description": "Capabilities that Fabric must expose or manage outside the native harness config." - }, - "mcp_servers": { - "additionalProperties": { - "$ref": "#/$defs/McpServerPlan" - }, - "description": "MCP server exposure plan.", - "type": "object" - }, - "native": { - "$ref": "#/$defs/CapabilityTargetPlan", - "default": { - "tools_configured": false - }, - "description": "Capabilities mapped into the harness-native surface." - }, - "routes": { - "description": "Routing decisions made while planning the configured capabilities.", - "items": { - "$ref": "#/$defs/CapabilityRoute" - }, - "type": "array" - }, - "skill_paths": { - "description": "Resolved skill paths.", - "items": { - "type": "string" - }, - "type": "array" - }, - "tools": { - "$ref": "#/$defs/ToolsPlan", - "default": {}, - "description": "Normalized tool policy." - }, - "tools_configured": { - "default": false, - "description": "Whether tool configuration was provided.", - "type": "boolean" - }, - "unsupported": { - "$ref": "#/$defs/CapabilityTargetPlan", - "default": { - "tools_configured": false - }, - "description": "Capabilities that are configured but not executable by this Fabric build." - } - }, - "type": "object" - }, - "CapabilityRoute": { - "description": "One capability routing decision.", - "properties": { - "kind": { - "$ref": "#/$defs/CapabilityKind", - "description": "Capability kind." - }, - "name": { - "description": "Capability name.", - "type": "string" - }, - "reason": { - "description": "Human-readable reason for the selected route.", - "type": "string" - }, - "target": { - "$ref": "#/$defs/CapabilityTarget", - "description": "Routing target." - } - }, - "required": [ - "kind", - "name", - "target", - "reason" - ], - "type": "object" - }, - "CapabilityTarget": { - "description": "Capability routing target.", - "oneOf": [ - { - "const": "harness_native", - "description": "Adapter maps the capability into harness-native config.", - "type": "string" - }, - { - "const": "fabric_managed", - "description": "Fabric exposes or manages the capability around the harness.", - "type": "string" - }, - { - "const": "unsupported", - "description": "Capability is configured but no executable surface exists.", - "type": "string" - } - ] - }, - "CapabilityTargetPlan": { - "description": "Capabilities routed to one target.", - "properties": { - "mcp_servers": { - "additionalProperties": { - "$ref": "#/$defs/McpServerPlan" - }, - "description": "MCP servers for this target.", - "type": "object" - }, - "skill_paths": { - "description": "Resolved skill paths for this target.", - "items": { - "type": "string" - }, - "type": "array" - }, - "tools_configured": { - "default": false, - "description": "Whether tool configuration was provided for this target.", - "type": "boolean" - } - }, - "type": "object" - }, - "ControlLocation": { - "description": "Where Fabric control code runs relative to the environment.", - "oneOf": [ - { - "const": "external_control", - "description": "Fabric runs on the host/control plane and starts or connects to the harness in the environment.", - "type": "string" - }, - { - "const": "in_env_control", - "description": "Fabric runs inside the prepared environment with the harness.", - "type": "string" - } - ] - }, - "EnvironmentConfig": { - "additionalProperties": true, - "description": "Execution environment configuration.", - "properties": { - "artifacts": { - "description": "Artifact path inside or outside the provider.", - "type": [ - "string", - "null" - ] - }, - "connection": { - "additionalProperties": true, - "description": "Provider connection metadata, such as server URL, credential reference, or namespace.", - "type": "object" - }, - "control_location": { - "$ref": "#/$defs/ControlLocation", - "default": "in_env_control", - "description": "Where Fabric control code runs relative to the environment." - }, - "metadata": { - "additionalProperties": true, - "description": "Consumer-provided environment metadata.", - "type": "object" - }, - "ownership": { - "$ref": "#/$defs/EnvironmentOwnership", - "default": "caller_owned", - "description": "Whether Fabric owns the environment resource." - }, - "provider": { - "description": "Environment provider, for example `local`, `docker`, `opensandbox`, or `k8s`.", - "type": "string" - }, - "settings": { - "additionalProperties": true, - "description": "Provider-specific settings.", - "type": "object" - }, - "workspace": { - "description": "Workspace path inside or outside the provider.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "provider" - ], - "type": "object" - }, - "EnvironmentHandle": { - "description": "Resolved execution environment context.", - "properties": { - "artifacts": { - "description": "Artifact root visible to the harness runtime.", - "type": [ - "string", - "null" - ] - }, - "connection": { - "additionalProperties": true, - "description": "Provider connection metadata.", - "type": "object" - }, - "control_location": { - "$ref": "#/$defs/ControlLocation", - "description": "Where Fabric control code runs." - }, - "environment_id": { - "description": "Environment handle id.", - "type": "string" - }, - "metadata": { - "additionalProperties": true, - "description": "Provider-specific metadata.", - "type": "object" - }, - "ownership": { - "$ref": "#/$defs/EnvironmentOwnership", - "description": "Whether Fabric owns the environment resource." - }, - "provider": { - "description": "Environment provider.", - "type": "string" - }, - "workspace": { - "description": "Workspace visible to the harness runtime.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "environment_id", - "provider", - "control_location", - "ownership" - ], - "type": "object" - }, - "EnvironmentOwnership": { - "description": "Whether Fabric owns the underlying environment resource.", - "oneOf": [ - { - "const": "caller_owned", - "description": "The caller or a surrounding system owns the environment resource.", - "type": "string" - }, - { - "const": "fabric_owned", - "description": "Fabric created or leased the environment resource and may release it.", - "type": "string" - } - ] - }, - "FabricConfig": { - "additionalProperties": true, - "description": "Versioned Fabric agent config.", - "properties": { - "environment": { - "anyOf": [ - { - "$ref": "#/$defs/EnvironmentConfig" - }, - { - "type": "null" - } - ], - "description": "Environment where the harness or its tools execute." - }, - "harness": { - "$ref": "#/$defs/HarnessConfig", - "description": "Harness selection and harness-specific settings." - }, - "mcp": { - "anyOf": [ - { - "$ref": "#/$defs/McpConfig" - }, - { - "type": "null" - } - ], - "description": "MCP capability configuration." - }, - "metadata": { - "$ref": "#/$defs/MetadataConfig", - "description": "Human-readable metadata." - }, - "models": { - "additionalProperties": { - "$ref": "#/$defs/ModelConfig" - }, - "description": "Model aliases.", - "type": "object" - }, - "relay": { - "anyOf": [ - { - "$ref": "#/$defs/RelayConfig" - }, - { - "type": "null" - } - ], - "description": "First-class NeMo Relay integration configuration." - }, - "runtime": { - "$ref": "#/$defs/RuntimeConfig", - "description": "Runtime input/output contract." - }, - "schema_version": { - "description": "Config schema version.", - "type": "string" - }, - "skills": { - "anyOf": [ - { - "$ref": "#/$defs/SkillConfig" - }, - { - "type": "null" - } - ], - "description": "Skill capability configuration." - }, - "telemetry": { - "anyOf": [ - { - "$ref": "#/$defs/TelemetryConfig" - }, - { - "type": "null" - } - ], - "description": "Telemetry configuration." - }, - "tools": { - "anyOf": [ - { - "$ref": "#/$defs/ToolsConfig" - }, - { - "type": "null" - } - ], - "description": "Tool capability configuration." - } - }, - "required": [ - "schema_version", - "metadata", - "harness", - "runtime" - ], - "type": "object" - }, - "HarnessConfig": { - "additionalProperties": true, - "description": "Harness selection.", - "properties": { - "adapter_id": { - "description": "Adapter implementation id.", - "type": "string" - }, - "resolution": { - "anyOf": [ - { - "$ref": "#/$defs/ResolutionStrategy" - }, - { - "type": "null" - } - ], - "description": "Selected install or availability strategy." - }, - "settings": { - "additionalProperties": true, - "description": "Harness-specific settings.", - "type": "object" - } - }, - "required": [ - "adapter_id" - ], - "type": "object" - }, - "McpConfig": { - "additionalProperties": true, - "description": "MCP capability configuration.", - "properties": { - "servers": { - "additionalProperties": { - "$ref": "#/$defs/McpServerConfig" - }, - "description": "Named MCP servers.", - "type": "object" - } - }, - "type": "object" - }, - "McpExposure": { - "description": "MCP exposure strategy.", - "oneOf": [ - { - "const": "harness_native", - "description": "Map into harness-native MCP config through the selected adapter.", - "type": "string" - }, - { - "const": "fabric_managed", - "description": "Fabric manages MCP and exposes basic tools/actions.", - "type": "string" - } - ] - }, - "McpServerConfig": { - "additionalProperties": true, - "description": "MCP server configuration.", - "properties": { - "exposure": { - "$ref": "#/$defs/McpExposure", - "description": "How Fabric exposes the MCP capability to the harness." - }, - "transport": { - "description": "MCP transport.", - "type": "string" - }, - "url": { - "description": "MCP server URL or process command, depending on transport.", - "type": "string" - } - }, - "required": [ - "transport", - "url", - "exposure" - ], - "type": "object" - }, - "McpServerPlan": { - "description": "Resolved MCP server exposure.", - "properties": { - "exposure": { - "$ref": "#/$defs/McpExposure", - "description": "Exposure strategy." - }, - "transport": { - "description": "MCP transport.", - "type": "string" - }, - "url": { - "description": "MCP URL or command.", - "type": "string" - } - }, - "required": [ - "transport", - "url", - "exposure" - ], - "type": "object" - }, - "MetadataConfig": { - "additionalProperties": true, - "description": "Human-readable metadata.", - "properties": { - "description": { - "description": "Optional description.", - "type": [ - "string", - "null" - ] - }, - "name": { - "description": "Agent/config name.", - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - }, - "ModelConfig": { - "additionalProperties": true, - "description": "Model configuration.", - "properties": { - "api_key_env": { - "description": "Optional environment variable containing an API key.", - "type": [ - "string", - "null" - ] - }, - "model": { - "description": "Provider model identifier.", - "type": "string" - }, - "provider": { - "description": "Model provider name.", - "type": "string" - }, - "settings": { - "additionalProperties": true, - "description": "Provider-specific settings.", - "type": "object" - }, - "temperature": { - "description": "Optional temperature.", - "format": "double", - "type": [ - "number", - "null" - ] - } - }, - "required": [ - "provider", - "model" - ], - "type": "object" - }, - "RelayAtifConfig": { - "additionalProperties": true, - "description": "Relay ATIF export configuration.", - "properties": { - "agent_name": { - "default": "NeMo Relay", - "description": "Agent name written into ATIF.", - "type": "string" - }, - "agent_version": { - "description": "Agent version written into ATIF.", - "type": [ - "string", - "null" - ] - }, - "enabled": { - "default": false, - "description": "Whether ATIF export is enabled.", - "type": "boolean" - }, - "extra": { - "description": "Extra ATIF metadata." - }, - "filename_template": { - "default": "nemo-relay-atif-{session_id}.json", - "description": "ATIF file name template.", - "type": "string" - }, - "model_name": { - "default": "unknown", - "description": "Model name written into ATIF.", - "type": "string" - }, - "output_directory": { - "description": "Directory used for ATIF files.", - "type": [ - "string", - "null" - ] - }, - "storage": { - "description": "Optional ATIF remote storage.", - "items": { - "$ref": "#/$defs/RelayAtifStorageConfig" - }, - "type": [ - "array", - "null" - ] - }, - "tool_definitions": { - "description": "Tool definitions written into ATIF.", - "items": true, - "type": [ - "array", - "null" - ] - } - }, - "type": "object" - }, - "RelayAtifStorageConfig": { - "description": "Relay ATIF remote storage configuration.", - "oneOf": [ - { - "additionalProperties": true, - "description": "Upload ATIF artifacts to HTTP storage.", - "properties": { - "endpoint": { - "default": "", - "description": "HTTP storage endpoint.", - "type": "string" - }, - "header_env": { - "additionalProperties": { - "type": "string" - }, - "description": "Environment-variable-backed HTTP headers.", - "type": "object" - }, - "headers": { - "additionalProperties": { - "type": "string" - }, - "description": "Static HTTP headers.", - "type": "object" - }, - "timeout_millis": { - "default": 3000, - "description": "Request timeout in milliseconds.", - "format": "uint64", - "minimum": 0, - "type": "integer" - }, - "type": { - "const": "http", - "type": "string" - } - }, - "required": [ - "type" - ], - "type": "object" - }, - { - "additionalProperties": true, - "description": "Upload ATIF artifacts to S3-compatible storage.", - "properties": { - "access_key_id": { - "description": "AWS access key id.", - "type": [ - "string", - "null" - ] - }, - "allow_http": { - "description": "Allow HTTP endpoints for S3-compatible storage.", - "type": [ - "boolean", - "null" - ] - }, - "bucket": { - "default": "", - "description": "S3 bucket name.", - "type": "string" - }, - "endpoint_url": { - "description": "S3-compatible endpoint URL.", - "type": [ - "string", - "null" - ] - }, - "key_prefix": { - "description": "Optional S3 object key prefix.", - "type": [ - "string", - "null" - ] - }, - "region": { - "description": "AWS region.", - "type": [ - "string", - "null" - ] - }, - "secret_access_key_var": { - "description": "Environment variable containing the AWS secret access key.", - "type": [ - "string", - "null" - ] - }, - "session_token_var": { - "description": "Environment variable containing the AWS session token.", - "type": [ - "string", - "null" - ] - }, - "type": { - "const": "s3", - "type": "string" - } - }, - "required": [ - "type" - ], - "type": "object" - } - ] - }, - "RelayAtofConfig": { - "additionalProperties": true, - "description": "Relay ATOF export configuration.", - "properties": { - "enabled": { - "default": false, - "description": "Whether ATOF export is enabled.", - "type": "boolean" - }, - "endpoints": { - "description": "Optional remote ATOF endpoints.", - "items": { - "$ref": "#/$defs/RelayAtofEndpointConfig" - }, - "type": "array" - }, - "filename": { - "description": "ATOF file name.", - "type": [ - "string", - "null" - ] - }, - "mode": { - "$ref": "#/$defs/RelayAtofMode", - "default": "append", - "description": "File write mode." - }, - "output_directory": { - "description": "Directory used for ATOF files.", - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, - "RelayAtofEndpointConfig": { - "additionalProperties": true, - "description": "Relay ATOF endpoint configuration.", - "properties": { - "field_name_policy": { - "$ref": "#/$defs/RelayAtofEndpointFieldNamePolicy", - "default": "preserve", - "description": "Field-name handling policy." - }, - "headers": { - "additionalProperties": { - "type": "string" - }, - "description": "Endpoint headers.", - "type": "object" - }, - "timeout_millis": { - "default": 3000, - "description": "Request timeout in milliseconds.", - "format": "uint64", - "minimum": 0, - "type": "integer" - }, - "transport": { - "$ref": "#/$defs/RelayAtofEndpointTransport", - "default": "http_post", - "description": "Endpoint transport." - }, - "url": { - "description": "Endpoint URL.", - "type": "string" - } - }, - "required": [ - "url" - ], - "type": "object" - }, - "RelayAtofEndpointFieldNamePolicy": { - "description": "Relay ATOF endpoint field-name policy.", - "oneOf": [ - { - "const": "preserve", - "description": "Preserve field names.", - "type": "string" - }, - { - "const": "replace_dots", - "description": "Replace dots in field names.", - "type": "string" - } - ] - }, - "RelayAtofEndpointTransport": { - "description": "Relay ATOF endpoint transport.", - "oneOf": [ - { - "const": "http_post", - "description": "HTTP POST transport.", - "type": "string" - }, - { - "const": "websocket", - "description": "WebSocket transport.", - "type": "string" - }, - { - "const": "ndjson", - "description": "NDJSON transport.", - "type": "string" - } - ] - }, - "RelayAtofMode": { - "description": "Relay ATOF file mode.", - "oneOf": [ - { - "const": "append", - "description": "Append to an existing ATOF file.", - "type": "string" - }, - { - "const": "overwrite", - "description": "Overwrite an existing ATOF file.", - "type": "string" - } - ] - }, - "RelayComponentConfig": { - "additionalProperties": true, - "description": "Generic NeMo Relay plugin component configuration.", - "properties": { - "config": { - "additionalProperties": true, - "description": "Component-local Relay plugin config.", - "type": "object" - }, - "enabled": { - "default": true, - "description": "Whether this Relay component should be activated.", - "type": "boolean" - }, - "kind": { - "description": "Registered Relay plugin kind.", - "type": "string" - } - }, - "required": [ - "kind" - ], - "type": "object" - }, - "RelayConfig": { - "additionalProperties": true, - "description": "NeMo Relay integration configuration.", - "properties": { - "components": { - "description": "Additional Relay plugin components.", - "items": { - "$ref": "#/$defs/RelayComponentConfig" - }, - "type": "array" - }, - "observability": { - "anyOf": [ - { - "$ref": "#/$defs/RelayObservabilityConfig" - }, - { - "type": "null" - } - ], - "description": "Relay observability component configuration." - }, - "output_dir": { - "description": "Optional Relay output directory.", - "type": [ - "string", - "null" - ] - }, - "policy": { - "anyOf": [ - { - "$ref": "#/$defs/RelayConfigPolicy" - }, - { - "type": "null" - } - ], - "description": "Relay plugin validation policy." - }, - "project": { - "description": "Optional project name for Relay backends.", - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, - "RelayConfigPolicy": { - "description": "Relay validation policy.", - "properties": { - "unknown_component": { - "$ref": "#/$defs/RelayUnsupportedBehavior", - "default": "warn", - "description": "Policy for unknown components." - }, - "unknown_field": { - "$ref": "#/$defs/RelayUnsupportedBehavior", - "default": "warn", - "description": "Policy for unknown fields." - }, - "unsupported_value": { - "$ref": "#/$defs/RelayUnsupportedBehavior", - "default": "error", - "description": "Policy for unsupported values." - } - }, - "type": "object" - }, - "RelayObservabilityConfig": { - "additionalProperties": true, - "description": "NeMo Relay observability component configuration.", - "properties": { - "atif": { - "anyOf": [ - { - "$ref": "#/$defs/RelayAtifConfig" - }, - { - "type": "null" - } - ], - "description": "ATIF export configuration." - }, - "atof": { - "anyOf": [ - { - "$ref": "#/$defs/RelayAtofConfig" - }, - { - "type": "null" - } - ], - "description": "ATOF export configuration." - }, - "openinference": { - "anyOf": [ - { - "$ref": "#/$defs/RelayOtlpConfig" - }, - { - "type": "null" - } - ], - "description": "OpenInference export configuration." - }, - "opentelemetry": { - "anyOf": [ - { - "$ref": "#/$defs/RelayOtlpConfig" - }, - { - "type": "null" - } - ], - "description": "OpenTelemetry export configuration." - }, - "policy": { - "anyOf": [ - { - "$ref": "#/$defs/RelayConfigPolicy" - }, - { - "type": "null" - } - ], - "description": "Relay config validation policy." + "ControlLocation": { + "description": "Where Fabric control code runs relative to the environment.", + "oneOf": [ + { + "const": "external_control", + "description": "Fabric runs on the host/control plane and starts or connects to the harness in the environment.", + "type": "string" }, - "version": { - "default": 1, - "description": "Relay observability config version.", - "format": "uint32", - "minimum": 0, - "type": "integer" + { + "const": "in_env_control", + "description": "Fabric runs inside the prepared environment with the harness.", + "type": "string" } - }, - "type": "object" + ] }, - "RelayOtlpConfig": { - "additionalProperties": true, - "description": "Relay OpenTelemetry/OpenInference export configuration.", + "EnvironmentHandle": { + "description": "Resolved execution environment context.", "properties": { - "enabled": { - "default": false, - "description": "Whether OTLP export is enabled.", - "type": "boolean" - }, - "endpoint": { - "description": "OTLP endpoint.", + "artifacts": { + "description": "Artifact root visible to the harness runtime.", "type": [ "string", "null" ] }, - "headers": { - "additionalProperties": { - "type": "string" - }, - "description": "OTLP headers.", + "connection": { + "additionalProperties": true, + "description": "Provider connection metadata.", "type": "object" }, - "instrumentation_scope": { - "description": "OTLP instrumentation scope.", - "type": [ - "string", - "null" - ] + "control_location": { + "$ref": "#/$defs/ControlLocation", + "description": "Where Fabric control code runs." }, - "resource_attributes": { - "additionalProperties": { - "type": "string" - }, - "description": "OTLP resource attributes.", + "environment_id": { + "description": "Environment handle id.", + "type": "string" + }, + "metadata": { + "additionalProperties": true, + "description": "Provider-specific metadata.", "type": "object" }, - "service_name": { - "default": "nemo-relay", - "description": "OTLP service name.", - "type": "string" + "ownership": { + "$ref": "#/$defs/EnvironmentOwnership", + "description": "Whether Fabric owns the environment resource." }, - "service_namespace": { - "description": "OTLP service namespace.", - "type": [ - "string", - "null" - ] + "provider": { + "description": "Environment provider.", + "type": "string" }, - "service_version": { - "description": "OTLP service version.", + "workspace": { + "description": "Workspace visible to the harness runtime.", "type": [ "string", "null" ] - }, - "timeout_millis": { - "default": 3000, - "description": "Request timeout in milliseconds.", - "format": "uint64", - "minimum": 0, - "type": "integer" - }, - "transport": { - "$ref": "#/$defs/RelayOtlpTransport", - "default": "http_binary", - "description": "OTLP transport." } }, + "required": [ + "environment_id", + "provider", + "control_location", + "ownership" + ], "type": "object" }, - "RelayOtlpTransport": { - "description": "Relay OTLP transport.", - "oneOf": [ - { - "const": "http_binary", - "description": "OTLP HTTP binary transport.", - "type": "string" - }, - { - "const": "grpc", - "description": "OTLP gRPC transport.", - "type": "string" - } - ] - }, - "RelayUnsupportedBehavior": { - "description": "Relay unsupported/unknown config handling.", - "oneOf": [ - { - "const": "ignore", - "description": "Ignore the unsupported or unknown value.", - "type": "string" - }, - { - "const": "warn", - "description": "Warn on the unsupported or unknown value.", - "type": "string" - }, - { - "const": "error", - "description": "Error on the unsupported or unknown value.", - "type": "string" - } - ] - }, - "ResolutionStrategy": { - "description": "Adapter install or availability strategy.", + "EnvironmentOwnership": { + "description": "Whether Fabric owns the underlying environment resource.", "oneOf": [ { - "const": "preinstalled", - "description": "Harness is already available in the prepared environment.", - "type": "string" - }, - { - "const": "image_provided", - "description": "Environment image already contains the harness and dependencies.", - "type": "string" - }, - { - "const": "pip_uv", - "description": "Adapter may install a Python package with pip or uv.", - "type": "string" - }, - { - "const": "npm", - "description": "Adapter may install a Node package.", - "type": "string" - }, - { - "const": "source", - "description": "Adapter may install from source.", - "type": "string" - }, - { - "const": "service", - "description": "Adapter connects to an already-running service.", + "const": "caller_owned", + "description": "The caller or a surrounding system owns the environment resource.", "type": "string" }, { - "const": "native_plugin", - "description": "Adapter is installed through a harness-native plugin manager.", + "const": "fabric_owned", + "description": "Fabric created or leased the environment resource and may release it.", "type": "string" } ] @@ -1240,30 +157,6 @@ ], "type": "object" }, - "RuntimeConfig": { - "additionalProperties": true, - "description": "Runtime input/output contract.", - "properties": { - "artifacts": { - "description": "Artifact directory.", - "type": [ - "string", - "null" - ] - }, - "input_schema": { - "default": "text", - "description": "Input schema label.", - "type": "string" - }, - "output_schema": { - "default": "text", - "description": "Output schema label.", - "type": "string" - } - }, - "type": "object" - }, "RuntimeContext": { "description": "Per-run/per-invocation context passed to harness adapters.", "properties": { @@ -1339,192 +232,21 @@ "relay_enabled" ], "type": "object" - }, - "SkillConfig": { - "additionalProperties": true, - "description": "Skill capability configuration.", - "properties": { - "paths": { - "description": "Skill paths resolved relative to the config base directory.", - "items": { - "type": "string" - }, - "type": "array" - } - }, - "type": "object" - }, - "TelemetryConfig": { - "additionalProperties": true, - "description": "Telemetry configuration.", - "properties": { - "providers": { - "additionalProperties": { - "$ref": "#/$defs/TelemetryProviderConfig" - }, - "description": "Telemetry providers enabled for this run.", - "type": "object" - } - }, - "type": "object" - }, - "TelemetryPlan": { - "description": "Resolved telemetry plan.", - "properties": { - "adapter_outputs": { - "description": "Telemetry outputs declared by the selected adapter descriptor.", - "items": { - "type": "string" - }, - "type": "array" - }, - "native_config": { - "description": "Native telemetry pass-through config." - }, - "providers": { - "description": "Telemetry providers selected for this run.", - "items": { - "$ref": "#/$defs/TelemetryProvider" - }, - "type": "array" - }, - "relay_config": { - "description": "Relay pass-through config." - }, - "relay_enabled": { - "description": "Whether Relay is enabled.", - "type": "boolean" - }, - "relay_output_dir": { - "description": "Relay output directory, when configured.", - "type": [ - "string", - "null" - ] - }, - "relay_project": { - "description": "Relay project, when configured.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "providers", - "relay_enabled" - ], - "type": "object" - }, - "TelemetryProvider": { - "description": "Telemetry runtime provider.", - "oneOf": [ - { - "const": "relay", - "description": "Use NeMo Relay for telemetry integration.", - "type": "string" - }, - { - "const": "native", - "description": "Let the selected adapter handle telemetry natively.", - "type": "string" - } - ] - }, - "TelemetryProviderConfig": { - "additionalProperties": true, - "description": "Provider-specific telemetry configuration.", - "properties": { - "config": { - "description": "Provider-specific pass-through config." - } - }, - "type": "object" - }, - "ToolsConfig": { - "additionalProperties": true, - "description": "Harness-neutral tool capability configuration.", - "properties": { - "blocked": { - "description": "Adapter-native tool names or toolset names to block.", - "items": { - "type": "string" - }, - "type": "array" - } - }, - "type": "object" - }, - "ToolsPlan": { - "description": "Normalized tool policy for a run.", - "properties": { - "blocked": { - "description": "Adapter-native tool names or toolset names to block.", - "items": { - "type": "string" - }, - "type": "array" - } - }, - "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "Adapter-facing invocation payload.", + "description": "One invocation against an initialized adapter runtime.", "properties": { - "agent_name": { - "description": "Stable agent name.", - "type": "string" - }, - "base_dir": { - "description": "Absolute base directory used to resolve relative Fabric paths.", - "type": "string" - }, - "capability_plan": { - "$ref": "#/$defs/CapabilityPlan", - "default": { - "managed": { - "tools_configured": false - }, - "native": { - "tools_configured": false - }, - "tools": {}, - "tools_configured": false, - "unsupported": { - "tools_configured": false - } - }, - "description": "Derived capability routing plan for the selected adapter." - }, - "config": { - "$ref": "#/$defs/FabricConfig", - "description": "Complete typed Fabric config." - }, "request": { "$ref": "#/$defs/RunRequest", - "description": "Per-invocation request." + "description": "Typed caller request for this invocation." }, "runtime_context": { "$ref": "#/$defs/RuntimeContext", - "description": "Per-runtime/per-invocation execution context." - }, - "telemetry_plan": { - "anyOf": [ - { - "$ref": "#/$defs/TelemetryPlan" - }, - { - "type": "null" - } - ], - "description": "Derived telemetry plan for the selected adapter." + "description": "Per-runtime and per-invocation context generated by Fabric." } }, "required": [ - "agent_name", - "base_dir", - "config", "runtime_context", "request" ], diff --git a/tests/adapters/test_adapters_common_lifecycle.py b/tests/adapters/test_adapters_common_lifecycle.py index be473acb..5cdf80ac 100644 --- a/tests/adapters/test_adapters_common_lifecycle.py +++ b/tests/adapters/test_adapters_common_lifecycle.py @@ -25,11 +25,6 @@ def _streams(requests: list[dict[str, Any]]) -> tuple[io.StringIO, io.StringIO]: return input_stream, io.StringIO() -def test_lifecycle_host_uses_unversioned_marker(): - assert lifecycle.is_lifecycle_host({lifecycle.LOCAL_HOST_ENV: "1"}) - assert not lifecycle.is_lifecycle_host({lifecycle.LOCAL_HOST_ENV: "0"}) - - def test_lifecycle_host_reuses_one_runtime_and_one_event_loop(): runtime_id = "runtime-1" input_stream, output_stream = _streams( @@ -94,6 +89,57 @@ async def stop(self): assert len(set(instances[0].loop_ids)) == 1 +def test_lifecycle_host_retains_start_config_outside_invoke_wire_payload(): + runtime_id = "runtime-1" + start_payload = { + "agent_name": "agent", + "base_dir": "/workspace", + "config": {"harness": {"settings": {"mode": "retained"}}}, + "runtime_context": { + "runtime_id": runtime_id, + "invocation_id": "runtime-start", + }, + "capability_plan": {"native": ["tools"]}, + } + invoke_payload = { + "runtime_context": { + "runtime_id": runtime_id, + "invocation_id": "invocation-1", + }, + "request": {"input": "hello"}, + } + input_stream, output_stream = _streams( + [ + _request("start", start_payload), + _request("invoke", invoke_payload), + _request("stop", {"runtime_id": runtime_id}), + ] + ) + invocations = [] + + class Runtime: + async def start(self, payload): + payload["config"]["harness"]["settings"]["mode"] = "mutated" + + async def invoke(self, payload): + invocations.append(payload) + return {"input": payload["request"]["input"]} + + async def stop(self): + pass + + lifecycle.serve(Runtime, input_stream=input_stream, output_stream=output_stream) + + assert invocations == [ + { + **start_payload, + "runtime_context": invoke_payload["runtime_context"], + "request": invoke_payload["request"], + } + ] + assert invocations[0]["config"]["harness"]["settings"]["mode"] == "retained" + + def test_lifecycle_host_rejects_runtime_mismatch_without_poisoning_runtime(): input_stream, output_stream = _streams( [ diff --git a/tests/adapters/test_claude_adapter.py b/tests/adapters/test_claude_adapter.py index d2ef87d8..bb15495d 100644 --- a/tests/adapters/test_claude_adapter.py +++ b/tests/adapters/test_claude_adapter.py @@ -1170,16 +1170,10 @@ def test_run_normalizes_unexpected_exception(claude_payload, monkeypatch): assert "secret" not in json.dumps(output) -def test_main_normalizes_payload_load_failure(monkeypatch, capsys): - monkeypatch.setattr( - adapter.common_utils, - "load_payload", - MagicMock(side_effect=ValueError("secret payload")), - ) +def test_main_serves_persistent_runtime(monkeypatch): + serve = MagicMock() + monkeypatch.setattr(adapter.lifecycle, "serve", serve) - with pytest.raises(SystemExit, match="2"): - adapter.main() + adapter.main() - output = json.loads(capsys.readouterr().out) - assert output["error"]["code"] == "claude_adapter_internal_error" - assert "secret" not in json.dumps(output) + serve.assert_called_once_with(adapter.ClaudeRuntime) diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index 8558e1b8..ff33e726 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -1089,3 +1089,12 @@ def test_environment_rejects_non_mapping_runtime_telemetry(codex_payload, teleme match=r"runtime_context\.telemetry must be a mapping", ): adapter.child_environment(codex_payload) + + +def test_main_serves_persistent_runtime(monkeypatch): + serve = MagicMock() + monkeypatch.setattr(adapter.lifecycle, "serve", serve) + + adapter.main() + + serve.assert_called_once_with(adapter.CodexRuntime) diff --git a/tests/adapters/test_deepagents.py b/tests/adapters/test_deepagents.py index f2f08f74..f3c48420 100644 --- a/tests/adapters/test_deepagents.py +++ b/tests/adapters/test_deepagents.py @@ -1110,3 +1110,12 @@ async def test_openai_compatible_provider_requires_api_key_env(tmp_path, make_pa assert output["failed"] is True assert "api_key_env" in output["error"] + + +def test_main_serves_persistent_runtime(monkeypatch): + serve = MagicMock() + monkeypatch.setattr(adapter.lifecycle, "serve", serve) + + adapter.main() + + serve.assert_called_once_with(adapter.DeepAgentsRuntime) diff --git a/tests/adapters/test_hermes_adapter.py b/tests/adapters/test_hermes_adapter.py index d8b969d8..674b3314 100644 --- a/tests/adapters/test_hermes_adapter.py +++ b/tests/adapters/test_hermes_adapter.py @@ -626,3 +626,12 @@ async def test_persistent_runtime_reuses_hermes_agent_session_and_history( assert Path(second["hermes_home"]) == ( tmp_path / "hermes-home" / "runtimes" / "runtime-fabric-123" ) + + +def test_main_serves_persistent_runtime(monkeypatch): + serve = MagicMock() + monkeypatch.setattr(adapter.lifecycle, "serve", serve) + + adapter.main() + + serve.assert_called_once_with(adapter.HermesRuntime) diff --git a/tests/fixtures/hermes-shim-agent/adapters/hermes-shim/fabric-adapter.json b/tests/fixtures/hermes-shim-agent/adapters/hermes-shim/fabric-adapter.json index b5c7a26c..7f33aaea 100644 --- a/tests/fixtures/hermes-shim-agent/adapters/hermes-shim/fabric-adapter.json +++ b/tests/fixtures/hermes-shim-agent/adapters/hermes-shim/fabric-adapter.json @@ -11,6 +11,9 @@ "PYTHONPATH": "adapters/hermes-shim/src" } }, + "runtime": { + "local_host": {} + }, "requirements": { "binaries": [ "python3" diff --git a/tests/fixtures/hermes-shim-agent/adapters/hermes-shim/src/nemo_fabric_test_adapters/hermes_shim/adapter.py b/tests/fixtures/hermes-shim-agent/adapters/hermes-shim/src/nemo_fabric_test_adapters/hermes_shim/adapter.py index 8aee6c9b..80b5af6c 100644 --- a/tests/fixtures/hermes-shim-agent/adapters/hermes-shim/src/nemo_fabric_test_adapters/hermes_shim/adapter.py +++ b/tests/fixtures/hermes-shim-agent/adapters/hermes-shim/src/nemo_fabric_test_adapters/hermes_shim/adapter.py @@ -6,18 +6,25 @@ from __future__ import annotations -import json -import sys from pathlib import Path from typing import Any +from nemo_fabric_adapters.common import lifecycle + def main() -> None: - payload = json.load(sys.stdin) - output = run(payload) - print(json.dumps(output, sort_keys=True)) - if output.get("failed"): - raise SystemExit(2) + lifecycle.serve(ShimRuntime) + + +class ShimRuntime: + async def start(self, _payload: dict[str, Any]) -> None: + pass + + async def invoke(self, payload: dict[str, Any]) -> dict[str, Any]: + return run_selected_mode(payload) + + async def stop(self) -> None: + pass def run(payload: dict[str, Any]) -> dict[str, Any]: diff --git a/tests/python/test_native_sdk.py b/tests/python/test_native_sdk.py index ab9576ed..cda9e6be 100644 --- a/tests/python/test_native_sdk.py +++ b/tests/python/test_native_sdk.py @@ -170,7 +170,7 @@ async def smoke(client: Fabric, fixture_agent: Path) -> None: assert result["status"] == "succeeded" assert result.harness == "hermes" assert result["adapter_kind"] == "python" - assert result["metadata"]["adapter_runner"] == "python" + assert result["metadata"]["adapter_runner"] == "persistent_local_host" assert result["output"]["received"] == "hello native" assert result.output["native_mcp_servers"] == ["github"] assert any(artifact.name == "stdout" for artifact in result.artifacts.artifacts) diff --git a/tests/python/test_typed_config.py b/tests/python/test_typed_config.py index 49946049..8dc8446b 100644 --- a/tests/python/test_typed_config.py +++ b/tests/python/test_typed_config.py @@ -121,7 +121,7 @@ async def runs_with_typed_config_and_adapter_directory(client: Fabric) -> None: assert result["status"] == "succeeded", result.get("status") assert result.request_id == "typed-request-1" assert result["adapter_kind"] == "python" - assert result["metadata"]["adapter_runner"] == "python" + assert result["metadata"]["adapter_runner"] == "persistent_local_host" assert result["output"]["received"] == "hello typed" From aebea965c9c137c9b6fc457fdc25afa920571f14 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Tue, 21 Jul 2026 13:49:29 -0700 Subject: [PATCH 25/34] docs: document persistent-only adapter execution Signed-off-by: Ajay Thorve --- README.md | 4 ++- adapters/codex/README.md | 9 ++---- adapters/common/README.md | 25 +++++++++------- .../nemo-fabric-core/runtime/index.mdx | 2 +- .../runtime/struct-adapterinvocation.mdx | 30 ++++--------------- .../schema/enum-schemaname.mdx | 2 +- docs/sdk/python.mdx | 25 +++++++++------- schemas/SCHEMA.md | 5 ++-- 8 files changed, 44 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 057edbeb..ac29c85f 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,9 @@ subagents that inherit the parent agent's capabilities. Consumers use the same `Fabric.start_runtime(...)` contract for all four bundled adapters. Adapter hosting remains descriptor-owned; it is not selected -through public `FabricConfig` settings. +through public `FabricConfig` settings. Local Process and Python adapters must +declare and implement `runtime.local_host`; Fabric does not provide a +process-per-invocation fallback. ## Claude Adapter diff --git a/adapters/codex/README.md b/adapters/codex/README.md index 7252a6ea..e1e51135 100644 --- a/adapters/codex/README.md +++ b/adapters/codex/README.md @@ -75,12 +75,9 @@ Each Fabric runtime currently starts one local adapter host and retains one `AsyncCodex` client and one Codex thread. The Codex SDK starts and controls its pinned local `codex app-server` subprocess over JSON-RPC. Ordered `Runtime.invoke(...)` calls reuse that client and thread directly; the adapter -closes the SDK client and app-server transport during `Runtime.stop()`. The -first successful invocation also persists the thread ID under the Fabric -artifact root for the adapter's direct per-invocation compatibility path. The -persistent host does not resume the thread between turns. Codex owns the -transcript; Fabric owns runtime-to-thread correlation, timeout, cancellation, -and cleanup. +closes the SDK client and app-server transport during `Runtime.stop()`. Codex +owns the transcript; Fabric owns runtime-to-thread correlation, timeout, +cancellation, and cleanup. The result includes the SDK's typed terminal response, turn status, token usage, timing, and completed thread items. It does not expose CLI commands, diff --git a/adapters/common/README.md b/adapters/common/README.md index 35cca5d9..723e62a6 100644 --- a/adapters/common/README.md +++ b/adapters/common/README.md @@ -23,10 +23,11 @@ pip install "nemo-fabric[adapters-common]" ## Persistent Local Hosts -Adapters that declare `runtime.local_host` in `fabric-adapter.json` implement -the local-host wire protocol with -`nemo_fabric_adapters.common.lifecycle`. Supply a factory that creates one -adapter-owned runtime with asynchronous `start`, `invoke`, and `stop` methods: +Every local Process or Python adapter declares `runtime.local_host` in +`fabric-adapter.json` and implements the ordered local-host wire protocol. +Python adapters can use `nemo_fabric_adapters.common.lifecycle`. Supply a +factory that creates one adapter-owned runtime with asynchronous `start`, +`invoke`, and `stop` methods: `runtime.local_host` is an unversioned capability marker, not a separate adapter contract. The nesting is intentionally provisional while local hosting @@ -48,17 +49,19 @@ class AdapterRuntime: await self.client.close() -if lifecycle.is_lifecycle_host(): - lifecycle.serve(AdapterRuntime) +lifecycle.serve(AdapterRuntime) ``` Fabric calls the factory once per local host to create one runtime instance and serializes invocations through that instance. The host keeps one event loop -alive for the complete lifecycle so -SDK clients, compiled graphs, checkpointers, and harness databases can remain -live safely. Adapter stdout is reserved for the protocol; diagnostics are -redirected to stderr. A host crash or protocol timeout is terminal for that -runtime and never falls back to per-invocation execution. +alive for the complete lifecycle so SDK clients, compiled graphs, +checkpointers, and harness databases can remain live safely. Fabric sends the +resolved configuration and capability plan during `start`. Each subsequent +`invoke` wire payload contains only `runtime_context` and `request`; the helper +retains the start payload and supplies a merged view to `AdapterRuntime.invoke`. +Adapter stdout is reserved for the protocol; diagnostics are redirected to +stderr. A host crash or protocol timeout is terminal for that runtime. Fabric +does not fall back to a new process. Refer to the [NeMo Fabric documentation](https://nvidia-nemo-fabric.docs.buildwithfern.com/nemo/fabric) for adapter and configuration guidance. Source code is available in the diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/index.mdx index 7a830121..3d008f1f 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/index.mdx @@ -13,7 +13,7 @@ Runtime invocation helpers. ## Structs -- [AdapterInvocation](/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-adapterinvocation): Adapter-facing invocation payload. +- [AdapterInvocation](/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-adapterinvocation): One invocation against an initialized adapter runtime. - [ArtifactManifest](/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-artifactmanifest): Manifest of run artifacts. - [ArtifactRef](/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-artifactref): Reference to one artifact. - [EnvironmentHandle](/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-environmenthandle): Resolved execution environment context. diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-adapterinvocation.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-adapterinvocation.mdx index 24052b9d..22fdcd81 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-adapterinvocation.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-adapterinvocation.mdx @@ -1,7 +1,7 @@ --- title: "Struct Adapter Invocation" sidebar-title: "AdapterInvocation" -description: "Adapter-facing invocation payload." +description: "One invocation against an initialized adapter runtime." position: 1 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. @@ -9,39 +9,19 @@ SPDX-License-Identifier: Apache-2.0 */} Generated from `cargo doc --no-deps -p nemo-fabric-core`. -
String,\n    pub base_dir: PathBuf,\n    pub config: FabricConfig,\n    pub runtime_context: RuntimeContext,\n    pub request: RunRequest,\n    pub capability_plan: CapabilityPlan,\n    pub telemetry_plan: Option<TelemetryPlan>,\n}"}} />
+
RuntimeContext,\n    pub request: RunRequest,\n}"}} />
-Adapter-facing invocation payload. +One invocation against an initialized adapter runtime. ## Fields -### `agent_name: String` - -Stable agent name. - -### `base_dir: PathBuf` - -Absolute base directory used to resolve relative Fabric paths. - -### `config: FabricConfig` - -Complete typed Fabric config. - ### `runtime_context: RuntimeContext` -Per-runtime/per-invocation execution context. +Per-runtime and per-invocation context generated by Fabric. ### `request: RunRequest` -Per-invocation request. - -### `capability_plan: CapabilityPlan` - -Derived capability routing plan for the selected adapter. - -### `telemetry_plan: Option` - -Derived telemetry plan for the selected adapter. +Typed caller request for this invocation. ## Trait Implementations diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/enum-schemaname.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/enum-schemaname.mdx index b7cc4cbf..5f8f36a7 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/enum-schemaname.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/enum-schemaname.mdx @@ -53,7 +53,7 @@ Resolved run plan schema.
-Adapter-facing invocation payload schema. +Initialized-runtime invocation payload schema. ### `RuntimeContext` diff --git a/docs/sdk/python.mdx b/docs/sdk/python.mdx index 12265c92..b68e9baa 100644 --- a/docs/sdk/python.mdx +++ b/docs/sdk/python.mdx @@ -116,12 +116,15 @@ process. An adapter may use an in-process SDK, a process, or shared service infrastructure while preserving isolated state for each NeMo Fabric runtime. Runtime hosting is selected by the adapter descriptor, not by a public -`FabricConfig` setting. An adapter that declares the persistent local-host wire -protocol starts one local host during `start_runtime(...)`, reuses its -adapter-owned native resources across ordered invocations, and attempts to -release them during `stop()`. `runtime.local_host` is an unversioned capability -marker whose nesting is intentionally provisional until remote hosting -requires a shared runtime protocol. +`FabricConfig` setting. Every local Process or Python adapter declares the +persistent local-host wire protocol. Fabric starts one host during +`start_runtime(...)`, reuses its adapter-owned native resources across ordered +invocations, and attempts to release them during `stop()`. The lifecycle start +operation carries the resolved configuration and capability plan. Each +subsequent `AdapterInvocation` carries only `runtime_context` and `request`. +`runtime.local_host` is an unversioned capability marker whose nesting is +intentionally provisional until remote hosting requires a shared runtime +protocol. ### Bundled Adapter Capability Matrix @@ -145,11 +148,11 @@ only JSON-shaped, in-process subagent definitions through `harness.settings.deepagents.subagents`; independently configured or remote subagent capabilities are not exposed. -Third-party adapters that have not implemented the contract continue to use -the per-invocation compatibility path. NeMo Fabric does not currently define a -remote-service adapter contract. A crashed persistent host is terminal for that -runtime. The same applies when the host exceeds the protocol response timeout. -NeMo Fabric does not silently respawn the host or replay a request. +Third-party local adapters must implement the persistent local-host contract. +NeMo Fabric does not currently define a remote-service adapter contract. A +crashed persistent host is terminal for that runtime. The same applies when the +host exceeds the protocol response timeout. NeMo Fabric does not silently +respawn the host or replay a request. Harness-native threads, sessions, and conversations remain adapter-owned state associated with the NeMo Fabric runtime. They are not additional NeMo Fabric lifecycle diff --git a/schemas/SCHEMA.md b/schemas/SCHEMA.md index ffbe59d2..dfebe5ff 100644 --- a/schemas/SCHEMA.md +++ b/schemas/SCHEMA.md @@ -31,8 +31,9 @@ The core schema generator exports the current public typed contract. ### Adapter Invocation -- `adapter-invocation`: adapter-facing payload sent to inline or process - adapters. +- `adapter-invocation`: per-turn payload sent to an initialized persistent + local adapter host. It contains only `runtime_context` and `request`; Fabric + sends configuration and capability planning data during lifecycle start. - `runtime-context`: per-run/per-invocation context included in adapter invocations. - `run-request`: per-invocation request/input. From c2846437614275392911e6633268a36445502386 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Tue, 21 Jul 2026 14:48:08 -0700 Subject: [PATCH 26/34] refactor(runtime): remove per-invocation adapter paths Signed-off-by: Ajay Thorve --- adapters/claude/fabric-adapter.json | 6 +- .../nemo_fabric_adapters/claude/adapter.py | 192 ++-------- adapters/codex/fabric-adapter.json | 6 +- .../src/nemo_fabric_adapters/codex/adapter.py | 210 +---------- .../nemo_fabric_adapters/common/lifecycle.py | 17 +- adapters/deepagents/fabric-adapter.json | 6 +- .../deepagents/adapter.py | 162 +++------ adapters/hermes/fabric-adapter.json | 6 +- .../nemo_fabric_adapters/hermes/adapter.py | 64 ++-- .../adapters/claude/fabric-adapter.json | 6 +- .../assets/adapters/codex/fabric-adapter.json | 6 +- .../adapters/deepagents/fabric-adapter.json | 6 +- .../adapters/hermes/fabric-adapter.json | 6 +- .../adapters/scripted/fabric-adapter.json | 3 - crates/fabric-core/src/config.rs | 145 +------- crates/fabric-core/src/lib.rs | 14 +- crates/fabric-core/src/runtime.rs | 46 +-- .../adapters/scripted/fabric-adapter.json | 3 - .../adapters/claude/fabric-adapter.json | 6 +- .../adapters/hermes/fabric-adapter.json | 6 +- schemas/adapter-descriptor.schema.json | 31 +- schemas/adapter-invocation.schema.json | 4 +- schemas/run-plan.schema.json | 31 +- schemas/run-result.schema.json | 4 +- schemas/runtime-context.schema.json | 2 +- schemas/runtime-handle.schema.json | 4 +- .../test_adapters_common_lifecycle.py | 19 +- tests/adapters/test_claude_adapter.py | 343 ++++++++---------- tests/adapters/test_codex_adapter.py | 320 ++++++++-------- tests/adapters/test_deepagents.py | 239 ++++++------ tests/adapters/test_hermes_adapter.py | 152 ++++---- tests/e2e/test_claude.py | 10 +- tests/e2e/test_codex.py | 16 +- tests/e2e/test_deepagents.py | 17 +- .../adapters/hermes-shim/fabric-adapter.json | 4 - .../hermes_shim/adapter.py | 44 ++- 36 files changed, 687 insertions(+), 1469 deletions(-) diff --git a/adapters/claude/fabric-adapter.json b/adapters/claude/fabric-adapter.json index 47ffc1fd..9f8996f9 100644 --- a/adapters/claude/fabric-adapter.json +++ b/adapters/claude/fabric-adapter.json @@ -4,8 +4,7 @@ "harness": "claude", "adapter_kind": "python", "runner": { - "module": "nemo_fabric_adapters.claude.adapter", - "callable": "run" + "module": "nemo_fabric_adapters.claude.adapter" }, "config": { "accepts": ["models", "tools", "tools.blocked", "mcp", "skills", "telemetry"] @@ -17,8 +16,5 @@ "integration_modes": ["hooks", "gateway"] } } - }, - "runtime": { - "local_host": {} } } diff --git a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py index 4d57f712..2a57fd10 100644 --- a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py +++ b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py @@ -29,7 +29,6 @@ from claude_agent_sdk import Message from claude_agent_sdk import ProcessError from claude_agent_sdk import ResultMessage -from claude_agent_sdk import query from claude_agent_sdk._errors import MessageParseError from nemo_fabric_adapters.common import lifecycle from nemo_fabric_adapters.common import relay_gateway @@ -130,10 +129,6 @@ class AdapterConfigError(ClaudeAdapterError): """Invalid Claude adapter configuration.""" -class AdapterStateError(ClaudeAdapterError): - """Invalid persisted runtime state.""" - - class AdapterRelayError(ClaudeAdapterError): """NeMo Relay setup or lifecycle failure.""" @@ -495,7 +490,6 @@ def discard_stderr(_: str) -> None: def build_options( payload: dict[str, Any], *, - resume: str | None, relay: ClaudeRelaySettings | None = None, ) -> ClaudeAgentOptions: settings = _settings(payload) @@ -538,7 +532,6 @@ def build_options( plugins.append({"type": "local", "path": str(relay.plugin_path)}) return ClaudeAgentOptions( - resume=resume, cwd=resolve_cwd(payload), model=selected_model(payload), system_prompt=system_prompt, @@ -575,58 +568,6 @@ def _artifact_root(payload: dict[str, Any]) -> Path: return Path(common_utils.base_dir(payload)) / "artifacts" / "claude" -def runtime_state_path(payload: dict[str, Any], fabric_runtime_id: str) -> Path: - digest = sha256(fabric_runtime_id.encode("utf-8")).hexdigest() - return ( - _artifact_root(payload) / ".fabric" / "claude" / "runtimes" / f"{digest}.json" - ) - - -def load_claude_session_id( - payload: dict[str, Any], fabric_runtime_id: str -) -> str | None: - path = runtime_state_path(payload, fabric_runtime_id) - if not path.exists(): - return None - try: - state = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(state, dict): - raise ValueError("state must be an object") - if state.get("runtime_id") != fabric_runtime_id: - raise ValueError("runtime mismatch") - session_id = state.get("claude_session_id") - if not isinstance(session_id, str) or not session_id: - raise ValueError("missing Claude session") - return session_id - except (OSError, ValueError, json.JSONDecodeError) as error: - raise AdapterStateError( - "claude_invalid_runtime_state", "Claude runtime state is invalid" - ) from error - - -def save_claude_session_id( - payload: dict[str, Any], fabric_runtime_id: str, claude_session_id: str -) -> None: - if not claude_session_id: - raise AdapterStateError( - "claude_invalid_runtime_state", "Claude session ID is missing" - ) - path = runtime_state_path(payload, fabric_runtime_id) - path.parent.mkdir(parents=True, exist_ok=True) - invocation_id = ( - common_utils.runtime_context(payload).get("invocation_id") or "invocation" - ) - temporary = path.with_suffix(f".{invocation_id}.tmp") - temporary.write_text( - json.dumps( - {"runtime_id": fabric_runtime_id, "claude_session_id": claude_session_id}, - sort_keys=True, - ), - encoding="utf-8", - ) - os.replace(temporary, path) - - def _json_safe(value: Any) -> Any: if is_dataclass(value) and not isinstance(value, type): return _json_safe(asdict(value)) @@ -821,43 +762,14 @@ def _cleanup_relay( return cleanup_error -def _merge_relay_output( - output: dict[str, Any], - relay: ClaudeRelaySettings | None, - cleanup_error: AdapterRelayError | None, -) -> dict[str, Any]: - if relay is None: - return output - output = _relay_output(output, relay) - if cleanup_error is None: - return output - cleanup: dict[str, Any] = { - "code": cleanup_error.code, - "message": cleanup_error.message, - "retryable": False, - } - if cleanup_error.metadata: - cleanup["metadata"] = cleanup_error.metadata - output["relay_runtime"]["cleanup_error"] = cleanup - if not output["failed"]: - output["completed"] = False - output["failed"] = True - output["error"] = cleanup - return output - - -def _persist_result_session( - payload: dict[str, Any], - fabric_runtime_id: str, - prior_session_id: str | None, - result: ResultMessage, +def _validate_result_session( + current_session_id: str | None, result: ResultMessage ) -> dict[str, Any] | None: - if prior_session_id is not None and result.session_id != prior_session_id: + if current_session_id is not None and result.session_id != current_session_id: return _failure( "claude_session_mismatch", - "Claude session identity changed during resume", + "Claude session identity changed during the runtime", ) - save_claude_session_id(payload, fabric_runtime_id, result.session_id) return None @@ -884,6 +796,7 @@ class ClaudeRuntime: """One connected Claude SDK client owned by a Fabric runtime.""" def __init__(self) -> None: + self._start_payload: dict[str, Any] | None = None self._fabric_runtime_id: str | None = None self._claude_session_id: str | None = None self._client: ClaudeSDKClient | None = None @@ -899,11 +812,10 @@ async def start(self, payload: dict[str, Any]) -> None: ) try: fabric_runtime_id = runtime_id(payload) - claude_session_id = load_claude_session_id(payload, fabric_runtime_id) relay = prepare_claude_relay(payload) self._relay = relay self._gateway_process = _start_relay_gateway(payload, relay) - options = build_options(payload, resume=claude_session_id, relay=relay) + options = build_options(payload, relay=relay) client = ClaudeSDKClient(options) await client.connect() except ClaudeAdapterError as error: @@ -916,23 +828,29 @@ async def start(self, payload: dict[str, Any]) -> None: self._cleanup_failed_start() raise + self._start_payload = payload self._fabric_runtime_id = fabric_runtime_id - self._claude_session_id = claude_session_id self._client = client - async def invoke(self, payload: dict[str, Any]) -> dict[str, Any]: + async def invoke(self, invocation: dict[str, Any]) -> dict[str, Any]: client = self._client + start_payload = self._start_payload fabric_runtime_id = self._fabric_runtime_id - if client is None or fabric_runtime_id is None: + if client is None or start_payload is None or fabric_runtime_id is None: raise lifecycle.LifecycleError( "claude_runtime_not_started", "Claude runtime is not started", ) - if runtime_id(payload) != fabric_runtime_id: + if runtime_id(invocation) != fabric_runtime_id: raise lifecycle.LifecycleError( "claude_runtime_mismatch", "Claude invocation does not match the connected runtime", ) + payload = { + **start_payload, + "runtime_context": invocation.get("runtime_context"), + "request": invocation.get("request"), + } if self._unusable: return _failure( "claude_runtime_unavailable", @@ -1006,15 +924,12 @@ def _normalize_invocation( try: output = normalize_result(payload, messages, result) if not output["failed"]: - persisted = _persist_result_session( - payload, - runtime_id(payload), - self._claude_session_id, - result, + invalid_session = _validate_result_session( + self._claude_session_id, result ) - if persisted is not None: + if invalid_session is not None: self._unusable = True - return persisted + return invalid_session self._claude_session_id = result.session_id return output except ClaudeAdapterError as error: @@ -1024,6 +939,7 @@ def _normalize_invocation( async def stop(self) -> None: client = self._client self._client = None + self._start_payload = None self._fabric_runtime_id = None self._claude_session_id = None self._unusable = True @@ -1075,72 +991,6 @@ async def _interrupt_failed_invocation(self) -> None: LOGGER.exception("Claude SDK invocation could not be interrupted") -async def run_claude(payload: dict[str, Any]) -> dict[str, Any]: - fabric_runtime_id = runtime_id(payload) - prior_session_id = load_claude_session_id(payload, fabric_runtime_id) - relay = prepare_claude_relay(payload) - gateway_process = None - cleanup_error: AdapterRelayError | None = None - messages: list[Message] = [] - result: ResultMessage | None = None - try: - gateway_process = _start_relay_gateway(payload, relay) - options = build_options(payload, resume=prior_session_id, relay=relay) - try: - async with asyncio.timeout(timeout_seconds(payload)): - async for message in query( - prompt=request_prompt(payload), options=options - ): - if isinstance(message, ResultMessage): - result = message - else: - messages.append(message) - except (TimeoutError, ClaudeSDKError) as error: - output = sdk_failure(error) - except Exception: - # Claude Agent SDK 0.2.120 can yield an error ResultMessage and then - # raise a plain Exception while closing the query stream. Preserve - # the typed terminal result, but do not hide unrelated exceptions. - if result is None or not _result_failed(result): - raise - LOGGER.exception("Claude SDK stream raised after a failed terminal result") - output = normalize_result(payload, messages, result) - else: - if result is None: - output = _failure( - "claude_missing_result", "Claude returned no terminal result" - ) - else: - output = normalize_result(payload, messages, result) - if not output["failed"]: - output = ( - _persist_result_session( - payload, - fabric_runtime_id, - prior_session_id, - result, - ) - or output - ) - finally: - cleanup_error = _cleanup_relay(relay, gateway_process) - - return _merge_relay_output(output, relay, cleanup_error) - - -def run(payload: dict[str, Any]) -> dict[str, Any]: - """Run one isolated adapter invocation for direct library tests.""" - - try: - return asyncio.run(run_claude(payload)) - except ClaudeAdapterError as error: - return adapter_failure(error) - except Exception: # Adapter boundary must always return normalized JSON. - return _failure( - "claude_adapter_internal_error", "Claude adapter failed unexpectedly" - ) - - def main() -> None: """Serve the persistent local-host lifecycle protocol.""" diff --git a/adapters/codex/fabric-adapter.json b/adapters/codex/fabric-adapter.json index 119e7c04..251ab043 100644 --- a/adapters/codex/fabric-adapter.json +++ b/adapters/codex/fabric-adapter.json @@ -4,8 +4,7 @@ "harness": "codex", "adapter_kind": "python", "runner": { - "module": "nemo_fabric_adapters.codex.adapter", - "callable": "run" + "module": "nemo_fabric_adapters.codex.adapter" }, "config": { "accepts": ["models", "mcp", "skills", "telemetry"] @@ -20,8 +19,5 @@ "outputs": ["otel"] } } - }, - "runtime": { - "local_host": {} } } diff --git a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py index dd88e10f..0d08f927 100644 --- a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py +++ b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py @@ -15,7 +15,6 @@ import subprocess from dataclasses import asdict, dataclass, is_dataclass from enum import Enum -from hashlib import sha256 from pathlib import Path from typing import Any @@ -127,10 +126,6 @@ class AdapterConfigError(CodexAdapterError): """Invalid Codex adapter configuration.""" -class AdapterStateError(CodexAdapterError): - """Invalid persisted runtime state.""" - - class AdapterRelayError(CodexAdapterError): """NeMo Relay setup or lifecycle failure.""" @@ -359,9 +354,8 @@ def nvidia_model_provider_config(payload: dict[str, Any]) -> dict[str, Any]: model_settings = _mapping( model_config.get("settings"), name="selected model settings" ) - base_url = ( - model_settings.get("base_url") - or os.environ.get("NVIDIA_FRONTIER_BASE_URL") + base_url = model_settings.get("base_url") or os.environ.get( + "NVIDIA_FRONTIER_BASE_URL" ) if not isinstance(base_url, str) or not base_url: raise AdapterConfigError( @@ -494,54 +488,6 @@ def state_dir(payload: dict[str, Any]) -> Path: return _artifact_root(payload) / ".fabric" / "codex" -def runtime_state_path(payload: dict[str, Any], fabric_runtime_id: str) -> Path: - digest = sha256(fabric_runtime_id.encode("utf-8")).hexdigest() - return state_dir(payload) / "runtimes" / f"{digest}.json" - - -def load_thread_id(payload: dict[str, Any], fabric_runtime_id: str) -> str | None: - path = runtime_state_path(payload, fabric_runtime_id) - if not path.exists(): - return None - try: - state = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(state, dict): - raise ValueError("state must be an object") - if state.get("runtime_id") != fabric_runtime_id: - raise ValueError("runtime mismatch") - thread_id = state.get("codex_thread_id") - if not isinstance(thread_id, str) or not thread_id: - raise ValueError("missing Codex thread") - return thread_id - except (OSError, ValueError, json.JSONDecodeError) as error: - raise AdapterStateError( - "codex_invalid_runtime_state", "Codex runtime state is invalid" - ) from error - - -def save_thread_id( - payload: dict[str, Any], fabric_runtime_id: str, codex_thread_id: str -) -> None: - if not codex_thread_id: - raise AdapterStateError( - "codex_invalid_runtime_state", "Codex thread ID is missing" - ) - path = runtime_state_path(payload, fabric_runtime_id) - path.parent.mkdir(parents=True, exist_ok=True) - invocation_id = ( - common_utils.runtime_context(payload).get("invocation_id") or "invocation" - ) - temporary = path.with_suffix(f".{invocation_id}.tmp") - temporary.write_text( - json.dumps( - {"runtime_id": fabric_runtime_id, "codex_thread_id": codex_thread_id}, - sort_keys=True, - ), - encoding="utf-8", - ) - os.replace(temporary, path) - - def _merge_config(target: dict[str, Any], layer: dict[str, Any]) -> None: for key, value in layer.items(): existing = target.get(key) @@ -813,14 +759,6 @@ def validate_runtime_payload(payload: dict[str, Any]) -> str: return fabric_runtime_id -def validate_payload(payload: dict[str, Any]) -> str: - """Validate runtime configuration and invocation input.""" - - fabric_runtime_id = validate_runtime_payload(payload) - request_prompt(payload) - return fabric_runtime_id - - def _json_safe(value: Any) -> Any: if hasattr(value, "model_dump"): return _json_safe(value.model_dump(mode="json", by_alias=True)) @@ -971,24 +909,14 @@ async def _open_thread( codex: AsyncCodex, payload: dict[str, Any], *, - prior_thread_id: str | None, relay: CodexRelaySettings | None, ) -> Any: settings = _settings(payload) options = _thread_options(payload, relay) - if prior_thread_id is None: - return await codex.thread_start( - **options, - service_name=_optional_string(settings, "service_name"), - ) - - thread = await codex.thread_resume(prior_thread_id, **options) - if thread.id != prior_thread_id: - raise AdapterStateError( - "codex_thread_mismatch", - "Codex thread identity changed during resume", - ) - return thread + return await codex.thread_start( + **options, + service_name=_optional_string(settings, "service_name"), + ) async def _invoke_thread( @@ -1015,56 +943,6 @@ async def _invoke_thread( return sdk_failure(error), False -async def invoke_codex_sdk( - payload: dict[str, Any], - *, - prior_thread_id: str | None, - relay: CodexRelaySettings | None, -) -> tuple[dict[str, Any], str | None]: - """Execute one SDK turn and always close the app-server transport.""" - - skill_paths = _native_skill_paths(payload) - client_config = sdk_config(payload, relay) - codex: AsyncCodex | None = None - output: dict[str, Any] | None = None - thread_id: str | None = None - try: - if selected_model_provider(payload) == "nvidia": - await asyncio.to_thread( - Path(client_config.env["CODEX_HOME"]).mkdir, - parents=True, - exist_ok=True, - ) - codex = AsyncCodex(config=client_config) - await _register_skill_roots(codex, skill_paths) - thread = await _open_thread( - codex, - payload, - prior_thread_id=prior_thread_id, - relay=relay, - ) - thread_id = thread.id - output, _ = await _invoke_thread(payload, thread) - except CodexAdapterError: - raise - except (CodexError, RuntimeError, OSError) as error: - output = sdk_failure(error) - finally: - if codex is not None: - try: - await codex.close() - except Exception: - LOGGER.exception("Codex SDK client failed to close after invocation") - if output is not None: - output["cleanup_error"] = { - "code": "codex_sdk_stop_failed", - "message": "Codex SDK runtime failed to stop", - "retryable": False, - } - assert output is not None - return output, thread_id - - def _relay_output(output: dict[str, Any], relay: CodexRelaySettings) -> dict[str, Any]: output["relay_runtime"] = { "enabled": True, @@ -1130,6 +1008,7 @@ class CodexRuntime: """One Codex app-server client and thread owned by a Fabric runtime.""" def __init__(self) -> None: + self._start_payload: dict[str, Any] | None = None self._fabric_runtime_id: str | None = None self._client: AsyncCodex | None = None self._thread: Any = None @@ -1146,7 +1025,6 @@ async def start(self, payload: dict[str, Any]) -> None: try: fabric_runtime_id = validate_runtime_payload(payload) - prior_thread_id = load_thread_id(payload, fabric_runtime_id) relay = prepare_codex_relay(payload) self._relay = relay self._gateway_process = _start_relay_gateway(payload, relay) @@ -1163,7 +1041,6 @@ async def start(self, payload: dict[str, Any]) -> None: thread = await _open_thread( client, payload, - prior_thread_id=prior_thread_id, relay=relay, ) except CodexAdapterError as error: @@ -1182,12 +1059,14 @@ async def start(self, payload: dict[str, Any]) -> None: await self._cleanup_failed_start() raise + self._start_payload = payload self._fabric_runtime_id = fabric_runtime_id self._thread = thread - async def invoke(self, payload: dict[str, Any]) -> dict[str, Any]: + async def invoke(self, invocation: dict[str, Any]) -> dict[str, Any]: if ( - self._client is None + self._start_payload is None + or self._client is None or self._thread is None or self._fabric_runtime_id is None ): @@ -1195,11 +1074,16 @@ async def invoke(self, payload: dict[str, Any]) -> dict[str, Any]: "codex_runtime_not_started", "Codex runtime is not started", ) - if runtime_id(payload) != self._fabric_runtime_id: + if runtime_id(invocation) != self._fabric_runtime_id: raise lifecycle.LifecycleError( "codex_runtime_mismatch", "Codex invocation does not match the connected runtime", ) + payload = { + **self._start_payload, + "runtime_context": invocation.get("runtime_context"), + "request": invocation.get("request"), + } if self._unusable: output = _failure( "codex_runtime_unavailable", @@ -1218,16 +1102,6 @@ async def invoke(self, payload: dict[str, Any]) -> dict[str, Any]: usable = True self._unusable = not usable - if not output["failed"]: - try: - save_thread_id( - payload, - self._fabric_runtime_id, - self._thread.id, - ) - except CodexAdapterError as error: - self._unusable = True - output = adapter_failure(error) if self._relay is not None: output = _relay_output(output, self._relay) return output @@ -1235,6 +1109,7 @@ async def invoke(self, payload: dict[str, Any]) -> dict[str, Any]: async def stop(self) -> None: client = self._client self._client = None + self._start_payload = None self._thread = None self._fabric_runtime_id = None self._unusable = True @@ -1285,55 +1160,6 @@ async def _cleanup_failed_start(self) -> None: ) -async def run_codex(payload: dict[str, Any]) -> dict[str, Any]: - """Run one Fabric invocation with SDK-owned Codex execution.""" - - fabric_runtime_id = validate_payload(payload) - prior_thread_id = load_thread_id(payload, fabric_runtime_id) - relay = prepare_codex_relay(payload) - gateway_process = None - cleanup_error: AdapterRelayError | None = None - try: - gateway_process = _start_relay_gateway(payload, relay) - output, thread_id = await invoke_codex_sdk( - payload, prior_thread_id=prior_thread_id, relay=relay - ) - if not output["failed"] and thread_id is not None: - save_thread_id(payload, fabric_runtime_id, thread_id) - finally: - cleanup_error = _cleanup_relay(relay, gateway_process) - - if relay is not None: - output = _relay_output(output, relay) - if cleanup_error is not None: - cleanup: dict[str, Any] = { - "code": cleanup_error.code, - "message": cleanup_error.message, - "retryable": False, - } - if cleanup_error.metadata: - cleanup["metadata"] = cleanup_error.metadata - output["relay_runtime"]["cleanup_error"] = cleanup - if not output["failed"]: - output["completed"] = False - output["failed"] = True - output["error"] = cleanup - return output - - -def run(payload: dict[str, Any]) -> dict[str, Any]: - """Run one isolated adapter invocation for direct library tests.""" - - try: - return asyncio.run(run_codex(payload)) - except CodexAdapterError as error: - return adapter_failure(error) - except Exception: - return _failure( - "codex_adapter_internal_error", "Codex adapter failed unexpectedly" - ) - - def main() -> None: """Serve the persistent local-host lifecycle protocol.""" diff --git a/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py b/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py index f5f2b153..0fcb7767 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py +++ b/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py @@ -6,7 +6,6 @@ from __future__ import annotations import asyncio -import copy import json import os import sys @@ -65,13 +64,11 @@ class _AdapterCallError(LifecycleError): class _HostState: runtime: AdapterRuntime | None = None runtime_id: str | None = None - start_payload: dict[str, Any] | None = None failed: bool = False def clear(self) -> None: self.runtime = None self.runtime_id = None - self.start_payload = None self.failed = False @@ -230,7 +227,6 @@ async def _handle_start( "Lifecycle host already owns a runtime", ) candidate = runtime_factory() - retained_payload = copy.deepcopy(payload) try: await _adapter_call("start", lambda: candidate.start(payload)) except LifecycleError: @@ -238,7 +234,6 @@ async def _handle_start( raise state.runtime = candidate state.runtime_id = message_runtime_id - state.start_payload = retained_payload state.failed = False return _response("start") @@ -253,18 +248,8 @@ async def _handle_invoke( "lifecycle_runtime_failed", "Lifecycle runtime cannot accept another invocation", ) - if state.start_payload is None: - raise LifecycleError( - "lifecycle_not_started", - "Lifecycle host has no retained start payload", - ) - invocation_payload = copy.deepcopy(state.start_payload) - invocation_payload["runtime_context"] = payload.get("runtime_context") - invocation_payload["request"] = payload.get("request") with _invocation_environment(payload): - output = await _adapter_call( - "invoke", lambda: runtime.invoke(invocation_payload) - ) + output = await _adapter_call("invoke", lambda: runtime.invoke(payload)) return _response("invoke", output=output) diff --git a/adapters/deepagents/fabric-adapter.json b/adapters/deepagents/fabric-adapter.json index 7435cf8f..c32aa03b 100644 --- a/adapters/deepagents/fabric-adapter.json +++ b/adapters/deepagents/fabric-adapter.json @@ -4,8 +4,7 @@ "harness": "deepagents", "adapter_kind": "python", "runner": { - "module": "nemo_fabric_adapters.deepagents.adapter", - "callable": "run" + "module": "nemo_fabric_adapters.deepagents.adapter" }, "requirements": {}, "config": { @@ -20,8 +19,5 @@ "outputs": ["otel", "openinference"] } } - }, - "runtime": { - "local_host": {} } } diff --git a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py index 75334f4a..0a3523e7 100644 --- a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py +++ b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py @@ -5,8 +5,8 @@ """LangChain Deep Agents adapter for Fabric. Maps Fabric's normalized invocation onto the ``deepagents`` SDK and returns a -normalized Fabric result. Supports single-run, multi-turn, and resumed execution -via a persistent LangGraph checkpointer keyed by the Fabric session id. +normalized Fabric result. A started runtime retains one compiled graph and +LangGraph checkpointer across ordered invocations. """ from __future__ import annotations @@ -122,14 +122,6 @@ def main() -> None: lifecycle.serve(DeepAgentsRuntime) -def run(payload: dict[str, Any]) -> dict[str, Any]: - """Run one isolated adapter invocation for direct library tests.""" - - import asyncio - - return asyncio.run(run_deepagents(payload)) - - def preflight_check(payload: dict[str, Any]) -> None: """Validate invocation-time prerequisites and fail fast with clear errors. @@ -325,12 +317,7 @@ def _mcp_connection(name: str, spec: dict[str, Any]) -> dict[str, Any]: return {"transport": transport, "url": target} -# --- runtime / resume state ------------------------------------------------ -# -# Resume is keyed by the Fabric ``runtime_id`` (stable across ``invoke`` calls in -# a started runtime, fresh for each one-shot ``run``), mirroring the Codex -# adapter. LangGraph owns the transcript via a persistent SQLite checkpointer; -# Fabric owns the runtime-to-LangGraph-thread correlation record. +# --- runtime state --------------------------------------------------------- def state_dir(payload: dict[str, Any]) -> Path: @@ -347,42 +334,14 @@ def state_dir(payload: dict[str, Any]) -> Path: return base_dir / "artifacts" / "deepagents" / ".fabric" -def runtime_state_paths(payload: dict[str, Any], runtime_id: str) -> tuple[Path, Path]: +def checkpointer_path(payload: dict[str, Any], runtime_id: str) -> Path: key = hashlib.sha256(runtime_id.encode("utf-8")).hexdigest() base = state_dir(payload) / "runtimes" - return base / f"{key}.json", base / f"{key}.sqlite" - - -def load_thread_id(payload: dict[str, Any], runtime_id: str) -> str | None: - json_path, _ = runtime_state_paths(payload, runtime_id) - if not json_path.is_file(): - return None - value = json.loads(json_path.read_text(encoding="utf-8")) - if ( - not isinstance(value, dict) - or value.get("runtime_id") != runtime_id - or not value.get("thread_id") - ): - raise RuntimeError(f"invalid Deep Agents runtime state in {json_path}") - return str(value["thread_id"]) - - -def save_thread_id(payload: dict[str, Any], runtime_id: str, thread_id: str) -> None: - json_path, _ = runtime_state_paths(payload, runtime_id) - json_path.parent.mkdir(parents=True, exist_ok=True) - invocation_id = ( - common_utils.runtime_context(payload).get("invocation_id") or "pending" - ) - tmp = json_path.with_suffix(f".{invocation_id}.tmp") - tmp.write_text( - json.dumps({"runtime_id": runtime_id, "thread_id": thread_id}, indent=2), - encoding="utf-8", - ) - os.replace(tmp, json_path) + return base / f"{key}.sqlite" async def open_checkpointer(state_sqlite: Path) -> Any: - """Open a persistent async LangGraph checkpointer so resume works across processes. + """Open the async LangGraph checkpointer owned by a started runtime. The agent is driven with ``astream``, so the checkpointer must be async: the synchronous ``SqliteSaver`` raises ``NotImplementedError`` from its async methods. @@ -393,7 +352,7 @@ async def open_checkpointer(state_sqlite: Path) -> Any: state_sqlite.parent.mkdir(parents=True, exist_ok=True) saver_cm = AsyncSqliteSaver.from_conn_string(str(state_sqlite)) saver = await saver_cm.__aenter__() - # Keep the context manager alive for the duration of the invocation. + # Keep the context manager alive for the duration of the runtime. saver._fabric_cm = saver_cm # type: ignore[attr-defined] return saver @@ -508,10 +467,10 @@ class DeepAgentsRuntime: def __init__(self) -> None: self._started = False + self._start_payload: dict[str, Any] | None = None self._runtime_id: str | None = None self._model_name: str | None = None self._base_url: str | None = None - self._prior_thread_id: str | None = None self._thread_id: str | None = None self._completed_invocations = 0 self._checkpointer: Any = None @@ -537,12 +496,7 @@ async def start(self, payload: dict[str, Any]) -> None: runtime_id = common_utils.runtime_context(payload).get("runtime_id") model, self._model_name, self._base_url = build_chat_model(payload) self._runtime_id = runtime_id - self._prior_thread_id = ( - load_thread_id(payload, runtime_id) if runtime_id else None - ) - self._thread_id = ( - (self._prior_thread_id or uuid.uuid4().hex) if runtime_id else None - ) + self._thread_id = uuid.uuid4().hex if runtime_id else None telemetry_providers = common_utils.telemetry_providers(payload) relay_enabled = common_utils.relay_enabled(payload) @@ -561,8 +515,9 @@ async def start(self, payload: dict[str, Any]) -> None: agent_kwargs = await build_agent_kwargs(payload, model, settings) if runtime_id: - _, state_sqlite = runtime_state_paths(payload, runtime_id) - self._checkpointer = await open_checkpointer(state_sqlite) + self._checkpointer = await open_checkpointer( + checkpointer_path(payload, runtime_id) + ) agent_kwargs["checkpointer"] = self._checkpointer if self._observability is not None: @@ -573,6 +528,7 @@ async def start(self, payload: dict[str, Any]) -> None: self._agent = create_deep_agent( **_supported_kwargs(create_deep_agent, agent_kwargs) ) + self._start_payload = payload self._started = True except BaseException: await self.stop() @@ -602,19 +558,25 @@ def _configure_observability(self, agent_kwargs: dict[str, Any]) -> dict[str, An self._callback_handler_type = NemoRelayDeepAgentsCallbackHandler return add_nemo_relay_integration(agent_kwargs) - async def invoke(self, payload: dict[str, Any]) -> dict[str, Any]: - if not self._started or self._agent is None: + async def invoke(self, invocation: dict[str, Any]) -> dict[str, Any]: + start_payload = self._start_payload + if not self._started or self._agent is None or start_payload is None: raise lifecycle.LifecycleError( "deepagents_runtime_not_started", "Deep Agents runtime is not started", ) - runtime_id = common_utils.runtime_context(payload).get("runtime_id") + runtime_id = common_utils.runtime_context(invocation).get("runtime_id") if runtime_id != self._runtime_id: raise lifecycle.LifecycleError( "deepagents_runtime_mismatch", "Deep Agents invocation does not match the active runtime", ) + payload = { + **start_payload, + "runtime_context": invocation.get("runtime_context"), + "request": invocation.get("request"), + } request = payload.get("request") or {} user_message = request.get("input") or "" if not isinstance(user_message, str): @@ -624,7 +586,7 @@ async def invoke(self, payload: dict[str, Any]) -> dict[str, Any]: events: list[dict[str, Any]] = [] turn_messages: list[dict[str, Any]] = [] error: str | None = None - resumed = bool(self._prior_thread_id) or self._completed_invocations > 0 + resumed = self._completed_invocations > 0 try: if self._observability is not None: callback_handler = self._callback_handler_type() @@ -651,8 +613,7 @@ async def invoke(self, payload: dict[str, Any]) -> dict[str, Any]: except Exception as exc: # normalized adapter failure error = f"{type(exc).__name__}: {exc}" - if error is None and self._runtime_id and self._thread_id: - save_thread_id(payload, self._runtime_id, self._thread_id) + if error is None: self._completed_invocations += 1 telemetry_runtime, relay_artifacts = self._telemetry_output() @@ -670,25 +631,6 @@ async def invoke(self, payload: dict[str, Any]) -> dict[str, Any]: relay_artifacts=relay_artifacts, ) - def failure_output( - self, payload: dict[str, Any], error: BaseException - ) -> dict[str, Any]: - telemetry_runtime, relay_artifacts = self._telemetry_output() - return normalize_output( - model_name=self._model_name, - base_url=self._base_url, - runtime_id=self._runtime_id - or common_utils.runtime_context(payload).get("runtime_id"), - thread_id=self._thread_id, - resumed=bool(self._prior_thread_id), - result_state=None, - events=[], - turn_messages=[], - error=f"{type(error).__name__}: {error}", - telemetry_runtime=telemetry_runtime, - relay_artifacts=relay_artifacts, - ) - def _telemetry_output( self, ) -> tuple[dict[str, Any] | None, list[dict[str, str]] | None]: @@ -708,28 +650,26 @@ def _telemetry_output( async def stop(self) -> None: checkpointer = self._checkpointer + self._start_payload = None + self._runtime_id = None + self._model_name = None + self._base_url = None + self._thread_id = None + self._completed_invocations = 0 self._checkpointer = None self._agent = None + self._observability = None + self._telemetry_provider = "" + self._relay_plugin = None + self._relay_scope = None + self._relay_scope_type = None + self._relay_api_config = None + self._callback_handler_type = None self._started = False if checkpointer is not None: await close_checkpointer(checkpointer) -async def run_deepagents(payload: dict[str, Any]) -> dict[str, Any]: - runtime = DeepAgentsRuntime() - try: - await runtime.start(payload) - except Exception as error: - output = runtime.failure_output(payload, error) - await runtime.stop() - return output - - try: - return await runtime.invoke(payload) - finally: - await runtime.stop() - - def _apply_callbacks( config: dict[str, Any], callbacks: list[Any] | None ) -> dict[str, Any]: @@ -745,25 +685,6 @@ def _apply_callbacks( return config -async def invoke_agent( - agent_kwargs: dict[str, Any], - user_message: str, - thread_id: str | None, - callbacks: list[Any] | None = None, -) -> tuple[Any, list[dict[str, Any]], list[dict[str, Any]]]: - """Create and run an isolated agent for direct library tests.""" - - from deepagents import create_deep_agent - - agent = create_deep_agent(**_supported_kwargs(create_deep_agent, agent_kwargs)) - return await invoke_compiled_agent( - agent, - user_message, - thread_id, - callbacks=callbacks, - ) - - async def invoke_compiled_agent( agent: Any, user_message: str, @@ -772,9 +693,10 @@ async def invoke_compiled_agent( ) -> tuple[Any, list[dict[str, Any]], list[dict[str, Any]]]: """Run a compiled agent and return state, events, and current-turn messages. - On a resumed run the final ``values`` state also replays prior-turn messages, - so usage/cost must be aggregated from the messages emitted *this* turn — the - ``updates`` deltas — rather than the full final state. + On a later runtime invocation the final ``values`` state also replays + prior-turn messages, so usage/cost must be aggregated from the messages + emitted *this* turn — the ``updates`` deltas — rather than the full final + state. ``callbacks`` (e.g. the NeMo Relay callback handler) are appended to the LangGraph run config; any callbacks already present are preserved rather than @@ -877,7 +799,7 @@ def normalize_output( ) -> dict[str, Any]: messages = _extract_messages(result_state) response = _final_response(messages) - # Aggregate usage/cost from this turn's messages only; the resumed final state + # Aggregate usage/cost from this turn's messages only; the replayed final state # replays prior-turn messages that must not be re-counted. usage = _aggregate_usage(turn_messages) diff --git a/adapters/hermes/fabric-adapter.json b/adapters/hermes/fabric-adapter.json index 46d9fc7f..ef60fb92 100644 --- a/adapters/hermes/fabric-adapter.json +++ b/adapters/hermes/fabric-adapter.json @@ -4,8 +4,7 @@ "harness": "hermes", "adapter_kind": "python", "runner": { - "module": "nemo_fabric_adapters.hermes.adapter", - "callable": "run" + "module": "nemo_fabric_adapters.hermes.adapter" }, "requirements": { "env": [ @@ -32,8 +31,5 @@ ] } } - }, - "runtime": { - "local_host": {} } } diff --git a/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py b/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py index 1dd14fb0..07fd53aa 100755 --- a/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py +++ b/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py @@ -161,12 +161,6 @@ def main() -> None: lifecycle.serve(HermesRuntime) -def run(payload: dict[str, Any]) -> dict[str, Any]: - """Run one isolated adapter invocation for direct library tests.""" - - return asyncio.run(run_hermes(payload)) - - def resolve_hermes_toolsets( settings: dict[str, Any], config: dict[str, Any] ) -> list[str] | None: @@ -179,31 +173,12 @@ def resolve_hermes_toolsets( return sorted(_get_platform_tools(config, platform)) -def load_runtime_history( - session_db: Any, session_id: str | None -) -> list[dict[str, Any]] | None: - if not session_id: - return None - - resolved_id = session_id - resolve_session = getattr(session_db, "resolve_resume_session_id", None) - if resolve_session is not None: - resolved_id = resolve_session(session_id) or session_id - if session_db.get_session(resolved_id) is None: - return None - - messages = session_db.get_messages_as_conversation(resolved_id) - messages = [ - message for message in messages if message.get("role") != "session_meta" - ] - return messages or None - - class HermesRuntime: """One Hermes agent and session database owned by a Fabric runtime.""" def __init__(self) -> None: self._started = False + self._start_payload: dict[str, Any] | None = None self._runtime_id: str | None = None self._settings: dict[str, Any] = {} self._model_config: dict[str, Any] = {} @@ -303,9 +278,7 @@ async def start(self, payload: dict[str, Any]) -> None: self._settings, loaded_hermes_config ) self._session_db = SessionDB() - self._conversation_history = load_runtime_history( - self._session_db, self._runtime_id - ) + self._conversation_history = None max_iterations = self._settings.get("max_iterations") if max_iterations is None: max_iterations = DEFAULT_MAX_ITERATIONS @@ -344,23 +317,30 @@ async def start(self, payload: dict[str, Any]) -> None: ) ) self._invoke_hook = invoke_hook + self._start_payload = payload self._started = True except BaseException: await self.stop() raise - async def invoke(self, payload: dict[str, Any]) -> dict[str, Any]: - if not self._started or self._agent is None: + async def invoke(self, invocation: dict[str, Any]) -> dict[str, Any]: + start_payload = self._start_payload + if not self._started or self._agent is None or start_payload is None: raise lifecycle.LifecycleError( "hermes_runtime_not_started", "Hermes runtime is not started", ) - if common_utils.runtime_id(payload) != self._runtime_id: + if common_utils.runtime_id(invocation) != self._runtime_id: raise lifecycle.LifecycleError( "hermes_runtime_mismatch", "Hermes invocation does not match the active runtime", ) + payload = { + **start_payload, + "runtime_context": invocation.get("runtime_context"), + "request": invocation.get("request"), + } request = common_utils.request_payload(payload) user_message = request.get("input") or "" if not isinstance(user_message, str): @@ -452,12 +432,23 @@ async def stop(self) -> None: errors.append(error) self._agent = None self._session_db = None + self._start_payload = None + self._runtime_id = None + self._settings = {} + self._model_config = {} + self._base_url = None + self._hermes_home = None + self._hermes_config_path = None + self._hermes_config = {} + self._enabled_toolsets = None + self._conversation_history = None self._relay_context = None self._relay_context_entered = False self._relay_session_pending = False self._relay_finalize_hook_invoked = False self._invoke_hook = None self._relay_plugin_config = None + self._relay_model_name = "unknown" self._started = False if agent is not None: @@ -490,15 +481,6 @@ async def stop(self) -> None: ) from errors[0] -async def run_hermes(payload: dict[str, Any]) -> dict[str, Any]: - runtime = HermesRuntime() - await runtime.start(payload) - try: - return await runtime.invoke(payload) - finally: - await runtime.stop() - - def _invoke_hermes_turn( *, agent: Any, diff --git a/crates/fabric-cli/assets/adapters/claude/fabric-adapter.json b/crates/fabric-cli/assets/adapters/claude/fabric-adapter.json index 47ffc1fd..9f8996f9 100644 --- a/crates/fabric-cli/assets/adapters/claude/fabric-adapter.json +++ b/crates/fabric-cli/assets/adapters/claude/fabric-adapter.json @@ -4,8 +4,7 @@ "harness": "claude", "adapter_kind": "python", "runner": { - "module": "nemo_fabric_adapters.claude.adapter", - "callable": "run" + "module": "nemo_fabric_adapters.claude.adapter" }, "config": { "accepts": ["models", "tools", "tools.blocked", "mcp", "skills", "telemetry"] @@ -17,8 +16,5 @@ "integration_modes": ["hooks", "gateway"] } } - }, - "runtime": { - "local_host": {} } } diff --git a/crates/fabric-cli/assets/adapters/codex/fabric-adapter.json b/crates/fabric-cli/assets/adapters/codex/fabric-adapter.json index 119e7c04..251ab043 100644 --- a/crates/fabric-cli/assets/adapters/codex/fabric-adapter.json +++ b/crates/fabric-cli/assets/adapters/codex/fabric-adapter.json @@ -4,8 +4,7 @@ "harness": "codex", "adapter_kind": "python", "runner": { - "module": "nemo_fabric_adapters.codex.adapter", - "callable": "run" + "module": "nemo_fabric_adapters.codex.adapter" }, "config": { "accepts": ["models", "mcp", "skills", "telemetry"] @@ -20,8 +19,5 @@ "outputs": ["otel"] } } - }, - "runtime": { - "local_host": {} } } diff --git a/crates/fabric-cli/assets/adapters/deepagents/fabric-adapter.json b/crates/fabric-cli/assets/adapters/deepagents/fabric-adapter.json index 7435cf8f..c32aa03b 100644 --- a/crates/fabric-cli/assets/adapters/deepagents/fabric-adapter.json +++ b/crates/fabric-cli/assets/adapters/deepagents/fabric-adapter.json @@ -4,8 +4,7 @@ "harness": "deepagents", "adapter_kind": "python", "runner": { - "module": "nemo_fabric_adapters.deepagents.adapter", - "callable": "run" + "module": "nemo_fabric_adapters.deepagents.adapter" }, "requirements": {}, "config": { @@ -20,8 +19,5 @@ "outputs": ["otel", "openinference"] } } - }, - "runtime": { - "local_host": {} } } diff --git a/crates/fabric-cli/assets/adapters/hermes/fabric-adapter.json b/crates/fabric-cli/assets/adapters/hermes/fabric-adapter.json index 46d9fc7f..ef60fb92 100644 --- a/crates/fabric-cli/assets/adapters/hermes/fabric-adapter.json +++ b/crates/fabric-cli/assets/adapters/hermes/fabric-adapter.json @@ -4,8 +4,7 @@ "harness": "hermes", "adapter_kind": "python", "runner": { - "module": "nemo_fabric_adapters.hermes.adapter", - "callable": "run" + "module": "nemo_fabric_adapters.hermes.adapter" }, "requirements": { "env": [ @@ -32,8 +31,5 @@ ] } } - }, - "runtime": { - "local_host": {} } } diff --git a/crates/fabric-cli/assets/adapters/scripted/fabric-adapter.json b/crates/fabric-cli/assets/adapters/scripted/fabric-adapter.json index 57c214cf..3a74fbb6 100644 --- a/crates/fabric-cli/assets/adapters/scripted/fabric-adapter.json +++ b/crates/fabric-cli/assets/adapters/scripted/fabric-adapter.json @@ -6,8 +6,5 @@ "runner": { "command": "python3", "script": "run.py" - }, - "runtime": { - "local_host": {} } } diff --git a/crates/fabric-core/src/config.rs b/crates/fabric-core/src/config.rs index 388ca91a..5316c433 100644 --- a/crates/fabric-core/src/config.rs +++ b/crates/fabric-core/src/config.rs @@ -119,9 +119,6 @@ pub struct AdapterDescriptor { /// Telemetry support declared by this adapter. #[serde(default)] pub telemetry: AdapterTelemetrySupport, - /// Adapter-owned runtime hosting support. - #[serde(default, skip_serializing_if = "AdapterRuntimeSupport::is_empty")] - pub runtime: AdapterRuntimeSupport, /// Runtime lifecycle operations supported by this adapter. #[serde(default)] pub capabilities: RuntimeCapabilities, @@ -130,34 +127,6 @@ pub struct AdapterDescriptor { pub extensions: BTreeMap, } -/// Runtime hosting mechanisms implemented by an adapter. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct AdapterRuntimeSupport { - /// Persistent local-host support, when implemented. - /// - /// This nesting is intentionally provisional until remote adapter hosting - /// introduces a shared runtime protocol. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub local_host: Option, - /// Additive adapter runtime-hosting fields. - #[serde(default, flatten)] - pub extensions: BTreeMap, -} - -impl AdapterRuntimeSupport { - fn is_empty(&self) -> bool { - self.local_host.is_none() && self.extensions.is_empty() - } -} - -/// Persistent local-host protocol implemented by an adapter. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct AdapterLocalHostSupport { - /// Additive persistent local-host fields. - #[serde(default, flatten)] - pub extensions: BTreeMap, -} - /// Where Fabric resolved an adapter descriptor from. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] @@ -408,11 +377,11 @@ impl ResolveContext { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum AdapterKind { - /// Launch and supervise a CLI process. + /// Launch and supervise a persistent adapter process. Process, /// Connect to a service or HTTP-backed harness. Http, - /// Call a Python SDK/plugin adapter. + /// Launch and supervise a persistent Python adapter host. Python, /// Delegate to a harness-native plugin package. NativePlugin, @@ -1125,24 +1094,6 @@ fn validate_adapter_descriptor_shape(descriptor: &AdapterDescriptor, path: &Path if descriptor.harness.trim().is_empty() { return invalid_adapter_descriptor(path, "harness must not be empty"); } - match descriptor.adapter_kind { - AdapterKind::Process | AdapterKind::Python => { - if descriptor.runtime.local_host.is_none() { - return invalid_adapter_descriptor( - path, - "local process and python adapters must declare runtime.local_host", - ); - } - } - AdapterKind::Http | AdapterKind::NativePlugin => { - if descriptor.runtime.local_host.is_some() { - return invalid_adapter_descriptor( - path, - "runtime.local_host is supported only by process and python adapters", - ); - } - } - } Ok(()) } @@ -1168,7 +1119,7 @@ fn resolve_runtime_capabilities( matches!( descriptor.adapter_kind, AdapterKind::Process | AdapterKind::Python - ) && descriptor.runtime.local_host.is_some() + ) }); let descriptor_capabilities = descriptor .map(|descriptor| descriptor.capabilities.clone()) @@ -1761,94 +1712,4 @@ mod tests { assert_eq!(descriptor.contract_version, ADAPTER_CONTRACT_VERSION); assert_eq!(descriptor.adapter_kind, AdapterKind::Python); } - - #[test] - fn repository_adapters_declare_local_host() { - let repository_root = repository_root(); - for relative_path in [ - "adapters/claude/fabric-adapter.json", - "adapters/codex/fabric-adapter.json", - "adapters/deepagents/fabric-adapter.json", - "adapters/hermes/fabric-adapter.json", - "crates/fabric-cli/assets/adapters/claude/fabric-adapter.json", - "crates/fabric-cli/assets/adapters/codex/fabric-adapter.json", - "crates/fabric-cli/assets/adapters/deepagents/fabric-adapter.json", - "crates/fabric-cli/assets/adapters/hermes/fabric-adapter.json", - "crates/fabric-cli/assets/adapters/scripted/fabric-adapter.json", - "examples/harbor/calculator/task/environment/fabric/adapters/scripted/fabric-adapter.json", - "examples/harbor/swebench/adapters/claude/fabric-adapter.json", - "examples/harbor/swebench/adapters/hermes/fabric-adapter.json", - "tests/fixtures/hermes-shim-agent/adapters/hermes-shim/fabric-adapter.json", - ] { - let descriptor = load_adapter_descriptor(repository_root.join(relative_path)) - .unwrap_or_else(|error| panic!("{relative_path}: {error}")); - - assert!(descriptor.runtime.local_host.is_some(), "{relative_path}"); - } - } - - #[test] - fn accepts_local_host_on_process_adapter() { - let descriptor: AdapterDescriptor = serde_json::from_value(serde_json::json!({ - "contract_version": ADAPTER_CONTRACT_VERSION, - "adapter_id": "acme.fabric.process-host", - "harness": "process-host", - "adapter_kind": "process", - "runtime": { - "local_host": {} - }, - })) - .expect("adapter descriptor"); - - validate_adapter_descriptor_shape( - &descriptor, - Path::new("process-host/fabric-adapter.json"), - ) - .expect("process adapter may implement the persistent local-host protocol"); - } - - #[test] - fn rejects_local_adapter_without_local_host() { - let descriptor: AdapterDescriptor = serde_json::from_value(serde_json::json!({ - "contract_version": ADAPTER_CONTRACT_VERSION, - "adapter_id": "acme.fabric.python-host", - "harness": "python-host", - "adapter_kind": "python", - })) - .expect("adapter descriptor"); - - let error = validate_adapter_descriptor_shape( - &descriptor, - Path::new("python-host/fabric-adapter.json"), - ) - .expect_err("python adapter must declare a persistent local host"); - assert!(matches!( - error, - FabricError::InvalidAdapterDescriptor { message, .. } - if message.contains("must declare runtime.local_host") - )); - } - - #[test] - fn rejects_local_host_on_remote_adapter() { - let descriptor: AdapterDescriptor = serde_json::from_value(serde_json::json!({ - "contract_version": ADAPTER_CONTRACT_VERSION, - "adapter_id": "acme.fabric.http-host", - "harness": "http-host", - "adapter_kind": "http", - "runtime": {"local_host": {}}, - })) - .expect("adapter descriptor"); - - let error = validate_adapter_descriptor_shape( - &descriptor, - Path::new("http-host/fabric-adapter.json"), - ) - .expect_err("http adapter must not declare a local host"); - assert!(matches!( - error, - FabricError::InvalidAdapterDescriptor { message, .. } - if message.contains("only by process and python adapters") - )); - } } diff --git a/crates/fabric-core/src/lib.rs b/crates/fabric-core/src/lib.rs index f93f5356..2d55cc98 100644 --- a/crates/fabric-core/src/lib.rs +++ b/crates/fabric-core/src/lib.rs @@ -11,13 +11,13 @@ pub mod schema; pub use config::{ ADAPTER_CONTRACT_VERSION, AdapterConfigSupport, AdapterDescriptor, AdapterDescriptorSource, - AdapterKind, AdapterLocalHostSupport, AdapterRequirements, AdapterRuntimeSupport, - AdapterTelemetryProviderSupport, AdapterTelemetrySupport, CapabilityPlan, ControlLocation, - EnvironmentConfig, EnvironmentOwnership, EnvironmentPlan, FabricConfig, HarnessConfig, - McpConfig, McpExposure, McpServerPlan, MetadataConfig, ModelConfig, ResolutionStrategy, - ResolveContext, ResolvedAdapterDescriptor, RunPlan, RuntimeCapabilities, RuntimeConfig, - SkillConfig, TelemetryConfig, TelemetryPlan, TelemetryProvider, TelemetryProviderConfig, - load_adapter_descriptor, resolve_run_plan_from_config, + AdapterKind, AdapterRequirements, AdapterTelemetryProviderSupport, AdapterTelemetrySupport, + CapabilityPlan, ControlLocation, EnvironmentConfig, EnvironmentOwnership, EnvironmentPlan, + FabricConfig, HarnessConfig, McpConfig, McpExposure, McpServerPlan, MetadataConfig, + ModelConfig, ResolutionStrategy, ResolveContext, ResolvedAdapterDescriptor, RunPlan, + RuntimeCapabilities, RuntimeConfig, SkillConfig, TelemetryConfig, TelemetryPlan, + TelemetryProvider, TelemetryProviderConfig, load_adapter_descriptor, + resolve_run_plan_from_config, }; pub use doctor::{DoctorCheck, DoctorReport, DoctorStatus, doctor_plan}; pub use error::{FabricError, Result}; diff --git a/crates/fabric-core/src/runtime.rs b/crates/fabric-core/src/runtime.rs index bce2746b..9b28af87 100644 --- a/crates/fabric-core/src/runtime.rs +++ b/crates/fabric-core/src/runtime.rs @@ -283,7 +283,7 @@ pub struct InvocationHandle { pub runtime_id: String, } -/// Per-run/per-invocation context passed to harness adapters. +/// Context generated for one invocation of a started runtime. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct RuntimeContext { /// Runtime handle id. @@ -320,7 +320,7 @@ pub struct RuntimeTelemetryContext { /// One invocation against an initialized adapter runtime. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct AdapterInvocation { - /// Per-runtime and per-invocation context generated by Fabric. + /// Invocation context generated by Fabric. pub runtime_context: RuntimeContext, /// Typed caller request for this invocation. pub request: RunRequest, @@ -534,7 +534,7 @@ pub fn prepare_environment(plan: &RunPlan) -> Result { pub fn start_runtime(plan: &RunPlan) -> Result { validate_blocked_tools_support(plan)?; let environment = prepare_environment(plan)?; - if uses_local_host(plan)? { + if uses_local_host(plan) { return LocalHostAdapter.start(plan, environment); } Err(FabricError::UnsupportedRuntimeAdapter { @@ -551,7 +551,7 @@ pub fn invoke_runtime( ) -> Result { validate_blocked_tools_support(plan)?; validate_runtime_handle(plan, runtime)?; - if uses_local_host(plan)? { + if uses_local_host(plan) { return LocalHostAdapter.invoke(plan, runtime, request); } Err(FabricError::UnsupportedRuntimeAdapter { @@ -575,7 +575,7 @@ fn validate_blocked_tools_support(plan: &RunPlan) -> Result<()> { /// Stop or detach from a harness runtime. pub fn stop_runtime(plan: &RunPlan, runtime: &RuntimeHandle) -> Result> { validate_runtime_handle(plan, runtime)?; - if uses_local_host(plan)? { + if uses_local_host(plan) { return LocalHostAdapter.stop(runtime); } Err(FabricError::UnsupportedRuntimeAdapter { @@ -584,12 +584,11 @@ pub fn stop_runtime(plan: &RunPlan, runtime: &RuntimeHandle) -> Result Result { - Ok(plan - .adapter_descriptor - .as_ref() - .and_then(|adapter| adapter.descriptor.runtime.local_host.as_ref()) - .is_some()) +fn uses_local_host(plan: &RunPlan) -> bool { + matches!( + adapter_kind(plan), + AdapterKind::Process | AdapterKind::Python + ) } fn validate_runtime_handle(plan: &RunPlan, runtime: &RuntimeHandle) -> Result<()> { @@ -2348,10 +2347,9 @@ fn event_with_metadata( } fn new_id(prefix: &str) -> String { - // The atomic counter only differentiates ids within a single process; a - // process-backed runner spawns a fresh `fabric` process per call, resetting it - // to 1. Include the process id (distinct across concurrently running - // processes) so ids stay unique when two runs land in the same millisecond. + // The atomic counter only differentiates ids within one Fabric process. + // Include the process id so independently running Fabric processes cannot + // collide when they generate ids in the same millisecond. let counter = NEXT_ID.fetch_add(1, Ordering::Relaxed); format!("{prefix}-{}-{}-{counter}", now_millis(), std::process::id()) } @@ -2370,19 +2368,6 @@ mod tests { use super::*; use crate::config::{ResolveContext, resolve_run_plan_from_config}; - #[test] - fn local_host_lifecycle_messages_are_unversioned() { - let request = - AdapterLifecycleRequest::new(AdapterLifecycleRequestKind::Stop(AdapterLifecycleStop { - runtime_id: "runtime-1".to_string(), - })); - let value = serde_json::to_value(request).expect("serialize lifecycle request"); - - assert_eq!(value["operation"], "stop"); - assert!(value.get("contract_version").is_none()); - assert!(value.get("protocol_version").is_none()); - } - fn local_host_plan(mode: &str) -> (PathBuf, RunPlan) { local_host_plan_with_relay(mode, false) } @@ -2403,9 +2388,6 @@ mod tests { "providers": { "relay": {"outputs": ["atif"]} } - }, - "runtime": { - "local_host": {} } }"#, ) @@ -2856,7 +2838,7 @@ for line in sys.stdin: } #[test] - fn local_host_crash_never_falls_back_to_per_invocation_execution() { + fn local_host_crash_is_terminal_for_runtime() { let (root, plan) = local_host_plan("crash_after_start"); let runtime = start_runtime(&plan).expect("start local host"); diff --git a/examples/harbor/calculator/task/environment/fabric/adapters/scripted/fabric-adapter.json b/examples/harbor/calculator/task/environment/fabric/adapters/scripted/fabric-adapter.json index b0412d43..f41862d3 100644 --- a/examples/harbor/calculator/task/environment/fabric/adapters/scripted/fabric-adapter.json +++ b/examples/harbor/calculator/task/environment/fabric/adapters/scripted/fabric-adapter.json @@ -6,8 +6,5 @@ "runner": { "command": "python3", "script": "run.py" - }, - "runtime": { - "local_host": {} } } diff --git a/examples/harbor/swebench/adapters/claude/fabric-adapter.json b/examples/harbor/swebench/adapters/claude/fabric-adapter.json index 47ffc1fd..9f8996f9 100644 --- a/examples/harbor/swebench/adapters/claude/fabric-adapter.json +++ b/examples/harbor/swebench/adapters/claude/fabric-adapter.json @@ -4,8 +4,7 @@ "harness": "claude", "adapter_kind": "python", "runner": { - "module": "nemo_fabric_adapters.claude.adapter", - "callable": "run" + "module": "nemo_fabric_adapters.claude.adapter" }, "config": { "accepts": ["models", "tools", "tools.blocked", "mcp", "skills", "telemetry"] @@ -17,8 +16,5 @@ "integration_modes": ["hooks", "gateway"] } } - }, - "runtime": { - "local_host": {} } } diff --git a/examples/harbor/swebench/adapters/hermes/fabric-adapter.json b/examples/harbor/swebench/adapters/hermes/fabric-adapter.json index 46d9fc7f..ef60fb92 100644 --- a/examples/harbor/swebench/adapters/hermes/fabric-adapter.json +++ b/examples/harbor/swebench/adapters/hermes/fabric-adapter.json @@ -4,8 +4,7 @@ "harness": "hermes", "adapter_kind": "python", "runner": { - "module": "nemo_fabric_adapters.hermes.adapter", - "callable": "run" + "module": "nemo_fabric_adapters.hermes.adapter" }, "requirements": { "env": [ @@ -32,8 +31,5 @@ ] } } - }, - "runtime": { - "local_host": {} } } diff --git a/schemas/adapter-descriptor.schema.json b/schemas/adapter-descriptor.schema.json index 32b153d4..97d42cb9 100644 --- a/schemas/adapter-descriptor.schema.json +++ b/schemas/adapter-descriptor.schema.json @@ -26,7 +26,7 @@ "oneOf": [ { "const": "process", - "description": "Launch and supervise a CLI process.", + "description": "Launch and supervise a persistent adapter process.", "type": "string" }, { @@ -36,7 +36,7 @@ }, { "const": "python", - "description": "Call a Python SDK/plugin adapter.", + "description": "Launch and supervise a persistent Python adapter host.", "type": "string" }, { @@ -46,11 +46,6 @@ } ] }, - "AdapterLocalHostSupport": { - "additionalProperties": true, - "description": "Persistent local-host protocol implemented by an adapter.", - "type": "object" - }, "AdapterRequirements": { "additionalProperties": true, "description": "Adapter runtime requirements.", @@ -93,24 +88,6 @@ }, "type": "object" }, - "AdapterRuntimeSupport": { - "additionalProperties": true, - "description": "Runtime hosting mechanisms implemented by an adapter.", - "properties": { - "local_host": { - "anyOf": [ - { - "$ref": "#/$defs/AdapterLocalHostSupport" - }, - { - "type": "null" - } - ], - "description": "Persistent local-host support, when implemented.\n\nThis nesting is intentionally provisional until remote adapter hosting\nintroduces a shared runtime protocol." - } - }, - "type": "object" - }, "AdapterTelemetryProviderSupport": { "additionalProperties": true, "description": "Telemetry capabilities for one adapter-supported provider.", @@ -226,10 +203,6 @@ "description": "Generic runner defaults consumed by the selected runtime adapter.", "type": "object" }, - "runtime": { - "$ref": "#/$defs/AdapterRuntimeSupport", - "description": "Adapter-owned runtime hosting support." - }, "telemetry": { "$ref": "#/$defs/AdapterTelemetrySupport", "default": {}, diff --git a/schemas/adapter-invocation.schema.json b/schemas/adapter-invocation.schema.json index 0401cc8b..756caa97 100644 --- a/schemas/adapter-invocation.schema.json +++ b/schemas/adapter-invocation.schema.json @@ -158,7 +158,7 @@ "type": "object" }, "RuntimeContext": { - "description": "Per-run/per-invocation context passed to harness adapters.", + "description": "Context generated for one invocation of a started runtime.", "properties": { "artifacts": { "$ref": "#/$defs/ArtifactManifest", @@ -243,7 +243,7 @@ }, "runtime_context": { "$ref": "#/$defs/RuntimeContext", - "description": "Per-runtime and per-invocation context generated by Fabric." + "description": "Invocation context generated by Fabric." } }, "required": [ diff --git a/schemas/run-plan.schema.json b/schemas/run-plan.schema.json index 3c904d4c..db8bb4ea 100644 --- a/schemas/run-plan.schema.json +++ b/schemas/run-plan.schema.json @@ -69,10 +69,6 @@ "description": "Generic runner defaults consumed by the selected runtime adapter.", "type": "object" }, - "runtime": { - "$ref": "#/$defs/AdapterRuntimeSupport", - "description": "Adapter-owned runtime hosting support." - }, "telemetry": { "$ref": "#/$defs/AdapterTelemetrySupport", "default": {}, @@ -107,7 +103,7 @@ "oneOf": [ { "const": "process", - "description": "Launch and supervise a CLI process.", + "description": "Launch and supervise a persistent adapter process.", "type": "string" }, { @@ -117,7 +113,7 @@ }, { "const": "python", - "description": "Call a Python SDK/plugin adapter.", + "description": "Launch and supervise a persistent Python adapter host.", "type": "string" }, { @@ -127,11 +123,6 @@ } ] }, - "AdapterLocalHostSupport": { - "additionalProperties": true, - "description": "Persistent local-host protocol implemented by an adapter.", - "type": "object" - }, "AdapterRequirements": { "additionalProperties": true, "description": "Adapter runtime requirements.", @@ -174,24 +165,6 @@ }, "type": "object" }, - "AdapterRuntimeSupport": { - "additionalProperties": true, - "description": "Runtime hosting mechanisms implemented by an adapter.", - "properties": { - "local_host": { - "anyOf": [ - { - "$ref": "#/$defs/AdapterLocalHostSupport" - }, - { - "type": "null" - } - ], - "description": "Persistent local-host support, when implemented.\n\nThis nesting is intentionally provisional until remote adapter hosting\nintroduces a shared runtime protocol." - } - }, - "type": "object" - }, "AdapterTelemetryProviderSupport": { "additionalProperties": true, "description": "Telemetry capabilities for one adapter-supported provider.", diff --git a/schemas/run-result.schema.json b/schemas/run-result.schema.json index 54272bb3..ed2da451 100644 --- a/schemas/run-result.schema.json +++ b/schemas/run-result.schema.json @@ -5,7 +5,7 @@ "oneOf": [ { "const": "process", - "description": "Launch and supervise a CLI process.", + "description": "Launch and supervise a persistent adapter process.", "type": "string" }, { @@ -15,7 +15,7 @@ }, { "const": "python", - "description": "Call a Python SDK/plugin adapter.", + "description": "Launch and supervise a persistent Python adapter host.", "type": "string" }, { diff --git a/schemas/runtime-context.schema.json b/schemas/runtime-context.schema.json index c6be1070..cda168be 100644 --- a/schemas/runtime-context.schema.json +++ b/schemas/runtime-context.schema.json @@ -166,7 +166,7 @@ } }, "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "Per-run/per-invocation context passed to harness adapters.", + "description": "Context generated for one invocation of a started runtime.", "properties": { "artifacts": { "$ref": "#/$defs/ArtifactManifest", diff --git a/schemas/runtime-handle.schema.json b/schemas/runtime-handle.schema.json index 85ba2296..9cda47df 100644 --- a/schemas/runtime-handle.schema.json +++ b/schemas/runtime-handle.schema.json @@ -5,7 +5,7 @@ "oneOf": [ { "const": "process", - "description": "Launch and supervise a CLI process.", + "description": "Launch and supervise a persistent adapter process.", "type": "string" }, { @@ -15,7 +15,7 @@ }, { "const": "python", - "description": "Call a Python SDK/plugin adapter.", + "description": "Launch and supervise a persistent Python adapter host.", "type": "string" }, { diff --git a/tests/adapters/test_adapters_common_lifecycle.py b/tests/adapters/test_adapters_common_lifecycle.py index 5cdf80ac..5b31afd7 100644 --- a/tests/adapters/test_adapters_common_lifecycle.py +++ b/tests/adapters/test_adapters_common_lifecycle.py @@ -89,7 +89,7 @@ async def stop(self): assert len(set(instances[0].loop_ids)) == 1 -def test_lifecycle_host_retains_start_config_outside_invoke_wire_payload(): +def test_lifecycle_host_passes_minimal_invoke_payload_unchanged(): runtime_id = "runtime-1" start_payload = { "agent_name": "agent", @@ -118,8 +118,8 @@ def test_lifecycle_host_retains_start_config_outside_invoke_wire_payload(): invocations = [] class Runtime: - async def start(self, payload): - payload["config"]["harness"]["settings"]["mode"] = "mutated" + async def start(self, _payload): + pass async def invoke(self, payload): invocations.append(payload) @@ -130,14 +130,7 @@ async def stop(self): lifecycle.serve(Runtime, input_stream=input_stream, output_stream=output_stream) - assert invocations == [ - { - **start_payload, - "runtime_context": invoke_payload["runtime_context"], - "request": invoke_payload["request"], - } - ] - assert invocations[0]["config"]["harness"]["settings"]["mode"] == "retained" + assert invocations == [invoke_payload] def test_lifecycle_host_rejects_runtime_mismatch_without_poisoning_runtime(): @@ -288,9 +281,7 @@ async def stop(self): ), ], ) -def test_lifecycle_host_rejects_invoke_after_adapter_failure( - failure, expected_code -): +def test_lifecycle_host_rejects_invoke_after_adapter_failure(failure, expected_code): runtime_id = "runtime-1" invoke_payload = { "runtime_context": {"runtime_id": runtime_id}, diff --git a/tests/adapters/test_claude_adapter.py b/tests/adapters/test_claude_adapter.py index bb15495d..574175d8 100644 --- a/tests/adapters/test_claude_adapter.py +++ b/tests/adapters/test_claude_adapter.py @@ -39,6 +39,41 @@ } +def lifecycle_invocation(payload: dict[str, Any]) -> dict[str, Any]: + return { + "runtime_context": payload["runtime_context"], + "request": payload["request"], + } + + +def install_fake_client(monkeypatch, response_factory): + clients = [] + + class FakeClient: + def __init__(self, options): + self.options = options + self.prompts = [] + clients.append(self) + + async def connect(self): + pass + + async def query(self, prompt): + self.prompts.append(prompt) + + def receive_response(self): + return response_factory(self) + + async def disconnect(self): + pass + + async def interrupt(self): + pass + + monkeypatch.setattr(adapter, "ClaudeSDKClient", FakeClient) + return clients + + def test_claude_descriptor_is_narrow_and_versioned(): descriptor_path = ROOT / "adapters" / "claude" / "fabric-adapter.json" descriptor = json.loads(descriptor_path.read_text(encoding="utf-8")) @@ -50,7 +85,6 @@ def test_claude_descriptor_is_narrow_and_versioned(): "adapter_kind": "python", "runner": { "module": "nemo_fabric_adapters.claude.adapter", - "callable": "run", }, "config": { "accepts": [ @@ -70,9 +104,6 @@ def test_claude_descriptor_is_narrow_and_versioned(): } } }, - "runtime": { - "local_host": {} - }, } @@ -87,27 +118,27 @@ def claude_payload_fixture(tmp_path) -> dict[str, Any]: "agent_name": "claude-test", "base_dir": str(tmp_path), "config": { - "harness": { - "adapter_id": "nvidia.fabric.claude", - "settings": { - "system_prompt": "Review carefully.", - "allowed_tools": ["Read"], - "permission_mode": "dontAsk", - "max_turns": 4, - "max_budget_usd": 1.5, - "setting_sources": [], - "timeout_seconds": 30, - "env": {"ANTHROPIC_API_KEY": "configured-secret"}, - }, + "harness": { + "adapter_id": "nvidia.fabric.claude", + "settings": { + "system_prompt": "Review carefully.", + "allowed_tools": ["Read"], + "permission_mode": "dontAsk", + "max_turns": 4, + "max_budget_usd": 1.5, + "setting_sources": [], + "timeout_seconds": 30, + "env": {"ANTHROPIC_API_KEY": "configured-secret"}, }, - "models": { - "default": { - "provider": "anthropic", - "model": "anthropic/claude-test-model", - "api_key_env": "ANTHROPIC_API_KEY", - } - }, - "tools": {"blocked": ["Bash"]}, + }, + "models": { + "default": { + "provider": "anthropic", + "model": "anthropic/claude-test-model", + "api_key_env": "ANTHROPIC_API_KEY", + } + }, + "tools": {"blocked": ["Bash"]}, }, "runtime_context": { "runtime_id": "runtime-claude-1", @@ -138,9 +169,7 @@ def claude_payload_fixture(tmp_path) -> dict[str, Any]: def test_build_options_maps_normalized_capabilities_and_claude_settings(claude_payload): - options = adapter.build_options(claude_payload, resume="claude-session") - - assert options.resume == "claude-session" + options = adapter.build_options(claude_payload) assert options.cwd == Path( claude_payload["runtime_context"]["environment"]["workspace"] ) @@ -298,7 +327,7 @@ def test_build_options_adds_relay_plugin_and_gateway_environment( ) relay = adapter.prepare_claude_relay(relay_payload) - options = adapter.build_options(relay_payload, resume=None, relay=relay) + options = adapter.build_options(relay_payload, relay=relay) assert options.env["NEMO_RELAY_GATEWAY_URL"] == relay.gateway.url assert options.env["ANTHROPIC_BASE_URL"] == relay.gateway.url @@ -325,7 +354,7 @@ def test_build_options_does_not_enable_skills_for_relay_plugin_alone( plugin_path=tmp_path / "relay-plugin", ) - options = adapter.build_options(relay_payload, resume=None, relay=relay) + options = adapter.build_options(relay_payload, relay=relay) assert options.tools is None assert options.skills is None @@ -333,11 +362,9 @@ def test_build_options_does_not_enable_skills_for_relay_plugin_alone( def test_build_options_maps_blocked_tools_to_disallowed_tools(claude_payload): - claude_payload["config"]["tools"] = { - "blocked": ["Bash", "WebFetch"] - } + claude_payload["config"]["tools"] = {"blocked": ["Bash", "WebFetch"]} - options = adapter.build_options(claude_payload, resume=None) + options = adapter.build_options(claude_payload) assert options.tools is None assert options.disallowed_tools == ["Bash", "WebFetch"] @@ -362,7 +389,7 @@ def test_build_options_rejects_normalized_capabilities_in_harness_settings( with pytest.raises( adapter.AdapterConfigError, match=normalized_field.replace(".", r"\.") ): - adapter.build_options(claude_payload, resume=None) + adapter.build_options(claude_payload) def test_build_options_rejects_skill_path_without_skill_manifest(claude_payload): @@ -370,7 +397,7 @@ def test_build_options_rejects_skill_path_without_skill_manifest(claude_payload) (skill_path / "SKILL.md").unlink() with pytest.raises(adapter.AdapterConfigError, match="SKILL.md"): - adapter.build_options(claude_payload, resume=None) + adapter.build_options(claude_payload) def test_build_options_maps_nvidia_provider_to_claude_gateway_environment( @@ -387,7 +414,7 @@ def test_build_options_maps_nvidia_provider_to_claude_gateway_environment( ) os.environ["NVIDIA_API_KEY"] = "nvidia-secret" - options = adapter.build_options(claude_payload, resume=None) + options = adapter.build_options(claude_payload) assert options.model == "aws/anthropic/claude-opus-4-5" assert options.env["ANTHROPIC_BASE_URL"] == "https://nvidia.example" @@ -410,7 +437,7 @@ def test_build_options_uses_nvidia_provider_endpoint_and_default_credential( os.environ["NVIDIA_API_KEY"] = "nvidia-secret" os.environ["NVIDIA_FRONTIER_BASE_URL"] = "https://frontier.example/v1" - options = adapter.build_options(claude_payload, resume=None) + options = adapter.build_options(claude_payload) assert options.env["ANTHROPIC_BASE_URL"] == "https://frontier.example" assert options.env["ANTHROPIC_API_KEY"] == "nvidia-secret" @@ -430,7 +457,7 @@ def test_build_options_requires_nvidia_provider_endpoint(claude_payload): os.environ.pop("NVIDIA_FRONTIER_BASE_URL", None) with pytest.raises(adapter.AdapterConfigError, match="NVIDIA_FRONTIER_BASE_URL"): - adapter.build_options(claude_payload, resume=None) + adapter.build_options(claude_payload) def test_build_options_requires_nvidia_provider_credential(claude_payload): @@ -445,7 +472,7 @@ def test_build_options_requires_nvidia_provider_credential(claude_payload): os.environ.pop("NVIDIA_API_KEY", None) with pytest.raises(adapter.AdapterConfigError, match="NVIDIA_API_KEY is required"): - adapter.build_options(claude_payload, resume=None) + adapter.build_options(claude_payload) def test_selected_model_rejects_unsupported_provider(claude_payload): @@ -458,28 +485,6 @@ def test_selected_model_rejects_unsupported_provider(claude_payload): adapter.selected_model(claude_payload) -def test_state_round_trip_is_keyed_by_fabric_runtime(claude_payload): - runtime_id = adapter.runtime_id(claude_payload) - adapter.save_claude_session_id(claude_payload, runtime_id, "claude-session") - - assert ( - adapter.load_claude_session_id(claude_payload, runtime_id) == "claude-session" - ) - state_path = adapter.runtime_state_path(claude_payload, runtime_id) - assert state_path.parent.name == "runtimes" - assert runtime_id not in state_path.name - - -def test_state_loader_rejects_non_object_json(claude_payload): - runtime_id = adapter.runtime_id(claude_payload) - state_path = adapter.runtime_state_path(claude_payload, runtime_id) - state_path.parent.mkdir(parents=True) - state_path.write_text("[]", encoding="utf-8") - - with pytest.raises(adapter.AdapterStateError, match="runtime state is invalid"): - adapter.load_claude_session_id(claude_payload, runtime_id) - - def test_normalize_result_exposes_session_usage_cost_and_buffered_events( claude_payload, ): @@ -517,51 +522,6 @@ def test_normalize_result_exposes_session_usage_cost_and_buffered_events( ] -async def test_run_claude_resumes_and_persists_session(claude_payload, monkeypatch): - captured = [] - - async def query_result(*, prompt, options): - captured.append((prompt, options.resume, dict(options.env), dict(os.environ))) - yield AssistantMessage( - content=[TextBlock(text="done")], model="claude-test-model" - ) - yield ResultMessage( - subtype="success", - duration_ms=100, - duration_api_ms=80, - is_error=False, - num_turns=1, - session_id="claude-session", - total_cost_usd=0.02, - usage={"input_tokens": 1, "output_tokens": 1}, - result="done", - ) - - mock_query = MagicMock(side_effect=query_result) - monkeypatch.setattr(adapter, "query", mock_query) - monkeypatch.setenv("FABRIC_UNRELATED_SECRET", "do-not-forward") - - first = await adapter.run_claude(claude_payload) - claude_payload["runtime_context"]["invocation_id"] = "invocation-2" - second = await adapter.run_claude(claude_payload) - - assert first["failed"] is False - assert second["failed"] is False - assert [entry[0] for entry in captured] == [ - "Inspect the patch", - "Inspect the patch", - ] - assert [entry[1] for entry in captured] == [None, "claude-session"] - assert all(entry[2]["FABRIC_UNRELATED_SECRET"] == "" for entry in captured) - assert all( - entry[2]["ANTHROPIC_API_KEY"] == "configured-secret" for entry in captured - ) - assert all( - entry[3]["FABRIC_UNRELATED_SECRET"] == "do-not-forward" for entry in captured - ) - assert os.environ["FABRIC_UNRELATED_SECRET"] == "do-not-forward" - - async def test_claude_runtime_reuses_one_connected_sdk_client( claude_payload, monkeypatch ): @@ -609,20 +569,19 @@ async def interrupt(self): start_payload.pop("request") runtime = adapter.ClaudeRuntime() await runtime.start(start_payload) - first = await runtime.invoke(claude_payload) + first = await runtime.invoke(lifecycle_invocation(claude_payload)) claude_payload["runtime_context"]["invocation_id"] = "invocation-2" claude_payload["request"]["input"] = {"not": "text"} - invalid = await runtime.invoke(claude_payload) + invalid = await runtime.invoke(lifecycle_invocation(claude_payload)) claude_payload["runtime_context"]["invocation_id"] = "invocation-3" claude_payload["request"]["input"] = "Inspect the tests" - second = await runtime.invoke(claude_payload) + second = await runtime.invoke(lifecycle_invocation(claude_payload)) await runtime.stop() assert len(clients) == 1 assert clients[0].connect_count == 1 assert clients[0].disconnect_count == 1 assert clients[0].prompts == ["Inspect the patch", "Inspect the tests"] - assert clients[0].options.resume is None assert first["response"] == "done-1" assert second["response"] == "done-2" assert invalid["error"]["code"] == "claude_invalid_request" @@ -686,9 +645,9 @@ async def interrupt(self): start_payload.pop("request") runtime = adapter.ClaudeRuntime() await runtime.start(start_payload) - first = await runtime.invoke(relay_payload) + first = await runtime.invoke(lifecycle_invocation(relay_payload)) relay_payload["runtime_context"]["invocation_id"] = "invocation-2" - second = await runtime.invoke(relay_payload) + second = await runtime.invoke(lifecycle_invocation(relay_payload)) mock_start.assert_called_once_with( launch=relay.gateway, @@ -704,9 +663,7 @@ async def interrupt(self): assert not relay.plugin_path.exists() -async def test_run_claude_supervises_relay_and_reports_artifacts( - relay_payload, monkeypatch, tmp_path -): +async def test_runtime_reports_relay_artifacts(relay_payload, monkeypatch, tmp_path): executable = tmp_path / "nemo-relay" executable.touch() relay = adapter.ClaudeRelaySettings( @@ -747,9 +704,9 @@ async def test_run_claude_supervises_relay_and_reports_artifacts( monkeypatch.setattr(adapter.relay_gateway, "start_relay_gateway", mock_start) monkeypatch.setattr(adapter.relay_gateway, "stop_relay_gateway", mock_stop) - async def query_result(*, prompt, options): - assert options.env["ANTHROPIC_BASE_URL"] == relay.gateway.url - assert Path(options.plugins[-1]["path"]) == relay.plugin_path + async def responses(client): + assert client.options.env["ANTHROPIC_BASE_URL"] == relay.gateway.url + assert Path(client.options.plugins[-1]["path"]) == relay.plugin_path yield ResultMessage( subtype="success", duration_ms=10, @@ -762,9 +719,14 @@ async def query_result(*, prompt, options): result="done", ) - monkeypatch.setattr(adapter, "query", MagicMock(side_effect=query_result)) - - output = await adapter.run_claude(relay_payload) + install_fake_client(monkeypatch, responses) + runtime = adapter.ClaudeRuntime() + start_payload = { + key: value for key, value in relay_payload.items() if key != "request" + } + await runtime.start(start_payload) + output = await runtime.invoke(lifecycle_invocation(relay_payload)) + await runtime.stop() assert output["relay_runtime"] == { "enabled": True, @@ -783,7 +745,7 @@ async def query_result(*, prompt, options): assert not relay.plugin_path.exists() -async def test_run_claude_preserves_result_when_relay_stop_fails( +async def test_runtime_stop_reports_relay_gateway_failure( relay_payload, monkeypatch, tmp_path ): relay = adapter.ClaudeRelaySettings( @@ -812,7 +774,7 @@ async def test_run_claude_preserves_result_when_relay_stop_fails( ), ) - async def query_result(*, prompt, options): + async def responses(_client): yield ResultMessage( subtype="success", duration_ms=10, @@ -825,25 +787,25 @@ async def query_result(*, prompt, options): result="done", ) - monkeypatch.setattr(adapter, "query", MagicMock(side_effect=query_result)) - - output = await adapter.run_claude(relay_payload) + install_fake_client(monkeypatch, responses) + runtime = adapter.ClaudeRuntime() + start_payload = { + key: value for key, value in relay_payload.items() if key != "request" + } + await runtime.start(start_payload) + output = await runtime.invoke(lifecycle_invocation(relay_payload)) + with pytest.raises(adapter.lifecycle.LifecycleError) as caught: + await runtime.stop() assert output["response"] == "done" - assert output["completed"] is False - assert output["failed"] is True - assert output["error"] == { - "code": "claude_relay_stop_failed", - "message": "NeMo Relay gateway failed to stop", - "retryable": False, - "metadata": {"gateway_log_path": str(relay.gateway.log_path)}, - } - assert output["relay_runtime"]["cleanup_error"] == output["error"] - assert "raw stop failure" not in json.dumps(output) + assert output["completed"] is True + assert caught.value.code == "claude_relay_stop_failed" + assert caught.value.metadata == {"gateway_log_path": str(relay.gateway.log_path)} + assert "raw stop failure" not in str(caught.value) assert not relay.plugin_path.exists() -async def test_run_claude_preserves_result_when_relay_plugin_cleanup_fails( +async def test_runtime_stop_reports_relay_plugin_cleanup_failure( relay_payload, monkeypatch, tmp_path ): relay = adapter.ClaudeRelaySettings( @@ -870,7 +832,7 @@ async def test_run_claude_preserves_result_when_relay_plugin_cleanup_fails( monkeypatch.setattr(adapter.relay_gateway, "stop_relay_gateway", mock_stop) monkeypatch.setattr(adapter.shutil, "rmtree", mock_rmtree) - async def query_result(**_): + async def responses(_client): yield ResultMessage( subtype="success", duration_ms=10, @@ -883,20 +845,20 @@ async def query_result(**_): result="done", ) - monkeypatch.setattr(adapter, "query", MagicMock(side_effect=query_result)) - - output = await adapter.run_claude(relay_payload) + install_fake_client(monkeypatch, responses) + runtime = adapter.ClaudeRuntime() + start_payload = { + key: value for key, value in relay_payload.items() if key != "request" + } + await runtime.start(start_payload) + output = await runtime.invoke(lifecycle_invocation(relay_payload)) + with pytest.raises(adapter.lifecycle.LifecycleError) as caught: + await runtime.stop() assert output["response"] == "done" - assert output["completed"] is False - assert output["failed"] is True - assert output["error"] == { - "code": "claude_relay_cleanup_failed", - "message": "Claude Relay hook configuration could not be removed", - "retryable": False, - } - assert output["relay_runtime"]["cleanup_error"] == output["error"] - assert "raw plugin cleanup failure" not in json.dumps(output) + assert output["completed"] is True + assert caught.value.code == "claude_relay_cleanup_failed" + assert "raw plugin cleanup failure" not in str(caught.value) mock_stop.assert_called_once_with(process) mock_rmtree.assert_called_once_with(relay.plugin_path) assert relay.plugin_path.exists() @@ -905,7 +867,7 @@ async def query_result(**_): @pytest.mark.parametrize( "failure", [ClaudeSDKError("sdk failed"), asyncio.CancelledError()] ) -async def test_run_claude_stops_relay_on_sdk_failure_or_cancellation( +async def test_runtime_stops_relay_after_sdk_failure_or_cancellation( relay_payload, monkeypatch, tmp_path, failure ): relay = adapter.ClaudeRelaySettings( @@ -930,19 +892,26 @@ async def test_run_claude_stops_relay_on_sdk_failure_or_cancellation( ) monkeypatch.setattr(adapter.relay_gateway, "stop_relay_gateway", mock_stop) - async def query_failure(*, prompt, options): + async def responses(_client): raise failure yield - monkeypatch.setattr(adapter, "query", MagicMock(side_effect=query_failure)) - - if isinstance(failure, asyncio.CancelledError): - with pytest.raises(asyncio.CancelledError): - await adapter.run_claude(relay_payload) - else: - output = await adapter.run_claude(relay_payload) - assert output["error"]["code"] == "claude_failed" - assert output["relay_runtime"]["enabled"] is True + install_fake_client(monkeypatch, responses) + runtime = adapter.ClaudeRuntime() + start_payload = { + key: value for key, value in relay_payload.items() if key != "request" + } + await runtime.start(start_payload) + try: + if isinstance(failure, asyncio.CancelledError): + with pytest.raises(asyncio.CancelledError): + await runtime.invoke(lifecycle_invocation(relay_payload)) + else: + output = await runtime.invoke(lifecycle_invocation(relay_payload)) + assert output["error"]["code"] == "claude_failed" + assert output["relay_runtime"]["enabled"] is True + finally: + await runtime.stop() mock_stop.assert_called_once_with(process) assert not relay.plugin_path.exists() @@ -952,14 +921,14 @@ async def query_failure(*, prompt, options): ("subtype", "is_error"), [("success", True), ("error_max_budget_usd", False)], ) -async def test_run_claude_preserves_failed_result_when_sdk_stream_raises( +async def test_runtime_preserves_failed_result_when_sdk_stream_raises( claude_payload, monkeypatch, caplog, subtype, is_error, ): - async def query_error_result(**_): + async def responses(_client): yield ResultMessage( subtype=subtype, duration_ms=10, @@ -971,9 +940,14 @@ async def query_error_result(**_): ) raise RuntimeError("raw SDK stream error") - monkeypatch.setattr(adapter, "query", MagicMock(side_effect=query_error_result)) - - output = await adapter.run_claude(claude_payload) + install_fake_client(monkeypatch, responses) + runtime = adapter.ClaudeRuntime() + start_payload = { + key: value for key, value in claude_payload.items() if key != "request" + } + await runtime.start(start_payload) + output = await runtime.invoke(lifecycle_invocation(claude_payload)) + await runtime.stop() assert output["response"] == "Not logged in" assert output["error"] == { @@ -986,7 +960,7 @@ async def query_error_result(**_): assert "raw SDK stream error" in caplog.text -def test_run_reports_relay_start_failure_without_raw_diagnostic( +async def test_runtime_start_reports_relay_failure_without_raw_diagnostic( relay_payload, monkeypatch, tmp_path ): executable = tmp_path / "nemo-relay" @@ -1014,15 +988,17 @@ def test_run_reports_relay_start_failure_without_raw_diagnostic( ), ) - output = adapter.run(relay_payload) - - assert output["error"] == { - "code": "claude_relay_start_failed", - "message": "NeMo Relay gateway failed to start", - "retryable": False, - "metadata": {"gateway_log_path": str(relay.gateway.log_path)}, + runtime = adapter.ClaudeRuntime() + start_payload = { + key: value for key, value in relay_payload.items() if key != "request" } - assert "secret" not in json.dumps(output) + with pytest.raises(adapter.lifecycle.LifecycleError) as caught: + await runtime.start(start_payload) + + assert caught.value.code == "claude_relay_start_failed" + assert caught.value.message == "NeMo Relay gateway failed to start" + assert caught.value.metadata == {"gateway_log_path": str(relay.gateway.log_path)} + assert "secret" not in str(caught.value) assert not relay.plugin_path.exists() @@ -1071,7 +1047,7 @@ def test_build_options_forwards_anthropic_auth_environment( os.environ["FABRIC_UNRELATED_SECRET"] = "do-not-forward" os.environ.update(auth_environment) - options = adapter.build_options(claude_payload, resume=None) + options = adapter.build_options(claude_payload) forwarded_auth_environment = { name: options.env[name] @@ -1087,7 +1063,7 @@ def test_build_options_preserves_unix_user_for_cached_login( ): os.environ["USER"] = "fabric-user" - options = adapter.build_options(claude_payload, resume=None) + options = adapter.build_options(claude_payload) assert options.env["USER"] == "fabric-user" @@ -1157,19 +1133,6 @@ def test_error_subtype_is_failure_when_sdk_flag_is_false(claude_payload): assert output["error"]["metadata"] == {"subtype": "error_max_budget_usd"} -def test_run_normalizes_unexpected_exception(claude_payload, monkeypatch): - monkeypatch.setattr( - adapter, - "run_claude", - MagicMock(side_effect=RuntimeError("secret")), - ) - - output = adapter.run(claude_payload) - - assert output["error"]["code"] == "claude_adapter_internal_error" - assert "secret" not in json.dumps(output) - - def test_main_serves_persistent_runtime(monkeypatch): serve = MagicMock() monkeypatch.setattr(adapter.lifecycle, "serve", serve) diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index ff33e726..7fe8f6c3 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -16,6 +16,40 @@ from openai_codex.types import TurnStatus +def lifecycle_start_payload(payload): + return {key: value for key, value in payload.items() if key != "request"} + + +def lifecycle_invocation(payload): + return { + "runtime_context": payload["runtime_context"], + "request": payload["request"], + } + + +async def invoke_once_async(payload): + runtime = adapter.CodexRuntime() + await runtime.start(lifecycle_start_payload(payload)) + try: + return await runtime.invoke(lifecycle_invocation(payload)) + finally: + await runtime.stop() + + +def invoke_once(payload): + return asyncio.run(invoke_once_async(payload)) + + +def runtime_start_error(payload): + async def scenario(): + runtime = adapter.CodexRuntime() + with pytest.raises(adapter.lifecycle.LifecycleError) as caught: + await runtime.start(lifecycle_start_payload(payload)) + return caught.value + + return asyncio.run(scenario()) + + @pytest.fixture(name="codex_payload") def codex_payload_fixture(tmp_path): workspace = tmp_path / "workspace" @@ -24,23 +58,23 @@ def codex_payload_fixture(tmp_path): "agent_name": "codex-test", "base_dir": str(tmp_path), "config": { - "harness": { - "adapter_id": "nvidia.fabric.codex", - "settings": { - "sandbox": "workspace-write", - "config_overrides": { - "features.web_search": False, - "model_reasoning_effort": "high", - }, + "harness": { + "adapter_id": "nvidia.fabric.codex", + "settings": { + "sandbox": "workspace-write", + "config_overrides": { + "features.web_search": False, + "model_reasoning_effort": "high", }, }, - "models": { - "default": { - "provider": "openai", - "model": "openai/gpt-5.4", - } - }, - "runtime": {}, + }, + "models": { + "default": { + "provider": "openai", + "model": "openai/gpt-5.4", + } + }, + "runtime": {}, }, "runtime_context": { "runtime_id": "runtime-1", @@ -105,7 +139,6 @@ def mock_codex_fixture(monkeypatch): mock_codex.next_thread_id = "thread-123" mock_codex.next_result = None mock_codex.next_thread = None - mock_codex.resume_thread_id = None mock_codex.skill_request = AsyncMock() mock_codex.close_error = None @@ -129,14 +162,8 @@ async def thread_start(**_kwargs): ) return mock_client.thread - async def thread_resume(thread_id, **_kwargs): - resumed_thread_id = mock_codex.resume_thread_id or thread_id - mock_client.thread = mock_thread(resumed_thread_id, mock_codex.next_result) - return mock_client.thread - mock_client.close.side_effect = close mock_client.thread_start.side_effect = thread_start - mock_client.thread_resume.side_effect = thread_resume mock_codex.instances.append(mock_client) return mock_client @@ -145,7 +172,7 @@ async def thread_resume(thread_id, **_kwargs): return mock_codex -def test_sdk_oneshot_uses_native_thread_and_turn_contract( +def test_single_invocation_uses_native_thread_and_turn_contract( codex_payload, mock_codex, tmp_path ): os.environ["CODEX_HOME"] = str(tmp_path / "codex-home") @@ -155,7 +182,7 @@ def test_sdk_oneshot_uses_native_thread_and_turn_contract( "CODEX_EXPLICIT": "forward-me" } - output = adapter.run(codex_payload) + output = invoke_once(codex_payload) assert output["completed"] is True assert output["adapter"] == "sdk" @@ -193,29 +220,32 @@ def test_sdk_oneshot_uses_native_thread_and_turn_contract( client._client.request.assert_not_awaited() -def test_sdk_close_failure_preserves_completed_turn_and_thread_state( +def test_runtime_stop_reports_close_failure_after_completed_turn( codex_payload, mock_codex, caplog ): mock_codex.close_error = RuntimeError("close failed") - output = adapter.run(codex_payload) + async def scenario(): + runtime = adapter.CodexRuntime() + await runtime.start(lifecycle_start_payload(codex_payload)) + output = await runtime.invoke(lifecycle_invocation(codex_payload)) + with pytest.raises(adapter.lifecycle.LifecycleError) as caught: + await runtime.stop() + return output, caught.value + + output, error = asyncio.run(scenario()) assert output["completed"] is True assert output["failed"] is False assert output["thread_id"] == "thread-123" assert output["response"] == "done" assert output["error"] is None - assert output["cleanup_error"] == { - "code": "codex_sdk_stop_failed", - "message": "Codex SDK runtime failed to stop", - "retryable": False, - } - assert adapter.load_thread_id(codex_payload, "runtime-1") == "thread-123" - assert "Codex SDK client failed to close after invocation" in caplog.text + assert error.code == "codex_sdk_stop_failed" + assert "Codex SDK client failed to close" in caplog.text mock_codex.instances[0].close.assert_awaited_once_with() -def test_sdk_close_failure_does_not_mask_adapter_error( +def test_start_failure_is_not_masked_by_sdk_close_failure( codex_payload, mock_codex, monkeypatch, caplog ): mock_codex.close_error = RuntimeError("close failed") @@ -227,13 +257,17 @@ def test_sdk_close_failure_does_not_mask_adapter_error( ) monkeypatch.setattr(adapter, "_register_skill_roots", register_skills) - output = adapter.run(codex_payload) + async def scenario(): + runtime = adapter.CodexRuntime() + with pytest.raises(adapter.lifecycle.LifecycleError) as caught: + await runtime.start(lifecycle_start_payload(codex_payload)) + return caught.value + + error = asyncio.run(scenario()) - assert output["failed"] is True - assert output["error"]["code"] == "codex_skill_registration_failed" - assert output["error"]["message"] == "Codex skill registration failed" - assert "cleanup_error" not in output - assert "Codex SDK client failed to close after invocation" in caplog.text + assert error.code == "codex_skill_registration_failed" + assert error.message == "Codex skill registration failed" + assert "Codex SDK cleanup after start failure also failed" in caplog.text register_skills.assert_awaited_once() mock_codex.instances[0].close.assert_awaited_once_with() @@ -254,11 +288,11 @@ def test_sdk_maps_native_mcp_servers_into_thread_config(codex_payload, mock_code } } } - codex_payload["config"]["harness"]["settings"][ - "config_overrides" - ]["mcp_servers.remote.required"] = True + codex_payload["config"]["harness"]["settings"]["config_overrides"][ + "mcp_servers.remote.required" + ] = True - output = adapter.run(codex_payload) + output = invoke_once(codex_payload) assert output["completed"] is True config = mock_codex.instances[0].thread_start.await_args.kwargs["config"] @@ -287,7 +321,7 @@ def test_sdk_registers_native_skill_roots(codex_payload, mock_codex, tmp_path): "native": {"skill_paths": ["skills/review", "skills/test"]} } - output = adapter.run(codex_payload) + output = invoke_once(codex_payload) assert output["completed"] is True mock_codex.instances[0].thread.turn.assert_awaited_once_with( @@ -312,14 +346,12 @@ def test_sdk_closes_when_skill_registration_is_unavailable( "---\nname: review\ndescription: Test skill.\n---\n", encoding="utf-8", ) - codex_payload["capability_plan"] = { - "native": {"skill_paths": ["skills/review"]} - } + codex_payload["capability_plan"] = {"native": {"skill_paths": ["skills/review"]}} mock_codex.skill_request = None - output = adapter.run(codex_payload) + error = runtime_start_error(codex_payload) - assert output["error"]["code"] == "codex_invalid_configuration" + assert error.code == "codex_invalid_configuration" client = mock_codex.instances[0] client.thread_start.assert_not_awaited() assert client.closed is True @@ -335,10 +367,10 @@ def test_sdk_rejects_unsupported_mcp_transport(codex_payload, mock_codex, transp } } - output = adapter.run(codex_payload) + error = runtime_start_error(codex_payload) - assert output["error"]["code"] == "codex_invalid_configuration" - assert f"unsupported Codex MCP transport: {transport}" in output["error"]["message"] + assert error.code == "codex_invalid_configuration" + assert f"unsupported Codex MCP transport: {transport}" in error.message mock_codex.assert_not_called() @@ -346,25 +378,21 @@ def test_sdk_rejects_invalid_native_skill_path(codex_payload, mock_codex, tmp_pa missing = tmp_path / "skills" / "missing" codex_payload["capability_plan"] = {"native": {"skill_paths": [str(missing)]}} - output = adapter.run(codex_payload) + error = runtime_start_error(codex_payload) - assert output["error"]["code"] == "codex_invalid_configuration" - assert "directory containing SKILL.md" in output["error"]["message"] + assert error.code == "codex_invalid_configuration" + assert "directory containing SKILL.md" in error.message mock_codex.assert_not_called() @pytest.mark.parametrize("skill_paths", [None, "", {}, False]) -def test_sdk_rejects_falsy_non_list_skill_paths( - codex_payload, mock_codex, skill_paths -): - codex_payload["capability_plan"] = { - "native": {"skill_paths": skill_paths} - } +def test_sdk_rejects_falsy_non_list_skill_paths(codex_payload, mock_codex, skill_paths): + codex_payload["capability_plan"] = {"native": {"skill_paths": skill_paths}} - output = adapter.run(codex_payload) + error = runtime_start_error(codex_payload) - assert output["error"]["code"] == "codex_invalid_configuration" - assert output["error"]["message"] == "native skill_paths must be a list of paths" + assert error.code == "codex_invalid_configuration" + assert error.message == "native skill_paths must be a list of paths" mock_codex.assert_not_called() @@ -372,23 +400,17 @@ def test_sdk_can_use_an_explicit_codex_runtime(codex_payload, mock_codex, tmp_pa codex_bin = tmp_path / "bin" / "codex" codex_bin.parent.mkdir() codex_bin.touch() - codex_payload["config"]["harness"]["settings"][ - "codex_bin" - ] = str(codex_bin) + codex_payload["config"]["harness"]["settings"]["codex_bin"] = str(codex_bin) - output = adapter.run(codex_payload) + output = invoke_once(codex_payload) assert output["completed"] is True assert mock_codex.instances[0].config.codex_bin == str(codex_bin) @pytest.mark.parametrize("codex_bin", ["bin/codex", "~/bin/codex"]) -def test_sdk_resolves_relative_codex_runtime_from_base_dir( - codex_payload, codex_bin -): - codex_payload["config"]["harness"]["settings"][ - "codex_bin" - ] = codex_bin +def test_sdk_resolves_relative_codex_runtime_from_base_dir(codex_payload, codex_bin): + codex_payload["config"]["harness"]["settings"]["codex_bin"] = codex_bin config = adapter.sdk_config(codex_payload, relay=None) @@ -398,36 +420,13 @@ def test_sdk_resolves_relative_codex_runtime_from_base_dir( def test_sdk_keeps_absolute_codex_runtime_path(codex_payload, tmp_path): codex_bin = tmp_path / "bin" / ".." / "codex" - codex_payload["config"]["harness"]["settings"][ - "codex_bin" - ] = str(codex_bin) + codex_payload["config"]["harness"]["settings"]["codex_bin"] = str(codex_bin) config = adapter.sdk_config(codex_payload, relay=None) assert config.codex_bin == str(codex_bin) -def test_runtime_resumes_sdk_thread_across_invocations(codex_payload, mock_codex): - first = adapter.run(codex_payload) - codex_payload["runtime_context"]["invocation_id"] = "invocation-2" - codex_payload["request"]["input"] = "Continue." - second = adapter.run(codex_payload) - - assert first["thread_id"] == second["thread_id"] == "thread-123" - mock_codex.instances[0].thread_start.assert_awaited_once() - assert mock_codex.instances[1].thread_resume.await_args.args[0] == "thread-123" - assert mock_codex.instances[1].thread.turn.await_args.args[0] == "Continue." - state = json.loads( - adapter.runtime_state_path(codex_payload, "runtime-1").read_text( - encoding="utf-8" - ) - ) - assert state == { - "runtime_id": "runtime-1", - "codex_thread_id": "thread-123", - } - - async def test_persistent_runtime_reuses_one_client_and_thread( codex_payload, mock_codex ): @@ -436,17 +435,16 @@ async def test_persistent_runtime_reuses_one_client_and_thread( runtime = adapter.CodexRuntime() await runtime.start(start_payload) - first = await runtime.invoke(codex_payload) + first = await runtime.invoke(lifecycle_invocation(codex_payload)) codex_payload["runtime_context"]["invocation_id"] = "invocation-2" codex_payload["request"]["input"] = "Continue." - second = await runtime.invoke(codex_payload) + second = await runtime.invoke(lifecycle_invocation(codex_payload)) await runtime.stop() assert first["thread_id"] == second["thread_id"] == "thread-123" assert len(mock_codex.instances) == 1 client = mock_codex.instances[0] client.thread_start.assert_awaited_once() - client.thread_resume.assert_not_awaited() assert client.thread.turn.await_count == 2 assert client.thread.turn.await_args_list[1].args[0] == "Continue." client.close.assert_awaited_once() @@ -477,9 +475,9 @@ async def test_persistent_runtime_registers_skills_once_and_maps_mcp( runtime = adapter.CodexRuntime() await runtime.start(start_payload) - await runtime.invoke(codex_payload) + await runtime.invoke(lifecycle_invocation(codex_payload)) codex_payload["runtime_context"]["invocation_id"] = "invocation-2" - await runtime.invoke(codex_payload) + await runtime.invoke(lifecycle_invocation(codex_payload)) await runtime.stop() client = mock_codex.instances[0] @@ -524,9 +522,9 @@ async def test_persistent_runtime_owns_one_relay_gateway( runtime = adapter.CodexRuntime() await runtime.start(start_payload) - await runtime.invoke(codex_payload) + await runtime.invoke(lifecycle_invocation(codex_payload)) codex_payload["runtime_context"]["invocation_id"] = "invocation-2" - await runtime.invoke(codex_payload) + await runtime.invoke(lifecycle_invocation(codex_payload)) stop_gateway.assert_not_called() await runtime.stop() @@ -538,22 +536,12 @@ async def test_persistent_runtime_owns_one_relay_gateway( stop_gateway.assert_called_once_with(process) -def test_runtime_rejects_corrupt_thread_state(codex_payload): - state_path = adapter.runtime_state_path(codex_payload, "runtime-1") - state_path.parent.mkdir(parents=True) - state_path.write_text("{", encoding="utf-8") - - output = adapter.run(codex_payload) - - assert output["error"]["code"] == "codex_invalid_runtime_state" - - def test_failed_sdk_turn_is_normalized_and_transport_is_closed( codex_payload, mock_codex ): mock_codex.next_result = RuntimeError("model request failed") - output = adapter.run(codex_payload) + output = invoke_once(codex_payload) assert output["error"] == { "code": "codex_turn_failed", @@ -561,30 +549,26 @@ def test_failed_sdk_turn_is_normalized_and_transport_is_closed( "retryable": False, } assert mock_codex.instances[0].closed is True - assert not adapter.runtime_state_path(codex_payload, "runtime-1").exists() -def test_incomplete_sdk_turn_is_failed_without_persisting_thread( - codex_payload, mock_codex -): +def test_incomplete_sdk_turn_is_failed(codex_payload, mock_codex): result = successful_result(response=None) mock_codex.next_result = result - output = adapter.run(codex_payload) + output = invoke_once(codex_payload) assert output["error"]["code"] == "codex_turn_incomplete" assert output["turn_status"] == "completed" - assert not adapter.runtime_state_path(codex_payload, "runtime-1").exists() def test_selected_model_rejects_unsupported_provider(codex_payload, mock_codex): model = codex_payload["config"]["models"]["default"] model["provider"] = "anthropic" - output = adapter.run(codex_payload) + error = runtime_start_error(codex_payload) - assert output["error"]["code"] == "codex_invalid_configuration" - assert "provider must be openai or nvidia" in output["error"]["message"] + assert error.code == "codex_invalid_configuration" + assert "provider must be openai or nvidia" in error.message mock_codex.assert_not_called() @@ -602,7 +586,7 @@ def test_nvidia_provider_uses_responses_api_and_nvidia_credential( ) os.environ["NVIDIA_API_KEY"] = "nvidia-secret" - output = adapter.run(codex_payload) + output = invoke_once(codex_payload) assert output["completed"] is True client = mock_codex.instances[0] @@ -641,9 +625,9 @@ async def fail_to_create_home(*_args, **_kwargs): monkeypatch.setattr(adapter.asyncio, "to_thread", fail_to_create_home) - output = adapter.run(codex_payload) + error = runtime_start_error(codex_payload) - assert output["error"]["code"] == "codex_runtime_unavailable" + assert error.code == "codex_runtime_unavailable" mock_codex.assert_not_called() @@ -658,10 +642,10 @@ def test_nvidia_provider_requires_credential(codex_payload, mock_codex): ) os.environ.pop("NVIDIA_API_KEY", None) - output = adapter.run(codex_payload) + error = runtime_start_error(codex_payload) - assert output["error"]["code"] == "codex_invalid_configuration" - assert "NVIDIA_API_KEY is required" in output["error"]["message"] + assert error.code == "codex_invalid_configuration" + assert "NVIDIA_API_KEY is required" in error.message assert not (adapter.state_dir(codex_payload) / "nvidia-home").exists() mock_codex.assert_not_called() @@ -679,24 +663,14 @@ def test_nvidia_provider_requires_endpoint(codex_payload, mock_codex): os.environ["NVIDIA_API_KEY"] = "nvidia-secret" os.environ.pop("NVIDIA_FRONTIER_BASE_URL", None) - output = adapter.run(codex_payload) + error = runtime_start_error(codex_payload) - assert output["error"]["code"] == "codex_invalid_configuration" - assert "NVIDIA_FRONTIER_BASE_URL" in output["error"]["message"] + assert error.code == "codex_invalid_configuration" + assert "NVIDIA_FRONTIER_BASE_URL" in error.message assert not (adapter.state_dir(codex_payload) / "nvidia-home").exists() mock_codex.assert_not_called() -def test_resume_rejects_changed_sdk_thread_identity(codex_payload, mock_codex): - adapter.save_thread_id(codex_payload, "runtime-1", "thread-persisted") - mock_codex.resume_thread_id = "thread-replaced" - - output = adapter.run(codex_payload) - - assert output["error"]["code"] == "codex_thread_mismatch" - assert mock_codex.instances[0].closed is True - - def test_relay_uses_gateway_and_request_scoped_sdk_config( codex_payload, mock_codex, monkeypatch, tmp_path ): @@ -725,7 +699,7 @@ def test_relay_uses_gateway_and_request_scoped_sdk_config( monkeypatch.setattr(adapter.relay_gateway, "stop_relay_gateway", stop_gateway) os.environ["FABRIC_RELAY_CONFIG_PATH"] = str(tmp_path / "relay.json") - output = adapter.run(codex_payload) + output = invoke_once(codex_payload) client = mock_codex.instances[0] start = client.thread_start.await_args.kwargs @@ -775,12 +749,10 @@ def test_relay_rejects_nvidia_provider(codex_payload, mock_codex): } os.environ["NVIDIA_API_KEY"] = "nvidia-secret" - output = adapter.run(codex_payload) + error = runtime_start_error(codex_payload) - assert output["error"]["code"] == "codex_invalid_configuration" - assert output["error"]["message"] == ( - "NeMo Relay requires the built-in openai model provider" - ) + assert error.code == "codex_invalid_configuration" + assert error.message == ("NeMo Relay requires the built-in openai model provider") mock_codex.assert_not_called() @@ -829,7 +801,7 @@ def test_prepare_relay_reuses_one_resolved_executable( @pytest.mark.usefixtures("mock_codex") -def test_relay_cleanup_failure_changes_success_to_failure( +def test_relay_stop_failure_is_reported_by_runtime_stop( codex_payload, monkeypatch, tmp_path ): gateway = adapter.relay_gateway.RelayGatewayLaunch( @@ -853,12 +825,18 @@ def test_relay_cleanup_failure_changes_success_to_failure( MagicMock(side_effect=adapter.relay_gateway.RelayGatewayError("stuck")), ) - output = adapter.run(codex_payload) + async def scenario(): + runtime = adapter.CodexRuntime() + await runtime.start(lifecycle_start_payload(codex_payload)) + output = await runtime.invoke(lifecycle_invocation(codex_payload)) + with pytest.raises(adapter.lifecycle.LifecycleError) as caught: + await runtime.stop() + return output, caught.value - assert output["failed"] is True - assert output["completed"] is False - assert output["error"]["code"] == "codex_relay_stop_failed" - assert output["relay_runtime"]["cleanup_error"] == output["error"] + output, error = asyncio.run(scenario()) + + assert output["completed"] is True + assert error.code == "codex_relay_stop_failed" def test_native_sdk_controls_and_telemetry_are_request_scoped( @@ -898,7 +876,7 @@ def test_native_sdk_controls_and_telemetry_are_request_scoped( }, } - output = adapter.run(codex_payload) + output = invoke_once(codex_payload) assert output["failed"] is False client = mock_codex.instances[0] @@ -927,11 +905,9 @@ async def block(): mock_blocking_thread.handle.run.side_effect = block mock_codex.next_thread = mock_blocking_thread - codex_payload["config"]["harness"]["settings"][ - "timeout_seconds" - ] = 0.01 + codex_payload["config"]["harness"]["settings"]["timeout_seconds"] = 0.01 - output = adapter.run(codex_payload) + output = invoke_once(codex_payload) client = mock_codex.instances[0] assert output["error"]["code"] == "codex_timed_out" @@ -943,14 +919,12 @@ async def block(): "setting", ["codex_command", "codex_args", "codex_profile", "skip_git_repo_check"] ) def test_cli_only_settings_are_rejected(codex_payload, setting): - codex_payload["config"]["harness"]["settings"][setting] = ( - "legacy" - ) + codex_payload["config"]["harness"]["settings"][setting] = "legacy" - output = adapter.run(codex_payload) + error = runtime_start_error(codex_payload) - assert output["error"]["code"] == "codex_invalid_configuration" - assert setting in output["error"]["message"] + assert error.code == "codex_invalid_configuration" + assert setting in error.message @pytest.mark.parametrize( @@ -962,10 +936,10 @@ def test_normalized_capabilities_reject_harness_settings( ): codex_payload["config"]["harness"]["settings"][setting] = {} - output = adapter.run(codex_payload) + error = runtime_start_error(codex_payload) - assert output["error"]["code"] == "codex_invalid_configuration" - assert normalized_field in output["error"]["message"] + assert error.code == "codex_invalid_configuration" + assert normalized_field in error.message mock_codex.assert_not_called() @@ -974,7 +948,7 @@ def test_adapter_rejects_structured_input(codex_payload): "messages": [{"role": "user", "content": "Inspect the change."}] } - output = adapter.run(codex_payload) + output = invoke_once(codex_payload) assert output["error"]["code"] == "codex_invalid_request" @@ -989,7 +963,6 @@ def test_descriptor_has_no_codex_binary_requirement(): assert descriptor["adapter_id"] == "nvidia.fabric.codex" assert descriptor["runner"] == { "module": "nemo_fabric_adapters.codex.adapter", - "callable": "run", } assert descriptor["config"]["accepts"] == [ "models", @@ -997,7 +970,6 @@ def test_descriptor_has_no_codex_binary_requirement(): "skills", "telemetry", ] - assert descriptor["runtime"]["local_host"] == {} assert "requirements" not in descriptor diff --git a/tests/adapters/test_deepagents.py b/tests/adapters/test_deepagents.py index f3c48420..e5b4ca63 100644 --- a/tests/adapters/test_deepagents.py +++ b/tests/adapters/test_deepagents.py @@ -5,7 +5,7 @@ These tests stub the ``deepagents``/``langchain``/``langgraph`` SDKs so they run without the real harness installed; they assert the normalized Fabric result and -the session/resume thread-id handling. The real SDK is exercised by the opt-in +the live runtime's thread continuity. The real SDK is exercised by the opt-in integration test in ``tests/e2e/test_deepagents.py``. """ @@ -24,6 +24,26 @@ from nemo_fabric_adapters.deepagents import adapter # noqa: E402 +def lifecycle_start_payload(payload: dict[str, Any]) -> dict[str, Any]: + return {key: value for key, value in payload.items() if key != "request"} + + +def lifecycle_invocation(payload: dict[str, Any]) -> dict[str, Any]: + return { + "runtime_context": payload["runtime_context"], + "request": payload["request"], + } + + +async def invoke_once(payload: dict[str, Any]) -> dict[str, Any]: + runtime = adapter.DeepAgentsRuntime() + await runtime.start(lifecycle_start_payload(payload)) + try: + return await runtime.invoke(lifecycle_invocation(payload)) + finally: + await runtime.stop() + + @pytest.fixture(name="fake_sdks", autouse=True) def fake_sdks_fixture(monkeypatch): """Stub the deepagents/langchain/langgraph SDKs with mocks. @@ -55,7 +75,7 @@ async def astream(inputs, config=None, *, stream_mode=None, subgraphs=False): } # subgraphs=True yields 3-tuples ``(namespace, mode, chunk)``; the main # graph has an empty namespace. ``updates`` carries the message produced - # this turn; ``values`` is the full (on resume, replayed) state. + # this turn; ``values`` is the full replayed state. yield ((), "updates", {"agent": {"messages": [ai]}}) yield ((), "values", {"messages": [{"role": "user", "content": user}, ai]}) @@ -129,14 +149,14 @@ def make(tmp_path: Path, *, runtime_id: str = "run-1") -> dict[str, Any]: return { "base_dir": str(tmp_path), "config": { - "harness": {"settings": {"system_prompt": "be concise"}}, - "models": { - "default": { - "provider": "nvidia", - "model": "nvidia/nemotron-3-nano-30b-a3b", - "api_key_env": "NVIDIA_API_KEY", - } - }, + "harness": {"settings": {"system_prompt": "be concise"}}, + "models": { + "default": { + "provider": "nvidia", + "model": "nvidia/nemotron-3-nano-30b-a3b", + "api_key_env": "NVIDIA_API_KEY", + } + }, }, "runtime_context": { "runtime_id": runtime_id, @@ -229,10 +249,10 @@ def use_real_langgraph_fixture(fake_sdks, monkeypatch): monkeypatch.delitem(sys.modules, name, raising=False) -async def test_oneshot_normalizes_response_usage_and_thread( +async def test_single_invocation_normalizes_response_usage_and_thread( tmp_path, make_payload, fake_sdks ): - output = await adapter.run_deepagents(make_payload(tmp_path)) + output = await invoke_once(make_payload(tmp_path)) assert output["harness"] == "deepagents" assert output["mode"] == "deepagents" @@ -248,7 +268,7 @@ async def test_oneshot_normalizes_response_usage_and_thread( assert output["events"] == [{"nodes": ["agent"]}] assert output["event_count"] == 1 assert output["runtime_id"] == "run-1" - # a LangGraph thread id is assigned and reported; a fresh runtime is not a resume + # The first invocation receives a newly assigned LangGraph thread id. assert output["thread_id"] assert output["resumed"] is False assert output["completed"] is True @@ -259,24 +279,20 @@ async def test_oneshot_normalizes_response_usage_and_thread( assert "instructions" not in fake_sdks["create_kwargs"] -async def test_missing_api_key_is_normalized(tmp_path, make_payload, monkeypatch): - # Missing model-provider auth is caught by the adapter preflight. Because the - # preflight runs inside the guarded scope, the failure is normalized into the - # Fabric result rather than raising a raw traceback. +async def test_missing_api_key_fails_runtime_start(tmp_path, make_payload, monkeypatch): monkeypatch.delenv("NVIDIA_API_KEY", raising=False) - output = await adapter.run_deepagents(make_payload(tmp_path)) - assert output["failed"] is True - assert output["completed"] is False - assert "NVIDIA_API_KEY" in output["error"] + with pytest.raises(RuntimeError, match="NVIDIA_API_KEY"): + await adapter.DeepAgentsRuntime().start( + lifecycle_start_payload(make_payload(tmp_path)) + ) -async def test_missing_deepagents_package_is_normalized( +async def test_missing_deepagents_package_fails_runtime_start( tmp_path, make_payload, monkeypatch ): - # Preflight reports a clear, normalized error when the deepagents package is - # absent. Force find_spec("deepagents") -> None so the test holds whether or - # not the real package is installed in the environment. + # Force find_spec("deepagents") -> None so the test holds whether or not the + # real package is installed in the environment. import importlib.util as importlib_util real_find_spec = importlib_util.find_spec @@ -289,25 +305,25 @@ def fake_find_spec( return real_find_spec(name, *args, **kwargs) monkeypatch.setattr(importlib_util, "find_spec", fake_find_spec) - output = await adapter.run_deepagents(make_payload(tmp_path)) - - assert output["failed"] is True - assert "deepagents" in output["error"] + with pytest.raises(RuntimeError, match="deepagents"): + await adapter.DeepAgentsRuntime().start( + lifecycle_start_payload(make_payload(tmp_path)) + ) -async def test_invocation_error_is_normalized(tmp_path, make_payload, monkeypatch): - # Errors raised during the agent run are normalized into the Fabric result. +async def test_agent_creation_error_fails_runtime_start( + tmp_path, make_payload, monkeypatch +): import deepagents def boom(**_kwargs): raise RuntimeError("agent exploded") monkeypatch.setattr(deepagents, "create_deep_agent", boom) - output = await adapter.run_deepagents(make_payload(tmp_path)) - - assert output["failed"] is True - assert output["completed"] is False - assert "agent exploded" in output["error"] + with pytest.raises(RuntimeError, match="agent exploded"): + await adapter.DeepAgentsRuntime().start( + lifecycle_start_payload(make_payload(tmp_path)) + ) async def test_relay_telemetry_wraps_agent_and_reports_artifacts( @@ -336,7 +352,7 @@ async def test_relay_telemetry_wraps_agent_and_reports_artifacts( "adapter_outputs": [], } - output = await adapter.run_deepagents(payload) + output = await invoke_once(payload) assert fake_relay["wrapped"] assert fake_relay["plugin_open"] @@ -391,7 +407,7 @@ async def test_native_telemetry_exports_without_artifacts( "adapter_outputs": [], } - output = await adapter.run_deepagents(payload) + output = await invoke_once(payload) assert fake_relay["wrapped"] assert fake_relay["plugin_open"] @@ -415,7 +431,7 @@ async def test_relay_disabled_adds_no_scope_or_callbacks( ): # With telemetry disabled the invocation runs without a Relay scope, callback # handler, or middleware, preserving the Relay-neutral default behavior. - output = await adapter.run_deepagents(make_payload(tmp_path)) + output = await invoke_once(make_payload(tmp_path)) assert output["completed"] is True assert "telemetry" not in output @@ -423,7 +439,7 @@ async def test_relay_disabled_adds_no_scope_or_callbacks( assert "relay-mw" not in (fake_sdks["create_kwargs"].get("middleware") or []) -async def test_missing_nemo_relay_with_native_telemetry_is_normalized( +async def test_missing_nemo_relay_with_native_telemetry_fails_runtime_start( tmp_path, make_payload, monkeypatch ): # Native telemetry also runs through the nemo_relay plugin, so a core-only @@ -457,15 +473,12 @@ def fake_find_spec( "adapter_outputs": [], } - output = await adapter.run_deepagents(payload) - - assert output["failed"] is True - assert "nemo-relay" in output["error"] - assert "[relay]" in output["error"] + with pytest.raises(RuntimeError, match="nemo-relay.*\\[relay\\]"): + await adapter.DeepAgentsRuntime().start(lifecycle_start_payload(payload)) @pytest.mark.usefixtures("fake_relay") -async def test_incomplete_nemo_relay_install_is_normalized( +async def test_incomplete_nemo_relay_install_fails_runtime_start( tmp_path, make_payload, monkeypatch ): monkeypatch.delitem(sys.modules, "nemo_relay.integrations.deepagents") @@ -482,11 +495,8 @@ async def test_incomplete_nemo_relay_install_is_normalized( "adapter_outputs": [], } - output = await adapter.run_deepagents(payload) - - assert output["failed"] is True - assert "compatible 'nemo-relay' package" in output["error"] - assert "[relay]" in output["error"] + with pytest.raises(RuntimeError, match="compatible 'nemo-relay'.*\\[relay\\]"): + await adapter.DeepAgentsRuntime().start(lifecycle_start_payload(payload)) def test_apply_callbacks_preserves_existing_ahead_of_new(): @@ -506,11 +516,12 @@ def test_apply_callbacks_without_callbacks_leaves_config_untouched(): } -async def test_invoke_agent_wires_callbacks_into_run_config(fake_sdks): - # invoke_agent threads the supplied callbacks into the LangGraph run config. - agent_kwargs = {"model": object()} - await adapter.invoke_agent( - agent_kwargs, "hello", "thread-1", callbacks=["cb-a", "cb-b"] +async def test_invoke_compiled_agent_wires_callbacks_into_run_config(fake_sdks): + from deepagents import create_deep_agent + + agent = create_deep_agent(model=object()) + await adapter.invoke_compiled_agent( + agent, "hello", "thread-1", callbacks=["cb-a", "cb-b"] ) config = fake_sdks["config"] @@ -518,15 +529,18 @@ async def test_invoke_agent_wires_callbacks_into_run_config(fake_sdks): assert config["callbacks"] == ["cb-a", "cb-b"] -async def test_invoke_agent_without_callbacks_sets_no_callbacks_key(fake_sdks): - await adapter.invoke_agent({"model": object()}, "hello", None, callbacks=None) +async def test_invoke_compiled_agent_without_callbacks_sets_no_callbacks_key(fake_sdks): + from deepagents import create_deep_agent + + agent = create_deep_agent(model=object()) + await adapter.invoke_compiled_agent(agent, "hello", None, callbacks=None) # No thread and no callbacks means the agent is streamed without a config. assert fake_sdks["config"] is None async def test_workspace_roots_filesystem_backend(tmp_path, make_payload, fake_sdks): - await adapter.run_deepagents(make_payload(tmp_path)) + await invoke_once(make_payload(tmp_path)) backend_kwargs = fake_sdks["fs_backend"].call_args.kwargs assert backend_kwargs["root_dir"] == str(tmp_path) # virtual_mode=True confines the agent to root_dir: absolute paths and ``..`` @@ -538,7 +552,7 @@ async def test_checkpointer_closed_on_success_and_failure( tmp_path, make_payload, monkeypatch, fake_sdks ): # The async checkpointer must be closed on both the success and error paths. - await adapter.run_deepagents(make_payload(tmp_path)) + await invoke_once(make_payload(tmp_path)) assert fake_sdks["saver_exits"] == 1 import deepagents @@ -547,8 +561,10 @@ def boom(**_kwargs): raise RuntimeError("boom") monkeypatch.setattr(deepagents, "create_deep_agent", boom) - output = await adapter.run_deepagents(make_payload(tmp_path)) - assert output["failed"] is True + with pytest.raises(RuntimeError, match="boom"): + await adapter.DeepAgentsRuntime().start( + lifecycle_start_payload(make_payload(tmp_path)) + ) assert fake_sdks["saver_exits"] == 2 @@ -583,7 +599,7 @@ async def test_mcp_servers_become_adapter_tools( } } - output = await adapter.run_deepagents(payload) + output = await invoke_once(payload) assert output["failed"] is False # Fabric MCP transport is normalized; stdio command/args come from ``url`` @@ -646,7 +662,7 @@ def build(**kwargs): monkeypatch.setattr(deepagents, "create_deep_agent", build) - output = await adapter.run_deepagents(make_payload(tmp_path)) + output = await invoke_once(make_payload(tmp_path)) assert output["failed"] is False, output["error"] assert output["response"] == "ok" @@ -664,7 +680,7 @@ async def test_openai_provider_keeps_openai_endpoint( "api_key_env": "OPENAI_API_KEY", } - output = await adapter.run_deepagents(payload) + output = await invoke_once(payload) # openai must NOT be redirected to NVIDIA's endpoint assert output["base_url"] is None @@ -675,7 +691,7 @@ async def test_skill_paths_map_to_skills(tmp_path, make_payload, fake_sdks): payload = make_payload(tmp_path) payload["capability_plan"] = {"native": {"skill_paths": ["/skills/a", "/skills/b"]}} - await adapter.run_deepagents(payload) + await invoke_once(payload) assert fake_sdks["create_kwargs"]["skills"] == ["/skills/a", "/skills/b"] @@ -699,15 +715,15 @@ async def astream(inputs, config=None, *, stream_mode=None, subgraphs=False): agent = MagicMock() agent.astream = astream monkeypatch.setattr(deepagents, "create_deep_agent", MagicMock(return_value=agent)) - output = await adapter.run_deepagents(make_payload(tmp_path)) + output = await invoke_once(make_payload(tmp_path)) assert output["usage"]["cost"] == 0.0025 -async def test_resumed_usage_counts_current_turn_only( +async def test_replayed_state_usage_counts_current_turn_only( tmp_path, make_payload, monkeypatch ): - # On a resumed run the final state replays the prior turn's messages; usage and + # On a later turn the final state replays prior messages; usage and # cost must reflect only the message emitted this turn, not the replayed one. import deepagents @@ -727,14 +743,14 @@ async def test_resumed_usage_counts_current_turn_only( async def astream(inputs, config=None, *, stream_mode=None, subgraphs=False): # Only the current turn's message is emitted as an update... yield ((), "updates", {"agent": {"messages": [current]}}) - # ...but the resumed final state also replays the prior turn. + # ...but the final state also replays the prior turn. yield ((), "values", {"messages": [prior, current]}) agent = MagicMock() agent.astream = astream monkeypatch.setattr(deepagents, "create_deep_agent", MagicMock(return_value=agent)) - output = await adapter.run_deepagents(make_payload(tmp_path)) + output = await invoke_once(make_payload(tmp_path)) assert output["usage"] == { "prompt_tokens": 2, @@ -746,38 +762,19 @@ async def astream(inputs, config=None, *, stream_mode=None, subgraphs=False): assert output["message_count"] == 2 -async def test_runtime_resume_reuses_thread_id(tmp_path, make_payload, fake_sdks): - # Two invocations of the same runtime_id (a started runtime) resume the same - # LangGraph thread; a one-shot run gets a fresh runtime_id and never resumes. - payload = make_payload(tmp_path, runtime_id="run-42") - - first = await adapter.run_deepagents(payload) - assert first["resumed"] is False - thread_id = first["thread_id"] - assert thread_id - # thread id was threaded into the LangGraph config on the invocation - assert fake_sdks["config"] == {"configurable": {"thread_id": thread_id}} - - second = await adapter.run_deepagents(payload) - assert second["resumed"] is True - assert second["thread_id"] == thread_id - - async def test_persistent_runtime_reuses_compiled_agent_and_checkpointer( tmp_path, make_payload, fake_sdks ): import deepagents payload = make_payload(tmp_path, runtime_id="run-persistent") - start_payload = dict(payload) - start_payload.pop("request") runtime = adapter.DeepAgentsRuntime() - await runtime.start(start_payload) - first = await runtime.invoke(payload) + await runtime.start(lifecycle_start_payload(payload)) + first = await runtime.invoke(lifecycle_invocation(payload)) payload["runtime_context"]["invocation_id"] = "inv-2" payload["request"]["input"] = "continue" - second = await runtime.invoke(payload) + second = await runtime.invoke(lifecycle_invocation(payload)) assert first["resumed"] is False assert second["resumed"] is True @@ -819,15 +816,13 @@ async def test_persistent_runtime_scopes_relay_per_invocation( "native_config": None, "adapter_outputs": ["atif"], } - start_payload = dict(payload) - start_payload.pop("request") runtime = adapter.DeepAgentsRuntime() - await runtime.start(start_payload) - first = await runtime.invoke(payload) + await runtime.start(lifecycle_start_payload(payload)) + first = await runtime.invoke(lifecycle_invocation(payload)) payload["runtime_context"]["invocation_id"] = "inv-2" payload["request"]["input"] = "continue" - second = await runtime.invoke(payload) + second = await runtime.invoke(lifecycle_invocation(payload)) await runtime.stop() assert fake_relay["integration_adds"] == 1 @@ -845,7 +840,7 @@ async def test_persistent_runtime_scopes_relay_per_invocation( async def test_stream_requests_subgraphs(tmp_path, make_payload, fake_sdks): # Streaming must opt into subgraphs so delegated (subagent) steps are visible # for usage aggregation. - await adapter.run_deepagents(make_payload(tmp_path)) + await invoke_once(make_payload(tmp_path)) assert fake_sdks["subgraphs"] is True @@ -953,7 +948,7 @@ async def test_deepagents_passthrough_forwards_supported_options( "interrupt_on": {"write_file": True} } - await adapter.run_deepagents(payload) + await invoke_once(payload) assert fake_sdks["create_kwargs"]["interrupt_on"] == {"write_file": True} @@ -968,10 +963,8 @@ async def test_deepagents_passthrough_cannot_override_fabric_owned_keys( "backend": {"root_dir": "/etc"} } - output = await adapter.run_deepagents(payload) - - assert output["failed"] is True - assert "backend" in output["error"] + with pytest.raises(adapter.AdapterConfigError, match="backend"): + await adapter.DeepAgentsRuntime().start(lifecycle_start_payload(payload)) async def test_deepagents_passthrough_rejects_unknown_option(tmp_path, make_payload): @@ -981,10 +974,8 @@ async def test_deepagents_passthrough_rejects_unknown_option(tmp_path, make_payl "interupt_on": {} # note the typo } - output = await adapter.run_deepagents(payload) - - assert output["failed"] is True - assert "interupt_on" in output["error"] + with pytest.raises(adapter.AdapterConfigError, match="interupt_on"): + await adapter.DeepAgentsRuntime().start(lifecycle_start_payload(payload)) async def test_subagent_usage_folded_from_subgraph(tmp_path, make_payload, monkeypatch): @@ -1018,7 +1009,7 @@ async def astream(inputs, config=None, *, stream_mode=None, subgraphs=False): agent.astream = astream monkeypatch.setattr(deepagents, "create_deep_agent", MagicMock(return_value=agent)) - output = await adapter.run_deepagents(make_payload(tmp_path)) + output = await invoke_once(make_payload(tmp_path)) # subagent (10/20/30) + main (2/3/5), the duplicate subagent message counted once assert output["usage"] == { @@ -1030,7 +1021,7 @@ async def astream(inputs, config=None, *, stream_mode=None, subgraphs=False): assert any(evt.get("subgraph") == "task:researcher" for evt in output["events"]) -async def test_bad_mcp_transport_is_normalized_failure(tmp_path, make_payload): +async def test_bad_mcp_transport_fails_runtime_start(tmp_path, make_payload): # A misconfigured MCP server must fail loudly, not be silently dropped. payload = make_payload(tmp_path) payload["capability_plan"] = { @@ -1041,22 +1032,18 @@ async def test_bad_mcp_transport_is_normalized_failure(tmp_path, make_payload): } } - output = await adapter.run_deepagents(payload) + with pytest.raises(adapter.AdapterConfigError, match="transport"): + await adapter.DeepAgentsRuntime().start(lifecycle_start_payload(payload)) - assert output["failed"] is True - assert "transport" in output["error"] - -async def test_empty_mcp_url_is_normalized_failure(tmp_path, make_payload): +async def test_empty_mcp_url_fails_runtime_start(tmp_path, make_payload): payload = make_payload(tmp_path) payload["capability_plan"] = { "native": {"mcp_servers": {"bad": {"transport": "streamable_http", "url": ""}}} } - output = await adapter.run_deepagents(payload) - - assert output["failed"] is True - assert "url" in output["error"] + with pytest.raises(adapter.AdapterConfigError, match="url"): + await adapter.DeepAgentsRuntime().start(lifecycle_start_payload(payload)) async def test_unknown_provider_requires_api_key_env( @@ -1071,10 +1058,8 @@ async def test_unknown_provider_requires_api_key_env( "model": "claude-x", } - output = await adapter.run_deepagents(payload) - - assert output["failed"] is True - assert "api_key_env" in output["error"] + with pytest.raises(adapter.AdapterConfigError, match="api_key_env"): + await adapter.DeepAgentsRuntime().start(lifecycle_start_payload(payload)) async def test_openai_provider_defaults_to_openai_key( @@ -1090,7 +1075,7 @@ async def test_openai_provider_defaults_to_openai_key( "model": "gpt-4o", } - output = await adapter.run_deepagents(payload) + output = await invoke_once(payload) assert output["failed"] is False, output["error"] assert output["base_url"] is None @@ -1106,10 +1091,8 @@ async def test_openai_compatible_provider_requires_api_key_env(tmp_path, make_pa "model": "some/model", } - output = await adapter.run_deepagents(payload) - - assert output["failed"] is True - assert "api_key_env" in output["error"] + with pytest.raises(adapter.AdapterConfigError, match="api_key_env"): + await adapter.DeepAgentsRuntime().start(lifecycle_start_payload(payload)) def test_main_serves_persistent_runtime(monkeypatch): diff --git a/tests/adapters/test_hermes_adapter.py b/tests/adapters/test_hermes_adapter.py index 674b3314..710b4af9 100644 --- a/tests/adapters/test_hermes_adapter.py +++ b/tests/adapters/test_hermes_adapter.py @@ -179,26 +179,26 @@ def test_build_hermes_config_maps_fabric_config_to_hermes_config(): } }, "config": { - "harness": { - "settings": { - "model": "review", - "max_iterations": 4, - "disabled_toolsets": ["browser"], - "terminal_backend": "local", - "terminal_timeout": 90, - "enabled_toolsets": "git", - "toolset_platform": "cli", - "plugins_enabled": ["custom/plugin"], - } - }, - "tools": {"blocked": ["shell", "browser"]}, - "models": { - "review": { - "provider": "nvidia", - "model": "nvidia/review-model", - "settings": {"base_url": "https://model.example/v1"}, - } - }, + "harness": { + "settings": { + "model": "review", + "max_iterations": 4, + "disabled_toolsets": ["browser"], + "terminal_backend": "local", + "terminal_timeout": 90, + "enabled_toolsets": "git", + "toolset_platform": "cli", + "plugins_enabled": ["custom/plugin"], + } + }, + "tools": {"blocked": ["shell", "browser"]}, + "models": { + "review": { + "provider": "nvidia", + "model": "nvidia/review-model", + "settings": {"base_url": "https://model.example/v1"}, + } + }, }, } @@ -255,9 +255,7 @@ def test_build_hermes_config_omits_max_turns_when_max_iterations_unset(): payload = { "config": { "harness": {"settings": {}}, - "models": { - "default": {"provider": "nvidia", "model": "nvidia/test-model"} - }, + "models": {"default": {"provider": "nvidia", "model": "nvidia/test-model"}}, } } @@ -272,9 +270,7 @@ def test_build_hermes_config_omits_max_turns_when_max_iterations_null(): payload = { "config": { "harness": {"settings": {"max_iterations": None}}, - "models": { - "default": {"provider": "nvidia", "model": "nvidia/test-model"} - }, + "models": {"default": {"provider": "nvidia", "model": "nvidia/test-model"}}, } } @@ -331,20 +327,20 @@ def test_hermes_config_variation_matrix_surfaces_supported_capabilities( "agent_name": "matrix-agent", "base_dir": str(tmp_path), "config": { - "harness": { - "settings": { - "model": "review", - "enabled_toolsets": ["git", "shell"], - "toolset_platform": "cli", - "terminal_backend": "local", - } - }, - "models": { - "review": { - "provider": "nvidia", - "model": "nvidia/review-model", - } - }, + "harness": { + "settings": { + "model": "review", + "enabled_toolsets": ["git", "shell"], + "toolset_platform": "cli", + "terminal_backend": "local", + } + }, + "models": { + "review": { + "provider": "nvidia", + "model": "nvidia/review-model", + } + }, }, } @@ -387,9 +383,7 @@ def test_write_hermes_config_writes_file(tmp_path: Path): payload = { "config": { "harness": {"settings": {}}, - "models": { - "default": {"provider": "nvidia", "model": "nvidia/test-model"} - }, + "models": {"default": {"provider": "nvidia", "model": "nvidia/test-model"}}, } } @@ -461,25 +455,20 @@ def test_summarize_hermes_config(): } -async def test_hermes_rejects_native_telemetry(): +async def test_runtime_start_rejects_native_telemetry(): payload = {"telemetry_plan": {"providers": ["native"], "relay_enabled": False}} with pytest.raises( ValueError, match="only relay telemetry is supported for Hermes" ): - await adapter.run_hermes(payload) + await adapter.HermesRuntime().start(payload) async def test_persistent_runtime_reuses_hermes_agent_session_and_history( monkeypatch, tmp_path: Path, ): - db_history = [{"role": "user", "content": "from hermes db"}] - mock_session_db = MagicMock(spec=SessionDB) - mock_session_db.get_session.return_value = {"id": "runtime-resolved-456"} - mock_session_db.resolve_resume_session_id.return_value = "runtime-resolved-456" - mock_session_db.get_messages_as_conversation.return_value = db_history mock_session_db_type = MagicMock(spec=SessionDB, return_value=mock_session_db) mock_ai_agent = MagicMock(spec=AIAgent) @@ -490,7 +479,6 @@ async def test_persistent_runtime_reuses_hermes_agent_session_and_history( AIAgent.run_conversation ) first_messages = [ - *db_history, {"role": "assistant", "content": "first response"}, ] second_messages = [ @@ -541,22 +529,22 @@ async def test_persistent_runtime_reuses_hermes_agent_session_and_history( "agent_name": "demo", "base_dir": str(tmp_path), "config": { - "harness": { - "settings": { - "hermes_home": "./hermes-home", - "enabled_toolsets": [], - "system_prompt": "system", - # Explicit null must resolve to DEFAULT_MAX_ITERATIONS (not int(None)). - "max_iterations": None, - } - }, - "models": { - "default": { - "provider": "test-provider", - "model": "test-model", - "api_key_env": "TEST_API_KEY", - } - }, + "harness": { + "settings": { + "hermes_home": "./hermes-home", + "enabled_toolsets": [], + "system_prompt": "system", + # Explicit null must resolve to DEFAULT_MAX_ITERATIONS (not int(None)). + "max_iterations": None, + } + }, + "models": { + "default": { + "provider": "test-provider", + "model": "test-model", + "api_key_env": "TEST_API_KEY", + } + }, }, "runtime_context": { "runtime_id": "runtime-fabric-123", @@ -569,25 +557,27 @@ async def test_persistent_runtime_reuses_hermes_agent_session_and_history( "capability_plan": {"native": {}}, } - start_payload = dict(payload) - start_payload.pop("request") + start_payload = {key: value for key, value in payload.items() if key != "request"} runtime = adapter.HermesRuntime() await runtime.start(start_payload) - first = await runtime.invoke(payload) + first = await runtime.invoke( + { + "runtime_context": payload["runtime_context"], + "request": payload["request"], + } + ) payload["runtime_context"]["invocation_id"] = "invocation-2" payload["request"]["input"] = "continue" - second = await runtime.invoke(payload) + second = await runtime.invoke( + { + "runtime_context": payload["runtime_context"], + "request": payload["request"], + } + ) await runtime.stop() mock_session_db_type.assert_called_once_with() - mock_session_db.resolve_resume_session_id.assert_called_once_with( - "runtime-fabric-123" - ) - mock_session_db.get_session.assert_called_once_with("runtime-resolved-456") - mock_session_db.get_messages_as_conversation.assert_called_once_with( - "runtime-resolved-456" - ) mock_ai_agent_type.assert_called_once_with( base_url=None, api_key="secret", @@ -611,7 +601,7 @@ async def test_persistent_runtime_reuses_hermes_agent_session_and_history( assert first_call.args == ("hello",) assert first_call.kwargs == { "system_message": "system", - "conversation_history": db_history, + "conversation_history": None, } assert second_call.args == ("continue",) assert second_call.kwargs == { @@ -620,6 +610,10 @@ async def test_persistent_runtime_reuses_hermes_agent_session_and_history( } mock_ai_agent.close.assert_called_once_with() mock_session_db.close.assert_called_once_with() + assert runtime._agent is None + assert runtime._session_db is None + assert runtime._start_payload is None + assert runtime._conversation_history is None assert first["response"] == "first response" assert second["response"] == "second response" assert "session_id" not in second diff --git a/tests/e2e/test_claude.py b/tests/e2e/test_claude.py index b36ce50b..c3eb2810 100644 --- a/tests/e2e/test_claude.py +++ b/tests/e2e/test_claude.py @@ -230,14 +230,14 @@ async def test_fabric_claude_accepts_real_relay_gateway_with_mock_claude(tmp_pat os.environ.get("RUN_FABRIC_CLAUDE_INTEGRATION") != "1", reason="set RUN_FABRIC_CLAUDE_INTEGRATION=1 to run Claude Agent SDK integration", ) -async def test_live_claude_one_shot_and_session(tmp_path): +async def test_live_claude_single_invocation_and_runtime(tmp_path): fabric = Fabric() - one_shot = await fabric.run( - fabric_config(tmp_path / "oneshot"), - base_dir=tmp_path / "oneshot", + single = await fabric.run( + fabric_config(tmp_path / "single"), + base_dir=tmp_path / "single", input="Reply only with: FABRIC_CLAUDE_OK", ) - assert one_shot.status == "succeeded" + assert single.status == "succeeded" session_root = tmp_path / "session" async with await fabric.start_runtime( diff --git a/tests/e2e/test_codex.py b/tests/e2e/test_codex.py index 1e780404..ac059d4d 100644 --- a/tests/e2e/test_codex.py +++ b/tests/e2e/test_codex.py @@ -56,17 +56,17 @@ async def _run() -> None: config = _select_codex_runtime(codex_config()) nonce = f"fabric-{uuid.uuid4().hex[:8]}" client = Fabric() - oneshot = await client.run( + single = await client.run( config, base_dir=BASE_DIR, - input="Reply with exactly: FABRIC_CODEX_ONESHOT_OK", + input="Reply with exactly: FABRIC_CODEX_SINGLE_INVOCATION_OK", ) - assert oneshot["status"] == "succeeded", oneshot.to_mapping() - assert "fabric_codex_oneshot_ok" in oneshot["output"]["response"].lower(), ( - oneshot.to_mapping() - ) - assert oneshot["output"]["adapter"] == "sdk", oneshot.to_mapping() - assert "command" not in oneshot["output"], oneshot.to_mapping() + assert single["status"] == "succeeded", single.to_mapping() + assert ( + "fabric_codex_single_invocation_ok" in single["output"]["response"].lower() + ), single.to_mapping() + assert single["output"]["adapter"] == "sdk", single.to_mapping() + assert "command" not in single["output"], single.to_mapping() async with await client.start_runtime( config, diff --git a/tests/e2e/test_deepagents.py b/tests/e2e/test_deepagents.py index e33a9deb..5a90d3d0 100644 --- a/tests/e2e/test_deepagents.py +++ b/tests/e2e/test_deepagents.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Opt-in real Deep Agents smoke for Fabric one-shot and multi-turn runtimes. +"""Opt-in real Deep Agents smoke for single-invocation and multi-turn runtimes. RUN_FABRIC_DEEPAGENTS_INTEGRATION=1 NVIDIA_API_KEY=... \ pytest tests/e2e/test_deepagents.py @@ -106,20 +106,19 @@ def _require_integration_fixture() -> None: @pytest.mark.usefixtures("_require_integration") -async def test_deepagents_oneshot(): +async def test_deepagents_single_invocation(): from examples.code_review_agent import BASE_DIR, deepagents_config from nemo_fabric import Fabric client = Fabric() - oneshot = await client.run( + single = await client.run( deepagents_config(), base_dir=BASE_DIR, - input="Reply with exactly: FABRIC_DEEPAGENTS_ONESHOT_OK", + input="Reply with exactly: FABRIC_DEEPAGENTS_SINGLE_INVOCATION_OK", ) - assert oneshot["status"] == "succeeded", oneshot.to_mapping() - assert oneshot["output"]["response"], oneshot.to_mapping() - # each one-shot run gets a fresh runtime, so it is never a resume - assert oneshot["output"]["resumed"] is False, oneshot.to_mapping() + assert single["status"] == "succeeded", single.to_mapping() + assert single["output"]["response"], single.to_mapping() + assert single["output"]["resumed"] is False, single.to_mapping() @pytest.mark.usefixtures("_require_integration") @@ -144,7 +143,7 @@ async def test_deepagents_multi_turn(): assert first["output"]["thread_id"] == second["output"]["thread_id"], results assert first["metadata"]["adapter_runner"] == "persistent_local_host", results assert first["metadata"]["host_pid"] == second["metadata"]["host_pid"], results - # the first turn opens the runtime; the second resumes and recalls turn one + # The second turn continues the live runtime and recalls turn one. assert first["output"]["resumed"] is False, results assert second["output"]["resumed"] is True, results assert nonce in second["output"]["response"], second.to_mapping() diff --git a/tests/fixtures/hermes-shim-agent/adapters/hermes-shim/fabric-adapter.json b/tests/fixtures/hermes-shim-agent/adapters/hermes-shim/fabric-adapter.json index 7f33aaea..3df5e70c 100644 --- a/tests/fixtures/hermes-shim-agent/adapters/hermes-shim/fabric-adapter.json +++ b/tests/fixtures/hermes-shim-agent/adapters/hermes-shim/fabric-adapter.json @@ -5,15 +5,11 @@ "adapter_kind": "python", "runner": { "module": "nemo_fabric_test_adapters.hermes_shim.adapter", - "callable": "run", "cwd": ".", "env": { "PYTHONPATH": "adapters/hermes-shim/src" } }, - "runtime": { - "local_host": {} - }, "requirements": { "binaries": [ "python3" diff --git a/tests/fixtures/hermes-shim-agent/adapters/hermes-shim/src/nemo_fabric_test_adapters/hermes_shim/adapter.py b/tests/fixtures/hermes-shim-agent/adapters/hermes-shim/src/nemo_fabric_test_adapters/hermes_shim/adapter.py index 80b5af6c..5fab3af0 100644 --- a/tests/fixtures/hermes-shim-agent/adapters/hermes-shim/src/nemo_fabric_test_adapters/hermes_shim/adapter.py +++ b/tests/fixtures/hermes-shim-agent/adapters/hermes-shim/src/nemo_fabric_test_adapters/hermes_shim/adapter.py @@ -17,20 +17,24 @@ def main() -> None: class ShimRuntime: - async def start(self, _payload: dict[str, Any]) -> None: - pass - - async def invoke(self, payload: dict[str, Any]) -> dict[str, Any]: + def __init__(self) -> None: + self._start_payload: dict[str, Any] | None = None + + async def start(self, payload: dict[str, Any]) -> None: + self._start_payload = payload + + async def invoke(self, invocation: dict[str, Any]) -> dict[str, Any]: + if self._start_payload is None: + raise RuntimeError("shim runtime is not started") + payload = { + **self._start_payload, + "runtime_context": invocation.get("runtime_context"), + "request": invocation.get("request"), + } return run_selected_mode(payload) async def stop(self) -> None: - pass - - -def run(payload: dict[str, Any]) -> dict[str, Any]: - """Test adapter entrypoint used by SDK smoke tests.""" - - return run_selected_mode(payload) + self._start_payload = None def fabric_config(payload: dict[str, Any]) -> dict[str, Any]: @@ -46,11 +50,13 @@ def request_payload(payload: dict[str, Any]) -> dict[str, Any]: def environment_payload(payload: dict[str, Any]) -> dict[str, Any]: - return runtime_context(payload).get("environment") or payload.get("environment") or {} + return ( + runtime_context(payload).get("environment") or payload.get("environment") or {} + ) def settings_payload(payload: dict[str, Any]) -> dict[str, Any]: - harness = (fabric_config(payload).get("harness") or {}) + harness = fabric_config(payload).get("harness") or {} return harness.get("settings") or payload.get("settings") or {} @@ -80,9 +86,15 @@ def run_shim(payload: dict[str, Any]) -> dict[str, Any]: "runtime_id": context.get("runtime_id"), "workspace": environment.get("workspace") or settings.get("workspace"), "native_skill_paths": (capabilities.get("native") or {}).get("skill_paths", []), - "native_mcp_servers": sorted((capabilities.get("native") or {}).get("mcp_servers", {}).keys()), - "managed_skill_paths": (capabilities.get("managed") or {}).get("skill_paths", []), - "managed_mcp_servers": sorted((capabilities.get("managed") or {}).get("mcp_servers", {}).keys()), + "native_mcp_servers": sorted( + (capabilities.get("native") or {}).get("mcp_servers", {}).keys() + ), + "managed_skill_paths": (capabilities.get("managed") or {}).get( + "skill_paths", [] + ), + "managed_mcp_servers": sorted( + (capabilities.get("managed") or {}).get("mcp_servers", {}).keys() + ), "capability_routes": capabilities.get("routes", []), "telemetry": payload.get("telemetry"), } From 0e34be8d6c59519facfe88dbdbbe6ccb7d69e0f1 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Tue, 21 Jul 2026 14:48:39 -0700 Subject: [PATCH 27/34] docs: align persistent host contract Signed-off-by: Ajay Thorve --- README.md | 5 +- adapters/claude/README.md | 9 +- adapters/codex/README.md | 4 +- adapters/codex/testing.md | 13 ++- adapters/common/README.md | 24 ++-- adapters/deepagents/README.md | 48 ++++---- adapters/hermes/README.md | 4 +- docs/about-nemo-fabric/overview.mdx | 2 +- docs/integrations/claude.mdx | 9 +- docs/integrations/codex.mdx | 11 +- .../nemo_fabric.client.md | 2 +- .../config/enum-adapterkind.mdx | 4 +- .../config/enum-capabilitykind.mdx | 2 +- .../config/enum-capabilitytarget.mdx | 2 +- .../config/enum-controllocation.mdx | 2 +- .../config/enum-environmentownership.mdx | 2 +- .../config/enum-mcpexposure.mdx | 2 +- .../config/enum-relayatifstorageconfig.mdx | 2 +- .../enum-relayatofendpointfieldnamepolicy.mdx | 2 +- .../enum-relayatofendpointtransport.mdx | 2 +- .../config/enum-relayatofmode.mdx | 2 +- .../config/enum-relayotlptransport.mdx | 2 +- .../config/enum-relayunsupportedbehavior.mdx | 2 +- .../config/enum-resolutionstrategy.mdx | 2 +- .../config/enum-telemetryprovider.mdx | 2 +- .../config/fn-load-adapter-descriptor.mdx | 2 +- .../fn-resolve-run-plan-from-config.mdx | 2 +- .../nemo-fabric-core/config/index.mdx | 4 +- .../config/struct-adapterdescriptor.mdx | 6 +- .../config/struct-adapterlocalhostsupport.mdx | 94 --------------- .../config/struct-adapterrequirements.mdx | 2 +- .../config/struct-adapterruntimesupport.mdx | 108 ------------------ ...struct-adaptertelemetryprovidersupport.mdx | 2 +- .../config/struct-adaptertelemetrysupport.mdx | 2 +- .../config/struct-capabilityplan.mdx | 2 +- .../config/struct-capabilityroute.mdx | 2 +- .../config/struct-capabilitytargetplan.mdx | 2 +- .../config/struct-environmentconfig.mdx | 2 +- .../config/struct-environmentplan.mdx | 2 +- .../config/struct-fabricconfig.mdx | 2 +- .../config/struct-harnessconfig.mdx | 2 +- .../config/struct-mcpconfig.mdx | 2 +- .../config/struct-mcpserverconfig.mdx | 2 +- .../config/struct-mcpserverplan.mdx | 2 +- .../config/struct-metadataconfig.mdx | 2 +- .../config/struct-modelconfig.mdx | 2 +- .../config/struct-relayatifconfig.mdx | 2 +- .../config/struct-relayatofconfig.mdx | 2 +- .../config/struct-relayatofendpointconfig.mdx | 2 +- .../config/struct-relaycomponentconfig.mdx | 2 +- .../config/struct-relayconfig.mdx | 2 +- .../config/struct-relayconfigpolicy.mdx | 2 +- .../struct-relayobservabilityconfig.mdx | 2 +- .../config/struct-relayotlpconfig.mdx | 2 +- .../config/struct-resolvecontext.mdx | 2 +- .../struct-resolvedadapterdescriptor.mdx | 2 +- .../config/struct-runplan.mdx | 2 +- .../config/struct-runtimecapabilities.mdx | 2 +- .../config/struct-runtimeconfig.mdx | 2 +- .../config/struct-skillconfig.mdx | 2 +- .../config/struct-telemetryconfig.mdx | 2 +- .../config/struct-telemetryplan.mdx | 2 +- .../config/struct-telemetryproviderconfig.mdx | 2 +- .../config/struct-toolsconfig.mdx | 2 +- .../config/struct-toolsplan.mdx | 2 +- .../doctor/enum-doctorstatus.mdx | 2 +- .../doctor/fn-doctor-plan.mdx | 2 +- .../nemo-fabric-core/doctor/index.mdx | 2 +- .../doctor/struct-doctorcheck.mdx | 2 +- .../doctor/struct-doctorreport.mdx | 2 +- .../error/enum-fabricerror.mdx | 2 +- .../nemo-fabric-core/error/index.mdx | 2 +- .../nemo-fabric-core/error/type-result.mdx | 2 +- .../nemo-fabric-core/fn-version.mdx | 2 +- .../nemo-fabric-core/index.mdx | 2 - .../nemo-fabric-core/runtime/index.mdx | 4 +- .../runtime/struct-adapterinvocation.mdx | 2 +- .../runtime/struct-runtimecontext.mdx | 4 +- .../nemo-fabric-core/schema/index.mdx | 2 +- docs/sdk/python.mdx | 31 +++-- examples/notebooks/01_quickstart.ipynb | 2 +- python/src/nemo_fabric/client.py | 4 +- schemas/SCHEMA.md | 6 +- skills/README.md | 2 +- skills/nemo-fabric-integrate/SKILL.md | 21 ++-- .../references/sdk-api-inventory.md | 7 +- 86 files changed, 165 insertions(+), 385 deletions(-) delete mode 100644 docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterlocalhostsupport.mdx delete mode 100644 docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterruntimesupport.mdx diff --git a/README.md b/README.md index ac29c85f..6700b1d6 100644 --- a/README.md +++ b/README.md @@ -166,9 +166,8 @@ subagents that inherit the parent agent's capabilities. Consumers use the same `Fabric.start_runtime(...)` contract for all four bundled adapters. Adapter hosting remains descriptor-owned; it is not selected -through public `FabricConfig` settings. Local Process and Python adapters must -declare and implement `runtime.local_host`; Fabric does not provide a -process-per-invocation fallback. +through public `FabricConfig` settings. The `process` and `python` adapter kinds +run as persistent local hosts for the complete runtime lifecycle. ## Claude Adapter diff --git a/adapters/claude/README.md b/adapters/claude/README.md index 6469bfd4..1f3d37c0 100644 --- a/adapters/claude/README.md +++ b/adapters/claude/README.md @@ -71,7 +71,7 @@ for other supported installation methods. ## Execution Model -The Claude adapter declares Fabric's persistent local-host wire protocol. +The Claude adapter implements Fabric's persistent local-host wire protocol. `Fabric.start_runtime(...)` launches one adapter host, creates one `ClaudeSDKClient`, and connects it once. Every `Runtime.invoke(...)` reuses that client and its event loop; `Runtime.stop()` disconnects the client and exits the @@ -132,8 +132,9 @@ For each Relay-enabled Claude runtime, Fabric starts one `nemo-relay` gateway, waits for its health endpoint, and stops it with the runtime. Fabric passes the gateway URL to the connected Claude Code process through `ANTHROPIC_BASE_URL` and `NEMO_RELAY_GATEWAY_URL`. It also stages a runtime-scoped Claude plugin that -forwards lifecycle hooks with `nemo-relay hook-forward claude`. A one-shot -`Fabric.run(...)` naturally owns the gateway for only one invocation. +forwards lifecycle hooks with `nemo-relay hook-forward claude`. +`Fabric.run(...)` starts the same runtime, invokes it once, and stops it, so the +gateway has the same lifecycle as that single invocation. The Fabric result includes `relay_runtime.gateway_config_path`, `relay_runtime.gateway_log_path`, and the collected `relay_artifacts`. Relay @@ -201,7 +202,7 @@ config = FabricConfig( fabric = Fabric() ``` -## One-Shot Run +## Single Invocation ```python result = await fabric.run( diff --git a/adapters/codex/README.md b/adapters/codex/README.md index e1e51135..c26f36ea 100644 --- a/adapters/codex/README.md +++ b/adapters/codex/README.md @@ -125,8 +125,8 @@ Set model selection through `models` and the working directory through For `Fabric.start_runtime(...)`, the model provider, MCP configuration, skill roots, and `config_overrides` are fixed when the runtime starts and cannot vary between `Runtime.invoke(...)` calls. Start a new runtime to change them. -`Fabric.run(...)` creates a fresh one-shot runtime, so the same settings are -scoped to that invocation. +`Fabric.run(...)` starts the same runtime, invokes it once, and stops it, so the +same settings are scoped to that single invocation. The adapter filters the inherited environment. It retains portable OS and Codex state variables, the selected model's `api_key_env`, and explicit diff --git a/adapters/codex/testing.md b/adapters/codex/testing.md index da71fe45..8cd9f87b 100644 --- a/adapters/codex/testing.md +++ b/adapters/codex/testing.md @@ -18,9 +18,10 @@ RUN_FABRIC_CODEX_RELAY_INTEGRATION=1 \ Set `FABRIC_TEST_CODEX_BIN=/path/to/codex` on either opt-in command to validate an explicit app-server override instead of the SDK-pinned runtime. -The SDK test uses the current Codex authentication state and exercises both a -one-shot invocation and multi-turn thread resume. The Relay test additionally -requires an external gateway binary and verifies one-shot and resumed model -responses, stable thread identity, ATOF, and ATIF; gateway startup alone is not -a passing result. The semantic regression also requires decoded LLM request -content, a model, token usage, and the expected agent response in ATIF. \ No newline at end of file +The SDK test uses the current Codex authentication state and exercises both the +single-invocation convenience API and multiple turns against one started +runtime. The Relay test additionally requires an external gateway binary and +verifies model responses, stable thread identity across turns, ATOF, and ATIF; +gateway startup alone is not a passing result. The semantic regression also +requires decoded LLM request content, a model, token usage, and the expected +agent response in ATIF. diff --git a/adapters/common/README.md b/adapters/common/README.md index 723e62a6..0f91d19e 100644 --- a/adapters/common/README.md +++ b/adapters/common/README.md @@ -23,16 +23,10 @@ pip install "nemo-fabric[adapters-common]" ## Persistent Local Hosts -Every local Process or Python adapter declares `runtime.local_host` in -`fabric-adapter.json` and implements the ordered local-host wire protocol. -Python adapters can use `nemo_fabric_adapters.common.lifecycle`. Supply a -factory that creates one adapter-owned runtime with asynchronous `start`, -`invoke`, and `stop` methods: - -`runtime.local_host` is an unversioned capability marker, not a separate -adapter contract. The nesting is intentionally provisional while local hosting -is the only supported mode; a shared runtime protocol can replace it when -remote adapter hosting is introduced. +Every local Process or Python adapter implements the ordered persistent-host +wire protocol. Python adapters can use +`nemo_fabric_adapters.common.lifecycle`. Supply a factory that creates one +adapter-owned runtime with asynchronous `start`, `invoke`, and `stop` methods: ```python from nemo_fabric_adapters.common import lifecycle @@ -57,11 +51,11 @@ serializes invocations through that instance. The host keeps one event loop alive for the complete lifecycle so SDK clients, compiled graphs, checkpointers, and harness databases can remain live safely. Fabric sends the resolved configuration and capability plan during `start`. Each subsequent -`invoke` wire payload contains only `runtime_context` and `request`; the helper -retains the start payload and supplies a merged view to `AdapterRuntime.invoke`. -Adapter stdout is reserved for the protocol; diagnostics are redirected to -stderr. A host crash or protocol timeout is terminal for that runtime. Fabric -does not fall back to a new process. +`invoke` wire payload contains only `runtime_context` and `request`, and the +helper passes that payload to `AdapterRuntime.invoke` unchanged. An adapter that +needs configuration during invocation retains it as runtime-owned state during +`start`. Adapter stdout is reserved for the protocol; diagnostics are redirected +to stderr. A host crash or protocol timeout terminates that runtime. Refer to the [NeMo Fabric documentation](https://nvidia-nemo-fabric.docs.buildwithfern.com/nemo/fabric) for adapter and configuration guidance. Source code is available in the diff --git a/adapters/deepagents/README.md b/adapters/deepagents/README.md index b62158aa..1294290d 100644 --- a/adapters/deepagents/README.md +++ b/adapters/deepagents/README.md @@ -6,8 +6,8 @@ SPDX-License-Identifier: Apache-2.0 # NVIDIA NeMo Fabric LangChain Deep Agents Adapter Runs a [LangChain Deep Agents](https://github.com/langchain-ai/deepagents) agent -through Fabric's inline Python adapter lifecycle. The same adapter supports -one-shot, multi-turn, and resumed execution. +inside Fabric's persistent Python adapter host. One started runtime retains the +compiled graph, checkpointer, and LangGraph thread across ordered invocations. To install just the Deep Agents adapter by itself: @@ -40,8 +40,9 @@ never sent to the wrong endpoint. Because `models.default.api_key_env` is provider-specific, the adapter declares no static env requirement; a runtime **preflight** verifies that the `deepagents` -package is importable and the configured credential is set, and returns a -normalized failure otherwise. `fabric doctor` validates adapter resolution. +package is importable and the configured credential is set. A failed preflight +fails runtime start with a stable lifecycle error. `fabric doctor` validates +adapter resolution. Fabric maps the following into the harness: @@ -88,25 +89,22 @@ per-step events, LangGraph thread id, token usage (and cost when the provider reports it), and errors. Usage aggregates the current turn across the main agent and any delegated subagents (streamed with `subgraphs=True`). Configuration and preflight failures (a missing credential, an absent `deepagents` package, an -invalid MCP server, or a passthrough option) are returned as a -normalized failure result rather than a raw traceback. - -## Runtime Modes - -A one-shot `run` starts a local adapter host, compiles one Deep Agents graph, -opens its async LangGraph checkpointer, invokes the graph once with `astream`, -and then closes the checkpointer and host. The result contains the final agent -message, buffered messages and per-step events, usage, and the LangGraph thread -ID. Each one-shot run gets a fresh Fabric `runtime_id`, so `resumed` is `false`. - -Multi-turn and resume are keyed by the Fabric `runtime_id`, which is stable -across `invoke` calls in a started runtime (`start_runtime`) and fresh for each -one-shot run. The host compiles the graph and opens the checkpointer once during -runtime start. Every turn reuses both native objects and the same LangGraph -thread ID; later turns report `resumed` as `true`. The checkpointer lives under +invalid MCP server, or a passthrough option) fail runtime start before an +invocation is accepted. + +## Runtime Lifecycle + +Fabric starts one local adapter host for every runtime. During runtime start, +the host compiles one Deep Agents graph, opens its async LangGraph checkpointer, +and creates one thread ID. Every invocation reuses those native objects; later +turns report `resumed` as `true`. The checkpointer lives under `harness.settings.state_dir` (default the runtime artifacts directory) and is -closed during runtime stop. Fabric owns the runtime-to-thread correlation -record; LangGraph owns the transcript. +closed during runtime stop. The live host owns the thread identity, and +LangGraph owns the transcript. + +`Fabric.run(...)` is a convenience over that same lifecycle: it starts the +runtime, invokes it once, and stops it. It does not use a separate adapter +entrypoint or execution path. The `deepagents_config()` builder in `examples/code_review_agent` is the SDK example. Run it from the CLI with @@ -120,13 +118,13 @@ from nemo_fabric import Fabric config = deepagents_config() client = Fabric() -# One-shot: each run gets a fresh runtime, so `resumed` is False. +# Single invocation through the standard runtime lifecycle. result = await client.run( config, base_dir=BASE_DIR, input="Review the workspace changes." ) print(result["output"]["response"]) -# Multi-turn + resume: one started runtime keeps the LangGraph thread across turns. +# Multi-turn: one started runtime keeps the LangGraph thread across turns. async with await client.start_runtime(config, base_dir=BASE_DIR) as runtime: await runtime.invoke(input="Remember the value 42.") reply = await runtime.invoke(input="What value did I ask you to remember?") @@ -148,7 +146,7 @@ pip install "nemo-fabric-adapters-deepagents[relay]" # -> nemo-relay[deepagent - **Relay** (`telemetry.providers.relay`): the SDK-native integration attaches three complementary pieces around `create_deep_agent`, applied uniformly to - one-shot, multi-turn, resumed, and subagent-enabled runs: + single-invocation, multi-turn, and subagent-enabled runs: - `nemo_relay.integrations.deepagents.add_nemo_relay_integration(...)` injects Deep Agents-aware **middleware** that routes model and tool calls through Relay and emits skill/subagent configuration marks. diff --git a/adapters/hermes/README.md b/adapters/hermes/README.md index 58ef1d2c..de6d14ce 100644 --- a/adapters/hermes/README.md +++ b/adapters/hermes/README.md @@ -57,8 +57,8 @@ Keep `fabric-adapter.json` aligned with the Python implementation: - `contract_version` must match the adapter contract supported by Fabric core. - `adapter_id` is the stable id selected by `harness.adapter_id`. - `adapter_kind` is `python` because Fabric can invoke it through Python. -- `runner.module` names the module that Fabric invokes with `python -m`. - `runner.callable` names the equivalent reusable Python function. +- `runner.module` names the persistent host module that Fabric invokes with + `python -m`. - `requirements` powers `fabric doctor`; keep required env vars, binaries, or packages current. - `config.accepts` must match the Fabric sections this adapter maps into Hermes. diff --git a/docs/about-nemo-fabric/overview.mdx b/docs/about-nemo-fabric/overview.mdx index 324dca0e..cb10aaa4 100644 --- a/docs/about-nemo-fabric/overview.mdx +++ b/docs/about-nemo-fabric/overview.mdx @@ -28,7 +28,7 @@ into the caller. SDK instead of embedding harness launch logic in every consumer. - Resolve configs, inspect capabilities, run one-shot jobs, and hold + Resolve configs, inspect capabilities, run single-invocation jobs, and hold multi-turn runtimes with typed requests, plans, handles, and results. diff --git a/docs/integrations/claude.mdx b/docs/integrations/claude.mdx index 434a0695..deb7bca1 100644 --- a/docs/integrations/claude.mdx +++ b/docs/integrations/claude.mdx @@ -87,10 +87,11 @@ variables take precedence over federation even when their value is empty. NeMo Relay does not authenticate Claude. A Relay-enabled NeMo Fabric runtime starts one gateway as a supervised sidecar, sets `ANTHROPIC_BASE_URL` for the -Claude runtime, and reuses the gateway across ordered invocations. A one-shot -`Fabric.run(...)` owns the gateway for one invocation. Claude still resolves its -credential through the selected mode, and NeMo Fabric does not write -authentication values to Relay configuration or artifacts. +Claude runtime, and reuses the gateway across ordered invocations. +`Fabric.run(...)` starts the same runtime, invokes it once, and stops it, so the +gateway is scoped to that single invocation. Claude still resolves its credential +through the selected mode, and NeMo Fabric does not write authentication values +to Relay configuration or artifacts. NeMo Fabric supports the external NeMo Relay CLI from `0.6.0` up to, but not including, `0.7.0`. The Python package named `nemo-relay` does not install this diff --git a/docs/integrations/codex.mdx b/docs/integrations/codex.mdx index 14073d11..008de52b 100644 --- a/docs/integrations/codex.mdx +++ b/docs/integrations/codex.mdx @@ -39,8 +39,8 @@ and MCP transports fail before the SDK runtime starts. For `Fabric.start_runtime(...)`, the model provider, MCP configuration, skill roots, and `harness.settings.config_overrides` are fixed when the runtime starts and cannot vary between `Runtime.invoke(...)` calls. Start a new runtime to -change them. `Fabric.run(...)` creates a fresh one-shot runtime, so the same -settings are scoped to that invocation. +change them. `Fabric.run(...)` starts the same runtime, invokes it once, and +stops it, so the same settings are scoped to that single invocation. The Codex adapter does not declare `tools.blocked` support. Codex can filter individual MCP server tools, but the pinned runtime does not provide one deny @@ -112,9 +112,10 @@ storage. Do not commit or copy it into NeMo Fabric configuration or artifacts. NeMo Relay does not replace OpenAI authentication. A Relay-enabled NeMo Fabric runtime starts one gateway as a supervised sidecar and directs the Codex SDK's built-in OpenAI provider through that gateway. The runtime reuses the gateway -and SDK client across ordered invocations. A one-shot `Fabric.run(...)` owns the -gateway for one invocation. The SDK still obtains credentials from the selected -Codex authentication mode. +and SDK client across ordered invocations. `Fabric.run(...)` starts the same +runtime, invokes it once, and stops it, so the gateway is scoped to that single +invocation. The SDK still obtains credentials from the selected Codex +authentication mode. Relay-enabled Codex runs require `models.default.provider` to be `openai`. The custom `nvidia` provider uses its configured NVIDIA Responses endpoint and diff --git a/docs/reference/api/python-library-reference/nemo_fabric.client.md b/docs/reference/api/python-library-reference/nemo_fabric.client.md index 59d91106..f8fec00e 100644 --- a/docs/reference/api/python-library-reference/nemo_fabric.client.md +++ b/docs/reference/api/python-library-reference/nemo_fabric.client.md @@ -21,7 +21,7 @@ Every lifecycle method accepts a complete, typed ``FabricConfig`` plus an option ``Fabric`` uses the native Rust extension. SDK calls raise ``FabricNativeUnavailableError`` when the native extension is not installed. -See the Getting Started overview for runnable one-shot, typed-config, and multi-turn examples. +See the Getting Started overview for runnable single-invocation, typed-config, and multi-turn examples. diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-adapterkind.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-adapterkind.mdx index c786a243..bdb4d02b 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-adapterkind.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-adapterkind.mdx @@ -26,7 +26,7 @@ Adapter implementation kind.
-Launch and supervise a CLI process. +Launch and supervise a persistent adapter process. ### `Http` @@ -38,7 +38,7 @@ Connect to a service or HTTP-backed harness.
-Call a Python SDK/plugin adapter. +Launch and supervise a persistent Python adapter host. ### `NativePlugin` diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitykind.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitykind.mdx index dc6679cc..b0d72b88 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitykind.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitykind.mdx @@ -2,7 +2,7 @@ title: "Enum Capability Kind" sidebar-title: "CapabilityKind" description: "Capability kind." -position: 41 +position: 39 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitytarget.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitytarget.mdx index bc7000dd..2b82413a 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitytarget.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitytarget.mdx @@ -2,7 +2,7 @@ title: "Enum Capability Target" sidebar-title: "CapabilityTarget" description: "Capability routing target." -position: 42 +position: 40 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-controllocation.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-controllocation.mdx index 46088e42..84d27e31 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-controllocation.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-controllocation.mdx @@ -2,7 +2,7 @@ title: "Enum Control Location" sidebar-title: "ControlLocation" description: "Where Fabric control code runs relative to the environment." -position: 12 +position: 10 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-environmentownership.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-environmentownership.mdx index 92fe3c8e..962a0afd 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-environmentownership.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-environmentownership.mdx @@ -2,7 +2,7 @@ title: "Enum Environment Ownership" sidebar-title: "EnvironmentOwnership" description: "Whether Fabric owns the underlying environment resource." -position: 14 +position: 12 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-mcpexposure.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-mcpexposure.mdx index dce2481c..b0623603 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-mcpexposure.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-mcpexposure.mdx @@ -2,7 +2,7 @@ title: "Enum McpExposure" sidebar-title: "McpExposure" description: "MCP exposure strategy." -position: 19 +position: 17 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatifstorageconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatifstorageconfig.mdx index 3e3bae70..1b2589f2 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatifstorageconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatifstorageconfig.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atif Storage Config" sidebar-title: "RelayAtifStorageConfig" description: "Relay ATIF remote storage configuration." -position: 46 +position: 44 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofendpointfieldnamepolicy.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofendpointfieldnamepolicy.mdx index ad00dee6..e500f6aa 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofendpointfieldnamepolicy.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofendpointfieldnamepolicy.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atof Endpoint Field Name Policy" sidebar-title: "RelayAtofEndpointFieldNamePolicy" description: "Relay ATOF endpoint field-name policy." -position: 47 +position: 45 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofendpointtransport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofendpointtransport.mdx index f8277ee0..089169e4 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofendpointtransport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofendpointtransport.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atof Endpoint Transport" sidebar-title: "RelayAtofEndpointTransport" description: "Relay ATOF endpoint transport." -position: 48 +position: 46 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofmode.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofmode.mdx index 60977018..010b8850 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofmode.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofmode.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atof Mode" sidebar-title: "RelayAtofMode" description: "Relay ATOF file mode." -position: 49 +position: 47 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayotlptransport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayotlptransport.mdx index 1f585700..fae226e2 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayotlptransport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayotlptransport.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Otlp Transport" sidebar-title: "RelayOtlpTransport" description: "Relay OTLP transport." -position: 50 +position: 48 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayunsupportedbehavior.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayunsupportedbehavior.mdx index e8d549f6..34db2ad9 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayunsupportedbehavior.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayunsupportedbehavior.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Unsupported Behavior" sidebar-title: "RelayUnsupportedBehavior" description: "Relay unsupported/unknown config handling." -position: 51 +position: 49 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-resolutionstrategy.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-resolutionstrategy.mdx index 1a861d09..86197cf3 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-resolutionstrategy.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-resolutionstrategy.mdx @@ -2,7 +2,7 @@ title: "Enum Resolution Strategy" sidebar-title: "ResolutionStrategy" description: "Adapter install or availability strategy." -position: 23 +position: 21 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-telemetryprovider.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-telemetryprovider.mdx index a8055df7..9cab3af6 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-telemetryprovider.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-telemetryprovider.mdx @@ -2,7 +2,7 @@ title: "Enum Telemetry Provider" sidebar-title: "TelemetryProvider" description: "Telemetry runtime provider." -position: 32 +position: 30 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-descriptor.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-descriptor.mdx index b9e8fdbe..93dff2ba 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-descriptor.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-descriptor.mdx @@ -2,7 +2,7 @@ title: "Function load_adapter_descriptor" sidebar-title: "load_adapter_descriptor" description: "Load an adapter descriptor from JSON package metadata." -position: 34 +position: 32 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-resolve-run-plan-from-config.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-resolve-run-plan-from-config.mdx index 3412e2fe..830eb41f 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-resolve-run-plan-from-config.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-resolve-run-plan-from-config.mdx @@ -2,7 +2,7 @@ title: "Function resolve_run_plan_from_config" sidebar-title: "resolve_run_plan_from_config" description: "Resolve a typed Fabric config into a runnable plan." -position: 35 +position: 33 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx index 3961f02e..4de4cef4 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx @@ -2,7 +2,7 @@ title: "Module config" sidebar-title: "config" description: "Fabric config models and loading helpers." -position: 67 +position: 65 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} @@ -15,9 +15,7 @@ Fabric config models and loading helpers. - [AdapterConfigSupport](/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterconfigsupport): Adapter config support. - [AdapterDescriptor](/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterdescriptor): Language-neutral adapter descriptor for a harness integration. -- [AdapterLocalHostSupport](/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterlocalhostsupport): Persistent local-host protocol implemented by an adapter. - [AdapterRequirements](/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterrequirements): Adapter runtime requirements. -- [AdapterRuntimeSupport](/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterruntimesupport): Runtime hosting mechanisms implemented by an adapter. - [AdapterTelemetryProviderSupport](/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adaptertelemetryprovidersupport): Telemetry capabilities for one adapter-supported provider. - [AdapterTelemetrySupport](/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adaptertelemetrysupport): Adapter telemetry support. - [CapabilityPlan](/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilityplan): Resolved capability configuration. diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterdescriptor.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterdescriptor.mdx index 097e6c76..2d1a3357 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterdescriptor.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterdescriptor.mdx @@ -9,7 +9,7 @@ SPDX-License-Identifier: Apache-2.0 */} Generated from `cargo doc --no-deps -p nemo-fabric-core`. -
String,\n    pub adapter_id: String,\n    pub harness: String,\n    pub adapter_kind: AdapterKind,\n    pub runner: Map<String, Value>,\n    pub requirements: AdapterRequirements,\n    pub config: AdapterConfigSupport,\n    pub telemetry: AdapterTelemetrySupport,\n    pub runtime: AdapterRuntimeSupport,\n    pub capabilities: RuntimeCapabilities,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
String,\n    pub adapter_id: String,\n    pub harness: String,\n    pub adapter_kind: AdapterKind,\n    pub runner: Map<String, Value>,\n    pub requirements: AdapterRequirements,\n    pub config: AdapterConfigSupport,\n    pub telemetry: AdapterTelemetrySupport,\n    pub capabilities: RuntimeCapabilities,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
Language-neutral adapter descriptor for a harness integration. @@ -47,10 +47,6 @@ Fabric config areas this adapter consumes or generates. Telemetry support declared by this adapter. -### `runtime: AdapterRuntimeSupport` - -Adapter-owned runtime hosting support. - ### `capabilities: RuntimeCapabilities` Runtime lifecycle operations supported by this adapter. diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterlocalhostsupport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterlocalhostsupport.mdx deleted file mode 100644 index 567be919..00000000 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterlocalhostsupport.mdx +++ /dev/null @@ -1,94 +0,0 @@ ---- -title: "Struct Adapter Local Host Support" -sidebar-title: "AdapterLocalHostSupport" -description: "Persistent local-host protocol implemented by an adapter." -position: 6 ---- -{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -SPDX-License-Identifier: Apache-2.0 */} - -Generated from `cargo doc --no-deps -p nemo-fabric-core`. - -
BTreeMap<String, Value>,\n}"}} />
- -Persistent local-host protocol implemented by an adapter. - -## Fields - -### `extensions: BTreeMap` - -Additive persistent local-host fields. - -## Trait Implementations - -### `impl Clone for AdapterLocalHostSupport` - -
Clone for AdapterLocalHostSupport"}} />
- -#### `clone` - -
clone(&self) -> AdapterLocalHostSupport"}} />
- -#### `clone_from` - -
clone_from(&mut self, source: &Self)"}} />
- -### `impl Debug for AdapterLocalHostSupport` - -
Debug for AdapterLocalHostSupport"}} />
- -#### `fmt` - -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
- -### `impl<'de> Deserialize<'de> for AdapterLocalHostSupport` - -
Deserialize<'de> for AdapterLocalHostSupport"}} />
- -#### `deserialize` - -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
- -### `impl JsonSchema for AdapterLocalHostSupport` - -
AdapterLocalHostSupport"}} />
- -#### `schema_name` - -
Cow<'static, str>"}} />
- -#### `schema_id` - -
Cow<'static, str>"}} />
- -#### `json_schema` - -
- -#### `inline_schema` - -
bool"}} />
- -### `impl PartialEq for AdapterLocalHostSupport` - -
PartialEq for AdapterLocalHostSupport"}} />
- -#### `eq` - -
eq(&self, other: &AdapterLocalHostSupport) -> bool"}} />
- -#### `ne` - -
ne(&self, other: &Rhs) -> bool"}} />
- -### `impl Serialize for AdapterLocalHostSupport` - -
Serialize for AdapterLocalHostSupport"}} />
- -#### `serialize` - -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
- -### `impl StructuralPartialEq for AdapterLocalHostSupport` - -
StructuralPartialEq for AdapterLocalHostSupport"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterrequirements.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterrequirements.mdx index a462ccfc..128e05df 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterrequirements.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterrequirements.mdx @@ -2,7 +2,7 @@ title: "Struct Adapter Requirements" sidebar-title: "AdapterRequirements" description: "Adapter runtime requirements." -position: 7 +position: 6 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterruntimesupport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterruntimesupport.mdx deleted file mode 100644 index 4d213d37..00000000 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterruntimesupport.mdx +++ /dev/null @@ -1,108 +0,0 @@ ---- -title: "Struct Adapter Runtime Support" -sidebar-title: "AdapterRuntimeSupport" -description: "Runtime hosting mechanisms implemented by an adapter." -position: 8 ---- -{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -SPDX-License-Identifier: Apache-2.0 */} - -Generated from `cargo doc --no-deps -p nemo-fabric-core`. - -
Option<AdapterLocalHostSupport>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
- -Runtime hosting mechanisms implemented by an adapter. - -## Fields - -### `local_host: Option` - -Persistent local-host support, when implemented. - -This nesting is intentionally provisional until remote adapter hosting introduces a shared runtime protocol. - -### `extensions: BTreeMap` - -Additive adapter runtime-hosting fields. - -## Trait Implementations - -### `impl Clone for AdapterRuntimeSupport` - -
Clone for AdapterRuntimeSupport"}} />
- -#### `clone` - -
clone(&self) -> AdapterRuntimeSupport"}} />
- -#### `clone_from` - -
clone_from(&mut self, source: &Self)"}} />
- -### `impl Debug for AdapterRuntimeSupport` - -
Debug for AdapterRuntimeSupport"}} />
- -#### `fmt` - -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
- -### `impl Default for AdapterRuntimeSupport` - -
Default for AdapterRuntimeSupport"}} />
- -#### `default` - -
default() -> AdapterRuntimeSupport"}} />
- -### `impl<'de> Deserialize<'de> for AdapterRuntimeSupport` - -
Deserialize<'de> for AdapterRuntimeSupport"}} />
- -#### `deserialize` - -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
- -### `impl JsonSchema for AdapterRuntimeSupport` - -
AdapterRuntimeSupport"}} />
- -#### `schema_name` - -
Cow<'static, str>"}} />
- -#### `schema_id` - -
Cow<'static, str>"}} />
- -#### `json_schema` - -
- -#### `inline_schema` - -
bool"}} />
- -### `impl PartialEq for AdapterRuntimeSupport` - -
PartialEq for AdapterRuntimeSupport"}} />
- -#### `eq` - -
eq(&self, other: &AdapterRuntimeSupport) -> bool"}} />
- -#### `ne` - -
ne(&self, other: &Rhs) -> bool"}} />
- -### `impl Serialize for AdapterRuntimeSupport` - -
Serialize for AdapterRuntimeSupport"}} />
- -#### `serialize` - -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
- -### `impl StructuralPartialEq for AdapterRuntimeSupport` - -
StructuralPartialEq for AdapterRuntimeSupport"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adaptertelemetryprovidersupport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adaptertelemetryprovidersupport.mdx index 8a081daf..329db488 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adaptertelemetryprovidersupport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adaptertelemetryprovidersupport.mdx @@ -2,7 +2,7 @@ title: "Struct Adapter Telemetry Provider Support" sidebar-title: "AdapterTelemetryProviderSupport" description: "Telemetry capabilities for one adapter-supported provider." -position: 9 +position: 7 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adaptertelemetrysupport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adaptertelemetrysupport.mdx index 90334dbf..455d860a 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adaptertelemetrysupport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adaptertelemetrysupport.mdx @@ -2,7 +2,7 @@ title: "Struct Adapter Telemetry Support" sidebar-title: "AdapterTelemetrySupport" description: "Adapter telemetry support." -position: 10 +position: 8 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilityplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilityplan.mdx index 554dd4d2..8e98b7c7 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilityplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilityplan.mdx @@ -2,7 +2,7 @@ title: "Struct Capability Plan" sidebar-title: "CapabilityPlan" description: "Resolved capability configuration." -position: 11 +position: 9 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilityroute.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilityroute.mdx index 6c31b087..f9bc76d8 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilityroute.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilityroute.mdx @@ -2,7 +2,7 @@ title: "Struct Capability Route" sidebar-title: "CapabilityRoute" description: "One capability routing decision." -position: 9 +position: 7 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilitytargetplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilitytargetplan.mdx index 1df0610f..73c0a0b9 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilitytargetplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-capabilitytargetplan.mdx @@ -2,7 +2,7 @@ title: "Struct Capability Target Plan" sidebar-title: "CapabilityTargetPlan" description: "Capabilities routed to one target." -position: 10 +position: 8 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-environmentconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-environmentconfig.mdx index 9354813c..97e9e0d1 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-environmentconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-environmentconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Environment Config" sidebar-title: "EnvironmentConfig" description: "Execution environment configuration." -position: 13 +position: 11 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-environmentplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-environmentplan.mdx index 1e371238..f0f664bc 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-environmentplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-environmentplan.mdx @@ -2,7 +2,7 @@ title: "Struct Environment Plan" sidebar-title: "EnvironmentPlan" description: "Resolved environment plan." -position: 15 +position: 13 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-fabricconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-fabricconfig.mdx index 8300c019..dca40ab9 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-fabricconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-fabricconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Fabric Config" sidebar-title: "FabricConfig" description: "Versioned Fabric agent config." -position: 16 +position: 14 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-harnessconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-harnessconfig.mdx index 11186975..ce73c2a1 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-harnessconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-harnessconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Harness Config" sidebar-title: "HarnessConfig" description: "Harness selection." -position: 17 +position: 15 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpconfig.mdx index 33a7d5e5..12e5c64c 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpconfig.mdx @@ -2,7 +2,7 @@ title: "Struct McpConfig" sidebar-title: "McpConfig" description: "MCP capability configuration." -position: 18 +position: 16 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpserverconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpserverconfig.mdx index 8e939ebd..a569574d 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpserverconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpserverconfig.mdx @@ -2,7 +2,7 @@ title: "Struct McpServer Config" sidebar-title: "McpServerConfig" description: "MCP server configuration." -position: 16 +position: 14 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpserverplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpserverplan.mdx index c51dbefb..6902533b 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpserverplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpserverplan.mdx @@ -2,7 +2,7 @@ title: "Struct McpServer Plan" sidebar-title: "McpServerPlan" description: "Resolved MCP server exposure." -position: 20 +position: 18 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-metadataconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-metadataconfig.mdx index 40ba1d63..c9bfdec0 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-metadataconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-metadataconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Metadata Config" sidebar-title: "MetadataConfig" description: "Human-readable metadata." -position: 21 +position: 19 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-modelconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-modelconfig.mdx index 43673736..92033d79 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-modelconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-modelconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Model Config" sidebar-title: "ModelConfig" description: "Model configuration." -position: 22 +position: 20 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatifconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatifconfig.mdx index 085d3343..2beb9d72 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatifconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatifconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Relay Atif Config" sidebar-title: "RelayAtifConfig" description: "Relay ATIF export configuration." -position: 20 +position: 18 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatofconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatofconfig.mdx index 6eefdbf7..0d91c211 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatofconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatofconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Relay Atof Config" sidebar-title: "RelayAtofConfig" description: "Relay ATOF export configuration." -position: 21 +position: 19 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatofendpointconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatofendpointconfig.mdx index 46c89049..74f69842 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatofendpointconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatofendpointconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Relay Atof Endpoint Config" sidebar-title: "RelayAtofEndpointConfig" description: "Relay ATOF endpoint configuration." -position: 22 +position: 20 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relaycomponentconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relaycomponentconfig.mdx index 3677df6e..44df6f4e 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relaycomponentconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relaycomponentconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Relay Component Config" sidebar-title: "RelayComponentConfig" description: "Generic NeMo Relay plugin component configuration." -position: 23 +position: 21 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayconfig.mdx index cac532d3..852abee8 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Relay Config" sidebar-title: "RelayConfig" description: "NeMo Relay integration configuration." -position: 24 +position: 22 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayconfigpolicy.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayconfigpolicy.mdx index ae463a19..66b07af7 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayconfigpolicy.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayconfigpolicy.mdx @@ -2,7 +2,7 @@ title: "Struct Relay Config Policy" sidebar-title: "RelayConfigPolicy" description: "Relay validation policy." -position: 25 +position: 23 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayobservabilityconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayobservabilityconfig.mdx index 4bbaef31..1fba378f 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayobservabilityconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayobservabilityconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Relay Observability Config" sidebar-title: "RelayObservabilityConfig" description: "NeMo Relay observability component configuration." -position: 26 +position: 24 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayotlpconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayotlpconfig.mdx index 56c4001e..0d9cfc97 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayotlpconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayotlpconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Relay Otlp Config" sidebar-title: "RelayOtlpConfig" description: "Relay OpenTelemetry/OpenInference export configuration." -position: 27 +position: 25 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvecontext.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvecontext.mdx index 1bcba569..1fd20085 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvecontext.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvecontext.mdx @@ -2,7 +2,7 @@ title: "Struct Resolve Context" sidebar-title: "ResolveContext" description: "Source context used when resolving an in-memory Fabric config." -position: 24 +position: 22 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvedadapterdescriptor.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvedadapterdescriptor.mdx index 87a0b145..c1891a95 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvedadapterdescriptor.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvedadapterdescriptor.mdx @@ -2,7 +2,7 @@ title: "Struct Resolved Adapter Descriptor" sidebar-title: "ResolvedAdapterDescriptor" description: "Adapter descriptor selected for a run plan." -position: 25 +position: 23 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runplan.mdx index 4d5b18cb..f042afb2 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runplan.mdx @@ -2,7 +2,7 @@ title: "Struct RunPlan" sidebar-title: "RunPlan" description: "Resolved Fabric run plan." -position: 26 +position: 24 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimecapabilities.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimecapabilities.mdx index 14aeae6a..7ef3c169 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimecapabilities.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimecapabilities.mdx @@ -2,7 +2,7 @@ title: "Struct Runtime Capabilities" sidebar-title: "RuntimeCapabilities" description: "Lifecycle behavior implemented by a resolved runtime path." -position: 27 +position: 25 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimeconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimeconfig.mdx index 82078f3a..86641ac9 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimeconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimeconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Runtime Config" sidebar-title: "RuntimeConfig" description: "Runtime input/output contract." -position: 28 +position: 26 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-skillconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-skillconfig.mdx index 1ae59d5a..9572bbf7 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-skillconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-skillconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Skill Config" sidebar-title: "SkillConfig" description: "Skill capability configuration." -position: 29 +position: 27 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryconfig.mdx index 03c2572f..5dd271a6 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Telemetry Config" sidebar-title: "TelemetryConfig" description: "Telemetry configuration." -position: 30 +position: 28 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryplan.mdx index 81806dcf..04c79e8c 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryplan.mdx @@ -2,7 +2,7 @@ title: "Struct Telemetry Plan" sidebar-title: "TelemetryPlan" description: "Resolved telemetry plan." -position: 31 +position: 29 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryproviderconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryproviderconfig.mdx index ed3c2581..21e9291b 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryproviderconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryproviderconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Telemetry Provider Config" sidebar-title: "TelemetryProviderConfig" description: "Provider-specific telemetry configuration." -position: 33 +position: 31 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsconfig.mdx index 4de361e4..ccedcf91 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Tools Config" sidebar-title: "ToolsConfig" description: "Harness-neutral tool capability configuration." -position: 37 +position: 35 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsplan.mdx index 40b50baf..948ab6ec 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsplan.mdx @@ -2,7 +2,7 @@ title: "Struct Tools Plan" sidebar-title: "ToolsPlan" description: "Normalized tool policy for a run." -position: 38 +position: 36 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/enum-doctorstatus.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/enum-doctorstatus.mdx index b6539931..0d424ce7 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/enum-doctorstatus.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/enum-doctorstatus.mdx @@ -2,7 +2,7 @@ title: "Enum Doctor Status" sidebar-title: "DoctorStatus" description: "Diagnostic status." -position: 38 +position: 36 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/fn-doctor-plan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/fn-doctor-plan.mdx index 2b6be88b..c2588703 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/fn-doctor-plan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/fn-doctor-plan.mdx @@ -2,7 +2,7 @@ title: "Function doctor_plan" sidebar-title: "doctor_plan" description: "Inspect a resolved run plan without mutating the environment." -position: 39 +position: 37 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/index.mdx index d7d6d886..5dff6b0b 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/index.mdx @@ -2,7 +2,7 @@ title: "Module doctor" sidebar-title: "doctor" description: "Plan diagnostics for Fabric." -position: 68 +position: 66 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorcheck.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorcheck.mdx index 21ce5e5f..75af4033 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorcheck.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorcheck.mdx @@ -2,7 +2,7 @@ title: "Struct Doctor Check" sidebar-title: "DoctorCheck" description: "Diagnostic check result." -position: 36 +position: 34 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorreport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorreport.mdx index 69993805..e7b635eb 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorreport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorreport.mdx @@ -2,7 +2,7 @@ title: "Struct Doctor Report" sidebar-title: "DoctorReport" description: "Diagnostic report for a resolved run plan." -position: 37 +position: 35 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/enum-fabricerror.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/enum-fabricerror.mdx index 3e65a64f..e0854e2f 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/enum-fabricerror.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/enum-fabricerror.mdx @@ -2,7 +2,7 @@ title: "Enum Fabric Error" sidebar-title: "FabricError" description: "Errors raised by Fabric config loading and validation." -position: 40 +position: 38 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/index.mdx index f385e5e5..bc0a4a45 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/index.mdx @@ -2,7 +2,7 @@ title: "Module error" sidebar-title: "error" description: "Error types for Fabric core." -position: 69 +position: 67 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/type-result.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/type-result.mdx index 343077c9..011b5b22 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/type-result.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/type-result.mdx @@ -2,7 +2,7 @@ title: "Type Alias Result" sidebar-title: "Result" description: "Core Fabric result type." -position: 41 +position: 39 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/fn-version.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/fn-version.mdx index a9cf553d..b9f5b49f 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/fn-version.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/fn-version.mdx @@ -2,7 +2,7 @@ title: "Function version" sidebar-title: "version" description: "Returns the crate version compiled into this build." -position: 72 +position: 70 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/index.mdx index 0ecdd040..0f9030b1 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/index.mdx @@ -18,9 +18,7 @@ Core config and runtime contract for NeMo Fabric. - `pub use config::AdapterDescriptor;` - `pub use config::AdapterDescriptorSource;` - `pub use config::AdapterKind;` -- `pub use config::AdapterLocalHostSupport;` - `pub use config::AdapterRequirements;` -- `pub use config::AdapterRuntimeSupport;` - `pub use config::AdapterTelemetryProviderSupport;` - `pub use config::AdapterTelemetrySupport;` - `pub use config::CapabilityPlan;` diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/index.mdx index 3d008f1f..aa347d62 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/index.mdx @@ -2,7 +2,7 @@ title: "Module runtime" sidebar-title: "runtime" description: "Runtime invocation helpers." -position: 70 +position: 68 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} @@ -22,7 +22,7 @@ Runtime invocation helpers. - [InvocationHandle](/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-invocationhandle): One request sent to a runtime. - [RunRequest](/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-runrequest): A request passed to a Fabric-managed harness runtime. - [RunResult](/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-runresult): Result from a Fabric-managed harness invocation. -- [RuntimeContext](/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-runtimecontext): Per-run/per-invocation context passed to harness adapters. +- [RuntimeContext](/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-runtimecontext): Context generated for one invocation of a started runtime. - [RuntimeHandle](/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-runtimehandle): Active or resumable harness runtime. - [RuntimeTelemetryContext](/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-runtimetelemetrycontext): Runtime telemetry config passed to adapters. - [TelemetryRef](/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-telemetryref): Reference to telemetry emitted by Relay or another configured telemetry path. diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-adapterinvocation.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-adapterinvocation.mdx index 22fdcd81..866c95b9 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-adapterinvocation.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-adapterinvocation.mdx @@ -17,7 +17,7 @@ One invocation against an initialized adapter runtime. ### `runtime_context: RuntimeContext` -Per-runtime and per-invocation context generated by Fabric. +Invocation context generated by Fabric. ### `request: RunRequest` diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-runtimecontext.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-runtimecontext.mdx index 3b27d359..c5349ecd 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-runtimecontext.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-runtimecontext.mdx @@ -1,7 +1,7 @@ --- title: "Struct Runtime Context" sidebar-title: "RuntimeContext" -description: "Per-run/per-invocation context passed to harness adapters." +description: "Context generated for one invocation of a started runtime." position: 10 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. @@ -11,7 +11,7 @@ Generated from `cargo doc --no-deps -p nemo-fabric-core`.
String,\n    pub invocation_id: String,\n    pub request_id: String,\n    pub environment: EnvironmentHandle,\n    pub artifacts: ArtifactManifest,\n    pub telemetry: Option<RuntimeTelemetryContext>,\n}"}} />
-Per-run/per-invocation context passed to harness adapters. +Context generated for one invocation of a started runtime. ## Fields diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/index.mdx index 8b052b82..a3475f92 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/index.mdx @@ -2,7 +2,7 @@ title: "Module schema" sidebar-title: "schema" description: "JSON Schema generation for the public Fabric contract." -position: 71 +position: 69 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/sdk/python.mdx b/docs/sdk/python.mdx index b68e9baa..724eff3b 100644 --- a/docs/sdk/python.mdx +++ b/docs/sdk/python.mdx @@ -101,10 +101,10 @@ Most application code works with four objects: | `RunResult` | The normalized outcome of one invocation. | Read its status, output, error, events, artifacts, and correlation IDs. | `Fabric` is a lightweight, reusable SDK facade. It resolves configuration, -creates plans and runtimes, and runs one-shot requests, but it does not -represent a started execution and does not require cleanup. A `Runtime` owns -stateful execution and shutdown, so it is the object used as an async context -manager. +creates plans and runtimes, and provides a single-invocation convenience API, +but it does not represent a started execution and does not require cleanup. A +`Runtime` owns stateful execution and shutdown, so it is the object used as an +async context manager. `RuntimeHandle` and `InvocationHandle` carry lifecycle identity across the native boundary. Most Python callers use their `runtime_id` and @@ -115,16 +115,13 @@ A runtime is a logical execution boundary, not necessarily an operating-system process. An adapter may use an in-process SDK, a process, or shared service infrastructure while preserving isolated state for each NeMo Fabric runtime. -Runtime hosting is selected by the adapter descriptor, not by a public -`FabricConfig` setting. Every local Process or Python adapter declares the -persistent local-host wire protocol. Fabric starts one host during +Runtime hosting is selected by the adapter kind, not by a public `FabricConfig` +setting. Every local Process or Python adapter implements the persistent +local-host wire protocol. Fabric starts one host during `start_runtime(...)`, reuses its adapter-owned native resources across ordered invocations, and attempts to release them during `stop()`. The lifecycle start operation carries the resolved configuration and capability plan. Each subsequent `AdapterInvocation` carries only `runtime_context` and `request`. -`runtime.local_host` is an unversioned capability marker whose nesting is -intentionally provisional until remote hosting requires a shared runtime -protocol. ### Bundled Adapter Capability Matrix @@ -250,7 +247,7 @@ multiple independent runtimes. | `Runtime.invoke(...)` | Yes | You need one turn on an existing runtime. | A runtime permits one active invocation at a time. | | `Runtime.stop()` | Yes | You need to stop or detach from the runtime. | Called automatically when using `async with`. | -## One-Shot Runs +## Single-Invocation Runs Use `run(...)` when the application has one input and does not need to preserve runtime state after the result is collected. @@ -460,12 +457,12 @@ All public SDK errors inherit from `FabricError`. | `FabricStateError` | Invalid runtime state transition, such as invoking after stop or starting overlapping invocations. | | `FabricNativeUnavailableError` | Native extension is not installed or importable. | -Consumers own job-level retries and rollout-level failure policy. One-shot runs -attempt to stop the runtime before returning. A `Runtime` used with `async with` -also attempts cleanup after an invocation error; if cleanup fails, that failure -is attached to the original exception rather than replacing it. NeMo Fabric records -structured error metadata when possible and returns enough detail for the -consumer to decide what to do next. +Consumers own job-level retries and rollout-level failure policy. +Single-invocation runs attempt to stop the runtime before returning. A `Runtime` +used with `async with` also attempts cleanup after an invocation error; if cleanup +fails, that failure is attached to the original exception rather than replacing +it. NeMo Fabric records structured error metadata when possible and returns enough +detail for the consumer to decide what to do next. ## SDK Contract Boundaries diff --git a/examples/notebooks/01_quickstart.ipynb b/examples/notebooks/01_quickstart.ipynb index ec207bf8..063037aa 100644 --- a/examples/notebooks/01_quickstart.ipynb +++ b/examples/notebooks/01_quickstart.ipynb @@ -253,7 +253,7 @@ "\n", "`run()` performs one full lifecycle in a single call: it starts a runtime, sends\n", "your input, collects the result, and stops the runtime. Reach for it for\n", - "one-shot work. It is async, and Jupyter lets you `await` it directly.\n", + "single-invocation work. It is async, and Jupyter lets you `await` it directly.\n", "\n", "It returns a **`RunResult`**: a normalized record of the invocation with the same\n", "shape regardless of harness. Always check `status` and `error` first, then read\n", diff --git a/python/src/nemo_fabric/client.py b/python/src/nemo_fabric/client.py index 64dc088a..f6d23c10 100644 --- a/python/src/nemo_fabric/client.py +++ b/python/src/nemo_fabric/client.py @@ -50,8 +50,8 @@ class Fabric: ``FabricNativeUnavailableError`` when the native extension is not installed. - See the Getting Started overview for runnable one-shot, typed-config, and - multi-turn examples. + See the Getting Started overview for runnable single-invocation, + typed-config, and multi-turn examples. """ def plan( diff --git a/schemas/SCHEMA.md b/schemas/SCHEMA.md index dfebe5ff..54ebe4b3 100644 --- a/schemas/SCHEMA.md +++ b/schemas/SCHEMA.md @@ -22,10 +22,8 @@ The core schema generator exports the current public typed contract. - `agent`: complete typed `FabricConfig`. - `adapter-descriptor`: minimal adapter descriptor consumed by Fabric. Each descriptor declares a `contract_version`; Fabric rejects descriptors for - unsupported adapter contracts during planning. Adapters can also declare a - persistent local-host wire protocol under `runtime.local_host`. This field is - an unversioned capability marker, and its nesting is intentionally - provisional until remote hosting requires a shared runtime protocol. + unsupported adapter contracts during planning. The `process` and `python` + adapter kinds use Fabric's persistent local-host wire protocol. - `run-plan`: executable plan containing the canonical typed config, absolute base directory, selected adapter, and derived execution metadata. diff --git a/skills/README.md b/skills/README.md index 0bbb3f74..8f00e7a9 100644 --- a/skills/README.md +++ b/skills/README.md @@ -49,7 +49,7 @@ symlink or `.agents/skills/` set); those serve Fabric's own contributors. | Skill | Use it when | |---|---| -| [`nemo-fabric-integrate`](nemo-fabric-integrate/SKILL.md) | You are adding NeMo Fabric to a consumer application, service, evaluation harness, or platform through the typed Python SDK — building an in-memory `FabricConfig`, choosing one-shot versus stateful-runtime execution, validating with `plan`/`doctor`, and consuming normalized results. | +| [`nemo-fabric-integrate`](nemo-fabric-integrate/SKILL.md) | You are adding NeMo Fabric to a consumer application, service, evaluation harness, or platform through the typed Python SDK — building an in-memory `FabricConfig`, choosing the single-invocation convenience API or an explicitly started runtime, validating with `plan`/`doctor`, and consuming normalized results. | ## Conventions diff --git a/skills/nemo-fabric-integrate/SKILL.md b/skills/nemo-fabric-integrate/SKILL.md index 9a3da3e0..5cc6bf1c 100644 --- a/skills/nemo-fabric-integrate/SKILL.md +++ b/skills/nemo-fabric-integrate/SKILL.md @@ -1,6 +1,6 @@ --- name: nemo-fabric-integrate -description: Use this skill when integrating NeMo Fabric into a consumer application, service, evaluation harness, or platform through the typed Python SDK — translating the consumer's own application, job, or deployment config into an in-memory FabricConfig, choosing one-shot run versus a stateful runtime, validating with plan and doctor, and consuming normalized results, artifacts, and telemetry. +description: Use this skill when integrating NeMo Fabric into a consumer application, service, evaluation harness, or platform through the typed Python SDK — translating the consumer's own application, job, or deployment config into an in-memory FabricConfig, choosing the single-invocation convenience API or an explicitly started runtime, validating with plan and doctor, and consuming normalized results, artifacts, and telemetry. license: Apache-2.0 metadata: author: NVIDIA Corporation and Affiliates @@ -115,8 +115,9 @@ construction. Pick the smallest lifecycle the consumer needs: -- **One-shot** — one input, no retained state. `await Fabric().run(config, input=...)` - runs the full start, invoke, and stop cycle and returns a `RunResult`. Pass +- **Single invocation** — one input, no retained state after the call. + `await Fabric().run(config, input=...)` runs the full start, invoke, and stop + cycle and returns a `RunResult`. Pass `request=RunRequest(...)` instead of `input=...` when the invocation needs a caller-owned request ID or context (the two are mutually exclusive). - **Stateful runtime** — ordered turns over one logical harness lifecycle. Start it with @@ -128,11 +129,11 @@ Pick the smallest lifecycle the consumer needs: The selected adapter owns the execution topology. The bundled Claude, Codex, Deep Agents, and Hermes adapters retain their native client, graph/checkpointer, -or agent/database inside one local host for the full runtime. Third-party -compatibility adapters may reconstruct continuity from persisted harness state. -Consumers do not select that mechanism in `FabricConfig` and must not replay an -invocation after a runtime failure. Stop the failed runtime and explicitly start -a new one according to the application's retry policy. +or agent/database inside one local host for the full runtime. Local `process` +and `python` adapters use this host lifecycle; consumers do not select another +local execution mechanism in `FabricConfig`. Do not replay an invocation after +a runtime failure. Stop the failed runtime and explicitly start a new one +according to the application's retry policy. The lifecycle fragment below shows both forms. It assumes the caller has already set `config = to_fabric_config(job)` and chosen `base`, as described in the @@ -147,7 +148,7 @@ from nemo_fabric import Fabric async def main() -> None: fabric = Fabric() - # One-shot + # Single invocation result = await fabric.run(config, base_dir=base, input="Review the changes.") # Multi-turn @@ -244,7 +245,7 @@ result-field and error inventory, and - [ ] The consumer config object is translated directly into an in-memory `FabricConfig`. - [ ] Only public `nemo_fabric` symbols are imported; no `_native` or adapter internals. - [ ] The consumer config is built in memory and passed directly to Fabric. -- [ ] The right lifecycle is chosen: `run(...)` for one-shot, `start_runtime(...)` with `async with` for multi-turn. +- [ ] The right lifecycle is chosen: `run(...)` for a single invocation, `start_runtime(...)` with `async with` for multi-turn. - [ ] `plan(...)` and `doctor(...)` validate adapter selection, capabilities, and environment before execution. - [ ] Installation, adapter dependencies, and credentials are owned by the environment, not consumer code. - [ ] `RunResult` status, error, and events are inspected before output; artifacts and telemetry are captured. diff --git a/skills/nemo-fabric-integrate/references/sdk-api-inventory.md b/skills/nemo-fabric-integrate/references/sdk-api-inventory.md index 819c8712..7656ae33 100644 --- a/skills/nemo-fabric-integrate/references/sdk-api-inventory.md +++ b/skills/nemo-fabric-integrate/references/sdk-api-inventory.md @@ -68,9 +68,8 @@ FabricConfig -> plan() -> RunPlan -> start_runtime() -> Runtime -> invoke() -> R adapter-owned state associated with the runtime. - Runtime hosting is adapter-declared, not consumer-configured. The bundled Claude, Codex, Deep Agents, and Hermes adapters retain their native runtime - resources in one local host; third-party compatibility adapters can - reconstruct continuity on each invocation. A persistent-host crash or - protocol timeout is terminal; Fabric does not silently respawn the host or - replay the request. + resources in one local host. Local `process` and `python` adapters use the + same host lifecycle. A host crash or protocol timeout terminates the runtime; + the application may explicitly start a new runtime according to its policy. - The application owns scheduling, queues, retries, and how many runtimes to run. Fabric provides only the runtime contract. From 405a2fc6b4604ee5847c01bcbd5f1c44e26c1f45 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Tue, 21 Jul 2026 14:52:43 -0700 Subject: [PATCH 28/34] test: annotate lifecycle protocol doubles Signed-off-by: Ajay Thorve --- tests/adapters/test_adapters_common_lifecycle.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/adapters/test_adapters_common_lifecycle.py b/tests/adapters/test_adapters_common_lifecycle.py index 5b31afd7..138e031c 100644 --- a/tests/adapters/test_adapters_common_lifecycle.py +++ b/tests/adapters/test_adapters_common_lifecycle.py @@ -118,14 +118,14 @@ def test_lifecycle_host_passes_minimal_invoke_payload_unchanged(): invocations = [] class Runtime: - async def start(self, _payload): + async def start(self, _payload) -> None: pass - async def invoke(self, payload): + async def invoke(self, payload) -> dict[str, str]: invocations.append(payload) return {"input": payload["request"]["input"]} - async def stop(self): + async def stop(self) -> None: pass lifecycle.serve(Runtime, input_stream=input_stream, output_stream=output_stream) From 95ccfefa700ac6ca9c6a22a6ef4835b4fe8d4ca6 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Tue, 21 Jul 2026 15:14:43 -0700 Subject: [PATCH 29/34] test: address adapter lifecycle review feedback Signed-off-by: Ajay Thorve --- adapters/codex/testing.md | 9 +++---- tests/adapters/test_claude_adapter.py | 24 ++++++++++--------- tests/adapters/test_codex_adapter.py | 9 +++---- tests/adapters/test_deepagents.py | 5 ++-- .../hermes_shim/adapter.py | 5 +++- 5 files changed, 30 insertions(+), 22 deletions(-) diff --git a/adapters/codex/testing.md b/adapters/codex/testing.md index 8cd9f87b..d5ccd987 100644 --- a/adapters/codex/testing.md +++ b/adapters/codex/testing.md @@ -21,7 +21,8 @@ an explicit app-server override instead of the SDK-pinned runtime. The SDK test uses the current Codex authentication state and exercises both the single-invocation convenience API and multiple turns against one started runtime. The Relay test additionally requires an external gateway binary and -verifies model responses, stable thread identity across turns, ATOF, and ATIF; -gateway startup alone is not a passing result. The semantic regression also -requires decoded LLM request content, a model, token usage, and the expected -agent response in ATIF. +verifies model responses, stable thread identity across turns, Agent Trajectory +Observability Format (ATOF), and Agent Trajectory Interchange Format (ATIF); +gateway startup alone is not a passing result. The semantic regression requires +the LLM request content to be decoded. It also requires ATIF to contain the +model, token usage, and expected agent response. diff --git a/tests/adapters/test_claude_adapter.py b/tests/adapters/test_claude_adapter.py index 574175d8..1e8f4dcc 100644 --- a/tests/adapters/test_claude_adapter.py +++ b/tests/adapters/test_claude_adapter.py @@ -7,6 +7,7 @@ import json import os import tomllib +from collections.abc import AsyncIterator from pathlib import Path from typing import Any from unittest.mock import MagicMock @@ -17,6 +18,7 @@ from claude_agent_sdk import CLIConnectionError from claude_agent_sdk import CLIJSONDecodeError from claude_agent_sdk import CLINotFoundError +from claude_agent_sdk import Message from claude_agent_sdk import ProcessError from claude_agent_sdk import ResultMessage from claude_agent_sdk import SystemMessage @@ -50,24 +52,24 @@ def install_fake_client(monkeypatch, response_factory): clients = [] class FakeClient: - def __init__(self, options): + def __init__(self, options) -> None: self.options = options self.prompts = [] clients.append(self) - async def connect(self): + async def connect(self) -> None: pass - async def query(self, prompt): + async def query(self, prompt) -> None: self.prompts.append(prompt) - def receive_response(self): + def receive_response(self) -> AsyncIterator[Message]: return response_factory(self) - async def disconnect(self): + async def disconnect(self) -> None: pass - async def interrupt(self): + async def interrupt(self) -> None: pass monkeypatch.setattr(adapter, "ClaudeSDKClient", FakeClient) @@ -704,7 +706,7 @@ async def test_runtime_reports_relay_artifacts(relay_payload, monkeypatch, tmp_p monkeypatch.setattr(adapter.relay_gateway, "start_relay_gateway", mock_start) monkeypatch.setattr(adapter.relay_gateway, "stop_relay_gateway", mock_stop) - async def responses(client): + async def responses(client) -> AsyncIterator[ResultMessage]: assert client.options.env["ANTHROPIC_BASE_URL"] == relay.gateway.url assert Path(client.options.plugins[-1]["path"]) == relay.plugin_path yield ResultMessage( @@ -774,7 +776,7 @@ async def test_runtime_stop_reports_relay_gateway_failure( ), ) - async def responses(_client): + async def responses(_client) -> AsyncIterator[ResultMessage]: yield ResultMessage( subtype="success", duration_ms=10, @@ -832,7 +834,7 @@ async def test_runtime_stop_reports_relay_plugin_cleanup_failure( monkeypatch.setattr(adapter.relay_gateway, "stop_relay_gateway", mock_stop) monkeypatch.setattr(adapter.shutil, "rmtree", mock_rmtree) - async def responses(_client): + async def responses(_client) -> AsyncIterator[ResultMessage]: yield ResultMessage( subtype="success", duration_ms=10, @@ -892,7 +894,7 @@ async def test_runtime_stops_relay_after_sdk_failure_or_cancellation( ) monkeypatch.setattr(adapter.relay_gateway, "stop_relay_gateway", mock_stop) - async def responses(_client): + async def responses(_client) -> AsyncIterator[ResultMessage]: raise failure yield @@ -928,7 +930,7 @@ async def test_runtime_preserves_failed_result_when_sdk_stream_raises( subtype, is_error, ): - async def responses(_client): + async def responses(_client) -> AsyncIterator[ResultMessage]: yield ResultMessage( subtype=subtype, duration_ms=10, diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index 7fe8f6c3..ea977d23 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -6,6 +6,7 @@ import os from pathlib import Path from types import SimpleNamespace +from typing import Any from unittest.mock import AsyncMock from unittest.mock import MagicMock @@ -41,7 +42,7 @@ def invoke_once(payload): def runtime_start_error(payload): - async def scenario(): + async def scenario() -> adapter.lifecycle.LifecycleError: runtime = adapter.CodexRuntime() with pytest.raises(adapter.lifecycle.LifecycleError) as caught: await runtime.start(lifecycle_start_payload(payload)) @@ -225,7 +226,7 @@ def test_runtime_stop_reports_close_failure_after_completed_turn( ): mock_codex.close_error = RuntimeError("close failed") - async def scenario(): + async def scenario() -> tuple[dict[str, Any], adapter.lifecycle.LifecycleError]: runtime = adapter.CodexRuntime() await runtime.start(lifecycle_start_payload(codex_payload)) output = await runtime.invoke(lifecycle_invocation(codex_payload)) @@ -257,7 +258,7 @@ def test_start_failure_is_not_masked_by_sdk_close_failure( ) monkeypatch.setattr(adapter, "_register_skill_roots", register_skills) - async def scenario(): + async def scenario() -> adapter.lifecycle.LifecycleError: runtime = adapter.CodexRuntime() with pytest.raises(adapter.lifecycle.LifecycleError) as caught: await runtime.start(lifecycle_start_payload(codex_payload)) @@ -825,7 +826,7 @@ def test_relay_stop_failure_is_reported_by_runtime_stop( MagicMock(side_effect=adapter.relay_gateway.RelayGatewayError("stuck")), ) - async def scenario(): + async def scenario() -> tuple[dict[str, Any], adapter.lifecycle.LifecycleError]: runtime = adapter.CodexRuntime() await runtime.start(lifecycle_start_payload(codex_payload)) output = await runtime.invoke(lifecycle_invocation(codex_payload)) diff --git a/tests/adapters/test_deepagents.py b/tests/adapters/test_deepagents.py index e5b4ca63..e0373fe3 100644 --- a/tests/adapters/test_deepagents.py +++ b/tests/adapters/test_deepagents.py @@ -12,6 +12,7 @@ from __future__ import annotations import importlib.machinery +import os import sys import types from collections.abc import Iterator @@ -279,8 +280,8 @@ async def test_single_invocation_normalizes_response_usage_and_thread( assert "instructions" not in fake_sdks["create_kwargs"] -async def test_missing_api_key_fails_runtime_start(tmp_path, make_payload, monkeypatch): - monkeypatch.delenv("NVIDIA_API_KEY", raising=False) +async def test_missing_api_key_fails_runtime_start(tmp_path, make_payload): + os.environ.pop("NVIDIA_API_KEY", None) with pytest.raises(RuntimeError, match="NVIDIA_API_KEY"): await adapter.DeepAgentsRuntime().start( diff --git a/tests/fixtures/hermes-shim-agent/adapters/hermes-shim/src/nemo_fabric_test_adapters/hermes_shim/adapter.py b/tests/fixtures/hermes-shim-agent/adapters/hermes-shim/src/nemo_fabric_test_adapters/hermes_shim/adapter.py index 5fab3af0..d89373e1 100644 --- a/tests/fixtures/hermes-shim-agent/adapters/hermes-shim/src/nemo_fabric_test_adapters/hermes_shim/adapter.py +++ b/tests/fixtures/hermes-shim-agent/adapters/hermes-shim/src/nemo_fabric_test_adapters/hermes_shim/adapter.py @@ -25,7 +25,10 @@ async def start(self, payload: dict[str, Any]) -> None: async def invoke(self, invocation: dict[str, Any]) -> dict[str, Any]: if self._start_payload is None: - raise RuntimeError("shim runtime is not started") + raise lifecycle.LifecycleError( + "hermes_runtime_not_started", + "shim runtime is not started", + ) payload = { **self._start_payload, "runtime_context": invocation.get("runtime_context"), From db8c33b9c8ae3c5bdf57f7aec87a5d8498f977dc Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Tue, 21 Jul 2026 15:25:47 -0700 Subject: [PATCH 30/34] docs: split harness and adapter compatibility guides Signed-off-by: Ajay Thorve --- README.md | 56 +++++++++++++++++------------------------ adapters/README.md | 63 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 33 deletions(-) create mode 100644 adapters/README.md diff --git a/README.md b/README.md index 6700b1d6..d617f1ed 100644 --- a/README.md +++ b/README.md @@ -144,30 +144,23 @@ under `examples/code_review_agent/artifacts/hermes/`. Its complete base config and clone-based variants live in `examples/code_review_agent/config.py`. -## Bundled Adapter Capability Matrix - -The adapter descriptors are the source of truth for normalized configuration, -telemetry, and runtime-hosting support. The bundled adapters currently expose -the following capabilities: - -| Adapter | Models | Tools / Blocked Tools | MCP | Skills | Subagents | Telemetry | Persistent Local Host | Remote Service | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | -| [Claude](adapters/claude/README.md) | Anthropic / Claude | `allowed_tools` adapter setting / normalized block list | Normalized | Normalized | Not exposed | Relay: ATIF, OTel, and OpenInference through hooks and gateway | Yes: `ClaudeSDKClient`, session, and optional Relay gateway | Not implemented | -| [Codex](adapters/codex/README.md) | OpenAI and NVIDIA Responses providers / Codex-compatible models | Not normalized | Normalized: stdio, HTTP, and streamable HTTP | Normalized: `SKILL.md` directories | Not exposed | Relay: ATIF, OTel, and OpenInference through hooks and gateway; native OTel | Yes: `AsyncCodex`, app server, thread, and optional Relay gateway | Not implemented | -| [Deep Agents](adapters/deepagents/README.md) | LangChain providers | Built-ins and MCP / normalized middleware block list | Normalized | Normalized | Constrained local delegation | Relay SDK: ATIF, OTel, and OpenInference; native OTel and OpenInference | Yes: compiled graph and async checkpointer | Not implemented | -| [Hermes](adapters/hermes/README.md) | Normalized provider and base URL | Toolsets / normalized disabled toolsets | Normalized | Normalized | Not exposed | Relay plugin: ATIF, OTel, and OpenInference | Yes: `AIAgent`, `SessionDB`, and Relay context | Not implemented | - -"Normalized" means the adapter accepts the corresponding `FabricConfig` -field. "Not normalized" does not mean that the underlying harness lacks the -feature; it means that Fabric does not expose a portable configuration surface -for it. Fabric currently normalizes a blocked-tool list, not a portable tool -definition catalog. Deep Agents subagents are limited to declarative local -subagents that inherit the parent agent's capabilities. - -Consumers use the same `Fabric.start_runtime(...)` contract for all four -bundled adapters. Adapter hosting remains descriptor-owned; it is not selected -through public `FabricConfig` settings. The `process` and `python` adapter kinds -run as persistent local hosts for the complete runtime lifecycle. +## Supported Agent Harnesses + +Choose a bundled agent harness based on your model ecosystem and application +needs. Every harness supports persistent multi-turn local runtimes through the +same Fabric lifecycle and returns normalized results, artifacts, and telemetry +references. + +| Agent harness | Choose it for | Model ecosystem | Key capabilities | Observability | +| --- | --- | --- | --- | --- | +| Claude | Claude-native coding and tool-use workflows | Anthropic and NVIDIA-hosted Anthropic Messages-compatible models | Tool guardrails, MCP, skills, and persistent Claude sessions | NeMo Relay | +| Codex | Codex-native coding workflows | OpenAI and NVIDIA-hosted Responses-compatible models | MCP, skills, and persistent Codex threads | NeMo Relay and native OpenTelemetry | +| LangChain Deep Agents | Composable LangChain and LangGraph agents | LangChain model providers | Built-in and MCP tools, guardrails, skills, and local subagents | NeMo Relay and native OpenTelemetry/OpenInference | +| Hermes Agent | Hermes workflows with custom model endpoints | Configurable provider, model, and base URL | Toolsets, guardrails, MCP, skills, and persistent conversation history | NeMo Relay | + +For package names, exact compatibility and limitations, runtime ownership, and +individual harness guides, refer to the +[adapter compatibility reference](adapters/README.md). ## Claude Adapter @@ -204,12 +197,10 @@ runtimes, authentication, and execution details. `disallowed_tools`, Deep Agents enforces them with middleware, and adapters without a native deny mechanism route the policy as unsupported. - **Adapters:** harness-specific integrations selected by `harness.adapter_id`. - The Hermes adapter lives under `adapters/hermes/`; the Codex SDK - adapter lives under `adapters/codex/`; the - [Claude adapter](adapters/claude/README.md) - lives under `adapters/claude/`; the LangChain Deep Agents adapter lives under - `adapters/deepagents/`. Harness-specific extensions belong under - `harness.settings` so the normalized contract can remain stable. + Harness-specific extensions belong under `harness.settings` so the normalized + contract can remain stable. Refer to the + [adapter compatibility reference](adapters/README.md) for bundled package and + implementation details. - **Artifacts:** normalized output, logs, patches, and telemetry references returned through an `ArtifactManifest`. @@ -236,9 +227,8 @@ the [Python SDK guide](docs/sdk/python.mdx). Exact signatures are in the - [Harbor examples](examples/harbor/README.md): validate the integration with a deterministic, credential-free calculator smoke, optionally run the same task with Hermes or Claude, and evaluate real coding tasks with SWE-Bench. -- Adapter guides: [Hermes](adapters/hermes/README.md), - [Codex SDK](adapters/codex/README.md), and - [Deep Agents](adapters/deepagents/README.md). +- [Adapter compatibility and guides](adapters/README.md): compare bundled + harness support, runtime ownership, telemetry integration, and package guides. ## Tests diff --git a/adapters/README.md b/adapters/README.md new file mode 100644 index 00000000..c95c1dbd --- /dev/null +++ b/adapters/README.md @@ -0,0 +1,63 @@ + + +# NVIDIA NeMo Fabric Agent Harness Adapters + +NeMo Fabric adapters translate the normalized Fabric contract into +harness-native models, tools, sessions, and telemetry. Use this reference to +compare the bundled adapters and then open the linked package guide for +installation, authentication, and configuration details. + +The adapter descriptor selected in `RunPlan` is authoritative for normalized +configuration and telemetry support. + +## Bundled Adapter Packages + +| Agent Harness | Adapter ID | Python Package | Supported Python | +| --- | --- | --- | --- | +| [Claude](claude/README.md) | `nvidia.fabric.claude` | `nemo-fabric-adapters-claude` | 3.11+ | +| [Codex](codex/README.md) | `nvidia.fabric.codex` | `nemo-fabric-adapters-codex` | 3.11+ | +| [LangChain Deep Agents](deepagents/README.md) | `nvidia.fabric.langchain.deepagents` | `nemo-fabric-adapters-deepagents` | 3.11+ | +| [Hermes Agent](hermes/README.md) | `nvidia.fabric.hermes` | `nemo-fabric-adapters-hermes` | 3.11-3.13 | + +## Configuration Compatibility + +| Agent Harness | Models | Tools / Blocked Tools | MCP | Skills | Subagents | +| --- | --- | --- | --- | --- | --- | +| [Claude](claude/README.md) | Anthropic and NVIDIA-hosted Anthropic Messages-compatible models | `allowed_tools` adapter setting / normalized `tools.blocked` | Normalized: stdio, HTTP, streamable HTTP, and SSE | Normalized `skills.paths` | Not exposed | +| [Codex](codex/README.md) | OpenAI; NVIDIA Responses-compatible models without Relay | Codex-native tools / `tools.blocked` not normalized | Normalized: stdio, HTTP, and streamable HTTP | Normalized `SKILL.md` directories | Not exposed | +| [LangChain Deep Agents](deepagents/README.md) | LangChain model providers | Built-ins and MCP / normalized middleware block list | Normalized through `langchain-mcp-adapters` | Normalized | Constrained declarative local delegation | +| [Hermes Agent](hermes/README.md) | Normalized provider, model, and base URL | Toolsets / normalized disabled toolsets | Normalized | Normalized | Not exposed | + +"Normalized" means that the adapter accepts the corresponding `FabricConfig` +field. "Not exposed" does not mean that the underlying harness lacks the +feature; it means that Fabric does not provide a portable configuration surface +for it. Fabric normalizes a blocked-tool list, not a portable tool-definition +catalog. Deep Agents subagents are limited to declarative local subagents that +inherit the parent agent's capabilities. + +## Runtime and Observability Compatibility + +All bundled adapters use one persistent Python adapter host with an ordered +`start` → `invoke*` → `stop` protocol. + +NeMo Relay records raw events in Agent Trajectory Observability Format (ATOF) +and produces normalized trajectories in Agent Trajectory Interchange Format +(ATIF). + +| Agent Harness | State Retained Across Turns | Relay Integration | Per-Turn Behavior | Stop Behavior | Remote Service | +| --- | --- | --- | --- | --- | --- | +| [Claude](claude/README.md) | `ClaudeSDKClient` and Claude session ID | Runtime-owned Relay CLI gateway and generated Claude hooks | Calls `client.query()`, validates the session ID, and collects ATOF and ATIF | Disconnects the client, stops the gateway, and removes the generated plugin | Not implemented | +| [Codex](codex/README.md) | `AsyncCodex` app-server client and SDK thread | Runtime-owned Relay CLI gateway and Codex SDK hooks | Reuses the SDK thread and persists its thread ID | Closes the SDK client and app server, then stops the gateway | Not implemented | +| [LangChain Deep Agents](deepagents/README.md) | Compiled LangGraph agent, checkpointer, and thread ID | NeMo Relay Python SDK integration added when the agent is compiled | Creates a fresh Relay request scope and callback for each invocation | Closes the checkpointer; no gateway process | Not implemented | +| [Hermes Agent](hermes/README.md) | `AIAgent`, `SessionDB`, and conversation history | Hermes NeMo Relay plugin context | Finalizes and flushes Relay after each invocation | Closes the agent and database, then exits the plugin context | Not implemented | + +Telemetry output names use the descriptor contract values. Claude, Codex, and +Hermes can emit NeMo Relay ATIF, OpenTelemetry, and OpenInference output. Deep +Agents supports the same Relay outputs plus native OpenTelemetry and +OpenInference; Codex also supports native OpenTelemetry. + +Shared lifecycle, Relay gateway, hook, and payload helpers are documented in +the [adapter utilities guide](common/README.md). From 3caa8d73381a3744974d8e887e19b8d9f9d01d7e Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Tue, 21 Jul 2026 15:40:22 -0700 Subject: [PATCH 31/34] test: use SDK mocks for Claude lifecycle coverage Signed-off-by: Ajay Thorve --- tests/adapters/test_claude_adapter.py | 47 +++++++++++++-------------- 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/tests/adapters/test_claude_adapter.py b/tests/adapters/test_claude_adapter.py index 1e8f4dcc..969eaec2 100644 --- a/tests/adapters/test_claude_adapter.py +++ b/tests/adapters/test_claude_adapter.py @@ -8,8 +8,10 @@ import os import tomllib from collections.abc import AsyncIterator +from collections.abc import Callable from pathlib import Path from typing import Any +from unittest.mock import AsyncMock from unittest.mock import MagicMock import pytest @@ -48,31 +50,26 @@ def lifecycle_invocation(payload: dict[str, Any]) -> dict[str, Any]: } -def install_fake_client(monkeypatch, response_factory): - clients = [] - - class FakeClient: - def __init__(self, options) -> None: - self.options = options - self.prompts = [] - clients.append(self) - - async def connect(self) -> None: - pass - - async def query(self, prompt) -> None: - self.prompts.append(prompt) - - def receive_response(self) -> AsyncIterator[Message]: - return response_factory(self) - - async def disconnect(self) -> None: - pass - - async def interrupt(self) -> None: - pass - - monkeypatch.setattr(adapter, "ClaudeSDKClient", FakeClient) +def install_fake_client( + monkeypatch: pytest.MonkeyPatch, + response_factory: Callable[[MagicMock], AsyncIterator[Message]], +) -> list[MagicMock]: + clients: list[MagicMock] = [] + client_type = adapter.ClaudeSDKClient + + def build_client(options: Any) -> MagicMock: + client = MagicMock(spec=client_type) + client.options = options + client.prompts = [] + client.connect = AsyncMock() + client.query = AsyncMock(side_effect=client.prompts.append) + client.receive_response.side_effect = lambda: response_factory(client) + client.disconnect = AsyncMock() + client.interrupt = AsyncMock() + clients.append(client) + return client + + monkeypatch.setattr(adapter, "ClaudeSDKClient", build_client) return clients From bbc0984ddb7575a5fd48fd95ce98abf50809c6bc Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Tue, 21 Jul 2026 15:40:26 -0700 Subject: [PATCH 32/34] docs: clarify Codex blocked-tool rejection Signed-off-by: Ajay Thorve --- adapters/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adapters/README.md b/adapters/README.md index c95c1dbd..d771fc02 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -27,7 +27,7 @@ configuration and telemetry support. | Agent Harness | Models | Tools / Blocked Tools | MCP | Skills | Subagents | | --- | --- | --- | --- | --- | --- | | [Claude](claude/README.md) | Anthropic and NVIDIA-hosted Anthropic Messages-compatible models | `allowed_tools` adapter setting / normalized `tools.blocked` | Normalized: stdio, HTTP, streamable HTTP, and SSE | Normalized `skills.paths` | Not exposed | -| [Codex](codex/README.md) | OpenAI; NVIDIA Responses-compatible models without Relay | Codex-native tools / `tools.blocked` not normalized | Normalized: stdio, HTTP, and streamable HTTP | Normalized `SKILL.md` directories | Not exposed | +| [Codex](codex/README.md) | OpenAI; NVIDIA Responses-compatible models without Relay | Codex-native tools / configuring `tools.blocked` is unsupported and raises `UnsupportedToolsPolicy` | Normalized: stdio, HTTP, and streamable HTTP | Normalized `SKILL.md` directories | Not exposed | | [LangChain Deep Agents](deepagents/README.md) | LangChain model providers | Built-ins and MCP / normalized middleware block list | Normalized through `langchain-mcp-adapters` | Normalized | Constrained declarative local delegation | | [Hermes Agent](hermes/README.md) | Normalized provider, model, and base URL | Toolsets / normalized disabled toolsets | Normalized | Normalized | Not exposed | From 54d85f4f905eee7ebd295d2530b313f45415bec7 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Tue, 21 Jul 2026 16:00:04 -0700 Subject: [PATCH 33/34] test: type Claude mock options explicitly Signed-off-by: Ajay Thorve --- tests/adapters/test_claude_adapter.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/adapters/test_claude_adapter.py b/tests/adapters/test_claude_adapter.py index 969eaec2..f575fe0b 100644 --- a/tests/adapters/test_claude_adapter.py +++ b/tests/adapters/test_claude_adapter.py @@ -16,6 +16,7 @@ import pytest from claude_agent_sdk import AssistantMessage +from claude_agent_sdk import ClaudeAgentOptions from claude_agent_sdk import ClaudeSDKError from claude_agent_sdk import CLIConnectionError from claude_agent_sdk import CLIJSONDecodeError @@ -57,7 +58,7 @@ def install_fake_client( clients: list[MagicMock] = [] client_type = adapter.ClaudeSDKClient - def build_client(options: Any) -> MagicMock: + def build_client(options: ClaudeAgentOptions) -> MagicMock: client = MagicMock(spec=client_type) client.options = options client.prompts = [] From a7293670c53e4b59e9fa0b3ab302a6edcd55f246 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Tue, 21 Jul 2026 16:20:39 -0700 Subject: [PATCH 34/34] fix: classify broken local-host pipes as crashes Signed-off-by: Ajay Thorve --- crates/fabric-core/src/runtime.rs | 38 +++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/fabric-core/src/runtime.rs b/crates/fabric-core/src/runtime.rs index 9b28af87..4c5b9b80 100644 --- a/crates/fabric-core/src/runtime.rs +++ b/crates/fabric-core/src/runtime.rs @@ -1400,8 +1400,10 @@ fn exchange_lifecycle_message( .write_all(message.as_bytes()) .and_then(|()| host.stdin.flush()) { - let code = match host.child.try_wait() { - Ok(Some(_)) => "host_crashed", + let code = match (source.kind(), host.child.try_wait()) { + // A closed lifecycle pipe is terminal even when the child exit + // status is not observable yet. + (ErrorKind::BrokenPipe, _) | (_, Ok(Some(_))) => "host_crashed", _ => "host_io", }; return Err(lifecycle_error( @@ -2397,6 +2399,7 @@ mod tests { r#"import json import os import sys +import time MODE = os.environ.get("FABRIC_FAKE_HOST_MODE", "success") invocations = 0 @@ -2430,7 +2433,9 @@ for line in sys.stdin: sys.exit(16) response("start") if MODE == "crash_after_start": + os.close(0) print("host crashed intentionally", file=sys.stderr, flush=True) + time.sleep(1) sys.exit(17) elif operation == "invoke": invocations += 1 @@ -2841,12 +2846,41 @@ for line in sys.stdin: fn local_host_crash_is_terminal_for_runtime() { let (root, plan) = local_host_plan("crash_after_start"); let runtime = start_runtime(&plan).expect("start local host"); + let host = local_hosts() + .get(&runtime.runtime_id) + .cloned() + .expect("active local host"); + let stderr_path = host.lock().expect("local host").stderr_path.clone(); + let closed_deadline = Instant::now() + Duration::from_secs(2); + while !fs::read_to_string(&stderr_path) + .expect("read host stderr") + .contains("host crashed intentionally") + { + assert!( + Instant::now() < closed_deadline, + "host did not close its lifecycle pipe" + ); + thread::sleep(Duration::from_millis(10)); + } + assert!( + host.lock() + .expect("local host") + .child + .try_wait() + .expect("inspect crashed host") + .is_none(), + "host must still be alive while its lifecycle pipe is closed" + ); let first = invoke_runtime(&plan, &runtime, RunRequest::text("first")) .expect_err("crashed host must reject invocation"); let second = invoke_runtime(&plan, &runtime, RunRequest::text("second")) .expect_err("dead runtime must remain unusable"); assert!(first.to_string().contains("host_crashed"), "{first}"); + assert!( + first.to_string().contains("failed to send invoke"), + "{first}" + ); assert!(second.to_string().contains("host_crashed"), "{second}"); let stopped = stop_runtime(&plan, &runtime).expect("crashed host cleanup");