From b083ba5772e07481e38b1a090d6e85f175da14a9 Mon Sep 17 00:00:00 2001 From: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:34:10 -0700 Subject: [PATCH 1/7] feat: add dependency-free adapter contract dataclasses Signed-off-by: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> --- adapter-contract/pypi.md | 27 +- adapter-contract/pyproject.toml | 5 +- .../src/nemo_fabric_adapter_contract/codec.py | 273 ++++++++ .../nemo_fabric_adapter_contract/models.py | 602 ++++++++---------- .../pydantic_support.py | 42 ++ adapter-contract/uv.lock | 7 +- adapters/common/README.md | 4 +- .../nemo_fabric_adapters/common/lifecycle.py | 24 +- docs/adapter-contract/execution.md | 2 +- .../normalized-configuration.md | 8 +- external/nat/README.md | 4 +- .../src/nemo_fabric_adapters/nat/adapter.py | 2 +- skills/nemo-fabric-build-adapter/SKILL.md | 5 +- tests/adapter_contract/test_agent_config.py | 66 +- .../adapter_contract/test_agent_execution.py | 56 +- .../test_dependency_boundary.py | 44 ++ .../adapters/test_adapter_package_metadata.py | 12 +- .../test_adapters_common_lifecycle.py | 4 +- tests/adapters/test_external_nat_adapter.py | 30 +- uv.lock | 6 +- 20 files changed, 824 insertions(+), 399 deletions(-) create mode 100644 adapter-contract/src/nemo_fabric_adapter_contract/codec.py create mode 100644 adapter-contract/src/nemo_fabric_adapter_contract/pydantic_support.py create mode 100644 tests/adapter_contract/test_dependency_boundary.py diff --git a/adapter-contract/pypi.md b/adapter-contract/pypi.md index 191ee8e17..9d99ca2ae 100644 --- a/adapter-contract/pypi.md +++ b/adapter-contract/pypi.md @@ -15,21 +15,18 @@ SPDX-License-Identifier: Apache-2.0 execution contract implemented by NeMo Fabric adapters. It does not include a lifecycle host, harness integration, or NeMo Relay integration. -Python adapters can use this package for Pydantic validation. Adapters in other -languages can consume the JSON Schemas published by NeMo Fabric without a -Python package dependency. - -Pydantic is the package's only runtime dependency. It provides strict typed -validation, JSON Schema generation for adapter extensions, and field-level -conditional serialization used by the wire models. The standard library and -dataclasses do not provide these capabilities, while adding a separate schema -and serialization stack would increase dependencies and duplicate validation. -Pydantic is therefore the narrowest fit. It is MIT licensed, and no unresolved -licensing question is known. +Python adapters can use the package's standard-library dataclasses without an +additional runtime dependency. Adapters in other languages can consume the JSON +Schemas published by NeMo Fabric without a Python package dependency. + +The dataclasses provide strict `from_mapping()` validation and JSON-compatible +`to_mapping()` serialization. Install the optional `pydantic` extra when an +adapter uses Pydantic models for typed extensions or wants Pydantic +interoperability. Both paths use the same contract dataclasses. An adapter descriptor opts into the southbound configuration with `config.input=agent_config`. Python adapters using the optional common -lifecycle host pass `AgentConfig` as the `config_model`. +lifecycle host pass `AgentConfig.from_mapping` as the `config_loader`. ## Install @@ -39,6 +36,12 @@ Install the package directly when developing a Python adapter: pip install nemo-fabric-adapter-contract ``` +Install optional Pydantic interoperability with: + +```bash +pip install "nemo-fabric-adapter-contract[pydantic]" +``` + Refer to the [NeMo Fabric documentation](https://docs.nvidia.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/adapter-contract/pyproject.toml b/adapter-contract/pyproject.toml index 00031f77a..466d85f5f 100644 --- a/adapter-contract/pyproject.toml +++ b/adapter-contract/pyproject.toml @@ -24,7 +24,10 @@ license = "Apache-2.0" license-files = ["LICENSE"] readme = "pypi.md" requires-python = ">=3.11" -dependencies = [ +dependencies = [] + +[project.optional-dependencies] +pydantic = [ "pydantic>=2.12,<3", ] diff --git a/adapter-contract/src/nemo_fabric_adapter_contract/codec.py b/adapter-contract/src/nemo_fabric_adapter_contract/codec.py new file mode 100644 index 000000000..eb1dacd3f --- /dev/null +++ b/adapter-contract/src/nemo_fabric_adapter_contract/codec.py @@ -0,0 +1,273 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Dependency-free encoding and validation for adapter contract dataclasses.""" + +from __future__ import annotations + +import math +import types +from collections.abc import Mapping +from dataclasses import MISSING +from dataclasses import Field +from dataclasses import fields +from enum import Enum +from pathlib import Path +from typing import Any +from typing import Literal +from typing import TypeVar +from typing import Union +from typing import get_args +from typing import get_origin +from typing import get_type_hints + + +# JSON's recursive type cannot be expressed as a named standard-library alias +# on every supported Python version. Contract fields marked as JSON are checked +# recursively by ``json_value`` at runtime. +JsonValue = Any + +_T = TypeVar("_T") + + +class ContractValidationError(ValueError): + """A southbound contract value failed dependency-free validation.""" + + def __init__(self, message: str, *, path: tuple[str, ...] = ()) -> None: + self.message = message + self.path = path + location = ".".join(path) + super().__init__(f"{location}: {message}" if location else message) + + def prepend(self, path: tuple[str, ...]) -> "ContractValidationError": + """Return the same validation failure beneath an outer field path.""" + + return ContractValidationError(self.message, path=(*path, *self.path)) + + +def json_value(value: Any, *, path: tuple[str, ...] = ()) -> JsonValue: + """Validate, copy, and return one JSON-compatible value.""" + + if value is None or isinstance(value, (bool, str)): + return value + if isinstance(value, int): + return value + if isinstance(value, float): + if not math.isfinite(value): + raise ContractValidationError("must be a finite JSON number", path=path) + return value + if isinstance(value, list): + return [ + json_value(item, path=(*path, str(index))) + for index, item in enumerate(value) + ] + if isinstance(value, Mapping): + result: dict[str, JsonValue] = {} + for key, item in value.items(): + if not isinstance(key, str): + raise ContractValidationError( + "JSON object keys must be strings", path=path + ) + result[key] = json_value(item, path=(*path, key)) + return result + raise ContractValidationError("must be a valid JSON value", path=path) + + +def json_mapping( + value: Mapping[str, Any], *, path: tuple[str, ...] = () +) -> dict[str, JsonValue]: + """Validate and detach one JSON object mapping.""" + + result = json_value(value, path=path) + if not isinstance(result, dict): # pragma: no cover - kept true by the annotation + raise ContractValidationError("must be a JSON object", path=path) + return result + + +def decode_dataclass(model: type[_T], value: Any, *, path: tuple[str, ...] = ()) -> _T: + """Decode one closed mapping into a contract dataclass.""" + + if isinstance(value, model): + return value + if not isinstance(value, Mapping): + raise ContractValidationError("must be an object", path=path) + if any(not isinstance(key, str) for key in value): + raise ContractValidationError("object keys must be strings", path=path) + + model_fields = {item.name: item for item in fields(model)} + unknown = sorted(set(value).difference(model_fields)) + if unknown: + raise ContractValidationError( + f"unexpected field {unknown[0]!r}", + path=path, + ) + missing = [ + item.name + for item in model_fields.values() + if item.default is MISSING + and item.default_factory is MISSING + and item.name not in value + ] + if missing: + raise ContractValidationError( + f"missing required field {missing[0]!r}", + path=path, + ) + + try: + return model(**dict(value)) + except ContractValidationError as error: + raise error.prepend(path) from error + + +def validate_dataclass(instance: Any) -> None: + """Validate and normalize all declared fields on a contract dataclass.""" + + annotations = get_type_hints(type(instance)) + for item in fields(instance): + value = getattr(instance, item.name) + decoded = _decode_value( + annotations[item.name], + value, + path=(item.name,), + field=item, + ) + setattr(instance, item.name, decoded) + + +def encode_dataclass(instance: Any) -> dict[str, Any]: + """Return a detached JSON-compatible mapping for a contract dataclass.""" + + result: dict[str, Any] = {} + for item in fields(instance): + value = getattr(instance, item.name) + if item.metadata.get("omit_none") and value is None: + continue + if item.metadata.get("omit_empty") and not value: + continue + result[item.name] = _encode_value(value, path=(item.name,)) + return result + + +def _decode_value( + annotation: Any, + value: Any, + *, + path: tuple[str, ...], + field: Field[Any] | None = None, +) -> Any: + if field is not None and field.metadata.get("json"): + return json_value(value, path=path) + + origin = get_origin(annotation) + arguments = get_args(annotation) + + if origin in (types.UnionType, Union): + if type(None) in arguments and value is None: + return None + options = tuple(option for option in arguments if option is not type(None)) + errors = [] + for option in options: + try: + return _decode_value(option, value, path=path) + except ContractValidationError as error: + errors.append(error) + if len(errors) == 1: + raise errors[0] + raise ContractValidationError( + "must match one of the declared types", path=path + ) from errors[-1] + + if origin is Literal: + if value not in arguments or any( + type(value) is not type(item) for item in arguments if value == item + ): + allowed = ", ".join(repr(item) for item in arguments) + raise ContractValidationError(f"must be one of: {allowed}", path=path) + return value + + if origin is list: + if not isinstance(value, list): + raise ContractValidationError("must be an array", path=path) + return [ + _decode_value(arguments[0], item, path=(*path, str(index))) + for index, item in enumerate(value) + ] + + if origin is dict: + if not isinstance(value, Mapping): + raise ContractValidationError("must be an object", path=path) + key_type, value_type = arguments + result = {} + for key, item in value.items(): + decoded_key = _decode_value(key_type, key, path=(*path, str(key))) + result[decoded_key] = _decode_value( + value_type, + item, + path=(*path, str(key)), + ) + return result + + if annotation is Any: + return value + if annotation is type(None): + if value is not None: + raise ContractValidationError("must be null", path=path) + return None + if isinstance(annotation, type) and issubclass(annotation, Enum): + if isinstance(value, annotation): + return value + try: + return annotation(value) + except (TypeError, ValueError) as error: + raise ContractValidationError( + f"must be a valid {annotation.__name__}", + path=path, + ) from error + if isinstance(annotation, type) and hasattr(annotation, "from_mapping"): + return decode_dataclass(annotation, value, path=path) + if annotation is Path: + if not isinstance(value, (str, Path)): + raise ContractValidationError("must be a path string", path=path) + return value + if annotation is bool: + if type(value) is not bool: + raise ContractValidationError("must be a boolean", path=path) + return value + if annotation is int: + if type(value) is not int: + raise ContractValidationError("must be an integer", path=path) + return value + if annotation is float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ContractValidationError("must be a number", path=path) + result = float(value) + if not math.isfinite(result): + raise ContractValidationError("must be a finite number", path=path) + return result + if annotation is str: + if not isinstance(value, str): + raise ContractValidationError("must be a string", path=path) + return value + if isinstance(annotation, type) and isinstance(value, annotation): + return value + raise ContractValidationError("has an unsupported declared type", path=path) + + +def _encode_value(value: Any, *, path: tuple[str, ...]) -> Any: + if hasattr(value, "to_mapping"): + return value.to_mapping() + if isinstance(value, Enum): + return value.value + if isinstance(value, Path): + return str(value) + if isinstance(value, list): + return [ + _encode_value(item, path=(*path, str(index))) + for index, item in enumerate(value) + ] + if isinstance(value, Mapping): + return { + key: _encode_value(item, path=(*path, key)) for key, item in value.items() + } + return json_value(value, path=path) diff --git a/adapter-contract/src/nemo_fabric_adapter_contract/models.py b/adapter-contract/src/nemo_fabric_adapter_contract/models.py index de51e2f19..7a16f905b 100644 --- a/adapter-contract/src/nemo_fabric_adapter_contract/models.py +++ b/adapter-contract/src/nemo_fabric_adapter_contract/models.py @@ -1,311 +1,302 @@ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Pydantic models for the southbound NeMo Fabric adapter contract.""" +"""Dependency-free dataclasses for the southbound NeMo Fabric adapter contract.""" from __future__ import annotations from collections.abc import Mapping +from dataclasses import MISSING +from dataclasses import dataclass +from dataclasses import field from enum import StrEnum from pathlib import Path from typing import Any +from typing import ClassVar from typing import Literal from typing import Self -from pydantic import BaseModel -from pydantic import ConfigDict -from pydantic import Field -from pydantic import JsonValue -from pydantic import TypeAdapter -from pydantic import field_validator -from pydantic import model_validator +from nemo_fabric_adapter_contract.codec import ContractValidationError +from nemo_fabric_adapter_contract.codec import JsonValue +from nemo_fabric_adapter_contract.codec import decode_dataclass +from nemo_fabric_adapter_contract.codec import encode_dataclass +from nemo_fabric_adapter_contract.codec import json_mapping +from nemo_fabric_adapter_contract.codec import validate_dataclass -_EXTENSIONS_ADAPTER = TypeAdapter(dict[str, JsonValue]) +def _optional(default: Any = None): + return field(default=default, metadata={"omit_none": True}) -def extension_schema(model: type[BaseModel]) -> dict[str, JsonValue]: - """Return a JSON-safe schema for one descriptor extension point.""" +def _empty_dict(): + return field(default_factory=dict, metadata={"omit_empty": True}) - return _EXTENSIONS_ADAPTER.validate_python(model.model_json_schema(mode="validation")) +def _json_dict(): + return field(default_factory=dict, metadata={"json": True, "omit_empty": True}) -class ContractModel(BaseModel): - """Base for adapter-facing contract models.""" - model_config = ConfigDict( - extra="forbid", - validate_assignment=True, - allow_inf_nan=False, - ) +def _empty_list(): + return field(default_factory=list, metadata={"omit_empty": True}) -class AgentContractBlock(ContractModel): - """Base for explicitly extensible adapter-owned contract blocks.""" +def _json_value_field(*, default: Any = MISSING, omit_empty: bool = False): + metadata = {"json": True} + if omit_empty: + metadata["omit_empty"] = True + if default is MISSING: + return field(metadata=metadata) + return field(default=default, metadata=metadata) - extensions: dict[str, JsonValue] = Field( - default_factory=dict, - exclude_if=lambda value: not value, - description="Adapter-owned fields validated by the selected adapter descriptor.", - ) - def set_extensions(self, value: BaseModel | Mapping[str, Any]) -> Self: - """Replace this block's adapter-owned extensions and return the block.""" +def _nonblank(value: str, field_name: str) -> None: + if not value.strip(): + raise ContractValidationError("must be a non-empty string", path=(field_name,)) + - raw = ( - value.model_dump(mode="json", exclude_none=True) - if isinstance(value, BaseModel) - else value +def _bounded_int(value: int | None, field_name: str, maximum: int) -> None: + if value is not None and not 0 <= value <= maximum: + raise ContractValidationError( + f"must be between 0 and {maximum}", + path=(field_name,), ) - self.extensions = _EXTENSIONS_ADAPTER.validate_python(raw) - return self + + +@dataclass(slots=True, kw_only=True) +class ContractModel: + """Base for adapter-facing contract dataclasses.""" + + # Pydantic reads this metadata only when its optional TypeAdapter is used. + # It does not require importing Pydantic in the base contract package. + __pydantic_config__: ClassVar[dict[str, Any]] = { + "extra": "forbid", + "allow_inf_nan": False, + } + + def __post_init__(self) -> None: + validate_dataclass(self) + self._validate() + + def _validate(self) -> None: + """Validate constraints that are more specific than field types.""" + + @classmethod + def from_mapping(cls, value: Any) -> Self: + """Validate and decode one closed adapter wire mapping.""" + + return decode_dataclass(cls, value) def to_mapping(self) -> dict[str, Any]: """Return a detached JSON-compatible adapter wire mapping.""" - return self.model_dump(mode="json") + return encode_dataclass(self) + + +@dataclass(slots=True, kw_only=True) +class AgentContractBlock(ContractModel): + """Base for explicitly extensible adapter-owned contract blocks.""" + + extensions: dict[str, JsonValue] = _json_dict() + + def set_extensions(self, value: Mapping[str, Any]) -> Self: + """Replace this block's adapter-owned extensions and return the block.""" + + if not isinstance(value, Mapping): + raise ContractValidationError("extensions must be an object") + self.extensions = json_mapping(value, path=("extensions",)) + return self AgentConfigBlock = AgentContractBlock +@dataclass(slots=True, kw_only=True) class AgentHarnessConfig(AgentContractBlock): """Adapter-owned target settings projected from the selected harness.""" - settings: dict[str, JsonValue] = Field( - default_factory=dict, - exclude_if=lambda value: not value, - ) + settings: dict[str, JsonValue] = _json_dict() +@dataclass(slots=True, kw_only=True) class AgentModelConfig(AgentContractBlock): """Configuration for one named model role.""" - provider: str = Field(min_length=1) - model: str = Field(min_length=1) - api_key_env: str | None = Field( - default=None, - min_length=1, - exclude_if=lambda value: value is None, - ) - temperature: float | None = Field( - default=None, - exclude_if=lambda value: value is None, - ) - base_url: str | None = Field( - default=None, - min_length=1, - exclude_if=lambda value: value is None, - ) - settings: dict[str, JsonValue] = Field( - default_factory=dict, - exclude_if=lambda value: not value, - ) - - @field_validator("provider") - @classmethod - def _validate_provider(cls, value: str) -> str: - if not value.strip() or value != value.strip() or value != value.lower(): - raise ValueError("provider must be a non-empty lowercase identifier") - return value + provider: str + model: str + api_key_env: str | None = _optional() + temperature: float | None = _optional() + base_url: str | None = _optional() + settings: dict[str, JsonValue] = _json_dict() - @field_validator("model", "api_key_env", "base_url") - @classmethod - def _validate_nonblank(cls, value: str | None) -> str | None: - if value is not None and not value.strip(): - raise ValueError("model fields must be non-empty strings") - return value + def _validate(self) -> None: + if ( + not self.provider.strip() + or self.provider != self.provider.strip() + or self.provider != self.provider.lower() + ): + raise ContractValidationError( + "must be a non-empty lowercase identifier", + path=("provider",), + ) + _nonblank(self.model, "model") + if self.api_key_env is not None: + _nonblank(self.api_key_env, "api_key_env") + if self.base_url is not None: + _nonblank(self.base_url, "base_url") +@dataclass(slots=True, kw_only=True) class AgentInstructionConfig(AgentContractBlock): """One normalized instruction value.""" - content: str = Field(min_length=1, pattern=r"\S") + content: str mode: Literal["replace"] = "replace" + def _validate(self) -> None: + _nonblank(self.content, "content") + +@dataclass(slots=True, kw_only=True) class AgentInstructionsConfig(AgentContractBlock): """Normalized instructions applied by the adapter target.""" - system: AgentInstructionConfig | None = Field( - default=None, - exclude_if=lambda value: value is None, - ) + system: AgentInstructionConfig | None = _optional() +@dataclass(slots=True, kw_only=True) class AgentRuntimeConfig(AgentContractBlock): """Runtime behavior applied by the adapter target.""" - max_turns: int | None = Field( - default=None, - gt=0, - le=(1 << 32) - 1, - exclude_if=lambda value: value is None, - ) + max_turns: int | None = _optional() + + def _validate(self) -> None: + if self.max_turns is not None and not 1 <= self.max_turns <= (1 << 32) - 1: + raise ContractValidationError( + f"must be between 1 and {(1 << 32) - 1}", + path=("max_turns",), + ) +@dataclass(slots=True, kw_only=True) class AgentSkillConfig(AgentContractBlock): """Skill paths made available to the adapter target.""" - paths: list[str | Path] = Field( - default_factory=list, - exclude_if=lambda value: not value, - ) + paths: list[str | Path] = _empty_list() +def _validate_tool_names(value: list[str] | None, field_name: str, label: str) -> None: + if value is not None and any(not tool.strip() for tool in value): + raise ContractValidationError( + f"{label} names must not be empty", + path=(field_name,), + ) + + +@dataclass(slots=True, kw_only=True) class AgentMcpServerConfig(AgentContractBlock): """One MCP server routed to the adapter target.""" - transport: str = Field(min_length=1, pattern=r"\S") - url: str = Field(min_length=1, pattern=r"\S") - args: list[str] = Field(default_factory=list, exclude_if=lambda value: not value) - env: dict[str, str] = Field( - default_factory=dict, - exclude_if=lambda value: not value, - ) - allowed_tools: list[str] | None = Field( - default=None, - exclude_if=lambda value: value is None, - description="Tool names to expose; None exposes all and an empty list exposes none.", - ) - blocked_tools: list[str] = Field( - default_factory=list, - exclude_if=lambda value: not value, - ) - - @field_validator("allowed_tools", "blocked_tools") - @classmethod - def _validate_tool_names(cls, value: list[str] | None) -> list[str] | None: - if value is not None and any(not tool.strip() for tool in value): - raise ValueError("MCP tool names must not be empty") - return value - - @model_validator(mode="after") - def _validate_tool_policy(self) -> Self: + transport: str + url: str + args: list[str] = _empty_list() + env: dict[str, str] = _empty_dict() + allowed_tools: list[str] | None = _optional() + blocked_tools: list[str] = _empty_list() + + def _validate(self) -> None: + _nonblank(self.transport, "transport") + _nonblank(self.url, "url") + _validate_tool_names(self.allowed_tools, "allowed_tools", "MCP tool") + _validate_tool_names(self.blocked_tools, "blocked_tools", "MCP tool") if self.allowed_tools is not None: overlap = set(self.allowed_tools).intersection(self.blocked_tools) if overlap: name = sorted(overlap)[0] - raise ValueError(f"MCP tool {name!r} cannot be both allowed and blocked") - return self + raise ContractValidationError( + f"MCP tool {name!r} cannot be both allowed and blocked" + ) +@dataclass(slots=True, kw_only=True) class AgentMcpConfig(AgentContractBlock): """Named MCP servers routed to the adapter target.""" - servers: dict[str, AgentMcpServerConfig] = Field( - default_factory=dict, - exclude_if=lambda value: not value, - ) + servers: dict[str, AgentMcpServerConfig] = _empty_dict() +@dataclass(slots=True, kw_only=True) class AgentToolDefinition(AgentContractBlock): """One named tool or tool-group definition resolved by the adapter.""" - kind: str = Field(min_length=1, pattern=r"\S") - ref: str = Field(min_length=1, pattern=r"\S") - settings: dict[str, JsonValue] = Field( - default_factory=dict, - exclude_if=lambda value: not value, - ) + kind: str + ref: str + settings: dict[str, JsonValue] = _json_dict() + + def _validate(self) -> None: + _nonblank(self.kind, "kind") + _nonblank(self.ref, "ref") +@dataclass(slots=True, kw_only=True) class AgentToolsConfig(AgentContractBlock): """Named tool definitions and effective target-level tool policy.""" - definitions: dict[str, AgentToolDefinition] = Field( - default_factory=dict, - exclude_if=lambda value: not value, - ) - enabled: list[str] | None = Field( - default=None, - exclude_if=lambda value: value is None, - description="Named tools to expose; None preserves the adapter-target default.", - ) - blocked: list[str] = Field( - default_factory=list, - exclude_if=lambda value: not value, - ) - - @field_validator("enabled", "blocked") - @classmethod - def _validate_tool_names(cls, value: list[str] | None) -> list[str] | None: - if value is not None and any(not tool.strip() for tool in value): - raise ValueError("tool names must not be empty") - return value + definitions: dict[str, AgentToolDefinition] = _empty_dict() + enabled: list[str] | None = _optional() + blocked: list[str] = _empty_list() - @model_validator(mode="after") - def _validate_tool_policy(self) -> Self: + def _validate(self) -> None: + _validate_tool_names(self.enabled, "enabled", "tool") + _validate_tool_names(self.blocked, "blocked", "tool") if self.enabled is not None: overlap = set(self.enabled).intersection(self.blocked) if overlap: name = sorted(overlap)[0] - raise ValueError(f"tool {name!r} cannot be both enabled and blocked") - return self + raise ContractValidationError( + f"tool {name!r} cannot be both enabled and blocked" + ) +@dataclass(slots=True, kw_only=True) class AgentWorkflowEntrypointConfig(AgentContractBlock): """Adapter-declared resolution semantics for one custom agent or workflow.""" - kind: str = Field(min_length=1, pattern=r"\S") - ref: str = Field(min_length=1, pattern=r"\S") + kind: str + ref: str + + def _validate(self) -> None: + _nonblank(self.kind, "kind") + _nonblank(self.ref, "ref") +@dataclass(slots=True, kw_only=True) class AgentWorkflowConfig(AgentContractBlock): """Custom agent or workflow selection and construction settings.""" entrypoint: AgentWorkflowEntrypointConfig - settings: dict[str, JsonValue] = Field( - default_factory=dict, - exclude_if=lambda value: not value, - ) + settings: dict[str, JsonValue] = _json_dict() +@dataclass(slots=True, kw_only=True) class AgentConfig(AgentContractBlock): """Configuration projected southbound to one adapter target.""" - harness: AgentHarnessConfig | None = Field( - default=None, - exclude_if=lambda value: value is None, - ) - models: dict[str, AgentModelConfig] = Field( - default_factory=dict, - exclude_if=lambda value: not value, - ) - instructions: AgentInstructionsConfig | None = Field( - default=None, - exclude_if=lambda value: value is None, - ) - runtime: AgentRuntimeConfig | None = Field( - default=None, - exclude_if=lambda value: value is None, - ) - skills: AgentSkillConfig | None = Field( - default=None, - exclude_if=lambda value: value is None, - ) - mcp: AgentMcpConfig | None = Field( - default=None, - exclude_if=lambda value: value is None, - ) - tools: AgentToolsConfig | None = Field( - default=None, - exclude_if=lambda value: value is None, - ) - workflow: AgentWorkflowConfig | None = Field( - default=None, - exclude_if=lambda value: value is None, - ) + harness: AgentHarnessConfig | None = _optional() + models: dict[str, AgentModelConfig] = _empty_dict() + instructions: AgentInstructionsConfig | None = _optional() + runtime: AgentRuntimeConfig | None = _optional() + skills: AgentSkillConfig | None = _optional() + mcp: AgentMcpConfig | None = _optional() + tools: AgentToolsConfig | None = _optional() + workflow: AgentWorkflowConfig | None = _optional() +@dataclass(slots=True, kw_only=True) class AgentRunRequest(AgentContractBlock): """Preview southbound request for the future typed invoke transport.""" - input: JsonValue - context: dict[str, JsonValue] = Field( - default_factory=dict, - exclude_if=lambda value: not value, - ) + input: JsonValue = _json_value_field() + context: dict[str, JsonValue] = _json_dict() class AgentRunStatus(StrEnum): @@ -316,100 +307,88 @@ class AgentRunStatus(StrEnum): CANCELLED = "cancelled" +@dataclass(slots=True, kw_only=True) class AgentRunError(AgentContractBlock): """Error reported by an adapter target.""" - code: str = Field(min_length=1, pattern=r"\S") - message: str = Field(min_length=1, pattern=r"\S") + code: str + message: str retryable: bool = False + def _validate(self) -> None: + _nonblank(self.code, "code") + _nonblank(self.message, "message") + + +def _validate_artifact_path(value: str | Path) -> None: + raw = str(value) + path = Path(raw) + components = raw.replace("\\", "/").split("/") + windows_drive_path = len(raw) >= 2 and raw[0].isalpha() and raw[1] == ":" + if ( + not raw + or path.is_absolute() + or raw.startswith(("/", "\\")) + or windows_drive_path + or ".." in components + ): + raise ContractValidationError( + "artifact path must be non-empty, relative, and contain no parent traversal", + path=("path",), + ) + +@dataclass(slots=True, kw_only=True) class AgentArtifact(AgentContractBlock): """One artifact produced by an adapter target.""" - name: str = Field(min_length=1, pattern=r"\S") - kind: str = Field(min_length=1, pattern=r"\S") + name: str + kind: str path: str | Path - media_type: str | None = Field( - default=None, - min_length=1, - pattern=r"\S", - exclude_if=lambda value: value is None, - ) - - @field_validator("path", mode="before") - @classmethod - def _validate_path(cls, value: str | Path) -> str | Path: - raw = str(value) - path = Path(raw) - components = raw.replace("\\", "/").split("/") - windows_drive_path = len(raw) >= 2 and raw[0].isalpha() and raw[1] == ":" - if ( - not raw - or path.is_absolute() - or raw.startswith(("/", "\\")) - or windows_drive_path - or ".." in components - ): - raise ValueError( - "artifact path must be non-empty, relative, and contain no parent traversal" - ) - return value + media_type: str | None = _optional() + + def _validate(self) -> None: + _nonblank(self.name, "name") + _nonblank(self.kind, "kind") + _validate_artifact_path(self.path) + if self.media_type is not None: + _nonblank(self.media_type, "media_type") +@dataclass(slots=True, kw_only=True) class AgentUsage(AgentContractBlock): """Normalized model usage reported by an adapter target.""" - input_tokens: int | None = Field( - default=None, - ge=0, - le=(1 << 64) - 1, - exclude_if=lambda value: value is None, - ) - output_tokens: int | None = Field( - default=None, - ge=0, - le=(1 << 64) - 1, - exclude_if=lambda value: value is None, - ) - total_tokens: int | None = Field( - default=None, - ge=0, - le=(1 << 64) - 1, - exclude_if=lambda value: value is None, - ) - cost_usd: float | None = Field( - default=None, - ge=0, - exclude_if=lambda value: value is None, - ) + input_tokens: int | None = _optional() + output_tokens: int | None = _optional() + total_tokens: int | None = _optional() + cost_usd: float | None = _optional() + + def _validate(self) -> None: + _bounded_int(self.input_tokens, "input_tokens", (1 << 64) - 1) + _bounded_int(self.output_tokens, "output_tokens", (1 << 64) - 1) + _bounded_int(self.total_tokens, "total_tokens", (1 << 64) - 1) + if self.cost_usd is not None and self.cost_usd < 0: + raise ContractValidationError( + "must be greater than or equal to 0", path=("cost_usd",) + ) +@dataclass(slots=True, kw_only=True) class AgentRunResult(AgentContractBlock): """Preview southbound result for the future typed invoke transport.""" status: AgentRunStatus - output: JsonValue - error: AgentRunError | None = Field( - default=None, - exclude_if=lambda value: value is None, - ) - usage: AgentUsage | None = Field( - default=None, - exclude_if=lambda value: value is None, - ) - artifacts: list[AgentArtifact] = Field( - default_factory=list, - exclude_if=lambda value: not value, - ) - - @model_validator(mode="after") - def _validate_status_and_error(self) -> Self: + output: JsonValue = _json_value_field() + error: AgentRunError | None = _optional() + usage: AgentUsage | None = _optional() + artifacts: list[AgentArtifact] = _empty_list() + + def _validate(self) -> None: if self.status is AgentRunStatus.FAILED and self.error is None: - raise ValueError("failed result requires an error") + raise ContractValidationError("failed result requires an error") if self.status is AgentRunStatus.SUCCEEDED and self.error is not None: - raise ValueError("succeeded result must not include an error") - return self + raise ContractValidationError("succeeded result must not include an error") class ControlLocation(StrEnum): @@ -426,89 +405,72 @@ class EnvironmentOwnership(StrEnum): FABRIC_OWNED = "fabric_owned" +@dataclass(slots=True, kw_only=True) class EnvironmentHandle(ContractModel): """Resolved execution environment visible to an adapter target.""" - environment_id: str = Field(min_length=1, pattern=r"\S") - provider: str = Field(min_length=1, pattern=r"\S") + environment_id: str + provider: str control_location: ControlLocation - workspace: str | Path | None = Field( - default=None, - exclude_if=lambda value: value is None, - ) - artifacts: str | Path | None = Field( - default=None, - exclude_if=lambda value: value is None, - ) - env: dict[str, str] = Field( - default_factory=dict, - exclude_if=lambda value: not value, - ) + workspace: str | Path | None = _optional() + artifacts: str | Path | None = _optional() + env: dict[str, str] = _empty_dict() ownership: EnvironmentOwnership - connection: dict[str, JsonValue] = Field( - default_factory=dict, - exclude_if=lambda value: not value, - ) - metadata: dict[str, JsonValue] = Field( - default_factory=dict, - exclude_if=lambda value: not value, - ) + connection: dict[str, JsonValue] = _json_dict() + metadata: dict[str, JsonValue] = _json_dict() + def _validate(self) -> None: + _nonblank(self.environment_id, "environment_id") + _nonblank(self.provider, "provider") + +@dataclass(slots=True, kw_only=True) class ArtifactRef(ContractModel): """Reference to one artifact visible through RuntimeContext.""" - name: str = Field(min_length=1, pattern=r"\S") - kind: str = Field(min_length=1, pattern=r"\S") + name: str + kind: str path: str | Path - media_type: str | None = Field( - default=None, - min_length=1, - pattern=r"\S", - exclude_if=lambda value: value is None, - ) + media_type: str | None = _optional() + metadata: dict[str, JsonValue] = _json_dict() + + def _validate(self) -> None: + _nonblank(self.name, "name") + _nonblank(self.kind, "kind") + if self.media_type is not None: + _nonblank(self.media_type, "media_type") +@dataclass(slots=True, kw_only=True) class ArtifactManifest(ContractModel): """Artifacts visible to an adapter at invocation start.""" - root: str | Path | None = Field( - default=None, - exclude_if=lambda value: value is None, - ) - artifacts: list[ArtifactRef] = Field( - default_factory=list, - exclude_if=lambda value: not value, - ) + root: str | Path | None = _optional() + artifacts: list[ArtifactRef] = _empty_list() +@dataclass(slots=True, kw_only=True) class RuntimeTelemetryContext(ContractModel): """Telemetry configuration generated for one adapter invocation.""" relay_enabled: bool - config_path: str | Path | None = Field( - default=None, - exclude_if=lambda value: value is None, - ) - env: dict[str, str] = Field( - default_factory=dict, - exclude_if=lambda value: not value, - ) - metadata: dict[str, JsonValue] = Field( - default_factory=dict, - exclude_if=lambda value: not value, - ) + config_path: str | Path | None = _optional() + env: dict[str, str] = _empty_dict() + metadata: dict[str, JsonValue] = _json_dict() +@dataclass(slots=True, kw_only=True) class RuntimeContext(ContractModel): """Fabric-generated context for one adapter invocation.""" - runtime_id: str = Field(min_length=1, pattern=r"\S") - invocation_id: str = Field(min_length=1, pattern=r"\S") - request_id: str = Field(min_length=1, pattern=r"\S") + runtime_id: str + invocation_id: str + request_id: str environment: EnvironmentHandle artifacts: ArtifactManifest - telemetry: RuntimeTelemetryContext | None = Field( - default=None, - exclude_if=lambda value: value is None, - ) + telemetry: RuntimeTelemetryContext | None = _optional() + + def _validate(self) -> None: + _nonblank(self.runtime_id, "runtime_id") + _nonblank(self.invocation_id, "invocation_id") + _nonblank(self.request_id, "request_id") diff --git a/adapter-contract/src/nemo_fabric_adapter_contract/pydantic_support.py b/adapter-contract/src/nemo_fabric_adapter_contract/pydantic_support.py new file mode 100644 index 000000000..63c8dde5f --- /dev/null +++ b/adapter-contract/src/nemo_fabric_adapter_contract/pydantic_support.py @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Optional Pydantic interoperability for adapter contract dataclasses.""" + +from __future__ import annotations + +from typing import TypeVar + +from pydantic import BaseModel +from pydantic import TypeAdapter + +from nemo_fabric_adapter_contract.codec import JsonValue +from nemo_fabric_adapter_contract.codec import json_mapping +from nemo_fabric_adapter_contract.models import AgentContractBlock + + +_T = TypeVar("_T") + + +def type_adapter(model: type[_T]) -> TypeAdapter[_T]: + """Return a Pydantic adapter for one canonical contract dataclass.""" + + return TypeAdapter(model) + + +def extension_schema(model: type[BaseModel]) -> dict[str, JsonValue]: + """Return a JSON-safe schema for one descriptor extension point.""" + + return json_mapping(model.model_json_schema(mode="validation")) + + +def set_pydantic_extensions( + block: _T, + value: BaseModel, +) -> _T: + """Set one typed Pydantic extension model on a contract block.""" + + if not isinstance(block, AgentContractBlock): + raise TypeError("block must be an AgentContractBlock") + block.set_extensions(value.model_dump(mode="json", exclude_none=True)) + return block diff --git a/adapter-contract/uv.lock b/adapter-contract/uv.lock index 23cdd9778..3ddb6deb4 100644 --- a/adapter-contract/uv.lock +++ b/adapter-contract/uv.lock @@ -15,12 +15,15 @@ wheels = [ name = "nemo-fabric-adapter-contract" version = "0.2.0" source = { editable = "." } -dependencies = [ + +[package.optional-dependencies] +pydantic = [ { name = "pydantic" }, ] [package.metadata] -requires-dist = [{ name = "pydantic", specifier = ">=2.12,<3" }] +requires-dist = [{ name = "pydantic", marker = "extra == 'pydantic'", specifier = ">=2.12,<3" }] +provides-extras = ["pydantic"] [[package]] name = "pydantic" diff --git a/adapters/common/README.md b/adapters/common/README.md index a0c989f15..a455e7f9a 100644 --- a/adapters/common/README.md +++ b/adapters/common/README.md @@ -50,11 +50,11 @@ host to validate the southbound contract before `start`: ```python from nemo_fabric_adapter_contract.models import AgentConfig -lifecycle.serve(AdapterRuntime, config_model=AgentConfig) +lifecycle.serve(AdapterRuntime, config_loader=AgentConfig.from_mapping) ``` The runtime then receives an `AgentConfig` instance in `payload["config"]`. -Omitting `config_model` preserves the legacy `FabricConfig` mapping. +Omitting `config_loader` preserves the legacy `FabricConfig` mapping. NeMo 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 diff --git a/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py b/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py index 392250525..4661d3a26 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py +++ b/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py @@ -36,7 +36,7 @@ async def stop(self) -> None: RuntimeFactory = Callable[[], AdapterRuntime] -ConfigModel = type[Any] +ConfigLoader = Callable[[Any], Any] class LifecycleError(Exception): @@ -221,16 +221,16 @@ async def _handle_start( runtime_factory: RuntimeFactory, payload: dict[str, Any], message_runtime_id: str, - config_model: ConfigModel | None, + config_loader: ConfigLoader | None, ) -> dict[str, Any]: if state.runtime is not None: raise LifecycleError( "lifecycle_already_started", "Lifecycle host already owns a runtime", ) - if config_model is not None: + if config_loader is not None: try: - config = config_model.model_validate(payload.get("config")) + config = config_loader(payload.get("config")) except Exception as error: raise LifecycleError( "lifecycle_invalid_config", @@ -282,7 +282,7 @@ async def _dispatch( operation: str, payload: dict[str, Any], message_runtime_id: str, - config_model: ConfigModel | None, + config_loader: ConfigLoader | None, ) -> dict[str, Any]: if operation == "start": return await _handle_start( @@ -290,7 +290,7 @@ async def _dispatch( runtime_factory, payload, message_runtime_id, - config_model, + config_loader, ) runtime = _active_runtime(state, message_runtime_id) if operation == "invoke": @@ -325,7 +325,7 @@ def _encode_response( async def _serve( runtime_factory: RuntimeFactory, *, - config_model: ConfigModel | None, + config_loader: ConfigLoader | None, input_stream: TextIO, output_stream: TextIO, ) -> None: @@ -353,7 +353,7 @@ async def _serve( operation, payload, message_runtime_id, - config_model, + config_loader, ) should_stop = operation == "stop" except LifecycleError as error: @@ -390,14 +390,14 @@ async def _serve( def serve( runtime_factory: RuntimeFactory, *, - config_model: ConfigModel | None = None, + config_loader: ConfigLoader | None = None, input_stream: TextIO = sys.stdin, output_stream: TextIO = sys.stdout, ) -> None: """Serve ordered lifecycle requests for exactly one Fabric runtime. - ``config_model`` opts an adapter into typed southbound configuration. The - host validates the start payload and passes the resulting model instance as + ``config_loader`` opts an adapter into typed southbound configuration. The + host passes it the start config and places the returned value in ``payload["config"]``. Omitting it preserves the legacy mapping unchanged. """ @@ -407,7 +407,7 @@ def serve( asyncio.run( _serve( runtime_factory, - config_model=config_model, + config_loader=config_loader, input_stream=input_stream, output_stream=output_stream, ) diff --git a/docs/adapter-contract/execution.md b/docs/adapter-contract/execution.md index 4a6d9f6bd..78a76dab7 100644 --- a/docs/adapter-contract/execution.md +++ b/docs/adapter-contract/execution.md @@ -94,7 +94,7 @@ class ExampleRuntime: def main() -> None: - lifecycle.serve(ExampleRuntime, config_model=AgentConfig) + lifecycle.serve(ExampleRuntime, config_loader=AgentConfig.from_mapping) ``` The host validates the start `config` as `AgentConfig`, serializes operations, diff --git a/docs/adapter-contract/normalized-configuration.md b/docs/adapter-contract/normalized-configuration.md index 01084c828..2cb3128c1 100644 --- a/docs/adapter-contract/normalized-configuration.md +++ b/docs/adapter-contract/normalized-configuration.md @@ -45,8 +45,8 @@ FabricConfig + adapter descriptor + resolved capability plan Use the generated [`AgentConfig` JSON Schema](https://github.com/NVIDIA/NeMo-Fabric/blob/main/schemas/adapter-contract/agent-config.schema.json) -for exact fields and constraints. Python adapters can import matching Pydantic -models from `nemo_fabric_adapter_contract.models`. +for exact fields and constraints. Python adapters can import matching +dataclasses from `nemo_fabric_adapter_contract.models`. ## Projection Rules @@ -80,8 +80,8 @@ Use extensions only when normalized fields cannot express the behavior: 1. Define a closed typed model for the adapter-owned data. 2. Publish its JSON Schema at the exact extension point. -3. Set the extension through the Pydantic block's `set_extensions(...)` helper - or an equivalent validated mapping. +3. Set the extension through the block's `set_extensions(...)` helper. Adapters + using the optional Pydantic integration can supply a typed extension model. 4. Reject extension data when the descriptor does not declare that extension point or the value does not satisfy its schema. diff --git a/external/nat/README.md b/external/nat/README.md index fb805753a..99a89a170 100644 --- a/external/nat/README.md +++ b/external/nat/README.md @@ -10,8 +10,8 @@ the NeMo Fabric lifecycle contract. It is a third-party adapter reference, not a bundled NeMo Fabric adapter or a published package. The implementation constructs NAT configuration in memory from the typed -southbound `AgentConfig`; it does not read a NAT YAML file or parse the -northbound `FabricConfig`. +southbound `AgentConfig` dataclass; it does not read a NAT YAML file, parse the +northbound `FabricConfig`, or depend on Pydantic for its contract boundary. ## Configuration Boundary diff --git a/external/nat/src/nemo_fabric_adapters/nat/adapter.py b/external/nat/src/nemo_fabric_adapters/nat/adapter.py index 57d51ebb8..710410ad6 100644 --- a/external/nat/src/nemo_fabric_adapters/nat/adapter.py +++ b/external/nat/src/nemo_fabric_adapters/nat/adapter.py @@ -56,7 +56,7 @@ def main() -> None: """Serve the persistent local-host lifecycle protocol.""" - lifecycle.serve(NatRuntime, config_model=AgentConfig) + lifecycle.serve(NatRuntime, config_loader=AgentConfig.from_mapping) def _config_error(code: str, message: str, **metadata: Any) -> lifecycle.LifecycleError: diff --git a/skills/nemo-fabric-build-adapter/SKILL.md b/skills/nemo-fabric-build-adapter/SKILL.md index 3ec83e6c6..394401e53 100644 --- a/skills/nemo-fabric-build-adapter/SKILL.md +++ b/skills/nemo-fabric-build-adapter/SKILL.md @@ -71,7 +71,8 @@ Install the descriptor in the standard shared-data location. For setuptools: "share/nemo-fabric/adapters/acme" = ["fabric-adapter.json"] ``` -Depend on `nemo-fabric-adapter-contract` for typed Pydantic models. Add +Depend on `nemo-fabric-adapter-contract` for typed standard-library dataclasses. +Install its optional `pydantic` extra only for Pydantic interoperability. Add `nemo-fabric-adapters-common` only if the adapter chooses its lifecycle or Relay helpers. A bare adapter package should not depend on the NeMo Fabric runtime. @@ -130,7 +131,7 @@ class TargetRuntime: def main() -> None: - lifecycle.serve(TargetRuntime, config_model=AgentConfig) + lifecycle.serve(TargetRuntime, config_loader=AgentConfig.from_mapping) ``` Keep current host request/result conversion in dedicated functions. The diff --git a/tests/adapter_contract/test_agent_config.py b/tests/adapter_contract/test_agent_config.py index bf07c6546..4be4bfd1c 100644 --- a/tests/adapter_contract/test_agent_config.py +++ b/tests/adapter_contract/test_agent_config.py @@ -6,6 +6,8 @@ from __future__ import annotations import json +from dataclasses import fields +from dataclasses import is_dataclass from pathlib import Path from typing import Any @@ -24,7 +26,10 @@ from nemo_fabric_adapter_contract.models import AgentToolsConfig from nemo_fabric_adapter_contract.models import AgentWorkflowConfig from nemo_fabric_adapter_contract.models import AgentWorkflowEntrypointConfig -from nemo_fabric_adapter_contract.models import extension_schema +from nemo_fabric_adapter_contract.codec import ContractValidationError +from nemo_fabric_adapter_contract.pydantic_support import extension_schema +from nemo_fabric_adapter_contract.pydantic_support import set_pydantic_extensions +from nemo_fabric_adapter_contract.pydantic_support import type_adapter from pydantic import BaseModel from pydantic import ConfigDict from pydantic import ValidationError @@ -72,7 +77,7 @@ def test_agent_config_blocks_set_mapping_extensions(block_type: type[AgentConfig def test_agent_config_block_accepts_typed_extensions(): extensions = _TypedExtensions(workflow_type="react_agent", retries=2) - config = AgentConfig().set_extensions(extensions) + config = set_pydantic_extensions(AgentConfig(), extensions) assert config.to_mapping() == { "extensions": { @@ -87,10 +92,10 @@ def test_agent_config_block_omits_empty_extensions(): def test_agent_config_blocks_reject_implicit_and_non_json_extensions(): - with pytest.raises(ValidationError, match="extra_forbidden"): + with pytest.raises(TypeError, match="unexpected keyword argument"): AgentConfig(implicit_extension=True) # type: ignore[call-arg] - with pytest.raises(ValidationError, match="valid JSON value"): + with pytest.raises(ContractValidationError, match="valid JSON value"): AgentConfig().set_extensions({"unsupported": object()}) @@ -98,8 +103,9 @@ def test_agent_config_blocks_reject_implicit_and_non_json_extensions(): def test_agent_config_schema_exposes_explicit_extensions_on_every_block( model: type[AgentConfigBlock], ): - schema = model.model_json_schema() + schema = type_adapter(model).json_schema() + assert is_dataclass(model) assert schema["additionalProperties"] is False assert "extensions" in schema["properties"] @@ -111,13 +117,59 @@ def test_extension_schema_uses_typed_pydantic_model(): assert schema["required"] == ["workflow_type"] +def test_optional_pydantic_adapter_reuses_canonical_dataclass(): + adapter = type_adapter(AgentConfig) + + config = adapter.validate_python({"harness": {"settings": {"profile": "pydantic"}}}) + + assert is_dataclass(config) + assert config.to_mapping() == {"harness": {"settings": {"profile": "pydantic"}}} + with pytest.raises(ValidationError, match="unexpected_keyword_argument"): + adapter.validate_python({"unknown": True}) + + +def test_agent_config_from_mapping_reports_nested_field_path(): + with pytest.raises( + ContractValidationError, + match=r"models\.default: missing required field 'model'", + ): + AgentConfig.from_mapping( + { + "models": { + "default": { + "provider": "nvidia", + } + } + } + ) + + def test_agent_config_model_tracks_rust_schema_root_fields(): rust_schema = json.loads( (ROOT / "schemas/adapter-contract/agent-config.schema.json").read_text( encoding="utf-8" ) ) - pydantic_schema = AgentConfig.model_json_schema() + dataclass_fields = {item.name for item in fields(AgentConfig)} assert rust_schema["additionalProperties"] is False - assert set(pydantic_schema["properties"]) == set(rust_schema["properties"]) + assert dataclass_fields == set(rust_schema["properties"]) + + +def test_agent_config_dataclasses_track_rust_schema_block_fields(): + rust_schema = json.loads( + (ROOT / "schemas/adapter-contract/agent-config.schema.json").read_text( + encoding="utf-8" + ) + ) + blocks = { + model.__name__: model + for model in AGENT_CONFIG_BLOCKS + if model not in {AgentConfig, AgentConfigBlock} + } + + assert set(blocks) == set(rust_schema["$defs"]).difference({"InstructionMode"}) + for name, model in blocks.items(): + assert {item.name for item in fields(model)} == set( + rust_schema["$defs"][name]["properties"] + ) diff --git a/tests/adapter_contract/test_agent_execution.py b/tests/adapter_contract/test_agent_execution.py index 4b3134a58..d2d85ad3d 100644 --- a/tests/adapter_contract/test_agent_execution.py +++ b/tests/adapter_contract/test_agent_execution.py @@ -6,6 +6,7 @@ from __future__ import annotations import json +from dataclasses import fields from pathlib import Path import pytest @@ -15,8 +16,13 @@ from nemo_fabric_adapter_contract.models import AgentRunResult from nemo_fabric_adapter_contract.models import AgentRunStatus from nemo_fabric_adapter_contract.models import AgentUsage +from nemo_fabric_adapter_contract.models import ArtifactManifest +from nemo_fabric_adapter_contract.models import ArtifactRef +from nemo_fabric_adapter_contract.models import EnvironmentHandle from nemo_fabric_adapter_contract.models import RuntimeContext -from pydantic import ValidationError +from nemo_fabric_adapter_contract.models import RuntimeTelemetryContext +from nemo_fabric_adapter_contract.codec import ContractValidationError +from nemo_fabric_adapter_contract.pydantic_support import type_adapter ROOT = Path(__file__).resolve().parents[2] @@ -32,7 +38,11 @@ def test_agent_run_request_contains_only_southbound_request_fields(): "input": {"messages": [{"role": "user", "content": "hello"}]}, "context": {"task": "sample"}, } - assert set(AgentRunRequest.model_fields) == {"input", "context", "extensions"} + assert {item.name for item in fields(AgentRunRequest)} == { + "input", + "context", + "extensions", + } def test_agent_run_result_contains_only_adapter_owned_result_fields(): @@ -77,7 +87,7 @@ def test_agent_run_result_contains_only_adapter_owned_result_fields(): } ], } - assert set(AgentRunResult.model_fields) == { + assert {item.name for item in fields(AgentRunResult)} == { "status", "output", "error", @@ -99,15 +109,49 @@ def test_agent_execution_models_track_rust_schema_root_fields(model, filename): rust_schema = json.loads( (ROOT / "schemas" / "adapter-contract" / filename).read_text(encoding="utf-8") ) - pydantic_schema = model.model_json_schema() + pydantic_schema = type_adapter(model).json_schema() assert rust_schema["additionalProperties"] is False assert pydantic_schema["additionalProperties"] is False assert set(pydantic_schema["properties"]) == set(rust_schema["properties"]) +@pytest.mark.parametrize( + ("filename", "models"), + [ + ( + "agent-run-result.schema.json", + (AgentArtifact, AgentRunError, AgentUsage), + ), + ( + "runtime-context.schema.json", + ( + ArtifactManifest, + ArtifactRef, + EnvironmentHandle, + RuntimeTelemetryContext, + ), + ), + ], +) +def test_agent_execution_dataclasses_track_rust_schema_block_fields( + filename, + models, +): + rust_schema = json.loads( + (ROOT / "schemas" / "adapter-contract" / filename).read_text(encoding="utf-8") + ) + + for model in models: + assert {item.name for item in fields(model)} == set( + rust_schema["$defs"][model.__name__]["properties"] + ) + + def test_failed_agent_run_result_requires_error(): - with pytest.raises(ValidationError, match="failed result requires an error"): + with pytest.raises( + ContractValidationError, match="failed result requires an error" + ): AgentRunResult(status=AgentRunStatus.FAILED, output=None) @@ -125,5 +169,5 @@ def test_failed_agent_run_result_requires_error(): ], ) def test_agent_artifact_rejects_unsafe_paths(path: str): - with pytest.raises(ValidationError, match="artifact path must be"): + with pytest.raises(ContractValidationError, match="artifact path must be"): AgentArtifact(name="output", kind="file", path=path) diff --git a/tests/adapter_contract/test_dependency_boundary.py b/tests/adapter_contract/test_dependency_boundary.py new file mode 100644 index 000000000..c887835ee --- /dev/null +++ b/tests/adapter_contract/test_dependency_boundary.py @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Verify that the canonical Python adapter contract does not require Pydantic.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def test_agent_config_import_and_round_trip_without_pydantic(): + script = """ +import sys + +class BlockPydantic: + def find_spec(self, fullname, path=None, target=None): + if fullname == "pydantic" or fullname.startswith("pydantic."): + raise ModuleNotFoundError("Pydantic import is blocked") + return None + +sys.meta_path.insert(0, BlockPydantic()) + +from nemo_fabric_adapter_contract.models import AgentConfig + +value = {"harness": {"settings": {"profile": "dependency-free"}}} +assert AgentConfig.from_mapping(value).to_mapping() == value +assert "pydantic" not in sys.modules +""" + env = os.environ.copy() + contract_source = str(ROOT / "adapter-contract" / "src") + current_path = env.get("PYTHONPATH") + env["PYTHONPATH"] = ( + f"{contract_source}{os.pathsep}{current_path}" + if current_path + else contract_source + ) + + subprocess.run([sys.executable, "-c", script], check=True, env=env) diff --git a/tests/adapters/test_adapter_package_metadata.py b/tests/adapters/test_adapter_package_metadata.py index 9a84b4e8e..a3419eed7 100644 --- a/tests/adapters/test_adapter_package_metadata.py +++ b/tests/adapters/test_adapter_package_metadata.py @@ -50,9 +50,7 @@ def load_pyproject(path: str) -> dict: f"nemo-fabric-adapters-hermes[harness] == {PACKAGE_VERSION}; " "python_version < '3.14'" ), - "harness": [ - "hermes-agent>=0.17.0; python_version < '3.14'" - ], + "harness": ["hermes-agent>=0.17.0; python_version < '3.14'"], "relay": ["nemo-relay>=0.6.0,<0.7"], }, } @@ -61,7 +59,7 @@ def load_pyproject(path: str) -> dict: @pytest.mark.parametrize( ("path", "expected"), [ - ("adapter-contract", ["pydantic>=2.12,<3"]), + ("adapter-contract", []), ("adapters/common", []), ( "adapters/claude", @@ -98,6 +96,12 @@ def test_adapter_runtime_dependencies(path: str, expected: list[str]): assert sorted(project.get("dependencies", [])) == sorted(expected) +def test_adapter_contract_offers_optional_pydantic_interop(): + extras = load_pyproject("adapter-contract")["project"]["optional-dependencies"] + + assert extras == {"pydantic": ["pydantic>=2.12,<3"]} + + def test_adapter_test_dependency_group_matches_leaf_harnesses(): manifest = load_pyproject("") expected = [ diff --git a/tests/adapters/test_adapters_common_lifecycle.py b/tests/adapters/test_adapters_common_lifecycle.py index 3e9142b0c..43b3b2324 100644 --- a/tests/adapters/test_adapters_common_lifecycle.py +++ b/tests/adapters/test_adapters_common_lifecycle.py @@ -162,7 +162,7 @@ async def stop(self) -> None: lifecycle.serve( Runtime, - config_model=AgentConfig, + config_loader=AgentConfig.from_mapping, input_stream=input_stream, output_stream=output_stream, ) @@ -202,7 +202,7 @@ async def stop(self) -> None: lifecycle.serve( Runtime, - config_model=AgentConfig, + config_loader=AgentConfig.from_mapping, input_stream=input_stream, output_stream=output_stream, ) diff --git a/tests/adapters/test_external_nat_adapter.py b/tests/adapters/test_external_nat_adapter.py index e7d3357d4..f4c0716de 100644 --- a/tests/adapters/test_external_nat_adapter.py +++ b/tests/adapters/test_external_nat_adapter.py @@ -41,7 +41,7 @@ def _fabric_workflow( def _mcp_server(**values: Any) -> AgentMcpServerConfig: - return AgentMcpServerConfig.model_validate(values) + return AgentMcpServerConfig.from_mapping(values) @pytest.fixture(name="make_payload") @@ -92,7 +92,7 @@ def make( return { "base_dir": str(tmp_path), - "config": AgentConfig.model_validate(config), + "config": AgentConfig.from_mapping(config), "runtime_context": { "runtime_id": "runtime-1", "environment": {"workspace": str(tmp_path)}, @@ -271,7 +271,10 @@ def test_main_opts_the_nat_host_into_typed_agent_config( adapter.main() - serve.assert_called_once_with(adapter.NatRuntime, config_model=AgentConfig) + serve.assert_called_once_with( + adapter.NatRuntime, + config_loader=AgentConfig.from_mapping, + ) def test_build_mapping_translates_components_models_and_instruction( @@ -425,9 +428,7 @@ def test_typed_examples_project_and_translate_through_one_nat_adapter( "ref": "fabric.agent.react", } assert "schema_version" not in southbound - nat_config = adapter.build_nat_config_mapping( - AgentConfig.model_validate(southbound) - ) + nat_config = adapter.build_nat_config_mapping(AgentConfig.from_mapping(southbound)) assert nat_config["workflow"]["_type"] == "react_agent" if example == "calculator.py": assert nat_config["function_groups"]["calculator"]["_type"] == "mcp_client" @@ -1080,11 +1081,11 @@ def test_mcp_stdio_rejects_unbalanced_quotes(): def test_mcp_stdio_rejects_a_whitespace_only_command(): + server = _mcp_server(transport="stdio", url="placeholder") + server.url = " \t\n " + with pytest.raises(adapter.lifecycle.LifecycleError) as error: - adapter.nat_mcp_server_config( - "calculator", - AgentMcpServerConfig.model_construct(transport="stdio", url=" \t\n "), - ) + adapter.nat_mcp_server_config("calculator", server) assert error.value.code == "nat_invalid_mcp_server" assert error.value.message == ( @@ -1094,13 +1095,8 @@ def test_mcp_stdio_rejects_a_whitespace_only_command(): @pytest.mark.parametrize("transport", ["websocket", ""]) def test_mcp_server_rejects_unsupported_transport(transport: str): - server = ( - _mcp_server(transport=transport, url="https://mcp.test") - if transport - else AgentMcpServerConfig.model_construct( - transport=transport, url="https://mcp.test" - ) - ) + server = _mcp_server(transport=transport or "placeholder", url="https://mcp.test") + server.transport = transport with pytest.raises(adapter.lifecycle.LifecycleError) as error: adapter.nat_mcp_server_config( "docs", diff --git a/uv.lock b/uv.lock index de9f43dcc..ad17f3127 100644 --- a/uv.lock +++ b/uv.lock @@ -2196,12 +2196,10 @@ test = [ name = "nemo-fabric-adapter-contract" version = "0.2.0" source = { editable = "adapter-contract" } -dependencies = [ - { name = "pydantic" }, -] [package.metadata] -requires-dist = [{ name = "pydantic", specifier = ">=2.12,<3" }] +requires-dist = [{ name = "pydantic", marker = "extra == 'pydantic'", specifier = ">=2.12,<3" }] +provides-extras = ["pydantic"] [[package]] name = "nemo-fabric-adapters-claude" From b9f314a687a7e3e76713148d829951086df544bd Mon Sep 17 00:00:00 2001 From: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:48:39 -0700 Subject: [PATCH 2/7] fix: address adapter contract review feedback Signed-off-by: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> --- adapter-contract/pypi.md | 2 +- .../src/nemo_fabric_adapter_contract/codec.py | 12 ++++++++++-- tests/adapter_contract/test_agent_config.py | 15 +++++++++++++++ tests/adapter_contract/test_agent_execution.py | 7 +++++++ 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/adapter-contract/pypi.md b/adapter-contract/pypi.md index 9d99ca2ae..1f818909b 100644 --- a/adapter-contract/pypi.md +++ b/adapter-contract/pypi.md @@ -36,7 +36,7 @@ Install the package directly when developing a Python adapter: pip install nemo-fabric-adapter-contract ``` -Install optional Pydantic interoperability with: +Install the optional `pydantic` extra to enable Pydantic interoperability. ```bash pip install "nemo-fabric-adapter-contract[pydantic]" diff --git a/adapter-contract/src/nemo_fabric_adapter_contract/codec.py b/adapter-contract/src/nemo_fabric_adapter_contract/codec.py index eb1dacd3f..0726dfa24 100644 --- a/adapter-contract/src/nemo_fabric_adapter_contract/codec.py +++ b/adapter-contract/src/nemo_fabric_adapter_contract/codec.py @@ -12,6 +12,7 @@ from dataclasses import Field from dataclasses import fields from enum import Enum +from functools import cache from pathlib import Path from typing import Any from typing import Literal @@ -79,7 +80,7 @@ def json_mapping( """Validate and detach one JSON object mapping.""" result = json_value(value, path=path) - if not isinstance(result, dict): # pragma: no cover - kept true by the annotation + if not isinstance(result, dict): raise ContractValidationError("must be a JSON object", path=path) return result @@ -120,10 +121,15 @@ def decode_dataclass(model: type[_T], value: Any, *, path: tuple[str, ...] = ()) raise error.prepend(path) from error +@cache +def _resolved_type_hints(model: type[Any]) -> dict[str, Any]: + return get_type_hints(model) + + def validate_dataclass(instance: Any) -> None: """Validate and normalize all declared fields on a contract dataclass.""" - annotations = get_type_hints(type(instance)) + annotations = _resolved_type_hints(type(instance)) for item in fields(instance): value = getattr(instance, item.name) decoded = _decode_value( @@ -157,6 +163,8 @@ def _decode_value( field: Field[Any] | None = None, ) -> Any: if field is not None and field.metadata.get("json"): + if get_origin(annotation) is dict: + return json_mapping(value, path=path) return json_value(value, path=path) origin = get_origin(annotation) diff --git a/tests/adapter_contract/test_agent_config.py b/tests/adapter_contract/test_agent_config.py index 4be4bfd1c..ac299d5fd 100644 --- a/tests/adapter_contract/test_agent_config.py +++ b/tests/adapter_contract/test_agent_config.py @@ -99,6 +99,21 @@ def test_agent_config_blocks_reject_implicit_and_non_json_extensions(): AgentConfig().set_extensions({"unsupported": object()}) +@pytest.mark.parametrize( + ("payload", "path"), + [ + ({"extensions": []}, "extensions"), + ({"harness": {"settings": "not-an-object"}}, "harness.settings"), + ], +) +def test_agent_config_rejects_non_object_json_mappings(payload, path): + with pytest.raises( + ContractValidationError, + match=rf"{path}: must be a JSON object", + ): + AgentConfig.from_mapping(payload) + + @pytest.mark.parametrize("model", AGENT_CONFIG_BLOCKS) def test_agent_config_schema_exposes_explicit_extensions_on_every_block( model: type[AgentConfigBlock], diff --git a/tests/adapter_contract/test_agent_execution.py b/tests/adapter_contract/test_agent_execution.py index d2d85ad3d..2f855ae35 100644 --- a/tests/adapter_contract/test_agent_execution.py +++ b/tests/adapter_contract/test_agent_execution.py @@ -6,6 +6,7 @@ from __future__ import annotations import json +from dataclasses import MISSING from dataclasses import fields from pathlib import Path @@ -146,6 +147,12 @@ def test_agent_execution_dataclasses_track_rust_schema_block_fields( assert {item.name for item in fields(model)} == set( rust_schema["$defs"][model.__name__]["properties"] ) + required = { + item.name + for item in fields(model) + if item.default is MISSING and item.default_factory is MISSING + } + assert required == set(rust_schema["$defs"][model.__name__].get("required", [])) def test_failed_agent_run_result_requires_error(): From 998d405abc04ce19b5bc89de55cabb61ec49acd9 Mon Sep 17 00:00:00 2001 From: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:48:41 -0700 Subject: [PATCH 3/7] docs: clarify adapter config loader contract Signed-off-by: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> --- .../common/src/nemo_fabric_adapters/common/lifecycle.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py b/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py index 4661d3a26..2fb237309 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py +++ b/adapters/common/src/nemo_fabric_adapters/common/lifecycle.py @@ -396,9 +396,12 @@ def serve( ) -> None: """Serve ordered lifecycle requests for exactly one Fabric runtime. - ``config_loader`` opts an adapter into typed southbound configuration. The - host passes it the start config and places the returned value in - ``payload["config"]``. Omitting it preserves the legacy mapping unchanged. + ``config_loader`` decodes and validates the southbound start configuration. + Contract-compliant adapters use ``AgentConfig.from_mapping`` so the runtime + receives the canonical ``AgentConfig`` in ``payload["config"]``. The + callable is generic only to keep this lifecycle host framework-neutral; it + does not define alternative adapter contract types. Omitting it preserves + the untyped mapping for adapters that have not yet migrated. """ # Reserve process stdout for the protocol for the entire host lifetime, From 94443c1493c901cbc4909aa45bdf67549addf82b Mon Sep 17 00:00:00 2001 From: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:07:34 -0700 Subject: [PATCH 4/7] fix: preserve adapter contract validation invariants Signed-off-by: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> --- .../src/nemo_fabric_adapter_contract/codec.py | 9 +++++++-- .../src/nemo_fabric_adapter_contract/models.py | 15 +++++++++++++++ tests/adapter_contract/test_agent_config.py | 12 ++++++++++++ tests/adapter_contract/test_agent_execution.py | 18 ++++++++++++++++++ 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/adapter-contract/src/nemo_fabric_adapter_contract/codec.py b/adapter-contract/src/nemo_fabric_adapter_contract/codec.py index 0726dfa24..3e45ca914 100644 --- a/adapter-contract/src/nemo_fabric_adapter_contract/codec.py +++ b/adapter-contract/src/nemo_fabric_adapter_contract/codec.py @@ -138,7 +138,7 @@ def validate_dataclass(instance: Any) -> None: path=(item.name,), field=item, ) - setattr(instance, item.name, decoded) + object.__setattr__(instance, item.name, decoded) def encode_dataclass(instance: Any) -> dict[str, Any]: @@ -249,7 +249,12 @@ def _decode_value( if annotation is float: if isinstance(value, bool) or not isinstance(value, (int, float)): raise ContractValidationError("must be a number", path=path) - result = float(value) + try: + result = float(value) + except OverflowError as error: + raise ContractValidationError( + "must be a finite number", path=path + ) from error if not math.isfinite(result): raise ContractValidationError("must be a finite number", path=path) return result diff --git a/adapter-contract/src/nemo_fabric_adapter_contract/models.py b/adapter-contract/src/nemo_fabric_adapter_contract/models.py index 7a16f905b..7902adac3 100644 --- a/adapter-contract/src/nemo_fabric_adapter_contract/models.py +++ b/adapter-contract/src/nemo_fabric_adapter_contract/models.py @@ -77,6 +77,21 @@ def __post_init__(self) -> None: validate_dataclass(self) self._validate() + def __setattr__(self, name: str, value: Any) -> None: + try: + previous = getattr(self, name) + except AttributeError: + object.__setattr__(self, name, value) + return + + object.__setattr__(self, name, value) + try: + validate_dataclass(self) + self._validate() + except ContractValidationError: + object.__setattr__(self, name, previous) + raise + def _validate(self) -> None: """Validate constraints that are more specific than field types.""" diff --git a/tests/adapter_contract/test_agent_config.py b/tests/adapter_contract/test_agent_config.py index ac299d5fd..461e3e5ba 100644 --- a/tests/adapter_contract/test_agent_config.py +++ b/tests/adapter_contract/test_agent_config.py @@ -159,6 +159,18 @@ def test_agent_config_from_mapping_reports_nested_field_path(): ) +def test_agent_model_config_rejects_float_overflow(): + with pytest.raises( + ContractValidationError, + match="temperature: must be a finite number", + ): + AgentModelConfig( + provider="nvidia", + model="test-model", + temperature=10**1000, + ) + + def test_agent_config_model_tracks_rust_schema_root_fields(): rust_schema = json.loads( (ROOT / "schemas/adapter-contract/agent-config.schema.json").read_text( diff --git a/tests/adapter_contract/test_agent_execution.py b/tests/adapter_contract/test_agent_execution.py index 2f855ae35..4b83ac30f 100644 --- a/tests/adapter_contract/test_agent_execution.py +++ b/tests/adapter_contract/test_agent_execution.py @@ -162,6 +162,24 @@ def test_failed_agent_run_result_requires_error(): AgentRunResult(status=AgentRunStatus.FAILED, output=None) +def test_contract_dataclasses_validate_assignment(): + usage = AgentUsage(input_tokens=1) + with pytest.raises( + ContractValidationError, + match="input_tokens: must be between 0", + ): + usage.input_tokens = -5 + assert usage.input_tokens == 1 + + result = AgentRunResult(status=AgentRunStatus.SUCCEEDED, output=None) + with pytest.raises( + ContractValidationError, + match="failed result requires an error", + ): + result.status = AgentRunStatus.FAILED + assert result.status is AgentRunStatus.SUCCEEDED + + @pytest.mark.parametrize( "path", [ From 0906c7116c46d03fa961cf84fcab206f29a05511 Mon Sep 17 00:00:00 2001 From: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:13:45 -0700 Subject: [PATCH 5/7] perf: validate only assigned contract fields Signed-off-by: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> --- .../src/nemo_fabric_adapter_contract/codec.py | 13 +++++++++++++ .../src/nemo_fabric_adapter_contract/models.py | 5 +++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/adapter-contract/src/nemo_fabric_adapter_contract/codec.py b/adapter-contract/src/nemo_fabric_adapter_contract/codec.py index 3e45ca914..7d75d7084 100644 --- a/adapter-contract/src/nemo_fabric_adapter_contract/codec.py +++ b/adapter-contract/src/nemo_fabric_adapter_contract/codec.py @@ -141,6 +141,19 @@ def validate_dataclass(instance: Any) -> None: object.__setattr__(instance, item.name, decoded) +def decode_field(instance: Any, name: str, value: Any) -> Any: + """Validate and normalize one field assignment.""" + + item = next(item for item in fields(instance) if item.name == name) + annotation = _resolved_type_hints(type(instance))[name] + return _decode_value( + annotation, + value, + path=(name,), + field=item, + ) + + def encode_dataclass(instance: Any) -> dict[str, Any]: """Return a detached JSON-compatible mapping for a contract dataclass.""" diff --git a/adapter-contract/src/nemo_fabric_adapter_contract/models.py b/adapter-contract/src/nemo_fabric_adapter_contract/models.py index 7902adac3..204fa38d0 100644 --- a/adapter-contract/src/nemo_fabric_adapter_contract/models.py +++ b/adapter-contract/src/nemo_fabric_adapter_contract/models.py @@ -19,6 +19,7 @@ from nemo_fabric_adapter_contract.codec import ContractValidationError from nemo_fabric_adapter_contract.codec import JsonValue from nemo_fabric_adapter_contract.codec import decode_dataclass +from nemo_fabric_adapter_contract.codec import decode_field from nemo_fabric_adapter_contract.codec import encode_dataclass from nemo_fabric_adapter_contract.codec import json_mapping from nemo_fabric_adapter_contract.codec import validate_dataclass @@ -84,9 +85,9 @@ def __setattr__(self, name: str, value: Any) -> None: object.__setattr__(self, name, value) return - object.__setattr__(self, name, value) + decoded = decode_field(self, name, value) + object.__setattr__(self, name, decoded) try: - validate_dataclass(self) self._validate() except ContractValidationError: object.__setattr__(self, name, previous) From 98fbd7cbc58a2a8e76b283b85c734fb562dcf035 Mon Sep 17 00:00:00 2001 From: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:16:33 -0700 Subject: [PATCH 6/7] fix: reject undeclared contract attributes Signed-off-by: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> --- adapter-contract/src/nemo_fabric_adapter_contract/models.py | 2 ++ tests/adapter_contract/test_agent_execution.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/adapter-contract/src/nemo_fabric_adapter_contract/models.py b/adapter-contract/src/nemo_fabric_adapter_contract/models.py index 204fa38d0..b84905197 100644 --- a/adapter-contract/src/nemo_fabric_adapter_contract/models.py +++ b/adapter-contract/src/nemo_fabric_adapter_contract/models.py @@ -79,6 +79,8 @@ def __post_init__(self) -> None: self._validate() def __setattr__(self, name: str, value: Any) -> None: + if name not in self.__dataclass_fields__: + raise AttributeError(f"{type(self).__name__} has no field {name!r}") try: previous = getattr(self, name) except AttributeError: diff --git a/tests/adapter_contract/test_agent_execution.py b/tests/adapter_contract/test_agent_execution.py index 4b83ac30f..42bf42635 100644 --- a/tests/adapter_contract/test_agent_execution.py +++ b/tests/adapter_contract/test_agent_execution.py @@ -164,6 +164,9 @@ def test_failed_agent_run_result_requires_error(): def test_contract_dataclasses_validate_assignment(): usage = AgentUsage(input_tokens=1) + with pytest.raises(AttributeError, match="AgentUsage has no field 'unknown'"): + usage.unknown = 1 # type: ignore[attr-defined] + with pytest.raises( ContractValidationError, match="input_tokens: must be between 0", From 54d14ffce8e3cb83b132d1a5057dff55fed3c142 Mon Sep 17 00:00:00 2001 From: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:21:54 -0700 Subject: [PATCH 7/7] test: preserve NAT invalid MCP coverage Signed-off-by: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> --- tests/adapters/test_external_nat_adapter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/adapters/test_external_nat_adapter.py b/tests/adapters/test_external_nat_adapter.py index 9de809b39..638e9c60f 100644 --- a/tests/adapters/test_external_nat_adapter.py +++ b/tests/adapters/test_external_nat_adapter.py @@ -1091,7 +1091,7 @@ def test_mcp_stdio_preserves_a_command_with_spaces_without_shell_parsing(): def test_mcp_stdio_rejects_a_whitespace_only_command(): server = _mcp_server(transport="stdio", url="placeholder") - server.url = " \t\n " + object.__setattr__(server, "url", " \t\n ") with pytest.raises(adapter.lifecycle.LifecycleError) as error: adapter.nat_mcp_server_config("calculator", server) @@ -1105,7 +1105,7 @@ def test_mcp_stdio_rejects_a_whitespace_only_command(): @pytest.mark.parametrize("transport", ["websocket", ""]) def test_mcp_server_rejects_unsupported_transport(transport: str): server = _mcp_server(transport=transport or "placeholder", url="https://mcp.test") - server.transport = transport + object.__setattr__(server, "transport", transport) with pytest.raises(adapter.lifecycle.LifecycleError) as error: adapter.nat_mcp_server_config( "docs",