From 88809f055462e35268a9d68a62f9a96ed3fc4e44 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Mon, 10 Aug 2026 23:42:54 -0700 Subject: [PATCH 01/12] feat: add TypeScript adapter contract Signed-off-by: Ajay Thorve --- .agents/skills/contribute-api/SKILL.md | 7 +- .agents/skills/maintain-packaging/SKILL.md | 13 +- .../skills/update-project-version/SKILL.md | 28 +- .agents/skills/validate-change/SKILL.md | 19 +- .github/workflows/ci_typescript.yml | 49 ++ .pre-commit-config.yaml | 2 +- AGENTS.md | 7 +- CONTRIBUTING.md | 33 +- README.md | 11 +- RELEASING.md | 9 +- crates/fabric-core/src/config.rs | 28 +- crates/fabric-core/src/schema.rs | 41 +- docs/adapter-contract/README.md | 6 + justfile | 38 +- schemas/SCHEMA.md | 30 +- .../adapter-descriptor.schema.json | 9 + schemas/run-plan.schema.json | 9 + scripts/ci/set_typescript_project_version.py | 79 +++ skills/nemo-fabric-build-adapter/SKILL.md | 8 + .../test_set_typescript_project_version.py | 99 ++++ typescript/adapter-contract/.gitignore | 6 + typescript/adapter-contract/LICENSE | 203 ++++++++ typescript/adapter-contract/README.md | 88 ++++ typescript/adapter-contract/package-lock.json | 232 +++++++++ typescript/adapter-contract/package.json | 57 +++ .../schemas/adapter-descriptor.schema.json | 365 ++++++++++++++ .../schemas/agent-config.schema.json | 469 ++++++++++++++++++ .../schemas/agent-run-request.schema.json | 25 + .../schemas/agent-run-result.schema.json | 259 ++++++++++ .../schemas/runtime-context.schema.json | 229 +++++++++ .../scripts/check-package.mjs | 187 +++++++ typescript/adapter-contract/scripts/clean.mjs | 10 + .../adapter-contract/scripts/generate.mjs | 425 ++++++++++++++++ .../scripts/projection-guards.mjs | 42 ++ .../src/generated/adapter-descriptor.ts | 184 +++++++ .../src/generated/agent-config.ts | 278 +++++++++++ .../src/generated/agent-run-request.ts | 28 ++ .../src/generated/agent-run-result.ts | 133 +++++ .../src/generated/runtime-context.ts | 135 +++++ typescript/adapter-contract/src/index.ts | 40 ++ typescript/adapter-contract/src/json.ts | 19 + typescript/adapter-contract/src/preview.ts | 11 + typescript/adapter-contract/src/version.ts | 8 + .../adapter-contract/test/preview.test.ts | 59 +++ .../test/projection-guards.test.mjs | 51 ++ .../adapter-contract/test/stable.test.ts | 188 +++++++ .../adapter-contract/test/tsconfig.json | 13 + .../adapter-contract/tsconfig.build.json | 16 + 48 files changed, 4242 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/ci_typescript.yml create mode 100644 scripts/ci/set_typescript_project_version.py create mode 100644 tests/scripts/test_set_typescript_project_version.py create mode 100644 typescript/adapter-contract/.gitignore create mode 100644 typescript/adapter-contract/LICENSE create mode 100644 typescript/adapter-contract/README.md create mode 100644 typescript/adapter-contract/package-lock.json create mode 100644 typescript/adapter-contract/package.json create mode 100644 typescript/adapter-contract/schemas/adapter-descriptor.schema.json create mode 100644 typescript/adapter-contract/schemas/agent-config.schema.json create mode 100644 typescript/adapter-contract/schemas/agent-run-request.schema.json create mode 100644 typescript/adapter-contract/schemas/agent-run-result.schema.json create mode 100644 typescript/adapter-contract/schemas/runtime-context.schema.json create mode 100644 typescript/adapter-contract/scripts/check-package.mjs create mode 100644 typescript/adapter-contract/scripts/clean.mjs create mode 100644 typescript/adapter-contract/scripts/generate.mjs create mode 100644 typescript/adapter-contract/scripts/projection-guards.mjs create mode 100644 typescript/adapter-contract/src/generated/adapter-descriptor.ts create mode 100644 typescript/adapter-contract/src/generated/agent-config.ts create mode 100644 typescript/adapter-contract/src/generated/agent-run-request.ts create mode 100644 typescript/adapter-contract/src/generated/agent-run-result.ts create mode 100644 typescript/adapter-contract/src/generated/runtime-context.ts create mode 100644 typescript/adapter-contract/src/index.ts create mode 100644 typescript/adapter-contract/src/json.ts create mode 100644 typescript/adapter-contract/src/preview.ts create mode 100644 typescript/adapter-contract/src/version.ts create mode 100644 typescript/adapter-contract/test/preview.test.ts create mode 100644 typescript/adapter-contract/test/projection-guards.test.mjs create mode 100644 typescript/adapter-contract/test/stable.test.ts create mode 100644 typescript/adapter-contract/test/tsconfig.json create mode 100644 typescript/adapter-contract/tsconfig.build.json diff --git a/.agents/skills/contribute-api/SKILL.md b/.agents/skills/contribute-api/SKILL.md index d28bd9b2..3d0dc4b6 100644 --- a/.agents/skills/contribute-api/SKILL.md +++ b/.agents/skills/contribute-api/SKILL.md @@ -1,6 +1,6 @@ --- name: contribute-api -description: Contribute a new NeMo Fabric public API surface safely, with Rust, CLI, Python, schema, adapter, and documentation parity in mind +description: Contribute a new NeMo Fabric public API surface safely, with Rust, CLI, Python, TypeScript, schema, adapter, and documentation parity in mind author: NVIDIA Corporation and Affiliates license: Apache-2.0 --- @@ -20,8 +20,8 @@ runtime or bindings. ## Default Guidance - Start from the shared Rust core behavior first -- Decide whether the CLI, PyO3 binding, Python SDK, type stubs, schemas, or - adapter contract must expose the new surface +- Decide whether the CLI, PyO3 binding, Python SDK, type stubs, schemas, or the + Python and TypeScript adapter-contract bindings must expose the new surface - Keep every affected public surface in parity - Update docs and examples in the same branch @@ -37,4 +37,5 @@ runtime or bindings. - `validate-change` - `review-doc-style` - `docs/python-sdk-contract.md` +- `schemas/SCHEMA.md` - `justfile` diff --git a/.agents/skills/maintain-packaging/SKILL.md b/.agents/skills/maintain-packaging/SKILL.md index 66cd7e32..2180aed4 100644 --- a/.agents/skills/maintain-packaging/SKILL.md +++ b/.agents/skills/maintain-packaging/SKILL.md @@ -1,6 +1,6 @@ --- name: maintain-packaging -description: Maintain NeMo Fabric Rust and Python dependencies, package metadata, module paths, native artifacts, lockfiles, license evidence, and release-facing build surfaces +description: Maintain NeMo Fabric Rust, Python, and TypeScript dependencies, package metadata, module paths, native artifacts, lockfiles, license evidence, and release-facing build surfaces author: NVIDIA Corporation and Affiliates license: Apache-2.0 --- @@ -24,6 +24,8 @@ consumed outside the source tree. - Python package metadata in `python/pyproject.toml` - Native extension naming and placement under `python/src/nemo_fabric` - Dependency resolution in `Cargo.lock` and `uv.lock` +- TypeScript adapter-contract metadata and dependency resolution in + `typescript/adapter-contract/package.json` and `package-lock.json` - Documentation tooling metadata in `docs/package.json` and `docs/package-lock.json` - CI workflows, install commands, and example commands @@ -59,6 +61,10 @@ commitment. `uv run --no-project python scripts/licensing/license_diff.py --base-ref origin/main` after updating manifests and lockfiles, then review added packages and license changes. +- For `typescript/adapter-contract/package-lock.json`, inspect the resolved + package entries and their `license` fields. The adapter-contract package must + keep an empty production dependency graph; build-only dependencies still + require permissive, recorded license evidence. - Regenerate the attribution files with the named pre-commit hooks instead of editing generated output: @@ -78,6 +84,8 @@ compatibility decisions using the distribution and linkage context. - [ ] CI references the same package names as local workflows - [ ] Public packaging changes are reflected in release-facing docs - [ ] Workspace, Python, and lockfile versions remain aligned where required +- [ ] The TypeScript adapter-contract package version follows the workspace + release version without changing its independent wire contract version - [ ] The editable maturin build still produces `nemo_fabric._native` - [ ] New dependencies are necessary, maintained, and narrower than the viable alternatives @@ -96,6 +104,9 @@ compatibility decisions using the distribution and linkage context. - `uv.lock` - `docs/package.json` - `docs/package-lock.json` +- `typescript/adapter-contract/package.json` +- `typescript/adapter-contract/package-lock.json` +- `.github/workflows/ci_typescript.yml` - `.github/workflows/ci_python.yml` - `.github/workflows/ci_rust.yml` - `.pre-commit-config.yaml` diff --git a/.agents/skills/update-project-version/SKILL.md b/.agents/skills/update-project-version/SKILL.md index bd8fb93d..ba0c6b18 100644 --- a/.agents/skills/update-project-version/SKILL.md +++ b/.agents/skills/update-project-version/SKILL.md @@ -1,6 +1,6 @@ --- name: update-project-version -description: Update the NeMo Fabric release version across Cargo, setuptools package metadata, internal Python dependency pins, integration metadata, and lockfiles. Use when bumping, synchronizing, or auditing NeMo Fabric package versions for a release. +description: Update the NeMo Fabric release version across Cargo, Python and TypeScript package metadata, internal Python dependency pins, integration metadata, and lockfiles. Use when bumping, synchronizing, or auditing NeMo Fabric package versions for a release. author: NVIDIA Corporation and Affiliates license: Apache-2.0 --- @@ -20,7 +20,8 @@ pre-release or build-metadata variants used during packaging. ## Source Of Truth - `Cargo.toml` `[workspace.package].version` is the source of truth for the Rust - workspace and Python build versioning. + workspace and the release version stamped into Python and TypeScript package + metadata. - Keep `Cargo.toml` `[workspace.dependencies]` self-references aligned when the workspace version changes. - `python/pyproject.toml` is the exception among the Python projects: do not add @@ -36,6 +37,8 @@ pre-release or build-metadata variants used during packaging. - All `nemo-fabric-* == ` requirements in the root `pyproject.toml` optional dependencies. - Each adapter's `nemo-fabric-adapters-common == ` dependency. +- Keep `typescript/adapter-contract/package.json` and the root package entries in + its `package-lock.json` aligned with the Cargo SemVer release version. For a normal release, use the same `X.Y.Z` string everywhere. For a prerelease or build-metadata version, use valid Cargo SemVer in `Cargo.toml` and the @@ -45,15 +48,17 @@ versions rather than blindly copying incompatible syntax. ## Workflow -1. Read the current version from `Cargo.toml` and decide the exact Cargo and - Python target version strings. -2. Run `just set-version `. The recipe converts supported Cargo - SemVer prereleases to PEP 440 and updates: +1. Read the current version from `Cargo.toml` and decide the exact Cargo, + Python, and TypeScript target version strings. +2. Run `just set-version `. The recipe preserves the normalized + SemVer for Cargo and TypeScript, converts it to PEP 440 for Python, and + updates: - `Cargo.toml` `[workspace.package].version` - `Cargo.toml` `workspace.dependencies.nemo-fabric-core.version` - The root setuptools `project.version` and every `adapters/**/pyproject.toml` `project.version` - Every internal `nemo-fabric-*` exact-version requirement + - The TypeScript adapter-contract `package.json` and `package-lock.json` - `Cargo.lock` through Cargo metadata resolution - The root, runtime, and adapter `uv.lock` files through `just lock-python` 3. Confirm that `python/pyproject.toml` remains dynamic and unchanged. @@ -62,7 +67,8 @@ versions rather than blindly copying incompatible syntax. If editing the helper code, keep these contracts aligned: -- `set_project_version` must call the Cargo and Python project version helpers. +- `set_project_version` must call the Cargo, Python, and TypeScript project + version helpers. - `set_cargo_workspace_version` must update the workspace version and the `nemo-fabric-core` workspace dependency, then verify every `nemo-fabric-*` workspace package through Cargo metadata. @@ -70,6 +76,8 @@ If editing the helper code, keep these contracts aligned: adapter `pyproject.toml` discovered recursively under `adapters/`, and all internal exact-version pins while rejecting a static version in `python/pyproject.toml`. +- `set_typescript_project_version` must update the package manifest and both + root version entries in the npm lockfile without changing dependency versions. - The `set-version` recipe must run `just lock-python` after source metadata is updated. @@ -81,11 +89,14 @@ If editing the helper code, keep these contracts aligned: `rg -n '^version =|nemo-fabric-[a-z-]+ == ' pyproject.toml adapters --glob 'pyproject.toml'` - Confirm the runtime remains dynamic: `rg -n 'dynamic = \["version"\]' python/pyproject.toml` +- Inspect the TypeScript package and lockfile root versions: + `rg -n '"version":' typescript/adapter-contract/package{,-lock}.json` - Run `cargo check --workspace --locked`. - Run `just build-python` to verify all Python package metadata resolves. - Run `just test-python` when the integration version or Python packaging behavior changes materially. - Run `just wheels` for release-facing validation of every Python wheel. +- Run `just pack-typescript` to verify the stamped TypeScript package metadata. - Run `git diff --check`. ## Avoid @@ -94,6 +105,7 @@ If editing the helper code, keep these contracts aligned: - Adding a literal version to `python/pyproject.toml`; Maturin owns that version. - Updating Python package versions without their exact internal dependency pins. - Forgetting `Cargo.lock`, the root `uv.lock`, or per-project `uv.lock` files. +- Updating the TypeScript package manifest without its npm lockfile root entry. - Blind repository-wide replacement of version-like strings. ## References @@ -106,4 +118,6 @@ If editing the helper code, keep these contracts aligned: - `python/uv.lock` - `adapters/**/pyproject.toml` - `adapters/**/uv.lock` +- `typescript/adapter-contract/package.json` +- `typescript/adapter-contract/package-lock.json` - `justfile` diff --git a/.agents/skills/validate-change/SKILL.md b/.agents/skills/validate-change/SKILL.md index 38c73e90..f6c7ec1a 100644 --- a/.agents/skills/validate-change/SKILL.md +++ b/.agents/skills/validate-change/SKILL.md @@ -23,6 +23,8 @@ surfaces touched by a change. test pass. - If Rust code changed, run `cargo fmt --all -- --check` and `just test-rust`. - If Python code or a Python-facing adapter changed, run `just test-python`. +- If the TypeScript adapter contract or one of its source schemas changed, run + `just test-typescript`. - If `crates/fabric-core` changed in a way exposed through Python, run both the Rust and Python suites. - If the PyO3 bridge or package metadata changed, run `just build-python` and @@ -30,10 +32,13 @@ surfaces touched by a change. - If public configuration types changed, confirm the schema snapshot tests in `just test-rust` pass and review generated schema diffs. - If an adapter or integration changed, run its focused tests. -- If a manifest or lockfile changed, run +- If a Cargo or Python manifest or lockfile changed, run `uv run --no-project python scripts/licensing/license_diff.py --base-ref origin/main`, review the transitive license changes, then run the `attributions-rust` and `attributions-python` pre-commit hooks. +- If the TypeScript manifest or npm lockfile changed, inspect the complete npm + dependency tree and license fields, confirm the package still has zero + production dependencies, and run its package and audit checks. - If documentation or examples changed, run `just docs` when practical and verify documented commands against the current repository. - If code changes alter APIs, commands, paths, packaging behavior, telemetry @@ -55,8 +60,8 @@ surfaces touched by a change. - **Harbor integration changed** Run `tests/test_harbor_runner.py`, then `just test-python`. - **Schema or public contract changed** - Run both language suites and review changes under `schemas/` and generated API - references. + Run the Rust, Python, and TypeScript suites and review changes under + `schemas/`, generated TypeScript sources, and generated API references. - **Documentation-only change** Use `contribute-docs` and `review-doc-style`. Run `just docs` for docs-site or generated-reference changes. @@ -69,6 +74,7 @@ surfaces touched by a change. ```bash just test-rust just test-python +just test-typescript ``` ## Common Targeted Commands @@ -85,6 +91,11 @@ just build-python just test-python uv run --no-sync pytest -k "" +# TypeScript adapter contract +just build-typescript +just test-typescript +just pack-typescript + # Documentation just docs @@ -114,5 +125,7 @@ Before review or handoff: - Build and test recipes: `justfile` - Python CI: `.github/workflows/ci_python.yml` - Rust CI: `.github/workflows/ci_rust.yml` +- TypeScript CI: `.github/workflows/ci_typescript.yml` - Documentation CI: `.github/workflows/fern-docs.yml` - Public Python contract: `docs/python-sdk-contract.md` +- Public adapter contract: `schemas/SCHEMA.md` diff --git a/.github/workflows/ci_typescript.yml b/.github/workflows/ci_typescript.yml new file mode 100644 index 00000000..5755c94f --- /dev/null +++ b/.github/workflows/ci_typescript.yml @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: TypeScript + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: ci-typescript-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + test: + name: Test (Node ${{ matrix.node-version }}) + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + node-version: ['20.18.3', '24'] + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: ${{ matrix.node-version }} + cache: npm + cache-dependency-path: typescript/adapter-contract/package-lock.json + + - name: Install just + uses: taiki-e/install-action@c070f87102a1c75b3183910f391c1cb887fe13c8 # v2.77.6 + with: + tool: just@1.50.0 + + - name: Test TypeScript contract package + run: just test-typescript diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 89886fde..c71fbe5b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: name: copyright header entry: python3 scripts/lint/check_copyright.py language: system - files: '\.(rs|py|pyi|toml|yaml|yml|md|mdx|sh)$|\.gitignore$' + files: '\.(rs|py|pyi|toml|yaml|yml|md|mdx|sh|js|mjs|ts)$|\.gitignore$' exclude: '(/SKILL\.md|node_modules/|target/|\.venv/|^\.github/pull_request_template\.md)$' # Python lint — enforce the flake8-bugbear cached-instance-method rule (B019) diff --git a/AGENTS.md b/AGENTS.md index 3965df66..105ef2cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,12 @@ These workflow notes keep public documentation, examples, and PR preparation ali with repository expectations. - Update user-facing entry points when public behavior, the `nemo-fabric` package (imported as `nemo_fabric`), examples, or supported bindings change: `README.md`, the Fern docs under `docs/` (navigation in `docs/index.yml`, site config in `fern/docs.yml`), and the adapter/integration READMEs (`adapters/*/README.md`, `python/src/nemo_fabric/integrations/*/README.md`, `examples/README.md`). -- Keep the Python/Rust binding contract current when the public API changes: `docs/sdk/python.mdx`, the JSON Schema notes in `schemas/SCHEMA.md`, the generated references under `docs/reference/api/`, and the integration skills under `skills/` (which restate public contracts and must be kept in parity). Regenerate docs with `just docs` after changing the docs site. +- Keep public bindings current when the API changes: `docs/sdk/python.mdx` for + the Python SDK; `adapter-contract/` and `typescript/adapter-contract/` for the + southbound adapter contract; the JSON Schema notes in `schemas/SCHEMA.md`; + the generated references under `docs/reference/api/`; and the integration + skills under `skills/` (which restate public contracts and must be kept in + parity). Regenerate docs with `just docs` after changing the docs site. - Keep release policy and the end-to-end maintainer workflow in `RELEASING.md`; keep packaging implementation guidance in `.agents/skills/maintain-packaging/SKILL.md`. Do not move release-history policy into user-facing docs or add a duplicate `CHANGELOG.md`. - Keep the stable public wrapper `scripts/generate_api_docs.sh` at the `scripts/` root in docs and examples. Reference namespaced helper paths under `scripts/docs/` only when documenting internal maintenance work. - Use branch prefixes for your work: `feat/`, `fix/`, `docs/`, `test/`, or `refactor/`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8ac5816f..6f6387f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,11 +19,12 @@ Install these tools before you start: - **Rust** (stable toolchain) -- install with [rustup](https://rustup.rs/) - **Python** >= 3.11 +- **Node.js** >= 20.18.3 with npm - **uv** -- follow the [uv installation guide](https://docs.astral.sh/uv/getting-started/installation/) - **just** >= 1.50.0 -- `cargo install just --locked` -Clone the repository, create a virtual environment, and build the Rust and -Python packages: +Clone the repository, create a virtual environment, and build the Rust, +Python, and TypeScript packages: ```bash git clone https://github.com/NVIDIA/NeMo-Fabric.git @@ -99,17 +100,25 @@ Follow the existing style in the Python SDK, adapters, examples, and tests. Use type annotations for public APIs and keep native binding declarations in sync with their Rust implementations. +### TypeScript + +Use strict TypeScript for the adapter-contract binding. Preserve the JSON wire +property names, run the checked-in generator instead of editing generated +declarations, and keep production dependencies out of the contract package. + ### General -Use the naming conventions appropriate to each language: Rust and Python use -`snake_case` for functions and variables, Rust types use `PascalCase`, and -Python classes use `PascalCase`. +Use the naming conventions appropriate to each language. Rust and Python use +`snake_case` for functions and variables. Rust, Python, and TypeScript types use +`PascalCase`. TypeScript contract properties preserve the wire `snake_case` +names. ## Testing **Run tests for every language surface affected by your changes.** If a change -touches the Rust core or public schemas, run both the Rust and Python suites -because the Python SDK and adapters depend on the native core contract. +touches the Rust core or public adapter-contract schemas, run the Rust, Python, +and TypeScript suites because both language bindings depend on the generated +wire contract. Run the affected test targets through the repository `justfile`: @@ -120,7 +129,10 @@ just test-rust # Python SDK, adapters, integrations, and examples just test-python -# Both suites +# TypeScript adapter contract +just test-typescript + +# All supported language surfaces just test-all ``` @@ -133,8 +145,9 @@ just no_uv=true test-all ``` When adding functionality, include tests in the corresponding Rust crate or in -the relevant area under `tests/`. Public contract changes must keep the checked-in -JSON Schema snapshots and native Python binding declarations synchronized. +the relevant area under `tests/`. Public contract changes must keep the +checked-in JSON Schema snapshots, Python representations, and generated +TypeScript declarations synchronized. ## Documentation Checklist diff --git a/README.md b/README.md index 5ff881a4..e8f5076c 100644 --- a/README.md +++ b/README.md @@ -261,13 +261,16 @@ through adapters. Use the following reference to compare the integrations: - [Adapter compatibility and guides](adapters/README.md): compare bundled harness support, runtime ownership, telemetry integration, and package guides. +- [Adapter contract](docs/adapter-contract/README.md): build third-party + adapters against the canonical schemas or the dependency-free Python and + TypeScript contract bindings. ## Roadmap -- **Custom harnesses:** Publish the NeMo Fabric adapter contract so third-party - developers can build integrations that are compatible with NeMo Fabric. - Support integrations maintained by NeMo Fabric and compatible third-party - integrations. +- **Custom harnesses:** Publish the NeMo Fabric adapter contract as canonical + schemas and dependency-free language bindings so third-party developers can + build integrations that are compatible with NeMo Fabric. Support integrations + maintained by NeMo Fabric and compatible third-party integrations. - **Custom agents:** Support custom agents built on maintained or third-party harness integrations without requiring an additional, agent-specific adapter. Preserve the normalized NeMo Fabric lifecycle, results, artifacts, and diff --git a/RELEASING.md b/RELEASING.md index 8a43df09..4681e2e4 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -46,6 +46,10 @@ NeMo Fabric versions are anchored on the workspace SemVer in the repository root - The root `pyproject.toml` and every `adapters/**/pyproject.toml` carry the Python package versions and internal dependency pins and must stay aligned with the same release version. +- `typescript/adapter-contract/package.json` and its lockfile carry the npm + adapter-contract package version and must stay aligned with the same release + version. The package version is independent of the + `fabric.adapter/v1alpha2` wire contract version. - The `nemo-fabric-runtime` Python package version is derived at packaging time. `python/pyproject.toml` stays `dynamic = ["version"]` in the repository, and Maturin derives the version from `crates/fabric-python/Cargo.toml`, which @@ -143,7 +147,9 @@ The helper updates: `nemo-fabric-core`. 3. [`pyproject.toml`](pyproject.toml), every `adapters/**/pyproject.toml`, and their internal dependency pins to the same release version. -4. [`Cargo.lock`](Cargo.lock), [`uv.lock`](uv.lock), and every Python project +4. [`typescript/adapter-contract/package.json`](typescript/adapter-contract/package.json) + and its npm lockfile. +5. [`Cargo.lock`](Cargo.lock), [`uv.lock`](uv.lock), and every Python project lockfile. Review docs and snippets that mention explicit versions, including: @@ -164,6 +170,7 @@ repository release, the safest baseline is: uv run pre-commit run --all-files just test-rust just test-python +just test-typescript just docs ``` diff --git a/crates/fabric-core/src/config.rs b/crates/fabric-core/src/config.rs index 3de97652..d337ce80 100644 --- a/crates/fabric-core/src/config.rs +++ b/crates/fabric-core/src/config.rs @@ -196,13 +196,13 @@ pub struct WorkflowConfig { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct AdapterDescriptor { /// Adapter descriptor contract version. - #[schemars(length(min = 1))] + #[schemars(schema_with = "adapter_contract_version_schema")] pub contract_version: String, /// Unique id for this adapter implementation. - #[schemars(length(min = 1))] + #[schemars(length(min = 1), regex(pattern = r"\S"))] pub adapter_id: String, /// Stable machine-readable harness identifier implemented by this adapter. - #[schemars(length(min = 1))] + #[schemars(length(min = 1), regex(pattern = r"\S"))] pub harness: String, /// Adapter implementation kind. pub adapter_kind: AdapterKind, @@ -239,6 +239,13 @@ pub struct AdapterDescriptor { pub extensions: BTreeMap, } +fn adapter_contract_version_schema(generator: &mut SchemaGenerator) -> Schema { + let mut schema = String::json_schema(generator); + schema.insert("const".into(), ADAPTER_CONTRACT_VERSION.into()); + schema.insert("minLength".into(), 1.into()); + schema +} + fn adapter_extension_schemas_schema(generator: &mut SchemaGenerator) -> Schema { let mut schema = BTreeMap::>::json_schema(generator); @@ -522,12 +529,25 @@ pub enum AdapterConfigField { pub struct AdapterTelemetrySupport { /// Provider-specific telemetry capabilities supported by this adapter. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + #[schemars(schema_with = "adapter_telemetry_providers_schema")] pub providers: BTreeMap, /// Additive adapter telemetry fields. #[serde(default, flatten)] pub extensions: BTreeMap, } +fn adapter_telemetry_providers_schema(generator: &mut SchemaGenerator) -> Schema { + let mut schema = + BTreeMap::::json_schema(generator); + schema.insert( + "propertyNames".into(), + serde_json::json!({ + "enum": TelemetryProvider::ALL.map(TelemetryProvider::as_str), + }), + ); + schema +} + /// Telemetry capabilities for one adapter-supported provider. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct AdapterTelemetryProviderSupport { @@ -1168,6 +1188,8 @@ pub enum TelemetryProvider { } impl TelemetryProvider { + const ALL: [Self; 2] = [Self::Relay, Self::Native]; + /// Return the stable configuration value for this provider. pub fn as_str(self) -> &'static str { match self { diff --git a/crates/fabric-core/src/schema.rs b/crates/fabric-core/src/schema.rs index 3c9a045e..e5aa85bf 100644 --- a/crates/fabric-core/src/schema.rs +++ b/crates/fabric-core/src/schema.rs @@ -310,12 +310,18 @@ mod tests { } #[test] - fn adapter_descriptor_schema_rejects_empty_identifiers() { + fn adapter_descriptor_schema_matches_runtime_constraints() { let schema = generate_schema(SchemaName::AdapterDescriptor).expect("schema generation"); + assert_eq!( + schema["properties"]["contract_version"]["const"], + crate::ADAPTER_CONTRACT_VERSION + ); assert_eq!(schema["properties"]["contract_version"]["minLength"], 1); assert_eq!(schema["properties"]["adapter_id"]["minLength"], 1); + assert_eq!(schema["properties"]["adapter_id"]["pattern"], r"\S"); assert_eq!(schema["properties"]["harness"]["minLength"], 1); + assert_eq!(schema["properties"]["harness"]["pattern"], r"\S"); assert_eq!( schema["properties"]["settings_schema"]["type"], serde_json::json!(["object", "null"]) @@ -347,6 +353,39 @@ mod tests { "usage" ]) ); + assert_eq!( + schema["$defs"]["AdapterTelemetrySupport"]["properties"]["providers"]["propertyNames"] + ["enum"], + serde_json::json!(["relay", "native"]) + ); + + let validator = jsonschema::validator_for(&schema).expect("valid descriptor schema"); + let descriptor = serde_json::json!({ + "contract_version": crate::ADAPTER_CONTRACT_VERSION, + "adapter_id": "test.fabric.schema", + "harness": "schema-test", + "adapter_kind": "python", + "telemetry": { + "providers": { + "relay": {} + } + } + }); + assert!(validator.is_valid(&descriptor)); + + let mut unsupported_contract = descriptor.clone(); + unsupported_contract["contract_version"] = serde_json::json!("fabric.adapter/v1alpha3"); + assert!(!validator.is_valid(&unsupported_contract)); + + let mut unsupported_provider = descriptor.clone(); + unsupported_provider["telemetry"]["providers"] = serde_json::json!({"custom": {}}); + assert!(!validator.is_valid(&unsupported_provider)); + + for field in ["adapter_id", "harness"] { + let mut blank_identifier = descriptor.clone(); + blank_identifier[field] = serde_json::json!(" \t"); + assert!(!validator.is_valid(&blank_identifier)); + } } #[test] diff --git a/docs/adapter-contract/README.md b/docs/adapter-contract/README.md index 13548794..aaf761c7 100644 --- a/docs/adapter-contract/README.md +++ b/docs/adapter-contract/README.md @@ -81,3 +81,9 @@ Canonical adapter-facing JSON Schemas are published in the repository [`schemas/adapter-contract/` directory](https://github.com/NVIDIA/NeMo-Fabric/tree/main/schemas/adapter-contract). Python adapters can validate the southbound models with `nemo-fabric-adapter-contract` without depending on the NeMo Fabric runtime. +TypeScript adapters can import the negotiated descriptor, configuration, and +runtime-context types from `@nvidia/nemo-fabric-adapter-contract`. Preview +request and result types are available only from +`@nvidia/nemo-fabric-adapter-contract/preview`. The TypeScript declarations +provide compile-time structure, not runtime JSON validation; validate untrusted +data against the packaged JSON Schemas. diff --git a/justfile b/justfile index 3dcd67ce..0eea943a 100644 --- a/justfile +++ b/justfile @@ -249,14 +249,22 @@ set_python_project_versions() { "$python_executable" scripts/ci/set_python_project_versions.py "$version" } +set_typescript_project_version() { + local version="$1" + local python_executable="" + python_executable="$(uv_python_executable)" + "$python_executable" scripts/ci/set_typescript_project_version.py "$version" +} + set_project_version() { local version="$1" set_cargo_workspace_version "$version" set_python_project_versions "$version" + set_typescript_project_version "$version" } ''' -# Remove local Rust and Python build and test artifacts. +# Remove local Rust, Python, and TypeScript build and test artifacts. clean: #!/usr/bin/env bash shopt -s globstar nullglob @@ -271,6 +279,8 @@ clean: **/coverage.xml \ **/dist \ docs/node_modules \ + typescript/adapter-contract/node_modules \ + typescript/adapter-contract/*.tgz \ target/ \ **/build/ @@ -298,8 +308,23 @@ build-python: --reinstall-package nemo-fabric-runtime fi +# Build the TypeScript adapter contract using the locked dependency set. +build-typescript: + npm ci --prefix typescript/adapter-contract --ignore-scripts + npm run build --prefix typescript/adapter-contract + +# Generate the TypeScript adapter contract from the committed JSON Schemas. +generate-typescript-contract: + npm ci --prefix typescript/adapter-contract --ignore-scripts + npm run generate --prefix typescript/adapter-contract + +# Verify the TypeScript adapter contract package tarball. +pack-typescript: + npm ci --prefix typescript/adapter-contract --ignore-scripts + npm run pack:check --prefix typescript/adapter-contract + # Build all supported language packages. -build-all: build-rust build-python +build-all: build-rust build-python build-typescript # Create or update the lockfile for every Python project. lock-python: @@ -372,8 +397,13 @@ test-python: test-rust: cargo test --workspace --locked -# Run all Rust and Python tests. -test-all: test-rust test-python +# Run the TypeScript adapter contract checks using the locked dependency set. +test-typescript: + npm ci --prefix typescript/adapter-contract --ignore-scripts + npm test --prefix typescript/adapter-contract + +# Run all Rust, Python, and TypeScript tests. +test-all: test-rust test-python test-typescript # Build wheels for every Python project into the repository dist directory. wheels: diff --git a/schemas/SCHEMA.md b/schemas/SCHEMA.md index 5f954dcd..a521e5d6 100644 --- a/schemas/SCHEMA.md +++ b/schemas/SCHEMA.md @@ -8,10 +8,12 @@ SPDX-License-Identifier: Apache-2.0 This directory contains committed JSON Schema snapshots for the public NeMo Fabric contract. The files are generated from the Rust core types, not edited by hand. -The Python SDK exposes Pydantic authoring models for application callers. Those -models are hand-maintained against these Rust-generated schemas for now. When a -schema-backed Rust type changes, update the matching Pydantic model and its -schema-alignment tests in the same change. +The Python adapter-contract package exposes dependency-free dataclasses with +optional Pydantic interoperability. Those models are hand-maintained against +these Rust-generated schemas. The TypeScript adapter-contract package generates +compile-time declarations from the committed schema snapshots. When a +schema-backed Rust type changes, update each applicable language binding and +its parity tests in the same change. ## Directory Layout @@ -35,6 +37,16 @@ An adapter author can treat `adapter-contract/` as the complete schema entry point. The `legacy/` subdirectory contains only the transitional local-host payload used while first-party adapters migrate to the typed execution types. +The language bindings preserve this boundary: + +- Python adapters use `nemo-fabric-adapter-contract` for dependency-free + dataclasses and optional Pydantic models. +- TypeScript adapters use `@nvidia/nemo-fabric-adapter-contract` for the + negotiated descriptor, configuration, and runtime-context types. Preview + request and result types are isolated under the package's `./preview` + subpath. The package also includes these canonical schemas for runtime + validation without selecting a validation-library dependency. + `FabricConfig` is the northbound source of consumer intent. Planning produces the `CapabilityPlan` as routed evidence and projects the fields accepted by the selected descriptor into `AgentConfig`, the authoritative southbound adapter @@ -122,3 +134,13 @@ To add a new schema-backed typed model: Run `cargo test` after regenerating schemas. The snapshot tests compare the committed files against the schemas generated from the current Rust types and fail on accidental drift. + +Regenerate the TypeScript projection after an intentional adapter-contract +schema change: + +```bash +just generate-typescript-contract +``` + +Run `just test-typescript` to check generated-file drift, strict compile-time +fixtures, package contents, and clean-consumer imports. diff --git a/schemas/adapter-contract/adapter-descriptor.schema.json b/schemas/adapter-contract/adapter-descriptor.schema.json index 0471bb1b..9e117468 100644 --- a/schemas/adapter-contract/adapter-descriptor.schema.json +++ b/schemas/adapter-contract/adapter-descriptor.schema.json @@ -198,6 +198,12 @@ "$ref": "#/$defs/AdapterTelemetryProviderSupport" }, "description": "Provider-specific telemetry capabilities supported by this adapter.", + "propertyNames": { + "enum": [ + "relay", + "native" + ] + }, "type": "object" } }, @@ -242,6 +248,7 @@ "adapter_id": { "description": "Unique id for this adapter implementation.", "minLength": 1, + "pattern": "\\S", "type": "string" }, "adapter_kind": { @@ -266,6 +273,7 @@ "description": "NeMo Fabric config areas this adapter consumes or generates." }, "contract_version": { + "const": "fabric.adapter/v1alpha2", "description": "Adapter descriptor contract version.", "minLength": 1, "type": "string" @@ -303,6 +311,7 @@ "harness": { "description": "Stable machine-readable harness identifier implemented by this adapter.", "minLength": 1, + "pattern": "\\S", "type": "string" }, "requirements": { diff --git a/schemas/run-plan.schema.json b/schemas/run-plan.schema.json index 452f14fd..fabb7f41 100644 --- a/schemas/run-plan.schema.json +++ b/schemas/run-plan.schema.json @@ -108,6 +108,7 @@ "adapter_id": { "description": "Unique id for this adapter implementation.", "minLength": 1, + "pattern": "\\S", "type": "string" }, "adapter_kind": { @@ -132,6 +133,7 @@ "description": "NeMo Fabric config areas this adapter consumes or generates." }, "contract_version": { + "const": "fabric.adapter/v1alpha2", "description": "Adapter descriptor contract version.", "minLength": 1, "type": "string" @@ -169,6 +171,7 @@ "harness": { "description": "Stable machine-readable harness identifier implemented by this adapter.", "minLength": 1, + "pattern": "\\S", "type": "string" }, "requirements": { @@ -331,6 +334,12 @@ "$ref": "#/$defs/AdapterTelemetryProviderSupport" }, "description": "Provider-specific telemetry capabilities supported by this adapter.", + "propertyNames": { + "enum": [ + "relay", + "native" + ] + }, "type": "object" } }, diff --git a/scripts/ci/set_typescript_project_version.py b/scripts/ci/set_typescript_project_version.py new file mode 100644 index 00000000..68f576b8 --- /dev/null +++ b/scripts/ci/set_typescript_project_version.py @@ -0,0 +1,79 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path +from typing import Any + + +SEMVER_PATTERN = re.compile( + r"\d+\.\d+\.\d+(?:-(?:alpha|beta|rc)(?:\.\d+)?)?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?" +) +PACKAGE_DIRECTORY = Path("typescript/adapter-contract") + + +def _read_json_object(path: Path) -> dict[str, Any]: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise SystemExit(f"Expected a JSON object in {path}") + return value + + +def _write_json_object(path: Path, value: dict[str, Any]) -> None: + path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + + +def set_typescript_project_version(root: Path, version: str) -> None: + if SEMVER_PATTERN.fullmatch(version) is None: + raise SystemExit(f"Unsupported TypeScript package version: {version}") + + package_path = root / PACKAGE_DIRECTORY / "package.json" + lock_path = root / PACKAGE_DIRECTORY / "package-lock.json" + package = _read_json_object(package_path) + lock = _read_json_object(lock_path) + + package_name = package.get("name") + lock_packages = lock.get("packages") + if not isinstance(package_name, str) or not package_name: + raise SystemExit(f"Expected a non-empty package name in {package_path}") + if "version" not in package: + raise SystemExit(f"Expected a package version in {package_path}") + if not isinstance(lock_packages, dict): + raise SystemExit(f"Expected a packages object in {lock_path}") + + lock_root = lock_packages.get("") + if not isinstance(lock_root, dict): + raise SystemExit(f"Expected a root package entry in {lock_path}") + if lock.get("name") != package_name or lock_root.get("name") != package_name: + raise SystemExit( + f"Package names in {package_path} and {lock_path} are not synchronized" + ) + if "version" not in lock or "version" not in lock_root: + raise SystemExit(f"Expected root package version fields in {lock_path}") + + changed = ( + package.get("version") != version + or lock.get("version") != version + or lock_root.get("version") != version + ) + package["version"] = version + lock["version"] = version + lock_root["version"] = version + + if changed: + _write_json_object(package_path, package) + _write_json_object(lock_path, lock) + print(f"{PACKAGE_DIRECTORY} version updated to {version}") + else: + print(f"{PACKAGE_DIRECTORY} already set to {version}") + + +if __name__ == "__main__": + if len(sys.argv) != 2: + raise SystemExit("Usage: set_typescript_project_version.py ") + set_typescript_project_version(Path.cwd(), sys.argv[1]) diff --git a/skills/nemo-fabric-build-adapter/SKILL.md b/skills/nemo-fabric-build-adapter/SKILL.md index 394401e5..7f56a375 100644 --- a/skills/nemo-fabric-build-adapter/SKILL.md +++ b/skills/nemo-fabric-build-adapter/SKILL.md @@ -77,6 +77,14 @@ Install its optional `pydantic` extra only for Pydantic interoperability. Add Relay helpers. A bare adapter package should not depend on the NeMo Fabric runtime. +For a TypeScript adapter, depend on +`@nvidia/nemo-fabric-adapter-contract`. Import the negotiated descriptor, +configuration, and runtime-context types from the package root. Import request +and result types from `@nvidia/nemo-fabric-adapter-contract/preview` only when +working on the future typed invocation boundary. TypeScript types do not +validate data received from a process or network boundary; validate untrusted +values against the JSON Schemas included with the package. + ## Map AgentConfig Accept a validated `AgentConfig` and translate each declared field once at the diff --git a/tests/scripts/test_set_typescript_project_version.py b/tests/scripts/test_set_typescript_project_version.py new file mode 100644 index 00000000..42288588 --- /dev/null +++ b/tests/scripts/test_set_typescript_project_version.py @@ -0,0 +1,99 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + + +CI_SCRIPTS = Path(__file__).resolve().parents[2] / "scripts" / "ci" +sys.path.insert(0, str(CI_SCRIPTS)) + +import set_typescript_project_version # noqa: E402 + + +def _write_package_files(root: Path) -> tuple[Path, Path]: + package_directory = root / "typescript" / "adapter-contract" + package_directory.mkdir(parents=True) + package_path = package_directory / "package.json" + lock_path = package_directory / "package-lock.json" + package_path.write_text( + json.dumps( + { + "name": "@nvidia/nemo-fabric-adapter-contract", + "version": "0.2.0", + "devDependencies": {"typescript": "5.9.3"}, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + lock_path.write_text( + json.dumps( + { + "name": "@nvidia/nemo-fabric-adapter-contract", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": True, + "packages": { + "": { + "name": "@nvidia/nemo-fabric-adapter-contract", + "version": "0.2.0", + "devDependencies": {"typescript": "5.9.3"}, + }, + "node_modules/typescript": { + "version": "5.9.3", + }, + }, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + return package_path, lock_path + + +@pytest.mark.parametrize("version", ["0.3.0-rc.2", "0.3.0+nightly.20260810"]) +def test_set_typescript_project_version_updates_manifest_and_lockfile( + tmp_path: Path, + version: str, +): + package_path, lock_path = _write_package_files(tmp_path) + + set_typescript_project_version.set_typescript_project_version(tmp_path, version) + + package = json.loads(package_path.read_text(encoding="utf-8")) + lock = json.loads(lock_path.read_text(encoding="utf-8")) + assert package["version"] == version + assert lock["version"] == version + assert lock["packages"][""]["version"] == version + assert lock["packages"]["node_modules/typescript"]["version"] == "5.9.3" + + +@pytest.mark.parametrize("version", ["v0.3.0", "0.3", "0.3.0-dev.1"]) +def test_set_typescript_project_version_rejects_unsupported_versions( + tmp_path: Path, + version: str, +): + _write_package_files(tmp_path) + + with pytest.raises(SystemExit, match="Unsupported TypeScript package version"): + set_typescript_project_version.set_typescript_project_version(tmp_path, version) + + +def test_set_typescript_project_version_rejects_lockfile_name_drift( + tmp_path: Path, +): + _, lock_path = _write_package_files(tmp_path) + lock = json.loads(lock_path.read_text(encoding="utf-8")) + lock["packages"][""]["name"] = "wrong-package" + lock_path.write_text(json.dumps(lock, indent=2) + "\n", encoding="utf-8") + + with pytest.raises(SystemExit, match="Package names .* are not synchronized"): + set_typescript_project_version.set_typescript_project_version(tmp_path, "0.3.0") diff --git a/typescript/adapter-contract/.gitignore b/typescript/adapter-contract/.gitignore new file mode 100644 index 00000000..a55e4814 --- /dev/null +++ b/typescript/adapter-contract/.gitignore @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +/dist/ +/node_modules/ +/*.tgz diff --git a/typescript/adapter-contract/LICENSE b/typescript/adapter-contract/LICENSE new file mode 100644 index 00000000..f13d24c3 --- /dev/null +++ b/typescript/adapter-contract/LICENSE @@ -0,0 +1,203 @@ +Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/typescript/adapter-contract/README.md b/typescript/adapter-contract/README.md new file mode 100644 index 00000000..40a02432 --- /dev/null +++ b/typescript/adapter-contract/README.md @@ -0,0 +1,88 @@ + + +# NVIDIA NeMo Fabric Adapter Contract for TypeScript + +Dependency-free TypeScript types for implementing adapters against the NVIDIA +NeMo Fabric adapter contract. The package is generated from the versioned JSON +Schemas maintained in the NeMo Fabric repository. + +## Install + +```bash +npm install @nvidia/nemo-fabric-adapter-contract +``` + +Use Node.js 20.18.3 or later and TypeScript 5.0 or later. Configure TypeScript +with `node16`, `nodenext`, or `bundler` module resolution so package export +subpaths resolve correctly. + +## Stable v1alpha2 contract + +The root entry point contains the negotiated descriptor, southbound agent +configuration, and runtime context types: + +```typescript +import { ADAPTER_CONTRACT_VERSION } from "@nvidia/nemo-fabric-adapter-contract"; +import type { + AdapterDescriptor, + AgentConfig, + RuntimeContext, +} from "@nvidia/nemo-fabric-adapter-contract"; + +const descriptor: AdapterDescriptor = { + contract_version: ADAPTER_CONTRACT_VERSION, + adapter_id: "pi", + harness: "pi", + adapter_kind: "process", +}; +``` + +Property names intentionally match the JSON wire format and remain +`snake_case`. Optional properties are distinct from properties whose value may +be `null`. + +## Preview invocation types + +Request and result types are not part of the negotiated v1alpha2 lifecycle +transport. Import them through the explicit preview entry point: + +```typescript +import type { + AgentRunRequest, + AgentRunResult, +} from "@nvidia/nemo-fabric-adapter-contract/preview"; +``` + +Do not depend on preview types as a stable transport contract. In particular, +token counts originate from JSON Schema `uint64` values but are represented as +JavaScript `number`; values greater than `Number.MAX_SAFE_INTEGER` cannot be +represented exactly. + +## JSON Schemas + +The package bundles byte-identical copies of the canonical schemas. Consumers +that need runtime validation can use their validator of choice, for example: + +```typescript +import agentConfigSchema from "@nvidia/nemo-fabric-adapter-contract/schemas/agent-config" with { type: "json" }; +``` + +The TypeScript declarations provide compile-time checking only. They do not +apply schema defaults or enforce runtime constraints such as string patterns, +numeric ranges, or relative paths. + +## Development + +From this directory: + +```bash +npm ci +npm run generate:check +npm test +``` + +Run `npm run generate` after the canonical schemas change. Generated source and +schema copies are committed so drift is reviewable. diff --git a/typescript/adapter-contract/package-lock.json b/typescript/adapter-contract/package-lock.json new file mode 100644 index 00000000..bb83c562 --- /dev/null +++ b/typescript/adapter-contract/package-lock.json @@ -0,0 +1,232 @@ +{ + "name": "@nvidia/nemo-fabric-adapter-contract", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@nvidia/nemo-fabric-adapter-contract", + "version": "0.2.0", + "license": "Apache-2.0", + "devDependencies": { + "json-schema-to-typescript": "15.0.4", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=20.18.3" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.9.3", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", + "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz", + "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-to-typescript": { + "version": "15.0.4", + "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-15.0.4.tgz", + "integrity": "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^11.5.5", + "@types/json-schema": "^7.0.15", + "@types/lodash": "^4.17.7", + "is-glob": "^4.0.3", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "prettier": "^3.2.5", + "tinyglobby": "^0.2.9" + }, + "bin": { + "json2ts": "dist/src/cli.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/typescript/adapter-contract/package.json b/typescript/adapter-contract/package.json new file mode 100644 index 00000000..08481d3a --- /dev/null +++ b/typescript/adapter-contract/package.json @@ -0,0 +1,57 @@ +{ + "name": "@nvidia/nemo-fabric-adapter-contract", + "version": "0.2.0", + "description": "Dependency-free TypeScript types for the NVIDIA NeMo Fabric adapter contract.", + "license": "Apache-2.0", + "type": "module", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./preview": { + "types": "./dist/preview.d.ts", + "import": "./dist/preview.js" + }, + "./schemas/*": "./schemas/*.schema.json", + "./package.json": "./package.json" + }, + "files": [ + "dist", + "schemas", + "README.md", + "LICENSE" + ], + "scripts": { + "generate": "node scripts/generate.mjs", + "generate:check": "node scripts/generate.mjs --check", + "build": "node scripts/clean.mjs && tsc -p tsconfig.build.json", + "test:generator": "node --test test/*.test.mjs", + "test:types": "tsc -p test/tsconfig.json --noEmit", + "pack:check": "npm run build && node scripts/check-package.mjs", + "test": "npm run generate:check && npm run test:generator && npm run test:types && npm run pack:check", + "prepack": "npm run generate:check && npm run build" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NVIDIA/NeMo-Fabric.git", + "directory": "typescript/adapter-contract" + }, + "bugs": { + "url": "https://github.com/NVIDIA/NeMo-Fabric/issues" + }, + "homepage": "https://github.com/NVIDIA/NeMo-Fabric/tree/main/typescript/adapter-contract#readme", + "engines": { + "node": ">=20.18.3" + }, + "devDependencies": { + "json-schema-to-typescript": "15.0.4", + "typescript": "5.9.3" + } +} diff --git a/typescript/adapter-contract/schemas/adapter-descriptor.schema.json b/typescript/adapter-contract/schemas/adapter-descriptor.schema.json new file mode 100644 index 00000000..9e117468 --- /dev/null +++ b/typescript/adapter-contract/schemas/adapter-descriptor.schema.json @@ -0,0 +1,365 @@ +{ + "$defs": { + "AdapterConfigField": { + "description": "Adapter-translated normalized NVIDIA NeMo Fabric configuration fields.", + "oneOf": [ + { + "const": "models", + "description": "Normalized model selection and credentials.", + "type": "string" + }, + { + "const": "models.base_url", + "description": "Custom model endpoint.", + "type": "string" + }, + { + "const": "models.temperature", + "description": "Model temperature.", + "type": "string" + }, + { + "const": "instructions.system", + "description": "Portable system instructions.", + "type": "string" + }, + { + "const": "runtime.max_turns", + "description": "Per-invocation harness turn limit.", + "type": "string" + }, + { + "const": "tools.enabled", + "description": "Adapter-native tool names to expose.", + "type": "string" + }, + { + "const": "tools.definitions", + "description": "Named normalized tool and tool-group definitions.", + "type": "string" + }, + { + "const": "tools.blocked", + "description": "Adapter-native tool names to block.", + "type": "string" + }, + { + "const": "mcp", + "description": "Harness-native MCP servers.", + "type": "string" + }, + { + "const": "mcp.tool_filters", + "description": "Per-server MCP tool allowlists and blocklists.", + "type": "string" + }, + { + "const": "skills", + "description": "Harness-native skills.", + "type": "string" + } + ] + }, + "AdapterConfigInput": { + "description": "Configuration object delivered to an adapter lifecycle host.", + "oneOf": [ + { + "const": "fabric_config", + "description": "Deliver the complete northbound `FabricConfig` for legacy adapters.", + "type": "string" + }, + { + "const": "agent_config", + "description": "Deliver the resolved southbound `AgentConfig` contract.", + "type": "string" + } + ] + }, + "AdapterConfigSupport": { + "additionalProperties": true, + "description": "Adapter config support.", + "properties": { + "accepts": { + "description": "Normalized NVIDIA NeMo Fabric config areas or policy paths accepted by this adapter.", + "items": { + "$ref": "#/$defs/AdapterConfigField" + }, + "type": "array" + }, + "generates": { + "description": "Harness-native files generated by this adapter.", + "items": { + "type": "string" + }, + "type": "array" + }, + "input": { + "$ref": "#/$defs/AdapterConfigInput", + "default": "fabric_config", + "description": "Configuration object delivered to the adapter lifecycle host." + } + }, + "type": "object" + }, + "AdapterKind": { + "description": "Adapter implementation kind.", + "oneOf": [ + { + "const": "process", + "description": "Launch and supervise a persistent adapter process.", + "type": "string" + }, + { + "const": "http", + "description": "Connect to a service or HTTP-backed harness.", + "type": "string" + }, + { + "const": "python", + "description": "Launch and supervise a persistent Python adapter host.", + "type": "string" + }, + { + "const": "native_plugin", + "description": "Delegate to a harness-native plugin package.", + "type": "string" + } + ] + }, + "AdapterRequirements": { + "additionalProperties": true, + "description": "Adapter runtime requirements.", + "properties": { + "binaries": { + "description": "Required binaries.", + "items": { + "type": "string" + }, + "type": "array" + }, + "env": { + "description": "Required environment variables.", + "items": { + "type": "string" + }, + "type": "array" + }, + "files": { + "description": "Required files.", + "items": { + "type": "string" + }, + "type": "array" + }, + "plugin_hooks": { + "description": "Required harness plugin hooks.", + "items": { + "type": "string" + }, + "type": "array" + }, + "services": { + "description": "Required services.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "AdapterTelemetryProviderSupport": { + "additionalProperties": true, + "description": "Telemetry capabilities for one adapter-supported provider.", + "properties": { + "integration_modes": { + "description": "Integration modes implemented by the adapter for this provider.", + "items": { + "type": "string" + }, + "type": "array" + }, + "outputs": { + "description": "Telemetry outputs the adapter can produce or forward for this provider.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "AdapterTelemetrySupport": { + "additionalProperties": true, + "description": "Adapter telemetry support.", + "properties": { + "providers": { + "additionalProperties": { + "$ref": "#/$defs/AdapterTelemetryProviderSupport" + }, + "description": "Provider-specific telemetry capabilities supported by this adapter.", + "propertyNames": { + "enum": [ + "relay", + "native" + ] + }, + "type": "object" + } + }, + "type": "object" + }, + "RuntimeCapabilities": { + "description": "Lifecycle behavior implemented by a resolved runtime path.", + "properties": { + "cancellation": { + "default": false, + "description": "Whether an in-flight invocation can be cancelled.", + "type": "boolean" + }, + "metadata": { + "additionalProperties": true, + "description": "Additional adapter-specific capability metadata.", + "type": "object" + }, + "service": { + "default": false, + "description": "Whether the selected runtime supports service lifecycle operations.", + "type": "boolean" + }, + "streaming": { + "default": false, + "description": "Whether invocations can emit progressive output.", + "type": "boolean" + }, + "updates": { + "default": false, + "description": "Whether a running runtime can accept config updates.", + "type": "boolean" + } + }, + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": true, + "description": "Language-neutral adapter descriptor for a harness integration.", + "properties": { + "adapter_id": { + "description": "Unique id for this adapter implementation.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + }, + "adapter_kind": { + "$ref": "#/$defs/AdapterKind", + "description": "Adapter implementation kind." + }, + "capabilities": { + "$ref": "#/$defs/RuntimeCapabilities", + "default": { + "cancellation": false, + "service": false, + "streaming": false, + "updates": false + }, + "description": "Runtime lifecycle operations supported by this adapter." + }, + "config": { + "$ref": "#/$defs/AdapterConfigSupport", + "default": { + "input": "fabric_config" + }, + "description": "NeMo Fabric config areas this adapter consumes or generates." + }, + "contract_version": { + "const": "fabric.adapter/v1alpha2", + "description": "Adapter descriptor contract version.", + "minLength": 1, + "type": "string" + }, + "extension_schemas": { + "additionalProperties": { + "additionalProperties": true, + "type": "object" + }, + "description": "JSON Schemas for adapter-owned `extensions` at southbound block types.", + "propertyNames": { + "enum": [ + "agent_config", + "harness", + "model", + "instructions", + "instruction", + "runtime", + "skills", + "mcp", + "mcp_server", + "tools", + "tool_definition", + "workflow", + "workflow_entrypoint", + "run_request", + "run_result", + "run_error", + "artifact", + "usage" + ] + }, + "type": "object" + }, + "harness": { + "description": "Stable machine-readable harness identifier implemented by this adapter.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + }, + "requirements": { + "$ref": "#/$defs/AdapterRequirements", + "default": {}, + "description": "Runtime requirements." + }, + "runner": { + "additionalProperties": true, + "description": "Generic runner defaults consumed by the selected runtime adapter.", + "type": "object" + }, + "settings_schema": { + "additionalProperties": true, + "description": "JSON Schema for adapter-owned `harness.settings`.", + "type": [ + "object", + "null" + ] + }, + "telemetry": { + "$ref": "#/$defs/AdapterTelemetrySupport", + "default": {}, + "description": "Telemetry support declared by this adapter." + }, + "tool_definition_schema": { + "additionalProperties": true, + "description": "JSON Schema applied to every normalized `FabricConfig.tools.definitions` entry.", + "type": [ + "object", + "null" + ] + }, + "workflow_schema": { + "additionalProperties": true, + "description": "JSON Schema for adapter-owned `FabricConfig.workflow`.", + "type": [ + "object", + "null" + ] + } + }, + "required": [ + "contract_version", + "adapter_id", + "harness", + "adapter_kind" + ], + "title": "AdapterDescriptor", + "type": "object" +} \ No newline at end of file diff --git a/typescript/adapter-contract/schemas/agent-config.schema.json b/typescript/adapter-contract/schemas/agent-config.schema.json new file mode 100644 index 00000000..2aad9230 --- /dev/null +++ b/typescript/adapter-contract/schemas/agent-config.schema.json @@ -0,0 +1,469 @@ +{ + "$defs": { + "AgentHarnessConfig": { + "additionalProperties": false, + "description": "Adapter-owned target settings projected from `FabricConfig.harness`.", + "properties": { + "extensions": { + "additionalProperties": true, + "description": "Adapter-owned harness fields.", + "type": "object" + }, + "settings": { + "additionalProperties": true, + "description": "Target-specific settings validated by the selected adapter descriptor.", + "type": "object" + } + }, + "type": "object" + }, + "AgentInstructionConfig": { + "additionalProperties": false, + "description": "One normalized instruction value projected to an adapter target.", + "properties": { + "content": { + "description": "Instruction text.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + }, + "extensions": { + "additionalProperties": true, + "description": "Adapter-owned instruction fields.", + "type": "object" + }, + "mode": { + "$ref": "#/$defs/InstructionMode", + "default": "replace", + "description": "How the instruction is applied." + } + }, + "required": [ + "content" + ], + "type": "object" + }, + "AgentInstructionsConfig": { + "additionalProperties": false, + "description": "Normalized instructions projected to an adapter target.", + "properties": { + "extensions": { + "additionalProperties": true, + "description": "Adapter-owned instruction categories.", + "type": "object" + }, + "system": { + "anyOf": [ + { + "$ref": "#/$defs/AgentInstructionConfig" + }, + { + "type": "null" + } + ], + "description": "System instructions for the selected adapter target." + } + }, + "type": "object" + }, + "AgentMcpConfig": { + "additionalProperties": false, + "description": "Named MCP servers routed to an adapter target.", + "properties": { + "extensions": { + "additionalProperties": true, + "description": "Adapter-owned MCP fields.", + "type": "object" + }, + "servers": { + "additionalProperties": { + "$ref": "#/$defs/AgentMcpServerConfig" + }, + "description": "MCP servers keyed by normalized server name.", + "type": "object" + } + }, + "type": "object" + }, + "AgentMcpServerConfig": { + "additionalProperties": false, + "description": "One MCP server routed to an adapter target.", + "properties": { + "allowed_tools": { + "description": "MCP tool names to expose. `None` exposes every discovered tool.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "args": { + "description": "Command-line arguments passed to an MCP stdio process.", + "items": { + "type": "string" + }, + "type": "array" + }, + "blocked_tools": { + "description": "MCP tool names blocked after applying the optional allowlist.", + "items": { + "type": "string" + }, + "type": "array" + }, + "env": { + "additionalProperties": { + "type": "string" + }, + "description": "Environment variables passed to an MCP stdio process.", + "type": "object" + }, + "extensions": { + "additionalProperties": true, + "description": "Adapter-owned MCP server fields.", + "type": "object" + }, + "transport": { + "description": "MCP transport identifier.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + }, + "url": { + "description": "MCP server URL for network transports or executable for stdio.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + "required": [ + "transport", + "url" + ], + "type": "object" + }, + "AgentModelConfig": { + "additionalProperties": false, + "description": "Configuration for one named model role projected to an adapter target.", + "properties": { + "api_key_env": { + "description": "Environment variable containing the provider credential.", + "type": [ + "string", + "null" + ] + }, + "base_url": { + "description": "Optional provider API base URL.", + "type": [ + "string", + "null" + ] + }, + "extensions": { + "additionalProperties": true, + "description": "Adapter-owned model fields.", + "type": "object" + }, + "model": { + "description": "Provider model identifier.", + "minLength": 1, + "type": "string" + }, + "provider": { + "description": "Model provider identifier.", + "minLength": 1, + "type": "string" + }, + "settings": { + "additionalProperties": true, + "description": "Provider-specific model settings.", + "type": "object" + }, + "temperature": { + "description": "Optional model temperature.", + "format": "double", + "type": [ + "number", + "null" + ] + } + }, + "required": [ + "provider", + "model" + ], + "type": "object" + }, + "AgentRuntimeConfig": { + "additionalProperties": false, + "description": "Runtime behavior applied by an adapter target.", + "properties": { + "extensions": { + "additionalProperties": true, + "description": "Adapter-owned runtime fields.", + "type": "object" + }, + "max_turns": { + "description": "Maximum number of agent turns allowed for one invocation.", + "format": "uint32", + "maximum": 4294967295, + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "AgentSkillConfig": { + "additionalProperties": false, + "description": "Skill paths made available to an adapter target.", + "properties": { + "extensions": { + "additionalProperties": true, + "description": "Adapter-owned skill fields.", + "type": "object" + }, + "paths": { + "description": "Skill paths resolved for the task environment.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "AgentToolDefinition": { + "additionalProperties": false, + "description": "One named tool or tool-group definition resolved by an adapter.", + "properties": { + "extensions": { + "additionalProperties": true, + "description": "Adapter-owned tool-definition fields.", + "type": "object" + }, + "kind": { + "description": "Resolution semantics declared by the selected adapter descriptor.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + }, + "ref": { + "description": "Executable or factory reference interpreted under `kind`.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + }, + "settings": { + "additionalProperties": true, + "description": "Definition-specific construction settings.", + "type": "object" + } + }, + "required": [ + "kind", + "ref" + ], + "type": "object" + }, + "AgentToolsConfig": { + "additionalProperties": false, + "description": "Named tool definitions and effective adapter-target tool policy.", + "properties": { + "blocked": { + "description": "Named tools to block.", + "items": { + "type": "string" + }, + "type": "array" + }, + "definitions": { + "additionalProperties": { + "$ref": "#/$defs/AgentToolDefinition" + }, + "description": "Tool and tool-group definitions keyed by normalized name.", + "type": "object" + }, + "enabled": { + "description": "Named tools to expose. `None` preserves the adapter-target default.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "extensions": { + "additionalProperties": true, + "description": "Adapter-owned tool fields.", + "type": "object" + } + }, + "type": "object" + }, + "AgentWorkflowConfig": { + "additionalProperties": false, + "description": "Custom agent or workflow selection and construction settings.", + "properties": { + "entrypoint": { + "$ref": "#/$defs/AgentWorkflowEntrypointConfig", + "description": "Entry point resolved by the selected adapter." + }, + "extensions": { + "additionalProperties": true, + "description": "Adapter-owned workflow fields.", + "type": "object" + }, + "settings": { + "additionalProperties": true, + "description": "Agent-specific construction settings.", + "type": "object" + } + }, + "required": [ + "entrypoint" + ], + "type": "object" + }, + "AgentWorkflowEntrypointConfig": { + "additionalProperties": false, + "description": "Adapter-declared resolution semantics for one custom agent or workflow.", + "properties": { + "extensions": { + "additionalProperties": true, + "description": "Adapter-owned entry-point fields.", + "type": "object" + }, + "kind": { + "description": "Resolution semantics declared by the selected adapter descriptor.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + }, + "ref": { + "description": "Executable or factory reference interpreted under `kind`.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + "required": [ + "kind", + "ref" + ], + "type": "object" + }, + "InstructionMode": { + "description": "How an instruction value is applied to the selected harness.", + "oneOf": [ + { + "const": "replace", + "description": "Replace the harness default instruction value.", + "type": "string" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "Configuration projected southbound to one adapter target.", + "properties": { + "extensions": { + "additionalProperties": true, + "description": "Adapter-owned fields validated against the selected adapter descriptor.", + "type": "object" + }, + "harness": { + "anyOf": [ + { + "$ref": "#/$defs/AgentHarnessConfig" + }, + { + "type": "null" + } + ], + "description": "Adapter-owned target settings." + }, + "instructions": { + "anyOf": [ + { + "$ref": "#/$defs/AgentInstructionsConfig" + }, + { + "type": "null" + } + ], + "description": "Normalized instructions applied by the adapter target." + }, + "mcp": { + "anyOf": [ + { + "$ref": "#/$defs/AgentMcpConfig" + }, + { + "type": "null" + } + ], + "description": "MCP servers routed to the adapter target." + }, + "models": { + "additionalProperties": { + "$ref": "#/$defs/AgentModelConfig" + }, + "description": "Named model roles applied by the adapter target.", + "type": "object" + }, + "runtime": { + "anyOf": [ + { + "$ref": "#/$defs/AgentRuntimeConfig" + }, + { + "type": "null" + } + ], + "description": "Adapter-applied runtime behavior." + }, + "skills": { + "anyOf": [ + { + "$ref": "#/$defs/AgentSkillConfig" + }, + { + "type": "null" + } + ], + "description": "Skills made available to the adapter target." + }, + "tools": { + "anyOf": [ + { + "$ref": "#/$defs/AgentToolsConfig" + }, + { + "type": "null" + } + ], + "description": "Named tool definitions and effective tool policy." + }, + "workflow": { + "anyOf": [ + { + "$ref": "#/$defs/AgentWorkflowConfig" + }, + { + "type": "null" + } + ], + "description": "Custom agent or workflow selection and construction settings." + } + }, + "title": "AgentConfig", + "type": "object" +} \ No newline at end of file diff --git a/typescript/adapter-contract/schemas/agent-run-request.schema.json b/typescript/adapter-contract/schemas/agent-run-request.schema.json new file mode 100644 index 00000000..dd49fb3c --- /dev/null +++ b/typescript/adapter-contract/schemas/agent-run-request.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "Preview southbound invocation request.\n\nThe current local-host transport does not enforce this type. It will join\nthe negotiated adapter contract when typed invoke transport is implemented.", + "properties": { + "context": { + "additionalProperties": true, + "description": "Caller-provided task, rollout, workflow, or application context.", + "type": "object" + }, + "extensions": { + "additionalProperties": true, + "description": "Adapter-owned request fields.", + "type": "object" + }, + "input": { + "description": "Request payload for the adapter target." + } + }, + "required": [ + "input" + ], + "title": "AgentRunRequest", + "type": "object" +} \ No newline at end of file diff --git a/typescript/adapter-contract/schemas/agent-run-result.schema.json b/typescript/adapter-contract/schemas/agent-run-result.schema.json new file mode 100644 index 00000000..0aee5b47 --- /dev/null +++ b/typescript/adapter-contract/schemas/agent-run-result.schema.json @@ -0,0 +1,259 @@ +{ + "$defs": { + "AgentArtifact": { + "additionalProperties": false, + "description": "One artifact produced by an adapter target.", + "properties": { + "extensions": { + "additionalProperties": true, + "description": "Adapter-owned artifact fields.", + "type": "object" + }, + "kind": { + "description": "Artifact kind.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + }, + "media_type": { + "description": "Optional media type.", + "minLength": 1, + "pattern": "\\S", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "Logical artifact name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + }, + "path": { + "description": "Path relative to the artifact root supplied in `RuntimeContext`.", + "minLength": 1, + "not": { + "anyOf": [ + { + "pattern": "^[\\\\/]" + }, + { + "pattern": "^[A-Za-z]:" + }, + { + "pattern": "(^|[\\\\/])\\.\\.([\\\\/]|$)" + } + ] + }, + "type": "string" + } + }, + "required": [ + "name", + "kind", + "path" + ], + "type": "object" + }, + "AgentRunError": { + "additionalProperties": false, + "description": "Error reported by an adapter target.", + "properties": { + "code": { + "description": "Stable adapter error code.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + }, + "extensions": { + "additionalProperties": true, + "description": "Adapter-owned error fields.", + "type": "object" + }, + "message": { + "description": "Human-readable error message.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + }, + "retryable": { + "default": false, + "description": "Whether the adapter considers the failure safe for a consumer-level retry.", + "type": "boolean" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "AgentRunStatus": { + "description": "Completion status reported by an adapter target.", + "oneOf": [ + { + "const": "succeeded", + "description": "The adapter target completed successfully.", + "type": "string" + }, + { + "const": "failed", + "description": "The adapter target completed with a failure.", + "type": "string" + }, + { + "const": "cancelled", + "description": "The adapter target cancelled the invocation.", + "type": "string" + } + ] + }, + "AgentUsage": { + "additionalProperties": false, + "description": "Normalized model usage reported by an adapter target.", + "properties": { + "cost_usd": { + "description": "Invocation cost in US dollars when reported by the provider.", + "format": "double", + "minimum": 0.0, + "type": [ + "number", + "null" + ] + }, + "extensions": { + "additionalProperties": true, + "description": "Adapter-owned usage fields.", + "type": "object" + }, + "input_tokens": { + "description": "Input tokens consumed by the invocation.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "output_tokens": { + "description": "Output tokens produced by the invocation.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "total_tokens": { + "description": "Total tokens reported by the provider.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "status": { + "const": "failed" + } + }, + "required": [ + "status" + ] + }, + "then": { + "properties": { + "error": { + "$ref": "#/$defs/AgentRunError" + } + }, + "required": [ + "error" + ] + } + }, + { + "if": { + "properties": { + "status": { + "const": "succeeded" + } + }, + "required": [ + "status" + ] + }, + "then": { + "not": { + "required": [ + "error" + ] + } + } + } + ], + "description": "Preview southbound terminal result.\n\nThe current local-host transport does not decode this type. It will join\nthe negotiated adapter contract when typed invoke transport is implemented.", + "properties": { + "artifacts": { + "description": "Artifacts produced by the adapter target.", + "items": { + "$ref": "#/$defs/AgentArtifact" + }, + "type": "array" + }, + "error": { + "anyOf": [ + { + "$ref": "#/$defs/AgentRunError" + }, + { + "type": "null" + } + ], + "description": "Adapter error when the invocation did not succeed." + }, + "extensions": { + "additionalProperties": true, + "description": "Adapter-owned result fields.", + "type": "object" + }, + "output": { + "description": "Primary adapter-target output." + }, + "status": { + "$ref": "#/$defs/AgentRunStatus", + "description": "Adapter-target completion status." + }, + "usage": { + "anyOf": [ + { + "$ref": "#/$defs/AgentUsage" + }, + { + "type": "null" + } + ], + "description": "Normalized model usage when reported by the adapter target." + } + }, + "required": [ + "status", + "output" + ], + "title": "AgentRunResult", + "type": "object" +} \ No newline at end of file diff --git a/typescript/adapter-contract/schemas/runtime-context.schema.json b/typescript/adapter-contract/schemas/runtime-context.schema.json new file mode 100644 index 00000000..e74831bb --- /dev/null +++ b/typescript/adapter-contract/schemas/runtime-context.schema.json @@ -0,0 +1,229 @@ +{ + "$defs": { + "ArtifactManifest": { + "additionalProperties": false, + "description": "Manifest of run artifacts.", + "properties": { + "artifacts": { + "description": "Artifact entries.", + "items": { + "$ref": "#/$defs/ArtifactRef" + }, + "type": "array" + }, + "root": { + "description": "Artifact root directory.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "ArtifactRef": { + "additionalProperties": false, + "description": "Reference to one artifact.", + "properties": { + "kind": { + "description": "Artifact kind.", + "type": "string" + }, + "media_type": { + "description": "Optional media type.", + "type": [ + "string", + "null" + ] + }, + "metadata": { + "additionalProperties": true, + "description": "Artifact-specific metadata preserved across the Rust and Python SDK boundary.", + "type": "object" + }, + "name": { + "description": "Logical artifact name.", + "type": "string" + }, + "path": { + "description": "Artifact path.", + "type": "string" + } + }, + "required": [ + "name", + "kind", + "path" + ], + "type": "object" + }, + "ControlLocation": { + "description": "Where NeMo Fabric control code runs relative to the environment.", + "oneOf": [ + { + "const": "external_control", + "description": "NeMo Fabric runs on the host/control plane and starts or connects to the harness in the environment.", + "type": "string" + }, + { + "const": "in_env_control", + "description": "NeMo Fabric runs inside the prepared environment with the harness.", + "type": "string" + } + ] + }, + "EnvironmentHandle": { + "additionalProperties": false, + "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 NeMo Fabric control code runs." + }, + "env": { + "additionalProperties": { + "type": "string" + }, + "description": "Environment variables visible to the harness and its tools.", + "type": "object" + }, + "environment_id": { + "description": "Environment handle id.", + "type": "string" + }, + "metadata": { + "additionalProperties": true, + "description": "Provider-specific metadata.", + "type": "object" + }, + "ownership": { + "$ref": "#/$defs/EnvironmentOwnership", + "description": "Whether NeMo 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 NeMo 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": "NeMo Fabric created or leased the environment resource and may release it.", + "type": "string" + } + ] + }, + "RuntimeTelemetryContext": { + "additionalProperties": false, + "description": "Runtime telemetry config passed to adapters.", + "properties": { + "config_path": { + "description": "Generated Relay config path for this invocation.", + "type": [ + "string", + "null" + ] + }, + "env": { + "additionalProperties": { + "type": "string" + }, + "description": "Environment variables NeMo Fabric applies while invoking the adapter.", + "type": "object" + }, + "metadata": { + "additionalProperties": true, + "description": "Additional telemetry metadata surfaced to consumers and adapters.", + "type": "object" + }, + "relay_enabled": { + "description": "Whether Relay is enabled for this invocation.", + "type": "boolean" + } + }, + "required": [ + "relay_enabled" + ], + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "Context generated for one invocation of a started runtime.", + "properties": { + "artifacts": { + "$ref": "#/$defs/ArtifactManifest", + "description": "Artifact manifest visible to the adapter at invocation start." + }, + "environment": { + "$ref": "#/$defs/EnvironmentHandle", + "description": "Prepared execution environment." + }, + "invocation_id": { + "description": "Invocation handle id.", + "type": "string" + }, + "request_id": { + "description": "Request id.", + "type": "string" + }, + "runtime_id": { + "description": "Runtime handle id.", + "type": "string" + }, + "telemetry": { + "anyOf": [ + { + "$ref": "#/$defs/RuntimeTelemetryContext" + }, + { + "type": "null" + } + ], + "description": "Runtime telemetry context generated for this invocation." + } + }, + "required": [ + "runtime_id", + "invocation_id", + "request_id", + "environment", + "artifacts" + ], + "title": "RuntimeContext", + "type": "object" +} \ No newline at end of file diff --git a/typescript/adapter-contract/scripts/check-package.mjs b/typescript/adapter-contract/scripts/check-package.mjs new file mode 100644 index 00000000..6f30a52f --- /dev/null +++ b/typescript/adapter-contract/scripts/check-package.mjs @@ -0,0 +1,187 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const npmCli = process.env.npm_execpath; +if (npmCli === undefined) { + throw new Error("npm_execpath is required; run this check through npm"); +} + +const temporaryRoot = await mkdtemp(join(tmpdir(), "nemo-fabric-ts-contract-")); +try { + const packOutput = execFileSync( + process.execPath, + [ + npmCli, + "pack", + "--json", + "--ignore-scripts", + "--pack-destination", + temporaryRoot, + ], + { cwd: packageRoot, encoding: "utf8" }, + ); + const [packResult] = JSON.parse(packOutput); + const packedFiles = new Set(packResult.files.map((file) => file.path)); + const expectedFiles = new Set([ + "LICENSE", + "README.md", + "dist/generated/adapter-descriptor.d.ts", + "dist/generated/adapter-descriptor.js", + "dist/generated/agent-config.d.ts", + "dist/generated/agent-config.js", + "dist/generated/agent-run-request.d.ts", + "dist/generated/agent-run-request.js", + "dist/generated/agent-run-result.d.ts", + "dist/generated/agent-run-result.js", + "dist/generated/runtime-context.d.ts", + "dist/generated/runtime-context.js", + "dist/index.d.ts", + "dist/index.js", + "dist/json.d.ts", + "dist/json.js", + "dist/preview.d.ts", + "dist/preview.js", + "dist/version.d.ts", + "dist/version.js", + "package.json", + "schemas/adapter-descriptor.schema.json", + "schemas/agent-config.schema.json", + "schemas/agent-run-request.schema.json", + "schemas/agent-run-result.schema.json", + "schemas/runtime-context.schema.json", + ]); + const missingFiles = [...expectedFiles].filter( + (file) => !packedFiles.has(file), + ); + if (missingFiles.length > 0) { + throw new Error(`Packed artifact is missing: ${missingFiles.join(", ")}`); + } + const unexpectedFiles = [...packedFiles].filter( + (file) => !expectedFiles.has(file), + ); + if (unexpectedFiles.length > 0) { + throw new Error( + `Packed artifact contains unexpected files: ${unexpectedFiles.join(", ")}`, + ); + } + + const tarball = join(temporaryRoot, packResult.filename); + const consumerRoot = join(temporaryRoot, "consumer"); + await writeFile( + join(temporaryRoot, "package.json"), + `${JSON.stringify({ private: true, type: "module" }, null, 2)}\n`, + ); + execFileSync( + process.execPath, + [ + npmCli, + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--package-lock=false", + tarball, + ], + { cwd: temporaryRoot, stdio: "inherit" }, + ); + + await mkdir(consumerRoot, { recursive: true }); + await writeFile( + join(consumerRoot, "package.json"), + `${JSON.stringify({ private: true, type: "module" }, null, 2)}\n`, + ); + await writeFile( + join(consumerRoot, "tsconfig.json"), + `${JSON.stringify( + { + compilerOptions: { + exactOptionalPropertyTypes: true, + module: "NodeNext", + moduleResolution: "NodeNext", + noUncheckedIndexedAccess: true, + outDir: "dist", + resolveJsonModule: true, + strict: true, + target: "ES2022", + }, + include: ["index.ts"], + }, + null, + 2, + )}\n`, + ); + await writeFile( + join(consumerRoot, "index.ts"), + `import { ADAPTER_CONTRACT_VERSION } from "@nvidia/nemo-fabric-adapter-contract"; +import agentConfigSchema from "@nvidia/nemo-fabric-adapter-contract/schemas/agent-config" with { type: "json" }; +import type { AdapterDescriptor } from "@nvidia/nemo-fabric-adapter-contract"; +import type { AgentRunResult } from "@nvidia/nemo-fabric-adapter-contract/preview"; + +const descriptor: AdapterDescriptor = { + adapter_id: "example", + adapter_kind: "process", + contract_version: ADAPTER_CONTRACT_VERSION, + harness: "example", +}; +const result: AgentRunResult = { + output: ["ok", null], + status: "succeeded", +}; +if ( + descriptor.contract_version !== ADAPTER_CONTRACT_VERSION || + result.status !== "succeeded" || + agentConfigSchema.title !== "AgentConfig" +) { + throw new Error("Unexpected adapter contract values"); +} +`, + ); + + const tsc = resolve(packageRoot, "node_modules/typescript/bin/tsc"); + execFileSync( + process.execPath, + [tsc, "-p", join(consumerRoot, "tsconfig.json")], + { + cwd: temporaryRoot, + stdio: "inherit", + }, + ); + execFileSync(process.execPath, [join(consumerRoot, "dist/index.js")], { + cwd: temporaryRoot, + stdio: "inherit", + }); + + const installedManifest = JSON.parse( + await readFile( + join( + temporaryRoot, + "node_modules/@nvidia/nemo-fabric-adapter-contract/package.json", + ), + "utf8", + ), + ); + for (const field of [ + "dependencies", + "optionalDependencies", + "peerDependencies", + "bundledDependencies", + ]) { + if (installedManifest[field] !== undefined) { + throw new Error(`Published package must not declare ${field}`); + } + } + for (const hook of ["preinstall", "install", "postinstall"]) { + if (installedManifest.scripts?.[hook] !== undefined) { + throw new Error(`Published package must not define an ${hook} hook`); + } + } +} finally { + await rm(temporaryRoot, { recursive: true, force: true }); +} diff --git a/typescript/adapter-contract/scripts/clean.mjs b/typescript/adapter-contract/scripts/clean.mjs new file mode 100644 index 00000000..9bf54c2d --- /dev/null +++ b/typescript/adapter-contract/scripts/clean.mjs @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { rm } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +await rm(resolve(packageRoot, "dist"), { recursive: true, force: true }); diff --git a/typescript/adapter-contract/scripts/generate.mjs b/typescript/adapter-contract/scripts/generate.mjs new file mode 100644 index 00000000..741c688f --- /dev/null +++ b/typescript/adapter-contract/scripts/generate.mjs @@ -0,0 +1,425 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { readFile, mkdir, readdir, writeFile } from "node:fs/promises"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { compile } from "json-schema-to-typescript"; + +import { + assertAdapterSchemaInventory, + assertRunResultConditionals, +} from "./projection-guards.mjs"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repositoryRoot = resolve(packageRoot, "../.."); +const schemaDirectory = resolve(repositoryRoot, "schemas/adapter-contract"); +const checkOnly = process.argv.slice(2).includes("--check"); + +const unexpectedArguments = process.argv + .slice(2) + .filter((argument) => argument !== "--check"); +if (unexpectedArguments.length > 0) { + throw new Error(`Unexpected arguments: ${unexpectedArguments.join(", ")}`); +} + +const banner = `// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// This file is generated from the canonical adapter-contract JSON Schemas. +// Do not edit it directly; run \`npm run generate\` instead.`; + +const schemaSpecs = [ + { + name: "adapter-descriptor", + output: "adapter-descriptor.ts", + project: projectAdapterDescriptor, + }, + { name: "agent-config", output: "agent-config.ts" }, + { + name: "agent-run-request", + output: "agent-run-request.ts", + project: projectRunRequest, + }, + { + name: "agent-run-result", + output: "agent-run-result.ts", + generate: generateRunResult, + }, + { name: "runtime-context", output: "runtime-context.ts" }, +]; + +const pendingFiles = new Map(); +let contractVersion; + +const schemaInventory = (await readdir(schemaDirectory, { withFileTypes: true })) + .filter( + (entry) => entry.isFile() && entry.name.endsWith(".schema.json"), + ) + .map((entry) => entry.name); +assertAdapterSchemaInventory( + schemaInventory, + schemaSpecs.map((spec) => `${spec.name}.schema.json`), +); + +for (const spec of schemaSpecs) { + const sourcePath = resolve(schemaDirectory, `${spec.name}.schema.json`); + const schemaBytes = await readFile(sourcePath); + const schema = JSON.parse(schemaBytes.toString("utf8")); + + if (spec.name === "adapter-descriptor") { + contractVersion = requireString( + schema.properties?.contract_version?.const, + "adapter-descriptor contract_version.const", + ); + } + + const generated = spec.generate + ? await spec.generate(schema) + : await generateSchema(spec.project ? spec.project(schema) : schema); + + pendingFiles.set( + resolve(packageRoot, "src/generated", spec.output), + generated, + ); + pendingFiles.set( + resolve(packageRoot, "schemas", `${spec.name}.schema.json`), + schemaBytes, + ); +} + +pendingFiles.set(resolve(packageRoot, "src/json.ts"), generateJsonTypes()); +pendingFiles.set( + resolve(packageRoot, "src/version.ts"), + generateVersion(contractVersion), +); + +const mismatches = []; +for (const [path, expected] of pendingFiles) { + if (checkOnly) { + let actual; + try { + actual = await readFile(path); + } catch (error) { + if (error.code !== "ENOENT") { + throw error; + } + } + const expectedBytes = Buffer.isBuffer(expected) + ? expected + : Buffer.from(expected, "utf8"); + if (actual === undefined || !actual.equals(expectedBytes)) { + mismatches.push(relative(packageRoot, path)); + } + continue; + } + + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, expected); +} + +if (mismatches.length > 0) { + throw new Error( + `Generated adapter-contract files are stale:\n${mismatches + .map((path) => ` - ${path}`) + .join("\n")}\nRun \`npm run generate\` and commit the result.`, + ); +} + +function deepClone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function requireString(value, label) { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`Expected ${label} to be a non-empty string`); + } + return value; +} + +function requireStringEnum(value, label) { + if ( + !Array.isArray(value) || + value.length === 0 || + value.some((entry) => typeof entry !== "string") + ) { + throw new Error(`Expected ${label} to be a non-empty string enum`); + } + return value; +} + +function projectJsonTypes(value) { + if (Array.isArray(value)) { + value.forEach(projectJsonTypes); + return; + } + if (value === null || typeof value !== "object") { + return; + } + + const hasNamedProperties = + value.properties !== undefined && Object.keys(value.properties).length > 0; + const isObject = + value.type === "object" || + (Array.isArray(value.type) && value.type.includes("object")); + if (isObject && value.additionalProperties === true && !hasNamedProperties) { + value.tsType = + Array.isArray(value.type) && value.type.includes("null") + ? "JsonObject | null" + : "JsonObject"; + } + + Object.values(value).forEach(projectJsonTypes); +} + +function projectAdapterDescriptor(original) { + const schema = deepClone(original); + const properties = schema.properties; + const definitions = schema.$defs; + if (properties === undefined || definitions === undefined) { + throw new Error("AdapterDescriptor must define properties and $defs"); + } + + const contractVersion = requireString( + properties.contract_version?.const, + "adapter-descriptor contract_version.const", + ); + properties.contract_version.tsType = JSON.stringify(contractVersion); + + const extensionPoints = requireStringEnum( + properties.extension_schemas?.propertyNames?.enum, + "adapter-descriptor extension_schemas.propertyNames.enum", + ); + const telemetryProviders = requireStringEnum( + definitions.AdapterTelemetrySupport?.properties?.providers?.propertyNames + ?.enum, + "AdapterTelemetrySupport providers.propertyNames.enum", + ); + schema.__projectionPrefix = `${renderStringUnion( + "AdapterExtensionPoint", + extensionPoints, + "Southbound extension location supported by an adapter descriptor.", + )}\n\n${renderStringUnion( + "TelemetryProvider", + telemetryProviders, + "Telemetry provider supported by an adapter descriptor.", + )}`; + schema.__propertyNameProjections = [ + { + property: "extension_schemas", + type: "Partial>", + }, + { + property: "providers", + type: "Partial>", + }, + ]; + return schema; +} + +function projectRunRequest(original) { + const schema = deepClone(original); + const input = schema.properties?.input; + if ( + input === undefined || + Object.keys(input).some((key) => key !== "description") + ) { + throw new Error( + "AgentRunRequest.input is no longer unconstrained JSON; update the TypeScript projection", + ); + } + input.tsType = "JsonValue"; + return schema; +} + +async function generateRunResult(original) { + const schema = deepClone(original); + const properties = schema.properties; + if (properties === undefined) { + throw new Error("AgentRunResult must define properties"); + } + + const output = properties.output; + if ( + output === undefined || + Object.keys(output).some((key) => key !== "description") + ) { + throw new Error( + "AgentRunResult.output is no longer unconstrained JSON; update the TypeScript projection", + ); + } + output.tsType = "JsonValue"; + + const statuses = requireStringEnum( + schema.$defs?.AgentRunStatus?.oneOf?.map((choice) => choice.const), + "AgentRunStatus variants", + ); + const expectedStatuses = ["succeeded", "failed", "cancelled"]; + if (JSON.stringify(statuses) !== JSON.stringify(expectedStatuses)) { + throw new Error( + `AgentRunStatus changed from ${expectedStatuses.join(", ")}; update the discriminated union projection`, + ); + } + assertRunResultConditionals(schema.allOf); + + delete schema.allOf; + delete schema.$defs.AgentRunStatus; + delete properties.status; + delete properties.error; + schema.required = schema.required.filter( + (field) => field !== "status" && field !== "error", + ); + schema.title = "AgentRunResultCommon"; + schema.description = + "Fields shared by every preview terminal result variant."; + schema.__projectionPrefix = `/** Preview southbound terminal result. */ +export type AgentRunResult = + | AgentRunSucceeded + | AgentRunFailed + | AgentRunCancelled; + +/** Successful terminal result. Successful results cannot carry an error. */ +export interface AgentRunSucceeded extends AgentRunResultCommon { + status: "succeeded"; + error?: never; +} + +/** Failed terminal result. Failed results must carry a non-null error. */ +export interface AgentRunFailed extends AgentRunResultCommon { + status: "failed"; + error: AgentRunError; +} + +/** Cancelled terminal result. Cancellation details are optional. */ +export interface AgentRunCancelled extends AgentRunResultCommon { + status: "cancelled"; + error?: AgentRunError | null; +}`; + + return generateSchema(schema, { unreachableDefinitions: true }); +} + +function renderStringUnion(name, values, description) { + return `/** ${description} */\nexport type ${name} =\n${values + .map((value, index) => { + const suffix = index === values.length - 1 ? ";" : ""; + return ` | ${JSON.stringify(value)}${suffix}`; + }) + .join("\n")}`; +} + +async function generateSchema(input, options = {}) { + const schema = deepClone(input); + const projectionPrefix = schema.__projectionPrefix; + const propertyNameProjections = schema.__propertyNameProjections ?? []; + delete schema.__projectionPrefix; + delete schema.__propertyNameProjections; + projectJsonTypes(schema); + + let output = await compile(schema, schema.title, { + additionalProperties: true, + bannerComment: banner, + enableConstEnums: false, + format: true, + unknownAny: true, + unreachableDefinitions: options.unreachableDefinitions ?? false, + }); + + for (const projection of propertyNameProjections) { + output = replaceGeneratedPropertyType( + output, + projection.property, + projection.type, + ); + } + output = projectOpenInterfaces(output); + + const referencedJsonTypes = ["JsonObject", "JsonValue"].filter((name) => + new RegExp(`\\b${name}\\b`).test(output.slice(banner.length)), + ); + const additions = []; + if (referencedJsonTypes.length > 0) { + additions.push( + `import type { ${referencedJsonTypes.join(", ")} } from "../json.js";`, + ); + } + if (projectionPrefix !== undefined) { + additions.push(projectionPrefix); + } + if (additions.length > 0) { + if (!output.startsWith(banner)) { + throw new Error("json-schema-to-typescript changed banner placement"); + } + output = `${banner}\n\n${additions.join("\n\n")}${output.slice(banner.length)}`; + } + + const unsafeAny = output.match(/(:|<|\[|\|)\s*any\b/); + if (unsafeAny !== null) { + const matchIndex = unsafeAny.index ?? 0; + throw new Error( + `${schema.title} generated an unsafe any type near ${JSON.stringify( + output.slice(Math.max(0, matchIndex - 80), matchIndex + 80), + )}`, + ); + } + return output; +} + +function replaceGeneratedPropertyType(output, property, projectedType) { + const escapedProperty = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const pattern = new RegExp( + `( ${escapedProperty}\\?: )\\{\\n(?: {4}[^\\n]*\\n)+? \\};`, + "g", + ); + const matches = [...output.matchAll(pattern)]; + if (matches.length !== 1) { + throw new Error( + `Expected exactly one generated ${property} map, found ${matches.length}`, + ); + } + return output.replace(pattern, `$1${projectedType};`); +} + +function projectOpenInterfaces(output) { + const openInterface = + /export interface ([A-Za-z][A-Za-z0-9]*) \{\n((?:(?!^export (?:interface|type) ).)*?) \[k: string\]: unknown;\n\}/gms; + const projected = output.replace( + openInterface, + (_match, name, fields) => + `export type ${name} = {\n${fields}} & JsonObject;`, + ); + if (projected.includes("[k: string]: unknown;")) { + throw new Error( + "Generated output contains an open object that was not projected to JsonObject", + ); + } + return projected; +} + +function generateJsonTypes() { + return `${banner} + +/** A JSON scalar value. */ +export type JsonPrimitive = string | number | boolean | null; + +/** A JSON object with recursively JSON-compatible values. */ +export interface JsonObject { + [key: string]: JsonValue; +} + +/** A JSON array with recursively JSON-compatible values. */ +export type JsonArray = JsonValue[]; + +/** Any value representable by JSON. */ +export type JsonValue = JsonPrimitive | JsonObject | JsonArray; +`; +} + +function generateVersion(version) { + return `${banner} + +/** The negotiated adapter descriptor contract version. */ +export const ADAPTER_CONTRACT_VERSION = ${JSON.stringify(version)} as const; +`; +} diff --git a/typescript/adapter-contract/scripts/projection-guards.mjs b/typescript/adapter-contract/scripts/projection-guards.mjs new file mode 100644 index 00000000..b6dd7de9 --- /dev/null +++ b/typescript/adapter-contract/scripts/projection-guards.mjs @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isDeepStrictEqual } from "node:util"; + +const supportedRunResultConditionals = [ + { + if: { + properties: { status: { const: "failed" } }, + required: ["status"], + }, + then: { + properties: { error: { $ref: "#/$defs/AgentRunError" } }, + required: ["error"], + }, + }, + { + if: { + properties: { status: { const: "succeeded" } }, + required: ["status"], + }, + then: { not: { required: ["error"] } }, + }, +]; + +export function assertRunResultConditionals(actual) { + if (!isDeepStrictEqual(actual, supportedRunResultConditionals)) { + throw new Error( + "AgentRunResult conditionals changed; update the discriminated union projection", + ); + } +} + +export function assertAdapterSchemaInventory(actual, expected) { + const actualSorted = [...actual].sort(); + const expectedSorted = [...expected].sort(); + if (!isDeepStrictEqual(actualSorted, expectedSorted)) { + throw new Error( + `Adapter-contract schema inventory changed:\n expected: ${expectedSorted.join(", ")}\n actual: ${actualSorted.join(", ")}\nUpdate the TypeScript schema projection explicitly.`, + ); + } +} diff --git a/typescript/adapter-contract/src/generated/adapter-descriptor.ts b/typescript/adapter-contract/src/generated/adapter-descriptor.ts new file mode 100644 index 00000000..f368dee8 --- /dev/null +++ b/typescript/adapter-contract/src/generated/adapter-descriptor.ts @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// This file is generated from the canonical adapter-contract JSON Schemas. +// Do not edit it directly; run `npm run generate` instead. + +import type { JsonObject } from "../json.js"; + +/** Southbound extension location supported by an adapter descriptor. */ +export type AdapterExtensionPoint = + | "agent_config" + | "harness" + | "model" + | "instructions" + | "instruction" + | "runtime" + | "skills" + | "mcp" + | "mcp_server" + | "tools" + | "tool_definition" + | "workflow" + | "workflow_entrypoint" + | "run_request" + | "run_result" + | "run_error" + | "artifact" + | "usage"; + +/** Telemetry provider supported by an adapter descriptor. */ +export type TelemetryProvider = + | "relay" + | "native"; + +/** + * Adapter-translated normalized NVIDIA NeMo Fabric configuration fields. + */ +export type AdapterConfigField = + | "models" + | "models.base_url" + | "models.temperature" + | "instructions.system" + | "runtime.max_turns" + | "tools.enabled" + | "tools.definitions" + | "tools.blocked" + | "mcp" + | "mcp.tool_filters" + | "skills"; + +/** + * Language-neutral adapter descriptor for a harness integration. + */ +export type AdapterDescriptor = { + /** + * Unique id for this adapter implementation. + */ + adapter_id: string; + /** + * Adapter implementation kind. + */ + adapter_kind: "process" | "http" | "python" | "native_plugin"; + capabilities?: RuntimeCapabilities; + config?: AdapterConfigSupport; + /** + * Adapter descriptor contract version. + */ + contract_version: "fabric.adapter/v1alpha2"; + /** + * JSON Schemas for adapter-owned `extensions` at southbound block types. + */ + extension_schemas?: Partial>; + /** + * Stable machine-readable harness identifier implemented by this adapter. + */ + harness: string; + requirements?: AdapterRequirements; + /** + * Generic runner defaults consumed by the selected runtime adapter. + */ + runner?: JsonObject; + /** + * JSON Schema for adapter-owned `harness.settings`. + */ + settings_schema?: JsonObject | null; + telemetry?: AdapterTelemetrySupport; + /** + * JSON Schema applied to every normalized `FabricConfig.tools.definitions` entry. + */ + tool_definition_schema?: JsonObject | null; + /** + * JSON Schema for adapter-owned `FabricConfig.workflow`. + */ + workflow_schema?: JsonObject | null; +} & JsonObject; +/** + * Runtime lifecycle operations supported by this adapter. + */ +export type RuntimeCapabilities = { + /** + * Whether an in-flight invocation can be cancelled. + */ + cancellation?: boolean; + /** + * Additional adapter-specific capability metadata. + */ + metadata?: JsonObject; + /** + * Whether the selected runtime supports service lifecycle operations. + */ + service?: boolean; + /** + * Whether invocations can emit progressive output. + */ + streaming?: boolean; + /** + * Whether a running runtime can accept config updates. + */ + updates?: boolean; +} & JsonObject; +/** + * NeMo Fabric config areas this adapter consumes or generates. + */ +export type AdapterConfigSupport = { + /** + * Normalized NVIDIA NeMo Fabric config areas or policy paths accepted by this adapter. + */ + accepts?: AdapterConfigField[]; + /** + * Harness-native files generated by this adapter. + */ + generates?: string[]; + /** + * Configuration object delivered to the adapter lifecycle host. + */ + input?: "fabric_config" | "agent_config"; +} & JsonObject; +/** + * Runtime requirements. + */ +export type AdapterRequirements = { + /** + * Required binaries. + */ + binaries?: string[]; + /** + * Required environment variables. + */ + env?: string[]; + /** + * Required files. + */ + files?: string[]; + /** + * Required harness plugin hooks. + */ + plugin_hooks?: string[]; + /** + * Required services. + */ + services?: string[]; +} & JsonObject; +/** + * Telemetry support declared by this adapter. + */ +export type AdapterTelemetrySupport = { + /** + * Provider-specific telemetry capabilities supported by this adapter. + */ + providers?: Partial>; +} & JsonObject; +/** + * Telemetry capabilities for one adapter-supported provider. + */ +export type AdapterTelemetryProviderSupport = { + /** + * Integration modes implemented by the adapter for this provider. + */ + integration_modes?: string[]; + /** + * Telemetry outputs the adapter can produce or forward for this provider. + */ + outputs?: string[]; +} & JsonObject; diff --git a/typescript/adapter-contract/src/generated/agent-config.ts b/typescript/adapter-contract/src/generated/agent-config.ts new file mode 100644 index 00000000..528fe433 --- /dev/null +++ b/typescript/adapter-contract/src/generated/agent-config.ts @@ -0,0 +1,278 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// This file is generated from the canonical adapter-contract JSON Schemas. +// Do not edit it directly; run `npm run generate` instead. + +import type { JsonObject } from "../json.js"; + +/** + * Configuration projected southbound to one adapter target. + */ +export interface AgentConfig { + /** + * Adapter-owned fields validated against the selected adapter descriptor. + */ + extensions?: JsonObject; + /** + * Adapter-owned target settings. + */ + harness?: AgentHarnessConfig | null; + /** + * Normalized instructions applied by the adapter target. + */ + instructions?: AgentInstructionsConfig | null; + /** + * MCP servers routed to the adapter target. + */ + mcp?: AgentMcpConfig | null; + /** + * Named model roles applied by the adapter target. + */ + models?: { + [k: string]: AgentModelConfig; + }; + /** + * Adapter-applied runtime behavior. + */ + runtime?: AgentRuntimeConfig | null; + /** + * Skills made available to the adapter target. + */ + skills?: AgentSkillConfig | null; + /** + * Named tool definitions and effective tool policy. + */ + tools?: AgentToolsConfig | null; + /** + * Custom agent or workflow selection and construction settings. + */ + workflow?: AgentWorkflowConfig | null; +} +/** + * Adapter-owned target settings projected from `FabricConfig.harness`. + */ +export interface AgentHarnessConfig { + /** + * Adapter-owned harness fields. + */ + extensions?: JsonObject; + /** + * Target-specific settings validated by the selected adapter descriptor. + */ + settings?: JsonObject; +} +/** + * Normalized instructions projected to an adapter target. + */ +export interface AgentInstructionsConfig { + /** + * Adapter-owned instruction categories. + */ + extensions?: JsonObject; + /** + * System instructions for the selected adapter target. + */ + system?: AgentInstructionConfig | null; +} +/** + * One normalized instruction value projected to an adapter target. + */ +export interface AgentInstructionConfig { + /** + * Instruction text. + */ + content: string; + /** + * Adapter-owned instruction fields. + */ + extensions?: JsonObject; + /** + * How the instruction is applied. + */ + mode?: "replace"; +} +/** + * Named MCP servers routed to an adapter target. + */ +export interface AgentMcpConfig { + /** + * Adapter-owned MCP fields. + */ + extensions?: JsonObject; + /** + * MCP servers keyed by normalized server name. + */ + servers?: { + [k: string]: AgentMcpServerConfig; + }; +} +/** + * One MCP server routed to an adapter target. + */ +export interface AgentMcpServerConfig { + /** + * MCP tool names to expose. `None` exposes every discovered tool. + */ + allowed_tools?: string[] | null; + /** + * Command-line arguments passed to an MCP stdio process. + */ + args?: string[]; + /** + * MCP tool names blocked after applying the optional allowlist. + */ + blocked_tools?: string[]; + /** + * Environment variables passed to an MCP stdio process. + */ + env?: { + [k: string]: string; + }; + /** + * Adapter-owned MCP server fields. + */ + extensions?: JsonObject; + /** + * MCP transport identifier. + */ + transport: string; + /** + * MCP server URL for network transports or executable for stdio. + */ + url: string; +} +/** + * Configuration for one named model role projected to an adapter target. + */ +export interface AgentModelConfig { + /** + * Environment variable containing the provider credential. + */ + api_key_env?: string | null; + /** + * Optional provider API base URL. + */ + base_url?: string | null; + /** + * Adapter-owned model fields. + */ + extensions?: JsonObject; + /** + * Provider model identifier. + */ + model: string; + /** + * Model provider identifier. + */ + provider: string; + /** + * Provider-specific model settings. + */ + settings?: JsonObject; + /** + * Optional model temperature. + */ + temperature?: number | null; +} +/** + * Runtime behavior applied by an adapter target. + */ +export interface AgentRuntimeConfig { + /** + * Adapter-owned runtime fields. + */ + extensions?: JsonObject; + /** + * Maximum number of agent turns allowed for one invocation. + */ + max_turns?: number | null; +} +/** + * Skill paths made available to an adapter target. + */ +export interface AgentSkillConfig { + /** + * Adapter-owned skill fields. + */ + extensions?: JsonObject; + /** + * Skill paths resolved for the task environment. + */ + paths?: string[]; +} +/** + * Named tool definitions and effective adapter-target tool policy. + */ +export interface AgentToolsConfig { + /** + * Named tools to block. + */ + blocked?: string[]; + /** + * Tool and tool-group definitions keyed by normalized name. + */ + definitions?: { + [k: string]: AgentToolDefinition; + }; + /** + * Named tools to expose. `None` preserves the adapter-target default. + */ + enabled?: string[] | null; + /** + * Adapter-owned tool fields. + */ + extensions?: JsonObject; +} +/** + * One named tool or tool-group definition resolved by an adapter. + */ +export interface AgentToolDefinition { + /** + * Adapter-owned tool-definition fields. + */ + extensions?: JsonObject; + /** + * Resolution semantics declared by the selected adapter descriptor. + */ + kind: string; + /** + * Executable or factory reference interpreted under `kind`. + */ + ref: string; + /** + * Definition-specific construction settings. + */ + settings?: JsonObject; +} +/** + * Custom agent or workflow selection and construction settings. + */ +export interface AgentWorkflowConfig { + entrypoint: AgentWorkflowEntrypointConfig; + /** + * Adapter-owned workflow fields. + */ + extensions?: JsonObject; + /** + * Agent-specific construction settings. + */ + settings?: JsonObject; +} +/** + * Entry point resolved by the selected adapter. + */ +export interface AgentWorkflowEntrypointConfig { + /** + * Adapter-owned entry-point fields. + */ + extensions?: JsonObject; + /** + * Resolution semantics declared by the selected adapter descriptor. + */ + kind: string; + /** + * Executable or factory reference interpreted under `kind`. + */ + ref: string; +} diff --git a/typescript/adapter-contract/src/generated/agent-run-request.ts b/typescript/adapter-contract/src/generated/agent-run-request.ts new file mode 100644 index 00000000..af23e3ec --- /dev/null +++ b/typescript/adapter-contract/src/generated/agent-run-request.ts @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// This file is generated from the canonical adapter-contract JSON Schemas. +// Do not edit it directly; run `npm run generate` instead. + +import type { JsonObject, JsonValue } from "../json.js"; + +/** + * Preview southbound invocation request. + * + * The current local-host transport does not enforce this type. It will join + * the negotiated adapter contract when typed invoke transport is implemented. + */ +export interface AgentRunRequest { + /** + * Caller-provided task, rollout, workflow, or application context. + */ + context?: JsonObject; + /** + * Adapter-owned request fields. + */ + extensions?: JsonObject; + /** + * Request payload for the adapter target. + */ + input: JsonValue; +} diff --git a/typescript/adapter-contract/src/generated/agent-run-result.ts b/typescript/adapter-contract/src/generated/agent-run-result.ts new file mode 100644 index 00000000..43a604eb --- /dev/null +++ b/typescript/adapter-contract/src/generated/agent-run-result.ts @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// This file is generated from the canonical adapter-contract JSON Schemas. +// Do not edit it directly; run `npm run generate` instead. + +import type { JsonObject, JsonValue } from "../json.js"; + +/** Preview southbound terminal result. */ +export type AgentRunResult = + | AgentRunSucceeded + | AgentRunFailed + | AgentRunCancelled; + +/** Successful terminal result. Successful results cannot carry an error. */ +export interface AgentRunSucceeded extends AgentRunResultCommon { + status: "succeeded"; + error?: never; +} + +/** Failed terminal result. Failed results must carry a non-null error. */ +export interface AgentRunFailed extends AgentRunResultCommon { + status: "failed"; + error: AgentRunError; +} + +/** Cancelled terminal result. Cancellation details are optional. */ +export interface AgentRunCancelled extends AgentRunResultCommon { + status: "cancelled"; + error?: AgentRunError | null; +} + +/** + * Fields shared by every preview terminal result variant. + */ +export interface AgentRunResultCommon { + /** + * Artifacts produced by the adapter target. + */ + artifacts?: AgentArtifact[]; + /** + * Adapter-owned result fields. + */ + extensions?: JsonObject; + /** + * Primary adapter-target output. + */ + output: JsonValue; + /** + * Normalized model usage when reported by the adapter target. + */ + usage?: AgentUsage | null; +} +/** + * One artifact produced by an adapter target. + * + * This interface was referenced by `AgentRunResultCommon`'s JSON-Schema + * via the `definition` "AgentArtifact". + */ +export interface AgentArtifact { + /** + * Adapter-owned artifact fields. + */ + extensions?: JsonObject; + /** + * Artifact kind. + */ + kind: string; + /** + * Optional media type. + */ + media_type?: string | null; + /** + * Logical artifact name. + */ + name: string; + /** + * Path relative to the artifact root supplied in `RuntimeContext`. + */ + path: string; +} +/** + * Normalized model usage reported by an adapter target. + * + * This interface was referenced by `AgentRunResultCommon`'s JSON-Schema + * via the `definition` "AgentUsage". + */ +export interface AgentUsage { + /** + * Invocation cost in US dollars when reported by the provider. + */ + cost_usd?: number | null; + /** + * Adapter-owned usage fields. + */ + extensions?: JsonObject; + /** + * Input tokens consumed by the invocation. + */ + input_tokens?: number | null; + /** + * Output tokens produced by the invocation. + */ + output_tokens?: number | null; + /** + * Total tokens reported by the provider. + */ + total_tokens?: number | null; +} +/** + * Error reported by an adapter target. + * + * This interface was referenced by `AgentRunResultCommon`'s JSON-Schema + * via the `definition` "AgentRunError". + */ +export interface AgentRunError { + /** + * Stable adapter error code. + */ + code: string; + /** + * Adapter-owned error fields. + */ + extensions?: JsonObject; + /** + * Human-readable error message. + */ + message: string; + /** + * Whether the adapter considers the failure safe for a consumer-level retry. + */ + retryable?: boolean; +} diff --git a/typescript/adapter-contract/src/generated/runtime-context.ts b/typescript/adapter-contract/src/generated/runtime-context.ts new file mode 100644 index 00000000..ae16a1fd --- /dev/null +++ b/typescript/adapter-contract/src/generated/runtime-context.ts @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// This file is generated from the canonical adapter-contract JSON Schemas. +// Do not edit it directly; run `npm run generate` instead. + +import type { JsonObject } from "../json.js"; + +/** + * Context generated for one invocation of a started runtime. + */ +export interface RuntimeContext { + artifacts: ArtifactManifest; + environment: EnvironmentHandle; + /** + * Invocation handle id. + */ + invocation_id: string; + /** + * Request id. + */ + request_id: string; + /** + * Runtime handle id. + */ + runtime_id: string; + /** + * Runtime telemetry context generated for this invocation. + */ + telemetry?: RuntimeTelemetryContext | null; +} +/** + * Artifact manifest visible to the adapter at invocation start. + */ +export interface ArtifactManifest { + /** + * Artifact entries. + */ + artifacts?: ArtifactRef[]; + /** + * Artifact root directory. + */ + root?: string | null; +} +/** + * Reference to one artifact. + */ +export interface ArtifactRef { + /** + * Artifact kind. + */ + kind: string; + /** + * Optional media type. + */ + media_type?: string | null; + /** + * Artifact-specific metadata preserved across the Rust and Python SDK boundary. + */ + metadata?: JsonObject; + /** + * Logical artifact name. + */ + name: string; + /** + * Artifact path. + */ + path: string; +} +/** + * Prepared execution environment. + */ +export interface EnvironmentHandle { + /** + * Artifact root visible to the harness runtime. + */ + artifacts?: string | null; + /** + * Provider connection metadata. + */ + connection?: JsonObject; + /** + * Where NeMo Fabric control code runs. + */ + control_location: "external_control" | "in_env_control"; + /** + * Environment variables visible to the harness and its tools. + */ + env?: { + [k: string]: string; + }; + /** + * Environment handle id. + */ + environment_id: string; + /** + * Provider-specific metadata. + */ + metadata?: JsonObject; + /** + * Whether NeMo Fabric owns the environment resource. + */ + ownership: "caller_owned" | "fabric_owned"; + /** + * Environment provider. + */ + provider: string; + /** + * Workspace visible to the harness runtime. + */ + workspace?: string | null; +} +/** + * Runtime telemetry config passed to adapters. + */ +export interface RuntimeTelemetryContext { + /** + * Generated Relay config path for this invocation. + */ + config_path?: string | null; + /** + * Environment variables NeMo Fabric applies while invoking the adapter. + */ + env?: { + [k: string]: string; + }; + /** + * Additional telemetry metadata surfaced to consumers and adapters. + */ + metadata?: JsonObject; + /** + * Whether Relay is enabled for this invocation. + */ + relay_enabled: boolean; +} diff --git a/typescript/adapter-contract/src/index.ts b/typescript/adapter-contract/src/index.ts new file mode 100644 index 00000000..340f7b0e --- /dev/null +++ b/typescript/adapter-contract/src/index.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + AdapterConfigSupport, + AdapterDescriptor, +} from "./generated/adapter-descriptor.js"; +import type { AgentInstructionConfig } from "./generated/agent-config.js"; +import type { EnvironmentHandle } from "./generated/runtime-context.js"; +import { ADAPTER_CONTRACT_VERSION } from "./version.js"; + +export type * from "./generated/adapter-descriptor.js"; +export type * from "./generated/agent-config.js"; +export type * from "./generated/runtime-context.js"; +export type { + JsonArray, + JsonObject, + JsonPrimitive, + JsonValue, +} from "./json.js"; + +/** Version literal accepted by the negotiated adapter descriptor contract. */ +export type AdapterContractVersion = typeof ADAPTER_CONTRACT_VERSION; + +/** Adapter implementation kind. */ +export type AdapterKind = AdapterDescriptor["adapter_kind"]; + +/** Configuration object delivered to an adapter lifecycle host. */ +export type AdapterConfigInput = NonNullable; + +/** How an instruction value is applied to the selected harness. */ +export type InstructionMode = NonNullable; + +/** Where NeMo Fabric control code runs relative to the environment. */ +export type ControlLocation = EnvironmentHandle["control_location"]; + +/** Whether NeMo Fabric owns the underlying environment resource. */ +export type EnvironmentOwnership = EnvironmentHandle["ownership"]; + +export { ADAPTER_CONTRACT_VERSION }; diff --git a/typescript/adapter-contract/src/json.ts b/typescript/adapter-contract/src/json.ts new file mode 100644 index 00000000..d1554415 --- /dev/null +++ b/typescript/adapter-contract/src/json.ts @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// This file is generated from the canonical adapter-contract JSON Schemas. +// Do not edit it directly; run `npm run generate` instead. + +/** A JSON scalar value. */ +export type JsonPrimitive = string | number | boolean | null; + +/** A JSON object with recursively JSON-compatible values. */ +export interface JsonObject { + [key: string]: JsonValue; +} + +/** A JSON array with recursively JSON-compatible values. */ +export type JsonArray = JsonValue[]; + +/** Any value representable by JSON. */ +export type JsonValue = JsonPrimitive | JsonObject | JsonArray; diff --git a/typescript/adapter-contract/src/preview.ts b/typescript/adapter-contract/src/preview.ts new file mode 100644 index 00000000..7b58fb00 --- /dev/null +++ b/typescript/adapter-contract/src/preview.ts @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { AgentRunResult } from "./generated/agent-run-result.js"; + +export type * from "./generated/agent-run-request.js"; +export type * from "./generated/agent-run-result.js"; +export type { JsonObject, JsonValue } from "./json.js"; + +/** Completion status reported by an adapter target. */ +export type AgentRunStatus = AgentRunResult["status"]; diff --git a/typescript/adapter-contract/src/version.ts b/typescript/adapter-contract/src/version.ts new file mode 100644 index 00000000..a35ff010 --- /dev/null +++ b/typescript/adapter-contract/src/version.ts @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// This file is generated from the canonical adapter-contract JSON Schemas. +// Do not edit it directly; run `npm run generate` instead. + +/** The negotiated adapter descriptor contract version. */ +export const ADAPTER_CONTRACT_VERSION = "fabric.adapter/v1alpha2" as const; diff --git a/typescript/adapter-contract/test/preview.test.ts b/typescript/adapter-contract/test/preview.test.ts new file mode 100644 index 00000000..ef97bf33 --- /dev/null +++ b/typescript/adapter-contract/test/preview.test.ts @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + AgentRunError, + AgentRunRequest, + AgentRunResult, + AgentRunStatus, +} from "../src/preview.js"; + +const requests: AgentRunRequest[] = [ + { input: null }, + { input: true }, + { input: 42 }, + { input: "prompt" }, + { input: ["prompt", null] }, + { input: { messages: [{ role: "user", content: "hello" }] } }, +]; + +const results: AgentRunResult[] = [ + { output: null, status: "succeeded" }, + { + error: { code: "target_error", message: "target failed" }, + output: { partial: true }, + status: "failed", + }, + { error: null, output: ["partial"], status: "cancelled" }, +]; +const status: AgentRunStatus = "succeeded"; +const runError: AgentRunError = { code: "example", message: "example" }; + +void requests; +void results; +void status; +void runError; + +// @ts-expect-error successful results cannot include an error +const invalidSuccess: AgentRunResult = { + error: { code: "unexpected", message: "unexpected" }, + output: "done", + status: "succeeded", +}; +void invalidSuccess; + +// @ts-expect-error failed results require a non-null error +const missingFailure: AgentRunResult = { output: null, status: "failed" }; +void missingFailure; + +// @ts-expect-error failed results cannot carry a null error +const nullFailure: AgentRunResult = { + error: null, + output: null, + status: "failed", +}; +void nullFailure; + +// @ts-expect-error functions are not JSON values +const invalidRequest: AgentRunRequest = { input: () => "not JSON" }; +void invalidRequest; diff --git a/typescript/adapter-contract/test/projection-guards.test.mjs b/typescript/adapter-contract/test/projection-guards.test.mjs new file mode 100644 index 00000000..c56e6bca --- /dev/null +++ b/typescript/adapter-contract/test/projection-guards.test.mjs @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + assertAdapterSchemaInventory, + assertRunResultConditionals, +} from "../scripts/projection-guards.mjs"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const runResultSchema = JSON.parse( + await readFile( + resolve( + packageRoot, + "../..", + "schemas/adapter-contract/agent-run-result.schema.json", + ), + "utf8", + ), +); +const currentConditionals = runResultSchema.allOf; + +test("result projection rejects an unhandled conditional constraint", () => { + const changed = structuredClone(currentConditionals); + changed[0].then.required.push("usage"); + + assert.throws( + () => assertRunResultConditionals(changed), + /AgentRunResult conditionals changed/, + ); +}); + +test("result projection accepts the exact supported conditional shape", () => { + assert.doesNotThrow(() => assertRunResultConditionals(currentConditionals)); +}); + +test("schema inventory rejects an unhandled canonical schema", () => { + assert.throws( + () => + assertAdapterSchemaInventory( + ["adapter-descriptor.schema.json", "new-contract.schema.json"], + ["adapter-descriptor.schema.json"], + ), + /schema inventory changed/, + ); +}); diff --git a/typescript/adapter-contract/test/stable.test.ts b/typescript/adapter-contract/test/stable.test.ts new file mode 100644 index 00000000..094c3862 --- /dev/null +++ b/typescript/adapter-contract/test/stable.test.ts @@ -0,0 +1,188 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ADAPTER_CONTRACT_VERSION } from "../src/index.js"; +import type { + AdapterConfigInput, + AdapterContractVersion, + AdapterDescriptor, + AdapterKind, + AdapterTelemetryProviderSupport, + AgentConfig, + ControlLocation, + EnvironmentOwnership, + InstructionMode, + JsonValue, + RuntimeContext, + TelemetryProvider, +} from "../src/index.js"; +// @ts-expect-error invocation types are available only from the preview entry point +import type { AgentRunResult } from "../src/index.js"; + +const descriptor: AdapterDescriptor = { + adapter_id: "pi", + adapter_kind: "process", + contract_version: ADAPTER_CONTRACT_VERSION, + custom_extension: { enabled: true }, + extension_schemas: { + agent_config: { type: "object", additionalProperties: true }, + }, + harness: "pi", +}; + +const config: AgentConfig = { + extensions: { enabled: true, nested: [1, null, "value"] }, + models: { + default: { + model: "example-model", + provider: "example-provider", + temperature: null, + }, + }, + tools: { enabled: null }, +}; + +const context: RuntimeContext = { + artifacts: { artifacts: [], root: null }, + environment: { + control_location: "external_control", + environment_id: "env-1", + ownership: "caller_owned", + provider: "local", + }, + invocation_id: "invocation-1", + request_id: "request-1", + runtime_id: "runtime-1", +}; + +const jsonValues: JsonValue[] = [ + null, + true, + 1, + "text", + ["nested"], + { nested: [false, null] }, +]; + +const supportTypes: [ + AdapterConfigInput, + AdapterContractVersion, + AdapterKind, + ControlLocation, + EnvironmentOwnership, + InstructionMode, + TelemetryProvider, + AdapterTelemetryProviderSupport, +] = [ + "agent_config", + ADAPTER_CONTRACT_VERSION, + "process", + "external_control", + "caller_owned", + "replace", + "relay", + { integration_modes: ["native"] }, +]; + +void descriptor; +void config; +void context; +void jsonValues; +void supportTypes; + +const wrongVersion: AdapterDescriptor = { + adapter_id: "pi", + adapter_kind: "process", + // @ts-expect-error contract_version is the exact negotiated literal + contract_version: "fabric.adapter/v1alpha1", + harness: "pi", +}; +void wrongVersion; + +const wrongExtensionPoint: AdapterDescriptor = { + adapter_id: "pi", + adapter_kind: "process", + contract_version: ADAPTER_CONTRACT_VERSION, + // @ts-expect-error extension_schemas accepts only canonical extension points + extension_schemas: { unknown_location: {} }, + harness: "pi", +}; +void wrongExtensionPoint; + +const wrongTelemetryProvider: AdapterDescriptor = { + adapter_id: "pi", + adapter_kind: "process", + contract_version: ADAPTER_CONTRACT_VERSION, + harness: "pi", + telemetry: { + providers: { + // @ts-expect-error telemetry provider keys are the Rust enum values + custom: {}, + }, + }, +}; +void wrongTelemetryProvider; + +// @ts-expect-error required descriptor fields cannot be omitted +const incompleteDescriptor: AdapterDescriptor = { adapter_id: "pi" }; +void incompleteDescriptor; + +const invalidFlattenedExtension: AdapterDescriptor = { + adapter_id: "pi", + adapter_kind: "process", + contract_version: ADAPTER_CONTRACT_VERSION, + // @ts-expect-error flattened descriptor extensions must be JSON-compatible + custom_hook: () => "not JSON", + harness: "pi", +}; +void invalidFlattenedExtension; + +const invalidClosedConfig: AgentConfig = { + models: { + default: { + model: "example-model", + provider: "example-provider", + // @ts-expect-error closed contract objects reject unknown keys + unexpected: true, + }, + }, +}; +void invalidClosedConfig; + +const invalidTelemetryProvider: AdapterDescriptor = { + adapter_id: "pi", + adapter_kind: "process", + contract_version: ADAPTER_CONTRACT_VERSION, + harness: "pi", + telemetry: { + providers: { + // @ts-expect-error telemetry provider names come from the canonical schema + custom: {}, + }, + }, +}; +void invalidTelemetryProvider; + +// @ts-expect-error undefined is not JSON +const undefinedJson: JsonValue = undefined; +void undefinedJson; + +// @ts-expect-error bigint is not JSON +const bigintJson: JsonValue = 1n; +void bigintJson; + +// @ts-expect-error functions are not JSON +const functionJson: JsonValue = () => "not JSON"; +void functionJson; + +// @ts-expect-error Date instances are not JSON objects +const dateJson: JsonValue = new Date(); +void dateJson; + +// @ts-expect-error Map instances are not JSON objects +const mapJson: JsonValue = new Map(); +void mapJson; + +// @ts-expect-error symbols are not JSON +const symbolJson: JsonValue = Symbol("not JSON"); +void symbolJson; diff --git a/typescript/adapter-contract/test/tsconfig.json b/typescript/adapter-contract/test/tsconfig.json new file mode 100644 index 00000000..018573e4 --- /dev/null +++ b/typescript/adapter-contract/test/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noUncheckedIndexedAccess": true, + "strict": true, + "target": "ES2022", + "verbatimModuleSyntax": true + }, + "include": ["../src/**/*.ts", "./**/*.test.ts"] +} diff --git a/typescript/adapter-contract/tsconfig.build.json b/typescript/adapter-contract/tsconfig.build.json new file mode 100644 index 00000000..d8b1aa2b --- /dev/null +++ b/typescript/adapter-contract/tsconfig.build.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declaration": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noUncheckedIndexedAccess": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "target": "ES2022", + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts"] +} From ef2f42f7ef3ab0ee604f6f38057270b045d20137 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Tue, 11 Aug 2026 09:11:27 -0700 Subject: [PATCH 02/12] fix: harden TypeScript adapter contract Signed-off-by: Ajay Thorve --- .agents/skills/contribute-api/SKILL.md | 2 +- .agents/skills/maintain-packaging/SKILL.md | 2 +- .../skills/update-project-version/SKILL.md | 2 +- .agents/skills/validate-change/SKILL.md | 3 +- .pre-commit-config.yaml | 2 +- RELEASING.md | 1 + crates/fabric-core/src/agent_config.rs | 4 +- crates/fabric-core/src/agent_execution.rs | 10 ++-- crates/fabric-core/src/config.rs | 8 +++ crates/fabric-core/src/schema.rs | 7 +++ justfile | 16 +++--- .../adapter-contract/agent-config.schema.json | 2 + .../agent-run-result.schema.json | 1 + schemas/run-plan.schema.json | 2 + scripts/ci/set_typescript_project_version.py | 8 ++- .../test_set_typescript_project_version.py | 52 ++++++++++++----- typescript/adapter-contract/README.md | 33 +++++++++-- typescript/adapter-contract/package.json | 3 +- .../schemas/agent-config.schema.json | 2 + .../schemas/agent-run-result.schema.json | 1 + .../scripts/check-dependencies.mjs | 57 +++++++++++++++++++ .../scripts/check-package.mjs | 1 + .../adapter-contract/scripts/generate.mjs | 4 +- .../test/projection-guards.test.mjs | 6 +- .../adapter-contract/test/tsconfig.json | 3 + .../adapter-contract/tsconfig.build.json | 3 + 26 files changed, 193 insertions(+), 42 deletions(-) create mode 100644 typescript/adapter-contract/scripts/check-dependencies.mjs diff --git a/.agents/skills/contribute-api/SKILL.md b/.agents/skills/contribute-api/SKILL.md index 3d0dc4b6..1642815f 100644 --- a/.agents/skills/contribute-api/SKILL.md +++ b/.agents/skills/contribute-api/SKILL.md @@ -1,6 +1,6 @@ --- name: contribute-api -description: Contribute a new NeMo Fabric public API surface safely, with Rust, CLI, Python, TypeScript, schema, adapter, and documentation parity in mind +description: Contribute a new NVIDIA NeMo Fabric public API surface safely, with Rust, CLI, Python, TypeScript, schema, adapter, and documentation parity in mind author: NVIDIA Corporation and Affiliates license: Apache-2.0 --- diff --git a/.agents/skills/maintain-packaging/SKILL.md b/.agents/skills/maintain-packaging/SKILL.md index 2180aed4..021afa41 100644 --- a/.agents/skills/maintain-packaging/SKILL.md +++ b/.agents/skills/maintain-packaging/SKILL.md @@ -1,6 +1,6 @@ --- name: maintain-packaging -description: Maintain NeMo Fabric Rust, Python, and TypeScript dependencies, package metadata, module paths, native artifacts, lockfiles, license evidence, and release-facing build surfaces +description: Maintain NVIDIA NeMo Fabric Rust, Python, and TypeScript dependencies, package metadata, module paths, native artifacts, lockfiles, license evidence, and release-facing build surfaces author: NVIDIA Corporation and Affiliates license: Apache-2.0 --- diff --git a/.agents/skills/update-project-version/SKILL.md b/.agents/skills/update-project-version/SKILL.md index ba0c6b18..48881872 100644 --- a/.agents/skills/update-project-version/SKILL.md +++ b/.agents/skills/update-project-version/SKILL.md @@ -1,6 +1,6 @@ --- name: update-project-version -description: Update the NeMo Fabric release version across Cargo, Python and TypeScript package metadata, internal Python dependency pins, integration metadata, and lockfiles. Use when bumping, synchronizing, or auditing NeMo Fabric package versions for a release. +description: Update the NVIDIA NeMo Fabric release version across Cargo, Python and TypeScript package metadata, internal Python dependency pins, integration metadata, and lockfiles. Use when bumping, synchronizing, or auditing NeMo Fabric package versions for a release. author: NVIDIA Corporation and Affiliates license: Apache-2.0 --- diff --git a/.agents/skills/validate-change/SKILL.md b/.agents/skills/validate-change/SKILL.md index f6c7ec1a..9cff0ac7 100644 --- a/.agents/skills/validate-change/SKILL.md +++ b/.agents/skills/validate-change/SKILL.md @@ -61,7 +61,8 @@ surfaces touched by a change. Run `tests/test_harbor_runner.py`, then `just test-python`. - **Schema or public contract changed** Run the Rust, Python, and TypeScript suites and review changes under - `schemas/`, generated TypeScript sources, and generated API references. + `schemas/`, the checked-in Python adapter-contract representations, generated + TypeScript sources, and generated API references. - **Documentation-only change** Use `contribute-docs` and `review-doc-style`. Run `just docs` for docs-site or generated-reference changes. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c71fbe5b..f63533bd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: name: copyright header entry: python3 scripts/lint/check_copyright.py language: system - files: '\.(rs|py|pyi|toml|yaml|yml|md|mdx|sh|js|mjs|ts)$|\.gitignore$' + files: '\.(rs|py|pyi|toml|yaml|yml|md|mdx|sh|js|mjs|ts|tsx)$|\.gitignore$' exclude: '(/SKILL\.md|node_modules/|target/|\.venv/|^\.github/pull_request_template\.md)$' # Python lint — enforce the flake8-bugbear cached-instance-method rule (B019) diff --git a/RELEASING.md b/RELEASING.md index 4681e2e4..4171c394 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -151,6 +151,7 @@ The helper updates: and its npm lockfile. 5. [`Cargo.lock`](Cargo.lock), [`uv.lock`](uv.lock), and every Python project lockfile. + Review docs and snippets that mention explicit versions, including: - [`README.md`](README.md) diff --git a/crates/fabric-core/src/agent_config.rs b/crates/fabric-core/src/agent_config.rs index b17a753a..01ec724f 100644 --- a/crates/fabric-core/src/agent_config.rs +++ b/crates/fabric-core/src/agent_config.rs @@ -64,10 +64,10 @@ pub struct AgentHarnessConfig { #[serde(deny_unknown_fields)] pub struct AgentModelConfig { /// Model provider identifier. - #[schemars(length(min = 1))] + #[schemars(length(min = 1), regex(pattern = r"\S"))] pub provider: String, /// Provider model identifier. - #[schemars(length(min = 1))] + #[schemars(length(min = 1), regex(pattern = r"\S"))] pub model: String, /// Environment variable containing the provider credential. #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/fabric-core/src/agent_execution.rs b/crates/fabric-core/src/agent_execution.rs index b805c7ab..7f9780f4 100644 --- a/crates/fabric-core/src/agent_execution.rs +++ b/crates/fabric-core/src/agent_execution.rs @@ -142,8 +142,8 @@ pub enum AgentRunResultValidationError { /// A successful result included an error. #[error("succeeded result must not include an error")] SucceededWithError, - /// An artifact path was empty, absolute, or contained parent traversal. - #[error("artifact path must be non-empty, relative, and contain no parent traversal: {0}")] + /// An artifact path was blank, absolute, or contained parent traversal. + #[error("artifact path must be non-blank and relative, and contain no parent traversal: {0}")] InvalidArtifactPath(PathBuf), } @@ -174,7 +174,7 @@ impl AgentRunResult { fn is_valid_agent_artifact_path(path: &Path) -> bool { let raw = path.to_string_lossy(); - !raw.is_empty() + raw.chars().any(|character| !character.is_whitespace()) && !path.is_absolute() && !raw.starts_with(['/', '\\']) && !raw @@ -197,7 +197,7 @@ where let path = PathBuf::deserialize(deserializer)?; if !is_valid_agent_artifact_path(&path) { return Err(serde::de::Error::custom( - "artifact path must be non-empty, relative, and contain no parent traversal", + "artifact path must be non-blank and relative, and contain no parent traversal", )); } Ok(path) @@ -206,6 +206,7 @@ where fn agent_artifact_path_schema(generator: &mut SchemaGenerator) -> Schema { let mut schema = String::json_schema(generator); schema.insert("minLength".into(), 1.into()); + schema.insert("pattern".into(), r"\S".into()); schema.insert( "not".into(), serde_json::json!({ @@ -304,6 +305,7 @@ mod tests { fn rejects_unsafe_artifact_paths_during_deserialization() { for path in [ "", + " \t", "/tmp/output", "../output", "nested/../output", diff --git a/crates/fabric-core/src/config.rs b/crates/fabric-core/src/config.rs index d337ce80..602437c7 100644 --- a/crates/fabric-core/src/config.rs +++ b/crates/fabric-core/src/config.rs @@ -1190,6 +1190,14 @@ pub enum TelemetryProvider { impl TelemetryProvider { const ALL: [Self; 2] = [Self::Relay, Self::Native]; + /// Keep [`Self::ALL`] synchronized when adding a provider variant. + #[allow(dead_code)] + const fn assert_all_variants_listed(self) { + match self { + Self::Relay | Self::Native => {} + } + } + /// Return the stable configuration value for this provider. pub fn as_str(self) -> &'static str { match self { diff --git a/crates/fabric-core/src/schema.rs b/crates/fabric-core/src/schema.rs index e5aa85bf..95112655 100644 --- a/crates/fabric-core/src/schema.rs +++ b/crates/fabric-core/src/schema.rs @@ -391,6 +391,12 @@ mod tests { #[test] fn adapter_contract_schemas_bound_rust_integer_types() { let config = generate_schema(SchemaName::AgentConfig).expect("schema generation"); + for field in ["provider", "model"] { + assert_eq!( + config["$defs"]["AgentModelConfig"]["properties"][field]["pattern"], + r"\S" + ); + } assert_eq!( config["$defs"]["AgentRuntimeConfig"]["properties"]["max_turns"]["maximum"], u32::MAX @@ -420,6 +426,7 @@ mod tests { "error": {"code": "target_error", "message": "target failed"} }))); for path in [ + " \t", "nested/../output", r"nested\..\output", r"C:\tmp\output", diff --git a/justfile b/justfile index 0eea943a..323fbd45 100644 --- a/justfile +++ b/justfile @@ -308,19 +308,20 @@ build-python: --reinstall-package nemo-fabric-runtime fi -# Build the TypeScript adapter contract using the locked dependency set. -build-typescript: +# Install the TypeScript adapter contract dependencies from the lockfile. +install-typescript: npm ci --prefix typescript/adapter-contract --ignore-scripts + +# Build the TypeScript adapter contract using the locked dependency set. +build-typescript: install-typescript npm run build --prefix typescript/adapter-contract # Generate the TypeScript adapter contract from the committed JSON Schemas. -generate-typescript-contract: - npm ci --prefix typescript/adapter-contract --ignore-scripts +generate-typescript-contract: install-typescript npm run generate --prefix typescript/adapter-contract # Verify the TypeScript adapter contract package tarball. -pack-typescript: - npm ci --prefix typescript/adapter-contract --ignore-scripts +pack-typescript: install-typescript npm run pack:check --prefix typescript/adapter-contract # Build all supported language packages. @@ -398,8 +399,7 @@ test-rust: cargo test --workspace --locked # Run the TypeScript adapter contract checks using the locked dependency set. -test-typescript: - npm ci --prefix typescript/adapter-contract --ignore-scripts +test-typescript: install-typescript npm test --prefix typescript/adapter-contract # Run all Rust, Python, and TypeScript tests. diff --git a/schemas/adapter-contract/agent-config.schema.json b/schemas/adapter-contract/agent-config.schema.json index 2aad9230..e533a9b4 100644 --- a/schemas/adapter-contract/agent-config.schema.json +++ b/schemas/adapter-contract/agent-config.schema.json @@ -170,11 +170,13 @@ "model": { "description": "Provider model identifier.", "minLength": 1, + "pattern": "\\S", "type": "string" }, "provider": { "description": "Model provider identifier.", "minLength": 1, + "pattern": "\\S", "type": "string" }, "settings": { diff --git a/schemas/adapter-contract/agent-run-result.schema.json b/schemas/adapter-contract/agent-run-result.schema.json index 0aee5b47..c6ac4081 100644 --- a/schemas/adapter-contract/agent-run-result.schema.json +++ b/schemas/adapter-contract/agent-run-result.schema.json @@ -46,6 +46,7 @@ } ] }, + "pattern": "\\S", "type": "string" } }, diff --git a/schemas/run-plan.schema.json b/schemas/run-plan.schema.json index fabb7f41..6729d886 100644 --- a/schemas/run-plan.schema.json +++ b/schemas/run-plan.schema.json @@ -611,11 +611,13 @@ "model": { "description": "Provider model identifier.", "minLength": 1, + "pattern": "\\S", "type": "string" }, "provider": { "description": "Model provider identifier.", "minLength": 1, + "pattern": "\\S", "type": "string" }, "settings": { diff --git a/scripts/ci/set_typescript_project_version.py b/scripts/ci/set_typescript_project_version.py index 68f576b8..5e7b8e29 100644 --- a/scripts/ci/set_typescript_project_version.py +++ b/scripts/ci/set_typescript_project_version.py @@ -10,9 +10,13 @@ from typing import Any +NUMERIC_IDENTIFIER = r"(?:0|[1-9]\d*)" +PRERELEASE_IDENTIFIER = rf"(?:{NUMERIC_IDENTIFIER}|\d*[A-Za-z-][0-9A-Za-z-]*)" +BUILD_IDENTIFIER = r"[0-9A-Za-z-]+" SEMVER_PATTERN = re.compile( - r"\d+\.\d+\.\d+(?:-(?:alpha|beta|rc)(?:\.\d+)?)?" - r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?" + rf"{NUMERIC_IDENTIFIER}\.{NUMERIC_IDENTIFIER}\.{NUMERIC_IDENTIFIER}" + rf"(?:-{PRERELEASE_IDENTIFIER}(?:\.{PRERELEASE_IDENTIFIER})*)?" + rf"(?:\+{BUILD_IDENTIFIER}(?:\.{BUILD_IDENTIFIER})*)?" ) PACKAGE_DIRECTORY = Path("typescript/adapter-contract") diff --git a/tests/scripts/test_set_typescript_project_version.py b/tests/scripts/test_set_typescript_project_version.py index 42288588..1b5d25a6 100644 --- a/tests/scripts/test_set_typescript_project_version.py +++ b/tests/scripts/test_set_typescript_project_version.py @@ -16,8 +16,9 @@ import set_typescript_project_version # noqa: E402 -def _write_package_files(root: Path) -> tuple[Path, Path]: - package_directory = root / "typescript" / "adapter-contract" +@pytest.fixture(name="package_files") +def package_files_fixture(tmp_path: Path) -> tuple[Path, Path]: + package_directory = tmp_path / "typescript" / "adapter-contract" package_directory.mkdir(parents=True) package_path = package_directory / "package.json" lock_path = package_directory / "package-lock.json" @@ -59,14 +60,23 @@ def _write_package_files(root: Path) -> tuple[Path, Path]: return package_path, lock_path -@pytest.mark.parametrize("version", ["0.3.0-rc.2", "0.3.0+nightly.20260810"]) +@pytest.mark.parametrize( + "version", + [ + "0.3.0-rc.2", + "0.3.0-dev.1", + "0.3.0-x.7.z.92", + "0.3.0+nightly.20260810", + ], +) def test_set_typescript_project_version_updates_manifest_and_lockfile( - tmp_path: Path, + package_files: tuple[Path, Path], version: str, ): - package_path, lock_path = _write_package_files(tmp_path) + package_path, lock_path = package_files + root = package_path.parents[2] - set_typescript_project_version.set_typescript_project_version(tmp_path, version) + set_typescript_project_version.set_typescript_project_version(root, version) package = json.loads(package_path.read_text(encoding="utf-8")) lock = json.loads(lock_path.read_text(encoding="utf-8")) @@ -76,24 +86,38 @@ def test_set_typescript_project_version_updates_manifest_and_lockfile( assert lock["packages"]["node_modules/typescript"]["version"] == "5.9.3" -@pytest.mark.parametrize("version", ["v0.3.0", "0.3", "0.3.0-dev.1"]) +@pytest.mark.parametrize( + "version", + [ + "v0.3.0", + "0.3", + "01.3.0", + "0.03.0", + "0.3.00", + "0.3.0-dev.01", + "0.3.0-", + "0.3.0+", + ], +) def test_set_typescript_project_version_rejects_unsupported_versions( - tmp_path: Path, + package_files: tuple[Path, Path], version: str, ): - _write_package_files(tmp_path) + package_path, _ = package_files + root = package_path.parents[2] with pytest.raises(SystemExit, match="Unsupported TypeScript package version"): - set_typescript_project_version.set_typescript_project_version(tmp_path, version) + set_typescript_project_version.set_typescript_project_version(root, version) def test_set_typescript_project_version_rejects_lockfile_name_drift( - tmp_path: Path, + package_files: tuple[Path, Path], ): - _, lock_path = _write_package_files(tmp_path) + package_path, lock_path = package_files + root = package_path.parents[2] lock = json.loads(lock_path.read_text(encoding="utf-8")) lock["packages"][""]["name"] = "wrong-package" lock_path.write_text(json.dumps(lock, indent=2) + "\n", encoding="utf-8") - with pytest.raises(SystemExit, match="Package names .* are not synchronized"): - set_typescript_project_version.set_typescript_project_version(tmp_path, "0.3.0") + with pytest.raises(SystemExit, match=r"Package names .* are not synchronized"): + set_typescript_project_version.set_typescript_project_version(root, "0.3.0") diff --git a/typescript/adapter-contract/README.md b/typescript/adapter-contract/README.md index 40a02432..0833cefa 100644 --- a/typescript/adapter-contract/README.md +++ b/typescript/adapter-contract/README.md @@ -11,15 +11,27 @@ Schemas maintained in the NeMo Fabric repository. ## Install +After the first npm release is published, install the package from the public +registry: + ```bash npm install @nvidia/nemo-fabric-adapter-contract ``` -Use Node.js 20.18.3 or later and TypeScript 5.0 or later. Configure TypeScript +Until then, build and install an exact tarball from a NeMo Fabric checkout: + +```bash +npm ci --prefix typescript/adapter-contract --ignore-scripts +npm pack --prefix typescript/adapter-contract +npm install ./nvidia-nemo-fabric-adapter-contract-0.2.0.tgz +``` + +Use Node.js 20.18.3 or later and TypeScript 5.3 or later. Configure TypeScript with `node16`, `nodenext`, or `bundler` module resolution so package export -subpaths resolve correctly. +subpaths resolve correctly. Enable `resolveJsonModule` when importing the +bundled JSON Schemas. -## Stable v1alpha2 contract +## Stable v1alpha2 Contract The root entry point contains the negotiated descriptor, southbound agent configuration, and runtime context types: @@ -44,7 +56,7 @@ Property names intentionally match the JSON wire format and remain `snake_case`. Optional properties are distinct from properties whose value may be `null`. -## Preview invocation types +## Preview Invocation Types Request and result types are not part of the negotiated v1alpha2 lifecycle transport. Import them through the explicit preview entry point: @@ -86,3 +98,16 @@ npm test Run `npm run generate` after the canonical schemas change. Generated source and schema copies are committed so drift is reviewable. + +### Build Dependencies + +`json-schema-to-typescript` generates declarations from the canonical JSON +Schemas. A hand-maintained declaration hierarchy was rejected because it would +create a second contract authority; a custom generator would duplicate an +existing focused build tool. `typescript` compiles the package and strict +positive and negative fixtures; transpilers cannot replace its type checker. +Both dependencies are exact-pinned build inputs and neither is present in the +published production dependency graph. + +The resolved development graph includes `argparse@2.0.1` under `Python-2.0`. +That build-only license remains an explicit dependency-approver review item. diff --git a/typescript/adapter-contract/package.json b/typescript/adapter-contract/package.json index 08481d3a..805a3dc5 100644 --- a/typescript/adapter-contract/package.json +++ b/typescript/adapter-contract/package.json @@ -31,8 +31,9 @@ "build": "node scripts/clean.mjs && tsc -p tsconfig.build.json", "test:generator": "node --test test/*.test.mjs", "test:types": "tsc -p test/tsconfig.json --noEmit", + "test:dependencies": "node scripts/check-dependencies.mjs && npm ls --all && npm audit --audit-level=high", "pack:check": "npm run build && node scripts/check-package.mjs", - "test": "npm run generate:check && npm run test:generator && npm run test:types && npm run pack:check", + "test": "npm run generate:check && npm run test:generator && npm run test:types && npm run test:dependencies && npm run pack:check", "prepack": "npm run generate:check && npm run build" }, "publishConfig": { diff --git a/typescript/adapter-contract/schemas/agent-config.schema.json b/typescript/adapter-contract/schemas/agent-config.schema.json index 2aad9230..e533a9b4 100644 --- a/typescript/adapter-contract/schemas/agent-config.schema.json +++ b/typescript/adapter-contract/schemas/agent-config.schema.json @@ -170,11 +170,13 @@ "model": { "description": "Provider model identifier.", "minLength": 1, + "pattern": "\\S", "type": "string" }, "provider": { "description": "Model provider identifier.", "minLength": 1, + "pattern": "\\S", "type": "string" }, "settings": { diff --git a/typescript/adapter-contract/schemas/agent-run-result.schema.json b/typescript/adapter-contract/schemas/agent-run-result.schema.json index 0aee5b47..c6ac4081 100644 --- a/typescript/adapter-contract/schemas/agent-run-result.schema.json +++ b/typescript/adapter-contract/schemas/agent-run-result.schema.json @@ -46,6 +46,7 @@ } ] }, + "pattern": "\\S", "type": "string" } }, diff --git a/typescript/adapter-contract/scripts/check-dependencies.mjs b/typescript/adapter-contract/scripts/check-dependencies.mjs new file mode 100644 index 00000000..74be8cb1 --- /dev/null +++ b/typescript/adapter-contract/scripts/check-dependencies.mjs @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const manifest = JSON.parse( + await readFile(resolve(packageRoot, "package.json"), "utf8"), +); +const lockfile = JSON.parse( + await readFile(resolve(packageRoot, "package-lock.json"), "utf8"), +); + +for (const field of [ + "dependencies", + "optionalDependencies", + "peerDependencies", + "bundledDependencies", + "bundleDependencies", +]) { + if (manifest[field] !== undefined) { + throw new Error(`Package must not declare ${field}`); + } +} + +const dependencies = Object.entries(lockfile.packages) + .filter(([path]) => path.length > 0) + .map(([path, dependency]) => ({ path, ...dependency })); +const productionDependencies = dependencies.filter( + (dependency) => dependency.dev !== true, +); +if (productionDependencies.length > 0) { + throw new Error( + `Package lock contains production dependencies: ${productionDependencies + .map((dependency) => dependency.path) + .join(", ")}`, + ); +} + +const missingLicenses = dependencies.filter( + (dependency) => + typeof dependency.license !== "string" || dependency.license.length === 0, +); +if (missingLicenses.length > 0) { + throw new Error( + `Package lock contains dependencies without license metadata: ${missingLicenses + .map((dependency) => dependency.path) + .join(", ")}`, + ); +} + +const licenseInventory = [ + ...new Set(dependencies.map((dependency) => dependency.license)), +].sort(); +console.log(`Development dependency licenses: ${licenseInventory.join(", ")}`); diff --git a/typescript/adapter-contract/scripts/check-package.mjs b/typescript/adapter-contract/scripts/check-package.mjs index 6f30a52f..7edcd908 100644 --- a/typescript/adapter-contract/scripts/check-package.mjs +++ b/typescript/adapter-contract/scripts/check-package.mjs @@ -172,6 +172,7 @@ if ( "optionalDependencies", "peerDependencies", "bundledDependencies", + "bundleDependencies", ]) { if (installedManifest[field] !== undefined) { throw new Error(`Published package must not declare ${field}`); diff --git a/typescript/adapter-contract/scripts/generate.mjs b/typescript/adapter-contract/scripts/generate.mjs index 741c688f..3d0a261f 100644 --- a/typescript/adapter-contract/scripts/generate.mjs +++ b/typescript/adapter-contract/scripts/generate.mjs @@ -92,7 +92,9 @@ for (const spec of schemaSpecs) { pendingFiles.set(resolve(packageRoot, "src/json.ts"), generateJsonTypes()); pendingFiles.set( resolve(packageRoot, "src/version.ts"), - generateVersion(contractVersion), + generateVersion( + requireString(contractVersion, "resolved adapter contract version"), + ), ); const mismatches = []; diff --git a/typescript/adapter-contract/test/projection-guards.test.mjs b/typescript/adapter-contract/test/projection-guards.test.mjs index c56e6bca..6ae28d9e 100644 --- a/typescript/adapter-contract/test/projection-guards.test.mjs +++ b/typescript/adapter-contract/test/projection-guards.test.mjs @@ -27,7 +27,11 @@ const currentConditionals = runResultSchema.allOf; test("result projection rejects an unhandled conditional constraint", () => { const changed = structuredClone(currentConditionals); - changed[0].then.required.push("usage"); + const failedConditional = changed.find( + (entry) => entry.if?.properties?.status?.const === "failed", + ); + assert.ok(failedConditional, "expected a failed-status conditional"); + failedConditional.then.required.push("usage"); assert.throws( () => assertRunResultConditionals(changed), diff --git a/typescript/adapter-contract/test/tsconfig.json b/typescript/adapter-contract/test/tsconfig.json index 018573e4..1654c4cb 100644 --- a/typescript/adapter-contract/test/tsconfig.json +++ b/typescript/adapter-contract/test/tsconfig.json @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + { "compilerOptions": { "exactOptionalPropertyTypes": true, diff --git a/typescript/adapter-contract/tsconfig.build.json b/typescript/adapter-contract/tsconfig.build.json index d8b1aa2b..06abf5c0 100644 --- a/typescript/adapter-contract/tsconfig.build.json +++ b/typescript/adapter-contract/tsconfig.build.json @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + { "compilerOptions": { "declaration": true, From 22f4596bc6baec2824a06c0fac55d515f8b86fc6 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Tue, 11 Aug 2026 09:43:33 -0700 Subject: [PATCH 03/12] fix: enforce adapter contract boundaries Signed-off-by: Ajay Thorve --- .../skills/update-project-version/SKILL.md | 2 +- crates/fabric-core/src/agent_config.rs | 75 +++++++++++++++++++ crates/fabric-core/src/config.rs | 10 +-- crates/fabric-core/src/runtime.rs | 26 +++++++ crates/fabric-core/src/schema.rs | 19 ++++- typescript/adapter-contract/README.md | 7 +- .../scripts/check-dependencies.mjs | 19 +++-- 7 files changed, 135 insertions(+), 23 deletions(-) diff --git a/.agents/skills/update-project-version/SKILL.md b/.agents/skills/update-project-version/SKILL.md index 48881872..33df5e11 100644 --- a/.agents/skills/update-project-version/SKILL.md +++ b/.agents/skills/update-project-version/SKILL.md @@ -1,6 +1,6 @@ --- name: update-project-version -description: Update the NVIDIA NeMo Fabric release version across Cargo, Python and TypeScript package metadata, internal Python dependency pins, integration metadata, and lockfiles. Use when bumping, synchronizing, or auditing NeMo Fabric package versions for a release. +description: Update the NVIDIA NeMo Fabric release version across Cargo, Python, and TypeScript package metadata, internal Python dependency pins, integration metadata, and lockfiles. Use when bumping, synchronizing, or auditing NeMo Fabric package versions for a release. author: NVIDIA Corporation and Affiliates license: Apache-2.0 --- diff --git a/crates/fabric-core/src/agent_config.rs b/crates/fabric-core/src/agent_config.rs index 01ec724f..aa6b256f 100644 --- a/crates/fabric-core/src/agent_config.rs +++ b/crates/fabric-core/src/agent_config.rs @@ -13,6 +13,7 @@ use serde_json::Value; use crate::config::{ AdapterConfigField, AdapterDescriptor, CapabilityPlan, FabricConfig, InstructionMode, }; +use crate::error::{FabricError, Result}; /// Configuration projected southbound to one adapter target. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] @@ -242,6 +243,80 @@ pub struct AgentWorkflowConfig { pub extensions: BTreeMap, } +/// Validate a projected adapter configuration before runtime handoff. +pub(crate) fn validate_agent_config(config: &AgentConfig) -> Result<()> { + for (role, model) in &config.models { + require_non_blank( + format!("agent_config.models.{role}.provider"), + &model.provider, + )?; + require_non_blank(format!("agent_config.models.{role}.model"), &model.model)?; + } + if let Some(system) = config + .instructions + .as_ref() + .and_then(|instructions| instructions.system.as_ref()) + { + require_non_blank("agent_config.instructions.system.content", &system.content)?; + } + if config + .runtime + .as_ref() + .is_some_and(|runtime| runtime.max_turns == Some(0)) + { + return invalid_agent_config( + "agent_config.runtime.max_turns", + "must be greater than zero", + ); + } + if let Some(mcp) = &config.mcp { + for (name, server) in &mcp.servers { + require_non_blank( + format!("agent_config.mcp.servers.{name}.transport"), + &server.transport, + )?; + require_non_blank(format!("agent_config.mcp.servers.{name}.url"), &server.url)?; + } + } + if let Some(tools) = &config.tools { + for (name, definition) in &tools.definitions { + require_non_blank( + format!("agent_config.tools.definitions.{name}.kind"), + &definition.kind, + )?; + require_non_blank( + format!("agent_config.tools.definitions.{name}.ref"), + &definition.r#ref, + )?; + } + } + if let Some(workflow) = &config.workflow { + require_non_blank( + "agent_config.workflow.entrypoint.kind", + &workflow.entrypoint.kind, + )?; + require_non_blank( + "agent_config.workflow.entrypoint.ref", + &workflow.entrypoint.r#ref, + )?; + } + Ok(()) +} + +fn require_non_blank(field: impl Into, value: &str) -> Result<()> { + if value.trim().is_empty() { + return invalid_agent_config(field, "must contain a non-whitespace character"); + } + Ok(()) +} + +fn invalid_agent_config(field: impl Into, reason: impl Into) -> Result { + Err(FabricError::InvalidConfig { + field: field.into(), + reason: reason.into(), + }) +} + /// Project a resolved northbound config into the selected adapter target contract. pub(crate) fn project_agent_config( config: &FabricConfig, diff --git a/crates/fabric-core/src/config.rs b/crates/fabric-core/src/config.rs index 602437c7..1445fc2f 100644 --- a/crates/fabric-core/src/config.rs +++ b/crates/fabric-core/src/config.rs @@ -1190,14 +1190,6 @@ pub enum TelemetryProvider { impl TelemetryProvider { const ALL: [Self; 2] = [Self::Relay, Self::Native]; - /// Keep [`Self::ALL`] synchronized when adding a provider variant. - #[allow(dead_code)] - const fn assert_all_variants_listed(self) { - match self { - Self::Relay | Self::Native => {} - } - } - /// Return the stable configuration value for this provider. pub fn as_str(self) -> &'static str { match self { @@ -2431,7 +2423,7 @@ fn resolve_telemetry_plan( let native_provider = telemetry.providers.get(&TelemetryProvider::Native); let relay = config.relay.as_ref(); let relay_enabled = relay_provider.is_some(); - let providers = [TelemetryProvider::Relay, TelemetryProvider::Native] + let providers = TelemetryProvider::ALL .into_iter() .filter(|provider| telemetry.providers.contains_key(provider)) .collect::>(); diff --git a/crates/fabric-core/src/runtime.rs b/crates/fabric-core/src/runtime.rs index a8c5fb71..d928cdc4 100644 --- a/crates/fabric-core/src/runtime.rs +++ b/crates/fabric-core/src/runtime.rs @@ -19,6 +19,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; +use crate::agent_config::validate_agent_config; use crate::config::{ AdapterConfigInput, AdapterKind, AgentConfig, CapabilityPlan, CapabilityTarget, ControlLocation, EnvironmentOwnership, FabricConfig, RunPlan, TelemetryPlan, @@ -557,6 +558,7 @@ pub fn prepare_environment(plan: &RunPlan) -> Result { /// Start or connect to a harness runtime. pub fn start_runtime(plan: &RunPlan) -> Result { validate_config(&plan.config)?; + validate_agent_config(&plan.agent_config)?; validate_harness_settings(&plan.config, plan.adapter_descriptor.as_ref())?; validate_workflow(&plan.config, plan.adapter_descriptor.as_ref())?; validate_adapter_compatibility(plan)?; @@ -2881,6 +2883,30 @@ for line in sys.stdin: let _ = fs::remove_dir_all(root); } + #[test] + fn local_host_revalidates_southbound_config_before_runtime_start() { + for field in ["provider", "model"] { + let (root, plan) = local_host_plan("success"); + let mut serialized = serde_json::to_value(plan).expect("serialize plan"); + serialized["agent_config"]["models"]["primary"] = serde_json::json!({ + "provider": "nvidia", + "model": "test-model" + }); + serialized["agent_config"]["models"]["primary"][field] = + Value::String(" \t".to_string()); + let plan: RunPlan = serde_json::from_value(serialized).expect("deserialize run plan"); + + let error = start_runtime(&plan).expect_err("start must reject blank agent config"); + assert!(matches!( + error, + FabricError::InvalidConfig { field: actual, .. } + if actual == format!("agent_config.models.primary.{field}") + )); + assert!(!root.join("artifacts").exists()); + let _ = fs::remove_dir_all(root); + } + } + #[test] fn local_host_revalidates_workflow_before_runtime_start() { let (root, mut plan) = local_host_plan("success"); diff --git a/crates/fabric-core/src/schema.rs b/crates/fabric-core/src/schema.rs index 95112655..31d5f613 100644 --- a/crates/fabric-core/src/schema.rs +++ b/crates/fabric-core/src/schema.rs @@ -220,6 +220,7 @@ where #[cfg(test)] mod tests { use super::*; + use crate::config::TelemetryProvider; fn schema_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../schemas") @@ -353,10 +354,22 @@ mod tests { "usage" ]) ); + let declared_providers = &schema["$defs"]["AdapterTelemetrySupport"]["properties"]["providers"] + ["propertyNames"]["enum"]; + assert_eq!(declared_providers, &serde_json::json!(["relay", "native"])); + let provider_schema = + serde_json::to_value(schema_for!(TelemetryProvider)).expect("provider schema"); + let derived_providers = Value::Array( + provider_schema["oneOf"] + .as_array() + .expect("provider variants") + .iter() + .map(|variant| variant["const"].clone()) + .collect(), + ); assert_eq!( - schema["$defs"]["AdapterTelemetrySupport"]["properties"]["providers"]["propertyNames"] - ["enum"], - serde_json::json!(["relay", "native"]) + declared_providers, &derived_providers, + "TelemetryProvider::ALL must include every enum variant" ); let validator = jsonschema::validator_for(&schema).expect("valid descriptor schema"); diff --git a/typescript/adapter-contract/README.md b/typescript/adapter-contract/README.md index 0833cefa..22b5e1e5 100644 --- a/typescript/adapter-contract/README.md +++ b/typescript/adapter-contract/README.md @@ -26,9 +26,10 @@ npm pack --prefix typescript/adapter-contract npm install ./nvidia-nemo-fabric-adapter-contract-0.2.0.tgz ``` -Use Node.js 20.18.3 or later and TypeScript 5.3 or later. Configure TypeScript -with `node16`, `nodenext`, or `bundler` module resolution so package export -subpaths resolve correctly. Enable `resolveJsonModule` when importing the +Use Node.js 20.18.3 or later and TypeScript 5.3 or later. Use a compatible +TypeScript module pair: `module: "NodeNext"` with +`moduleResolution: "NodeNext"`, or `module: "ESNext"` with +`moduleResolution: "bundler"`. Enable `resolveJsonModule` when importing the bundled JSON Schemas. ## Stable v1alpha2 Contract diff --git a/typescript/adapter-contract/scripts/check-dependencies.mjs b/typescript/adapter-contract/scripts/check-dependencies.mjs index 74be8cb1..6bc6e3dd 100644 --- a/typescript/adapter-contract/scripts/check-dependencies.mjs +++ b/typescript/adapter-contract/scripts/check-dependencies.mjs @@ -12,6 +12,7 @@ const manifest = JSON.parse( const lockfile = JSON.parse( await readFile(resolve(packageRoot, "package-lock.json"), "utf8"), ); +const reviewedPermissiveLicenses = new Set(["Apache-2.0", "MIT", "Python-2.0"]); for (const field of [ "dependencies", @@ -39,15 +40,19 @@ if (productionDependencies.length > 0) { ); } -const missingLicenses = dependencies.filter( - (dependency) => - typeof dependency.license !== "string" || dependency.license.length === 0, +const unreviewedLicenses = dependencies.filter( + (dependency) => !reviewedPermissiveLicenses.has(dependency.license), ); -if (missingLicenses.length > 0) { +if (unreviewedLicenses.length > 0) { + const details = unreviewedLicenses + .map( + (dependency) => + `${dependency.path} (${JSON.stringify(dependency.license) ?? "missing"})`, + ) + .join(", "); throw new Error( - `Package lock contains dependencies without license metadata: ${missingLicenses - .map((dependency) => dependency.path) - .join(", ")}`, + `Dependency licenses require explicit dependency-approver review: ${details}. ` + + "Add only reviewed permissive SPDX identifiers to the allowlist.", ); } From 10da9466cda9fa5b1827baf0c4828a480bd4a58e Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Tue, 11 Aug 2026 08:20:52 -0700 Subject: [PATCH 04/12] ci: publish TypeScript contract to npm Signed-off-by: Ajay Thorve --- .agents/skills/maintain-ci/SKILL.md | 6 + .agents/skills/maintain-packaging/SKILL.md | 4 + .github/workflows/publish_typescript.yml | 116 +++++++ RELEASING.md | 78 ++++- scripts/ci/publish_typescript_package.py | 296 ++++++++++++++++++ .../test_publish_typescript_package.py | 274 ++++++++++++++++ typescript/adapter-contract/package.json | 3 +- 7 files changed, 773 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/publish_typescript.yml create mode 100644 scripts/ci/publish_typescript_package.py create mode 100644 tests/scripts/test_publish_typescript_package.py diff --git a/.agents/skills/maintain-ci/SKILL.md b/.agents/skills/maintain-ci/SKILL.md index cb967439..11cc4cac 100644 --- a/.agents/skills/maintain-ci/SKILL.md +++ b/.agents/skills/maintain-ci/SKILL.md @@ -29,6 +29,10 @@ reliability, or reproducibility. `actions/cache`. - Use lockfiles or dependency manifests to drive cache invalidation. - Keep deploy and publish permissions isolated to the jobs that need them. +- Publish the TypeScript contract from the dedicated + `publish_typescript.yml` workflow through the protected `npmjs` environment. + Grant `id-token: write` for npm trusted publishing, and do not provide an npm + write token that could mask an OIDC configuration failure. - Read both caller and callee when a workflow uses `workflow_call`. - Keep documentation publish and preview credentials isolated to the Fern docs workflow. @@ -93,6 +97,8 @@ source instead of assuming local success proves remote success. - `.github/workflows/fern-docs.yml` - `.github/workflows/nightly-alpha-tag.yml` - `.github/workflows/publish_rust.yml` +- `.github/workflows/publish_typescript.yml` +- `scripts/ci/publish_typescript_package.py` - `.gitlab-ci.yml` - `RELEASING.md` - `Cargo.lock` diff --git a/.agents/skills/maintain-packaging/SKILL.md b/.agents/skills/maintain-packaging/SKILL.md index 021afa41..8beb9fc8 100644 --- a/.agents/skills/maintain-packaging/SKILL.md +++ b/.agents/skills/maintain-packaging/SKILL.md @@ -29,6 +29,8 @@ consumed outside the source tree. - Documentation tooling metadata in `docs/package.json` and `docs/package-lock.json` - CI workflows, install commands, and example commands +- npm trusted publishing through `.github/workflows/publish_typescript.yml` and + the protected `npmjs` environment - `justfile` build, test, clean, and documentation recipes - Release tags, registry publication, and release-facing documentation in `RELEASING.md` @@ -107,6 +109,8 @@ compatibility decisions using the distribution and linkage context. - `typescript/adapter-contract/package.json` - `typescript/adapter-contract/package-lock.json` - `.github/workflows/ci_typescript.yml` +- `.github/workflows/publish_typescript.yml` +- `scripts/ci/publish_typescript_package.py` - `.github/workflows/ci_python.yml` - `.github/workflows/ci_rust.yml` - `.pre-commit-config.yaml` diff --git a/.github/workflows/publish_typescript.yml b/.github/workflows/publish_typescript.yml new file mode 100644 index 00000000..8732cbdb --- /dev/null +++ b/.github/workflows/publish_typescript.yml @@ -0,0 +1,116 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Publish TypeScript package + +on: + push: + tags: + - 'v*' + - '!v*-alpha*' + +concurrency: + group: publish-typescript + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + publish-typescript: + name: Publish (npmjs.com) + # Stable, beta, and RC tags publish. Alpha tags remain artifact-only. + if: ${{ github.ref_type == 'tag' && !contains(github.ref_name, '-alpha') }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + id-token: write + environment: npmjs + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: '24' + package-manager-cache: false + + - name: Validate trusted publishing toolchain + run: | + set -euo pipefail + + node --version + npm_version="$(npm --version)" + echo "$npm_version" + NPM_VERSION="$npm_version" node <<'NODE' + const actual = process.env.NPM_VERSION.split('.').map(Number); + const minimum = [11, 5, 1]; + const comparison = actual.findIndex( + (part, index) => part !== minimum[index], + ); + + if ( + actual.length !== minimum.length || + actual.some(Number.isNaN) || + (comparison !== -1 && actual[comparison] < minimum[comparison]) + ) { + throw new Error('npm 11.5.1 or newer is required for trusted publishing'); + } + NODE + + - name: Prepare release metadata + id: release + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + + version="$(python3 scripts/ci/normalize_release_tag.py "$RELEASE_TAG")" + if [[ "$version" == *+* ]]; then + echo "npm publication does not support build metadata: $version" >&2 + exit 1 + fi + + case "$version" in + *-alpha*) + echo "Alpha releases are not published to npm: $version" >&2 + exit 1 + ;; + *-beta*|*-rc*) dist_tag="next" ;; + *-*) + echo "Unsupported npm prerelease: $version" >&2 + exit 1 + ;; + *) dist_tag="latest" ;; + esac + + python3 scripts/ci/set_typescript_project_version.py "$version" + { + echo "version=$version" + echo "dist_tag=$dist_tag" + } >> "$GITHUB_OUTPUT" + + - name: Install dependencies + working-directory: typescript/adapter-contract + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Test package + working-directory: typescript/adapter-contract + run: npm test + + - name: Publish package + env: + NPM_CONFIG_REGISTRY: https://registry.npmjs.org + RELEASE_VERSION: ${{ steps.release.outputs.version }} + RELEASE_DIST_TAG: ${{ steps.release.outputs.dist_tag }} + run: | + set -euo pipefail + python3 scripts/ci/publish_typescript_package.py \ + --package-directory typescript/adapter-contract \ + --version "$RELEASE_VERSION" \ + --dist-tag "$RELEASE_DIST_TAG" diff --git a/RELEASING.md b/RELEASING.md index 4171c394..53c0ed78 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -31,6 +31,7 @@ The release pipeline publishes these package surfaces from a tag push: | Ecosystem | Published Surface | |---|---| | crates.io | `nemo-fabric-core`, `nemo-fabric-cli` | +| npm | `@nvidia/nemo-fabric-adapter-contract` | | GitHub Actions | `nemo-fabric`, `nemo-fabric-runtime`, `nemo-fabric-adapters-common`, `nemo-fabric-adapters-claude`, `nemo-fabric-adapters-codex`, `nemo-fabric-adapters-deepagents`, and `nemo-fabric-adapters-hermes` wheel artifacts | | Fern | The documentation site | @@ -187,6 +188,61 @@ place. In a disposable CI workspace that is fine. In a local checkout, restore those temporary manifest edits before continuing if you are not committing them. +## Bootstrap npm Trusted Publishing + +The npm package must exist before npm can bind it to a GitHub trusted publisher. +This is a one-time bootstrap for +`@nvidia/nemo-fabric-adapter-contract`; normal releases use OpenID Connect (OIDC) +and do not use an npm write token in GitHub Actions. + +Before the first TypeScript package release: + +1. Create and protect the GitHub `npmjs` environment. Require the release + approvers who should authorize registry publication, and restrict deployment + tags to `v*`. +2. Commit the intended prerelease version on the release branch. From that + exact clean commit, run the version helper as an idempotency check and run + the same package checks used by CI. Use the real first release candidate + rather than a disposable version because npm versions are immutable: + + ```bash + just set-version 0.2.0-rc.1 + git diff --exit-code + just test-typescript + cd typescript/adapter-contract + npm login + npm publish --access public --tag next + npm logout + ``` + + The publisher needs write access to the `@nvidia` scope and account-level + two-factor authentication. Do not push the matching release tag yet. +3. In the npm package settings, configure the single trusted publisher with + these exact, case-sensitive values: + + - Organization or user: `NVIDIA` + - Repository: `NeMo-Fabric` + - Workflow filename: `publish_typescript.yml` + - Environment: `npmjs` + - Allowed action: `npm publish` + +4. Push the signed tag for that already-published release candidate. The + workflow verifies the existing package integrity and `next` dist-tag, then + exits successfully without republishing it. Approve the `npmjs` environment + when prompted. +5. After a later release publishes through OIDC, confirm its provenance on npm. + In the npm package settings, require two-factor authentication and disallow + token publication. Then remove or revoke any local or automation credentials + used for bootstrap. + +The workflow publishes stable versions with the `latest` dist-tag and beta or +RC versions with `next`. Alpha versions are not published. A retry skips only +when the immutable package version, packed artifact integrity, and expected +dist-tag all match. If any of them differs, the workflow fails so a maintainer +can inspect and repair the registry state explicitly. Publication also fails +rather than moving `latest` or `next` backward when cutting a patch from an +older release line. + ## Cut An RC Tag After the release commit is merged and validated, create and push a signed, @@ -320,6 +376,7 @@ Pushing a valid tag triggers : |---|---| | [`.github/workflows/ci_python.yml`](.github/workflows/ci_python.yml) | For all tags including alpha | | [`.github/workflows/publish_rust.yml`](.github/workflows/publish_rust.yml) | For RC, beta and release tags | +| [`.github/workflows/publish_typescript.yml`](.github/workflows/publish_typescript.yml) | For RC, beta and release tags | | [`.github/workflows/fern-docs.yml`](.github/workflows/fern-docs.yml) | For RC, beta and release tags | The release pipeline then: @@ -332,7 +389,11 @@ The release pipeline then: 3. Publishes `nemo-fabric-core` and `nemo-fabric-cli` to crates.io through trusted publishing for stable, beta, and RC tags. Alpha tags are not published to crates.io. -4. Publishes Fern documentation versions for stable, beta, and RC tags. Alpha +4. Publishes `@nvidia/nemo-fabric-adapter-contract` to npm through trusted + publishing for stable, beta, and RC tags. Stable releases use the `latest` + dist-tag; beta and RC releases use `next`. Alpha tags are not published to + npm. +5. Publishes Fern documentation versions for stable, beta, and RC tags. Alpha tags do not publish a separate documentation version. The workflow boundary is split intentionally: @@ -343,6 +404,9 @@ The workflow boundary is split intentionally: and publishes Fern documentation independently from package CI. - [`.github/workflows/publish_rust.yml`](.github/workflows/publish_rust.yml) owns crates.io publication decisions and credentials. +- [`.github/workflows/publish_typescript.yml`](.github/workflows/publish_typescript.yml) + owns npm publication decisions and requests a short-lived npm credential + through GitHub OIDC. It does not receive an npm write token. ## Publish The GitHub Release Entry @@ -380,5 +444,13 @@ After the release is live, verify: - [`nemo-fabric-adapters-codex`](https://pypi.nvidia.com/nemo-fabric-adapters-codex/) - [`nemo-fabric-adapters-deepagents`](https://pypi.nvidia.com/nemo-fabric-adapters-deepagents/) - [`nemo-fabric-adapters-hermes`](https://pypi.nvidia.com/nemo-fabric-adapters-hermes/) -4. The Fern documentation site shows the expected version and release notes. -5. The GitHub Release page is complete and accurate. +4. The TypeScript contract package is visible on npm with the expected version, + dist-tag, and provenance: + + ```bash + npm view "@nvidia/nemo-fabric-adapter-contract@" version + npm view "@nvidia/nemo-fabric-adapter-contract" dist-tags + ``` + +5. The Fern documentation site shows the expected version and release notes. +6. The GitHub Release page is complete and accurate. diff --git a/scripts/ci/publish_typescript_package.py b/scripts/ci/publish_typescript_package.py new file mode 100644 index 00000000..e08afc87 --- /dev/null +++ b/scripts/ci/publish_typescript_package.py @@ -0,0 +1,296 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +import time +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from pathlib import Path + + +VERSION_PATTERN = re.compile( + r"^(?P\d+)\.(?P\d+)\.(?P\d+)" + r"(?:-(?P