From 0da76d07f3361e41364ec40478eadbe7b2e9dc31 Mon Sep 17 00:00:00 2001 From: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:42:18 -0700 Subject: [PATCH 1/8] feat: add Strands harness adapter using ACP Signed-off-by: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> --- .github/workflows/publish_typescript.yml | 3 +- README.md | 1 + adapters/README.md | 3 + adapters/typescript/README.md | 2 + adapters/typescript/common/acp-runner.md | 2 + adapters/typescript/package-lock.json | 57 ++- adapters/typescript/package.json | 9 +- adapters/typescript/scripts/check-install.mjs | 69 +++- adapters/typescript/scripts/check-package.mjs | 14 + adapters/typescript/strands/LICENSE | 203 +++++++++++ adapters/typescript/strands/README.md | 145 ++++++++ adapters/typescript/strands/package.json | 52 +++ adapters/typescript/strands/src/cli.ts | 11 + .../typescript/strands/src/configuration.ts | 334 ++++++++++++++++++ adapters/typescript/strands/src/runtime.ts | 183 ++++++++++ .../typescript/strands/src/strands-acp.ts | 334 ++++++++++++++++++ .../strands/strands.fabric-adapter.json | 180 ++++++++++ .../strands/test/configuration.test.mjs | 211 +++++++++++ .../strands/test/fixtures/acp-agent.mjs | 73 ++++ .../typescript/strands/test/runtime.test.mjs | 193 ++++++++++ .../strands/test/strands-acp.test.mjs | 172 +++++++++ .../typescript/strands/tsconfig.build.json | 15 + docs/index.yml | 2 + docs/integrations/harness/overview.mdx | 1 + docs/integrations/harness/strands.mdx | 31 ++ examples/code_review_agent/README.md | 16 + examples/code_review_agent/__init__.py | 2 + examples/code_review_agent/__main__.py | 14 +- examples/code_review_agent/config.py | 17 + justfile | 7 +- scripts/ci/set_typescript_project_version.py | 19 + .../nemo_fabric_adapter_catalog/catalog.json | 189 ++++++++++ tests/adapters/test_strands_adapter.py | 119 +++++++ tests/e2e/test_strands.py | 163 +++++++++ .../test_publish_typescript_workflow.py | 2 + .../test_set_typescript_project_version.py | 20 ++ 36 files changed, 2859 insertions(+), 9 deletions(-) create mode 100644 adapters/typescript/strands/LICENSE create mode 100644 adapters/typescript/strands/README.md create mode 100644 adapters/typescript/strands/package.json create mode 100644 adapters/typescript/strands/src/cli.ts create mode 100644 adapters/typescript/strands/src/configuration.ts create mode 100644 adapters/typescript/strands/src/runtime.ts create mode 100644 adapters/typescript/strands/src/strands-acp.ts create mode 100644 adapters/typescript/strands/strands.fabric-adapter.json create mode 100644 adapters/typescript/strands/test/configuration.test.mjs create mode 100644 adapters/typescript/strands/test/fixtures/acp-agent.mjs create mode 100644 adapters/typescript/strands/test/runtime.test.mjs create mode 100644 adapters/typescript/strands/test/strands-acp.test.mjs create mode 100644 adapters/typescript/strands/tsconfig.build.json create mode 100644 docs/integrations/harness/strands.mdx create mode 100644 tests/adapters/test_strands_adapter.py create mode 100644 tests/e2e/test_strands.py diff --git a/.github/workflows/publish_typescript.yml b/.github/workflows/publish_typescript.yml index e023749c4..701ef826d 100644 --- a/.github/workflows/publish_typescript.yml +++ b/.github/workflows/publish_typescript.yml @@ -158,7 +158,8 @@ jobs: adapters/typescript/opencode \ adapters/typescript/qwen \ adapters/typescript/kilo \ - adapters/typescript/kiro; do + adapters/typescript/kiro \ + adapters/typescript/strands; do python3 scripts/ci/publish_typescript_package.py \ --action publish \ --package-directory "$package_directory" \ diff --git a/README.md b/README.md index 99dc3c307..a4a3d1a7b 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ NeMo Fabric includes adapters for the following harnesses and execution targets. | [Hermes Agent](adapters/python/hermes/README.md) | ✅ | ✅ | ✅ | ✅ | | [Kilo Code](adapters/typescript/kilo/README.md) | ✅ | ✅ | ✅ | — | | [Kiro](adapters/typescript/kiro/README.md) | — | ✅ | — | — | +| [Strands](adapters/typescript/strands/README.md) | — | ✅ | — | — | | [LangChain Deep Agents](adapters/python/deepagents/README.md) | ✅ | ✅ | ✅ | ✅ | | [mini-SWE-agent](adapters/python/mini-swe-agent/README.md) | — | — | — | ✅ | | [NOOA](adapters/python/nooa/README.md) | — | ✅ | ✅ | ✅ | diff --git a/adapters/README.md b/adapters/README.md index 9501b5575..d56b98dbb 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -62,6 +62,7 @@ build, and packaging conventions. | [Factory Droid](typescript/droid/README.md) | `nvidia.fabric.droid` | `nemo-fabric-adapters-droid` | Node.js 22.19+ | | [Kilo Code](typescript/kilo/README.md) | `nvidia.fabric.kilo` | `nemo-fabric-adapters-kilo` | Node.js 22.19+ | | [Kiro](typescript/kiro/README.md) | `nvidia.fabric.kiro` | `nemo-fabric-adapters-kiro` | Node.js 22.19+ | +| [Strands](typescript/strands/README.md) | `nvidia.fabric.strands` | `nemo-fabric-adapters-strands` | Node.js 22.19+ | | [OpenCode](typescript/opencode/README.md) | `nvidia.fabric.opencode` | `nemo-fabric-adapters-opencode` | Bun 1.4.2+ | | [Pi](typescript/pi/README.md) | `nvidia.fabric.pi` | `nemo-fabric-adapters-pi` | Node.js 22.19+ | | [Qwen Code](typescript/qwen/README.md) | `nvidia.fabric.qwen` | `nemo-fabric-adapters-qwen` | Node.js 22.19+ | @@ -104,6 +105,7 @@ integration shape and implement the minimum lifecycle. | [Kilo Code](typescript/kilo/README.md) | Local Kilo server and one Kilo session | Not supported | Reuses the session and submits one SDK prompt for ordered text input | Deletes the session, terminates the server, and removes the isolated profile | Adapter-owned loopback service | | [Kilo Code](typescript/kilo/README.md) | Configured Kilo provider and model with an optional OpenAI-compatible base URL | `tools.enabled` and `tools.blocked` map to native Kilo tool names | Normalized: stdio, streamable HTTP, and SSE | Normalized `skills.paths` | Not exposed | | [Kiro](typescript/kiro/README.md) | Optional model selected from the Kiro ACP session catalog | ACP permission requests use the closed adapter setting; normalized tool policy is not exposed | Normalized ACP session inputs: stdio, streamable HTTP, and SSE | Not exposed | Kiro native behavior | +| [Strands](typescript/strands/README.md) | Bedrock or explicit OpenAI-compatible Chat Completions endpoint | Built-in tool selection; stock harness permissions | Stdio, streamable HTTP, and SSE | Not exposed | Append system instructions | | [OpenCode](typescript/opencode/README.md) | Configured OpenCode provider and model with an optional OpenAI-compatible base URL | Not exposed | Normalized: stdio and streamable HTTP | Normalized `skills.paths` | Not exposed | | [Pi](typescript/pi/README.md) | One Pi-catalog provider and model with an optional base URL override | `tools.definitions`, `tools.enabled`, and `tools.blocked` cover built-ins, trusted local modules, and explicit extension tools | Not exposed | Normalized `skills.paths` | Not exposed | | [Qwen Code](typescript/qwen/README.md) | OpenAI-compatible provider and model | Native `tools.blocked` deny list | Normalized: stdio and streamable HTTP with per-server tool filters | Explicit `skills.paths` | Not exposed | @@ -214,6 +216,7 @@ Agent Trajectory Interchange Format (ATIF). | [Pi](typescript/pi/README.md) | In-memory Pi `AgentSession` | Runtime-owned Relay 0.9 or 0.10 CLI gateway and explicit Pi extension | Reuses the session and calls `prompt()` for ordered text input; with `streaming=True`, routes per-invocation model-turn ATOF for successful redirects through the embedded collector; startup `model_redirect` marks remain in configured Relay ATOF artifacts and are not included in `invoke_stream()`; `relay_artifacts` does not include local ATIF | Aborts work, emits extension shutdown so local ATIF finalizes on disk, disposes the session, and then stops the gateway | Not implemented | | [Qwen Code](typescript/qwen/README.md) | SDK query and bundled Qwen CLI child process | Not supported | Reuses one live SDK query across ordered text turns | Closes the query and removes isolated temporary settings | Not implemented | | [Kiro](typescript/kiro/README.md) | Kiro CLI V3 child process and ACP session | Not supported | Reuses one ACP session and submits ordered text prompts | Closes the ACP session and terminates the Kiro child process | Not implemented | +| [Strands](typescript/strands/README.md) | Strands CLI ACP child process and session | Not supported | Ordered text prompts retain one bounded conversation | Terminates the child process and removes temporary CLI state | Not implemented | | [Remote Agent](python/remote-agent/README.md) | `httpx.AsyncClient` and user/assistant transcript | Remote Relay publishes to a shared ATOF collector | Registers the request ID, maps it into body metadata, sends one HTTP request, and retains the completed transcript | Closes the HTTP client | Implemented over HTTP(S) | Telemetry output names use the descriptor contract values. Claude, Codex, diff --git a/adapters/typescript/README.md b/adapters/typescript/README.md index ffeb09e78..b21d1c52a 100644 --- a/adapters/typescript/README.md +++ b/adapters/typescript/README.md @@ -76,3 +76,5 @@ npm dependency. The OpenCode adapter runs with Bun 1.4.2 or later. Source and package checks run its process lifecycle under Bun while using Node.js for TypeScript builds and the shared workspace tooling. + +The [Strands adapter](strands/README.md) runs the standard Strands harness through the shared ACP runner. Install the CLI separately, then use `just install-typescript-strands` to install the adapter workspace dependencies. diff --git a/adapters/typescript/common/acp-runner.md b/adapters/typescript/common/acp-runner.md index 51852bbaa..dbbca7cc0 100644 --- a/adapters/typescript/common/acp-runner.md +++ b/adapters/typescript/common/acp-runner.md @@ -86,3 +86,5 @@ When an agent invocation exceeds its deadline, the runner attempts to send `sess Shutdown is idempotent. The runner disposes its active-session observer, requests `session/close` when the agent advertises that capability, and closes the ACP connection. On POSIX systems, the agent runs in a dedicated process group: cleanup sends `SIGTERM` to the group, waits for the agent to exit within the shutdown timeout, then sends `SIGKILL` to any remaining group members. On Windows, cleanup uses `taskkill /T /F` to terminate the agent and its descendants. The runner waits for the agent process to exit before returning. Refer to the [Kiro adapter](../kiro/README.md) for the first integration that uses the shared ACP runner. + +The [Strands adapter](../strands/README.md) also uses this runner. It configures the standard Strands CLI before session creation, relies on the new-session response for MCP readiness, and normalizes invocation-local usage. These target-specific choices remain in the adapter. diff --git a/adapters/typescript/package-lock.json b/adapters/typescript/package-lock.json index d4780bfb0..78ebf1fbe 100644 --- a/adapters/typescript/package-lock.json +++ b/adapters/typescript/package-lock.json @@ -16,7 +16,8 @@ "opencode", "qwen", "kilo", - "kiro" + "kiro", + "strands" ], "devDependencies": { "nemo-fabric-adapter-contract": "file:../../adapter-contract/typescript" @@ -7037,6 +7038,10 @@ "resolved": "qwen", "link": true }, + "node_modules/nemo-fabric-adapters-strands": { + "resolved": "strands", + "link": true + }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -8966,6 +8971,56 @@ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT" + }, + "strands": { + "name": "nemo-fabric-adapters-strands", + "version": "0.6.0", + "license": "Apache-2.0", + "dependencies": { + "nemo-fabric-adapter-contract": "0.6.0", + "nemo-fabric-adapters-common": "0.6.0" + }, + "bin": { + "nemo-fabric-adapters-strands": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "24.12.4", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "strands/node_modules/@types/node": { + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "strands/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" + } + }, + "strands/node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" } } } diff --git a/adapters/typescript/package.json b/adapters/typescript/package.json index 961b78917..d046d3254 100644 --- a/adapters/typescript/package.json +++ b/adapters/typescript/package.json @@ -12,16 +12,17 @@ "opencode", "qwen", "kilo", - "kiro" + "kiro", + "strands" ], "scripts": { - "build": "npm run build --workspace nemo-fabric-adapters-common && npm run build --workspace nemo-fabric-adapters-cline && npm run build --workspace nemo-fabric-adapters-droid && npm run build --workspace nemo-fabric-adapters-pi && npm run build --workspace nemo-fabric-adapters-opencode && npm run build --workspace nemo-fabric-adapters-qwen && npm run build --workspace nemo-fabric-adapters-kilo && npm run build --workspace nemo-fabric-adapters-kiro", + "build": "npm run build --workspace nemo-fabric-adapters-common && npm run build --workspace nemo-fabric-adapters-cline && npm run build --workspace nemo-fabric-adapters-droid && npm run build --workspace nemo-fabric-adapters-pi && npm run build --workspace nemo-fabric-adapters-opencode && npm run build --workspace nemo-fabric-adapters-qwen && npm run build --workspace nemo-fabric-adapters-kilo && npm run build --workspace nemo-fabric-adapters-kiro && npm run build --workspace nemo-fabric-adapters-strands", "pack:check": "npm run pack:contents && npm run pack:install-check", - "pack:contents": "npm run pack:check --workspace nemo-fabric-adapters-common && npm run pack:check --workspace nemo-fabric-adapters-cline && npm run pack:check --workspace nemo-fabric-adapters-droid && npm run pack:check --workspace nemo-fabric-adapters-pi && npm run pack:check --workspace nemo-fabric-adapters-opencode && npm run pack:check --workspace nemo-fabric-adapters-qwen && npm run pack:check --workspace nemo-fabric-adapters-kilo && npm run pack:check --workspace nemo-fabric-adapters-kiro", + "pack:contents": "npm run pack:check --workspace nemo-fabric-adapters-common && npm run pack:check --workspace nemo-fabric-adapters-cline && npm run pack:check --workspace nemo-fabric-adapters-droid && npm run pack:check --workspace nemo-fabric-adapters-pi && npm run pack:check --workspace nemo-fabric-adapters-opencode && npm run pack:check --workspace nemo-fabric-adapters-qwen && npm run pack:check --workspace nemo-fabric-adapters-kilo && npm run pack:check --workspace nemo-fabric-adapters-kiro && npm run pack:check --workspace nemo-fabric-adapters-strands", "pack:install-check": "node scripts/check-install.mjs", "test": "npm run test:units && npm run test:dependencies && npm run pack:check", "test:dependencies": "npm ls --all && node scripts/audit-dependencies.mjs", - "test:units": "node --test scripts/audit-dependencies.test.mjs && npm test --workspace nemo-fabric-adapters-common && npm test --workspace nemo-fabric-adapters-cline && npm test --workspace nemo-fabric-adapters-droid && npm test --workspace nemo-fabric-adapters-pi && npm test --workspace nemo-fabric-adapters-opencode && npm test --workspace nemo-fabric-adapters-qwen && npm test --workspace nemo-fabric-adapters-kilo && npm test --workspace nemo-fabric-adapters-kiro" + "test:units": "node --test scripts/audit-dependencies.test.mjs && npm test --workspace nemo-fabric-adapters-common && npm test --workspace nemo-fabric-adapters-cline && npm test --workspace nemo-fabric-adapters-droid && npm test --workspace nemo-fabric-adapters-pi && npm test --workspace nemo-fabric-adapters-opencode && npm test --workspace nemo-fabric-adapters-qwen && npm test --workspace nemo-fabric-adapters-kilo && npm test --workspace nemo-fabric-adapters-kiro && npm run test --workspace nemo-fabric-adapters-strands" }, "devDependencies": { "nemo-fabric-adapter-contract": "file:../../adapter-contract/typescript" diff --git a/adapters/typescript/scripts/check-install.mjs b/adapters/typescript/scripts/check-install.mjs index 10c64c8bc..182f68d65 100644 --- a/adapters/typescript/scripts/check-install.mjs +++ b/adapters/typescript/scripts/check-install.mjs @@ -10,7 +10,7 @@ import { execFileSync, spawnSync } from "node:child_process"; import { access, chmod, copyFile, 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"; +import { fileURLToPath, pathToFileURL } from "node:url"; const npmCli = process.env.npm_execpath; if (npmCli === undefined) { @@ -29,6 +29,7 @@ const packageRoots = [ join(repositoryRoot, "adapters/typescript/qwen"), join(repositoryRoot, "adapters/typescript/kilo"), join(repositoryRoot, "adapters/typescript/kiro"), + join(repositoryRoot, "adapters/typescript/strands"), ]; function npm(args, cwd) { @@ -155,6 +156,19 @@ function runKiroCli(kiroRoot, consumerRoot, requests, env = process.env) { return invocation.stdout.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line)); } +function runStrandsCli(strandsRoot, consumerRoot, requests, env = process.env) { + const invocation = spawnSync(process.execPath, [join(strandsRoot, "dist/cli.js")], { + cwd: consumerRoot, + encoding: "utf8", + env, + input: `${requests.map((request) => JSON.stringify(request)).join("\n")}\n`, + timeout: 60_000, + }); + if (invocation.error) throw invocation.error; + if (invocation.status !== 0) throw new Error(`Installed Strands CLI adapter failed: ${invocation.stderr}`); + return invocation.stdout.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line)); +} + function startRequest(consumerRoot) { return { operation: "start", @@ -283,6 +297,18 @@ function kiroStartRequest(consumerRoot) { }; } +function strandsStartRequest(consumerRoot) { + const request = openCodeStartRequest(consumerRoot); + return { + ...request, + payload: { + ...request.payload, + agent_name: "strands-install-check", + config: {}, + }, + }; +} + const temporaryRoot = await mkdtemp(join(tmpdir(), "nemo-fabric-ts-install-")); try { const tarballs = []; @@ -624,6 +650,47 @@ try { ) { throw new Error(`Packaged Kiro lifecycle failed: ${JSON.stringify(kiroResponses)}`); } + const strandsRoot = join(consumerRoot, "node_modules/nemo-fabric-adapters-strands"); + const strandsDescriptor = JSON.parse(await readFile(join(strandsRoot, "strands.fabric-adapter.json"), "utf8")); + if (strandsDescriptor.runner?.command !== "node" || strandsDescriptor.runner?.script !== "dist/cli.js") { + throw new Error("Installed Strands descriptor does not reference its packaged CLI"); + } + const [missingStrandsResponse] = runStrandsCli(strandsRoot, consumerRoot, + [strandsStartRequest(consumerRoot)], { ...process.env, PATH: missingKiroBin }); + if (missingStrandsResponse.outcome?.error?.code !== "strands_harness_unavailable") { + throw new Error(`Adapter-only install did not report the missing Strands CLI: ${JSON.stringify(missingStrandsResponse)}`); + } + const strandsBin = join(temporaryRoot, "strands-bin"); + await mkdir(strandsBin); + const strandsFixture = join(repositoryRoot, "adapters/typescript/strands/test/fixtures/acp-agent.mjs"); + if (process.platform === "win32") { + const scripts = join(strandsBin, "node_modules/@strands-agents/cli/bin"); + await mkdir(scripts, { recursive: true }); + await writeFile(join(scripts, "strands.js"), `import(${JSON.stringify(pathToFileURL(strandsFixture).href)});\n`); + } else { + await writeFile(join(strandsBin, "strands"), '#!/bin/sh\nexec "$TEST_NODE_EXECUTABLE" "$TEST_STRANDS_FIXTURE" "$@"\n'); + await chmod(join(strandsBin, "strands"), 0o755); + } + const strandsStart = strandsStartRequest(consumerRoot); + strandsStart.payload.runtime_context.environment.env = { + PATH: `${strandsBin}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`, + TEST_NODE_EXECUTABLE: process.execPath, TEST_STRANDS_FIXTURE: strandsFixture, + }; + const strandsResponses = runStrandsCli(strandsRoot, consumerRoot, [strandsStart, + { operation: "invoke", payload: { request: { input: "remember-me" }, runtime_context: { + ...strandsStart.payload.runtime_context, invocation_id: "strands-one" } } }, + { operation: "invoke", payload: { request: { input: "recall" }, runtime_context: { + ...strandsStart.payload.runtime_context, invocation_id: "strands-two" } } }, + { operation: "stop", payload: { runtime_id: strandsStart.payload.runtime_context.runtime_id } }, + ]); + if (strandsResponses.length !== 4 || strandsResponses[0].outcome?.status !== "succeeded" || + strandsResponses[1].outcome?.output?.output?.response !== "remember-me" || + strandsResponses[2].outcome?.output?.output?.response !== "remember-me" || + strandsResponses[2].outcome?.output?.usage?.input_tokens !== 12 || + strandsResponses[3].outcome?.status !== "succeeded") { + throw new Error(`Packaged Strands lifecycle failed: ${JSON.stringify(strandsResponses)}`); + } + } finally { await rm(temporaryRoot, { recursive: true, force: true }); } diff --git a/adapters/typescript/scripts/check-package.mjs b/adapters/typescript/scripts/check-package.mjs index 5bb5824c7..f5cd0bc7c 100644 --- a/adapters/typescript/scripts/check-package.mjs +++ b/adapters/typescript/scripts/check-package.mjs @@ -137,6 +137,20 @@ const expectedByPackage = { "kiro.fabric-adapter.json", "package.json", ], + "nemo-fabric-adapters-strands": [ + "LICENSE", + "README.md", + "dist/cli.d.ts", + "dist/cli.js", + "dist/configuration.d.ts", + "dist/configuration.js", + "dist/strands-acp.d.ts", + "dist/strands-acp.js", + "dist/runtime.d.ts", + "dist/runtime.js", + "strands.fabric-adapter.json", + "package.json", + ], }; const expectedFiles = expectedByPackage[manifest.name]; if (expectedFiles === undefined) { diff --git a/adapters/typescript/strands/LICENSE b/adapters/typescript/strands/LICENSE new file mode 100644 index 000000000..f13d24c3f --- /dev/null +++ b/adapters/typescript/strands/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/adapters/typescript/strands/README.md b/adapters/typescript/strands/README.md new file mode 100644 index 000000000..97f06607c --- /dev/null +++ b/adapters/typescript/strands/README.md @@ -0,0 +1,145 @@ + + +# Strands ACP Adapter + +Run the standard [Strands harness](https://github.com/strands-agents/harness-sdk/tree/main/strands-cli) through NVIDIA NeMo Fabric. The `nvidia.fabric.strands` adapter launches `strands --acp-server` using the [shared ACP runner](../common/acp-runner.md), retains one conversation per Fabric runtime, and submits ordered text invocations. + +```mermaid +flowchart LR + Fabric --> Adapter[Strands adapter] + Adapter --> Runner[ACP runner] + Runner --> CLI[Strands CLI] + CLI --> Model + CLI --> Tools +``` + +This adapter targets the standard harness. Loading arbitrary Python or TypeScript SDK agents through `--agent` requires a separate integration: the CLI's source-agent paths have different construction, MCP, permission, and usage behavior. + +## Install + +Use Node.js 22.19 or newer. Install the harness separately; it is not a dependency of the adapter. The tested CLI release is 0.2.1. + +```bash +npm install --global @strands-agents/cli@0.2.1 +``` + +For a source checkout, run from the repository root: + +```bash +just install-typescript-strands +npm run build --prefix adapters/typescript --workspace nemo-fabric-adapters-common +npm run build --prefix adapters/typescript --workspace nemo-fabric-adapters-strands +``` + +The published adapter package is `nemo-fabric-adapters-strands`. Its descriptor is `strands.fabric-adapter.json` in the installed package. Fabric's Python adapter catalog also includes the descriptor; source development can select it explicitly as below. + +## Configure an Agent Invocation + +```python +import asyncio +from pathlib import Path + +from nemo_fabric import ( + DiscoveryConfig, EnvironmentConfig, Fabric, FabricConfig, HarnessConfig, + InstructionConfig, InstructionsConfig, MetadataConfig, ModelConfig, RuntimeConfig, +) + +root = Path.cwd() +config = FabricConfig( + metadata=MetadataConfig(name="strands-review"), + discovery=DiscoveryConfig(local_paths=[ + root / "adapters/typescript/strands/strands.fabric-adapter.json", + ]), + harness=HarnessConfig(adapter_id="nvidia.fabric.strands"), + models={"default": ModelConfig( + provider="nvidia", + model="nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", + base_url="https://integrate.api.nvidia.com/v1", + api_key_env="NVIDIA_API_KEY", + )}, + instructions=InstructionsConfig(system=InstructionConfig( + mode="append", content="Read the relevant files before reviewing correctness.", + )), + environment=EnvironmentConfig(provider="local", workspace=root, artifacts=root / "artifacts/strands"), + runtime=RuntimeConfig(input_schema="text", output_schema="message", artifacts=root / "artifacts/strands"), +) + +async def main(): + async with await Fabric().start_runtime(config, base_dir=root) as runtime: + result = await runtime.invoke(input="Review README.md for unclear setup instructions.") + print(result.status, result.output) + +asyncio.run(main()) +``` + +Only the `default` model role is supported. With no configured model, Strands selects its native Bedrock default. To select a Bedrock model explicitly, use `ModelConfig(provider="bedrock", model="")` without `base_url` or `api_key_env`. Configure the AWS SDK credential chain and region before starting Fabric. AWS profiles and SSO caches remain accessible through the runtime's isolated home. + +For Chat Completions endpoints, use provider `openai` or `nvidia` and an explicit `base_url`. Both map to Strands' native `litellm/` profile, which creates a direct OpenAI-compatible Chat Completions client; a LiteLLM proxy is not required. The model ID is passed unchanged after the profile prefix. The CLI's `openai/` profile uses the Responses API, so it is not used for this mapping. + +Set `api_key_env` to the name of a credential in the task environment or parent process. An explicitly named but unavailable credential fails startup. Omit it for a keyless endpoint; the client receives a nonsecret placeholder credential. HTTPS and loopback HTTP are accepted. For a trusted HTTP server elsewhere, add its hostname or IP to `harness.settings.insecure_http_model_hosts`: + +```python +config.models["default"] = ModelConfig( + provider="openai", model="", base_url="http://model-server:8000/v1", +) +config.harness.settings["insecure_http_model_hosts"] = ["model-server"] +``` + +Query the endpoint's model catalog for its served ID. Strands does not provide a model server, translate model names, or make unsupported tool calling work on a model. + +## Tools, Instructions, and MCP + +`instructions.system` supports `append` only. The CLI appends domain instructions to the harness system prompt. Replacement fails planning and direct adapter startup. + +| Harness setting | Default | Behavior | +| --- | --- | --- | +| `builtin_tools` | `shell`, `read`, `write`, `edit` | Select from these tools and `web_fetch`; an empty list disables built-in tools. | +| `max_invocations` | `100` | Maximum dispatched invocations in one conversation, from 1 to 10,000. Start a new runtime when exhausted; history is not silently reset. | +| `insecure_http_model_hosts` | `[]` | Explicit hostnames or IPs allowed for HTTP model endpoints outside loopback. | + +The standard Strands ACP server executes enabled tools with the task environment's permissions. There is no adapter permission mode or normalized tool-policy support. Tool selection is not an operating-system sandbox; use an appropriate Fabric environment when running untrusted tasks. + +Configure MCP through Fabric's normalized `mcp.servers`, not harness settings: + +```python +import sys + +config.add_mcp_server( + "probe", transport="stdio", url=sys.executable, + args=[str(root / "tests/_utils/probe_mcp_server.py"), "--marker", "/tmp/probe-called", "--token", "example"], +) +``` + +The adapter supports stdio, streamable HTTP, and SSE. Stdio requires an absolute executable. Remote servers require HTTPS except on loopback. Explicit headers can reference `${ENVIRONMENT_VARIABLE}` values; task environment values take precedence over parent values. ACP-transport MCP, normalized authentication objects, per-server tool filters, and MCP extensions are rejected. Strands initializes the agent and supplied MCP clients before returning the new-session response, so no Kiro-specific readiness notification is needed. + +## Lifecycle and Boundaries + +Each runtime owns one child process, an isolated temporary CLI home, and one live ACP session. Personal CLI profiles, MCP discovery, skill discovery, session persistence, long-term memory, and the todos plugin are disabled. The native environment plugin supplies workspace context. Background tool execution is disabled so tool work stays within its invocation. The adapter inherits a small process/AWS environment and applies explicit task environment values. It preserves access to the caller's `.aws` directory for native credential resolution. The temporary home is removed after stop or startup failure. + +Warm invocations retain conversation history without saving it to the workspace. Deadline expiry attempts ACP cancellation, invalidates the session, and terminates its process group. Stop is idempotent. The adapter supports text input and returns `{ "response": "..." }`, with ACP session ID and stop reason in result metadata. Non-success stop reasons become failed or cancelled results. + +Strands CLI usage is invocation-local and is not differenced across turns. Inclusive input, output, total, and cached-read counters are normalized when valid. Cache writes are retained in `nemo_fabric.detailed_token_usage.v1`. Missing or malformed usage remains unknown without discarding a completed response. Cost is not estimated. Public cancellation, native OpenAI streaming, live updates, services, Relay telemetry, normalized runtime limits, and normalized skills are not advertised. + +## Validate + +Credential-free adapter tests: + +```bash +npm test --prefix adapters/typescript --workspace nemo-fabric-adapters-strands +uv run pytest tests/adapters/test_strands_adapter.py +``` + +Live tests run conversation continuity, independent-runtime isolation, a real MCP tool on the first turn, and a native workspace read. Set `STRANDS_TEST_MODEL` to a Bedrock model ID and configure AWS credentials/region, or select a Chat Completions endpoint: + +```bash +export RUN_FABRIC_STRANDS_INTEGRATION=1 +export STRANDS_TEST_MODEL='' +export STRANDS_TEST_BASE_URL='http://localhost:8000/v1' +# For an authenticated endpoint, also set STRANDS_TEST_API_KEY_ENV to the credential variable name. +uv run pytest tests/e2e/test_strands.py +``` + +The [code-review example](../../../examples/code_review_agent/README.md) includes `--variant strands` for a runnable application. diff --git a/adapters/typescript/strands/package.json b/adapters/typescript/strands/package.json new file mode 100644 index 000000000..5971b0219 --- /dev/null +++ b/adapters/typescript/strands/package.json @@ -0,0 +1,52 @@ +{ + "name": "nemo-fabric-adapters-strands", + "version": "0.6.0", + "description": "Strands ACP harness adapter for NVIDIA NeMo Fabric.", + "license": "Apache-2.0", + "type": "module", + "sideEffects": false, + "exports": { + "./descriptor": "./strands.fabric-adapter.json", + "./package.json": "./package.json" + }, + "bin": { + "nemo-fabric-adapters-strands": "dist/cli.js" + }, + "files": [ + "dist", + "strands.fabric-adapter.json", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "clean": "rm -rf dist", + "pack:check": "npm run build && node ../scripts/check-package.mjs", + "prepack": "npm run build", + "test": "npm run clean && npm run build && node --test test/*.test.mjs" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NVIDIA/NeMo-Fabric.git", + "directory": "adapters/typescript/strands" + }, + "bugs": { + "url": "https://github.com/NVIDIA/NeMo-Fabric/issues" + }, + "homepage": "https://github.com/NVIDIA/NeMo-Fabric/tree/main/adapters/typescript/strands#readme", + "engines": { + "node": ">=22.19.0" + }, + "dependencies": { + "nemo-fabric-adapter-contract": "0.6.0", + "nemo-fabric-adapters-common": "0.6.0" + }, + "devDependencies": { + "@types/node": "24.12.4", + "typescript": "5.9.3" + } +} diff --git a/adapters/typescript/strands/src/cli.ts b/adapters/typescript/strands/src/cli.ts new file mode 100644 index 000000000..fecc04337 --- /dev/null +++ b/adapters/typescript/strands/src/cli.ts @@ -0,0 +1,11 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { serve } from "nemo-fabric-adapters-common"; + +await serve(async () => { + const [{ StrandsAcpSessionFactory }, { StrandsAdapterRuntime }] = + await Promise.all([import("./strands-acp.js"), import("./runtime.js")]); + return new StrandsAdapterRuntime(new StrandsAcpSessionFactory()); +}); diff --git a/adapters/typescript/strands/src/configuration.ts b/adapters/typescript/strands/src/configuration.ts new file mode 100644 index 000000000..b879ce8bc --- /dev/null +++ b/adapters/typescript/strands/src/configuration.ts @@ -0,0 +1,334 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { realpath, stat } from "node:fs/promises"; +import { isAbsolute, resolve } from "node:path"; + +import type { AgentConfig } from "nemo-fabric-adapter-contract"; +import { + LifecycleError, + insecureHttpModelHosts, + modelEndpointAllowed, + type AcpMcpServer, + type AdapterStartInput, +} from "nemo-fabric-adapters-common"; + +export interface StrandsSettings { + builtinTools: string[]; + maxInvocations: number; + insecureHosts: string[]; +} + +const ENV_REFERENCE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/gu; +const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function selectSettings(config: AgentConfig): StrandsSettings { + const settings = config.harness?.settings ?? {}; + const supported = ["shell", "read", "write", "edit", "web_fetch"]; + if ( + !isRecord(settings) || + Object.keys(settings).some( + (name) => + ![ + "builtin_tools", + "max_invocations", + "insecure_http_model_hosts", + ].includes(name), + ) + ) { + throw new LifecycleError( + "strands_settings_invalid", + "Strands harness settings are invalid", + ); + } + const tools = settings.builtin_tools ?? ["shell", "read", "write", "edit"]; + const max = settings.max_invocations ?? 100; + if ( + !Array.isArray(tools) || + tools.some( + (tool) => typeof tool !== "string" || !supported.includes(tool), + ) || + new Set(tools).size !== tools.length || + !Number.isSafeInteger(max) || + Number(max) < 1 || + Number(max) > 10000 + ) { + throw new LifecycleError( + "strands_settings_invalid", + "Strands harness settings are invalid", + ); + } + return { + builtinTools: tools as string[], + maxInvocations: Number(max), + insecureHosts: insecureHttpModelHosts(settings, "strands_settings_invalid"), + }; +} + +export function selectModel( + config: AgentConfig, + settings: StrandsSettings, +): + | { + name: string; + endpoint?: string; + keyEnvironment?: string; + } + | undefined { + const models = Object.entries(config.models ?? {}); + if (models.length === 0) return undefined; + if (models.length !== 1 || models[0]?.[0] !== "default") { + throw new LifecycleError( + "strands_model_roles_unsupported", + "Strands accepts only the default model role", + ); + } + const model = models[0][1]; + if ( + typeof model.model !== "string" || + !model.model.trim() || + model.model.startsWith("-") + ) { + throw new LifecycleError( + "strands_model_invalid", + "Strands requires a non-empty model identifier", + ); + } + if (model.provider === "bedrock") { + if (model.base_url != null || model.api_key_env != null) { + throw new LifecycleError( + "strands_model_invalid", + "Bedrock uses the AWS SDK credential chain and endpoint", + ); + } + return { name: `bedrock/${model.model}` }; + } + if ( + !["openai", "nvidia"].includes(model.provider) || + !model.base_url || + !modelEndpointAllowed(model.base_url, settings.insecureHosts) + ) { + throw new LifecycleError( + "strands_model_invalid", + "Strands requires an allowed OpenAI-compatible model endpoint", + ); + } + const endpoint = new URL(model.base_url); + if ( + endpoint.username || + endpoint.password || + endpoint.search || + endpoint.hash || + (model.api_key_env != null && + !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(model.api_key_env)) + ) { + throw new LifecycleError( + "strands_model_invalid", + "Strands model endpoint or credential reference is invalid", + ); + } + // Strands' litellm profile is a direct Chat Completions client; no proxy is required. + return { + name: `litellm/${model.model}`, + endpoint: model.base_url, + ...(model.api_key_env == null ? {} : { keyEnvironment: model.api_key_env }), + }; +} + +export function systemInstructions(config: AgentConfig): string | undefined { + const instruction = config.instructions?.system; + if (instruction == null) return undefined; + if (instruction.mode !== "append") { + throw new LifecycleError( + "strands_instruction_mode_unsupported", + "Strands supports only append system instructions", + ); + } + return instruction.content; +} + +export async function workspacePath(input: AdapterStartInput): Promise { + const configured = + input.runtimeContext.environment.workspace ?? input.baseDir; + try { + const workspace = await realpath( + isAbsolute(configured) ? configured : resolve(input.baseDir, configured), + ); + if (!(await stat(workspace)).isDirectory()) + throw new Error("not a directory"); + return workspace; + } catch { + throw new LifecycleError( + "strands_workspace_invalid", + "The Strands workspace must be an existing directory", + ); + } +} + +function expandHeader( + value: string, + environment: Record, + parent: NodeJS.ProcessEnv, +): string { + return value.replaceAll(ENV_REFERENCE, (_reference, name: string) => { + const configured = environment[name]; + if (Object.hasOwn(environment, name) && configured !== undefined) + return configured; + const inherited = parent[name]; + if (Object.hasOwn(parent, name) && inherited !== undefined) + return inherited; + throw new LifecycleError( + "strands_mcp_header_variable_missing", + "A configured Strands MCP header references an unavailable environment variable", + ); + }); +} + +function validateHeader(name: string, value: string): void { + if ( + !HEADER_NAME.test(name) || + value.length === 0 || + value.trim() !== value || + /[\r\n\0]/u.test(value) + ) { + throw new LifecycleError( + "strands_mcp_invalid_header", + "A configured Strands MCP HTTP header is invalid", + ); + } +} + +function loopback(hostname: string): boolean { + return ( + hostname === "localhost" || + hostname === "[::1]" || + /^127(?:\.\d{1,3}){3}$/u.test(hostname) + ); +} + +export function selectMcpServers( + config: AgentConfig, + environment: Record, + parent: NodeJS.ProcessEnv, +): AcpMcpServer[] { + if (config.mcp?.extensions !== undefined) { + throw new LifecycleError( + "strands_mcp_extensions_unsupported", + "Strands does not support Fabric MCP extensions", + ); + } + const selected: AcpMcpServer[] = []; + for (const [name, server] of Object.entries(config.mcp?.servers ?? {})) { + if (name.trim().length === 0 || server.extensions !== undefined) { + throw new LifecycleError( + "strands_mcp_invalid_server", + "Strands MCP server configuration is invalid", + ); + } + if (server.authentication !== undefined && server.authentication !== null) { + throw new LifecycleError( + "strands_mcp_authentication_unsupported", + "Strands MCP authentication is unsupported; configure an explicit authorization header", + ); + } + if ( + (server.allowed_tools !== undefined && server.allowed_tools !== null) || + (server.blocked_tools?.length ?? 0) > 0 + ) { + throw new LifecycleError( + "strands_mcp_tool_filters_unsupported", + "Strands ACP does not support Fabric MCP per-server tool filters", + ); + } + if (server.transport === "stdio") { + if ( + !isAbsolute(server.url) || + server.url.trim().length === 0 || + Object.keys(server.custom_headers ?? {}).length > 0 + ) { + throw new LifecycleError( + "strands_mcp_invalid_server", + "Strands stdio MCP servers require an absolute executable and do not accept HTTP headers", + ); + } + selected.push({ + name, + command: server.url, + args: server.args ?? [], + env: Object.entries(server.env ?? {}).map(([envName, value]) => ({ + name: envName, + value, + })), + }); + continue; + } + if (server.transport === "streamable-http" || server.transport === "sse") { + if ( + (server.args?.length ?? 0) > 0 || + Object.keys(server.env ?? {}).length > 0 + ) { + throw new LifecycleError( + "strands_mcp_invalid_server", + "Strands remote MCP servers do not accept command arguments or process environment variables", + ); + } + let endpoint: URL; + try { + endpoint = new URL(server.url); + } catch { + throw new LifecycleError( + "strands_mcp_invalid_server", + "Strands remote MCP servers require an HTTP or HTTPS URL", + ); + } + if ( + (endpoint.protocol !== "http:" && endpoint.protocol !== "https:") || + (endpoint.protocol === "http:" && !loopback(endpoint.hostname)) + ) { + throw new LifecycleError( + "strands_mcp_invalid_server", + "Strands remote MCP servers require HTTPS unless the endpoint is loopback", + ); + } + const headerNames = new Set(); + const headers = Object.entries(server.custom_headers ?? {}).map( + ([headerName, rawValue]) => { + const normalized = headerName.toLowerCase(); + if (headerNames.has(normalized)) { + throw new LifecycleError( + "strands_mcp_invalid_header", + "A configured Strands MCP HTTP header is invalid", + ); + } + headerNames.add(normalized); + const value = expandHeader(rawValue, environment, parent); + validateHeader(headerName, value); + return { name: headerName, value }; + }, + ); + selected.push({ + type: server.transport === "streamable-http" ? "http" : "sse", + name, + url: server.url, + headers, + }); + continue; + } + throw new LifecycleError( + "strands_mcp_transport_unsupported", + "Strands supports stdio, streamable-http, and sse MCP transports", + { + metadata: { + field: `mcp.servers.${name}.transport`, + transport: server.transport, + supported_transports: ["stdio", "streamable-http", "sse"], + }, + }, + ); + } + return selected; +} diff --git a/adapters/typescript/strands/src/runtime.ts b/adapters/typescript/strands/src/runtime.ts new file mode 100644 index 000000000..115061ac7 --- /dev/null +++ b/adapters/typescript/strands/src/runtime.ts @@ -0,0 +1,183 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + AgentRunRequest, + AgentRunResult, + AgentUsage, + JsonObject, + RuntimeContext, +} from "nemo-fabric-adapter-contract"; +import { + LifecycleError, + type AdapterRuntime, + type AdapterStartInput, +} from "nemo-fabric-adapters-common"; + +import { selectSettings } from "./configuration.js"; + +import type { + StrandsPromptOutcome, + StrandsSessionFactory, + StrandsSessionHandle, +} from "./strands-acp.js"; + +function resultExtensions( + session: StrandsSessionHandle, + outcome: StrandsPromptOutcome, +): JsonObject { + return { session_id: session.id, stop_reason: outcome.stopReason }; +} + +function failed( + code: string, + message: string, + extensions: JsonObject, + usage?: AgentUsage, + retryable = false, +): AgentRunResult { + return { + status: "failed", + output: null, + error: { code, message, retryable }, + extensions, + ...(usage === undefined ? {} : { usage }), + }; +} + +export class StrandsAdapterRuntime implements AdapterRuntime { + private readonly factory: StrandsSessionFactory; + private session?: StrandsSessionHandle; + private invocations = 0; + private maxInvocations = 100; + + constructor(factory: StrandsSessionFactory) { + this.factory = factory; + } + + async start(input: AdapterStartInput): Promise { + if (this.session !== undefined) { + throw new LifecycleError( + "strands_already_started", + "Strands adapter runtime is already started", + ); + } + this.maxInvocations = selectSettings(input.config).maxInvocations; + this.session = await this.factory.create(input); + this.invocations = 0; + } + + private async invalidate(session: StrandsSessionHandle): Promise { + if (this.session !== session) return; + this.session = undefined; + try { + await session.stop(); + } catch { + // Preserve the invocation failure that made the session unusable. + } + } + + async invoke( + request: AgentRunRequest, + context: RuntimeContext, + ): Promise { + if (this.session === undefined) { + throw new LifecycleError( + "strands_not_started", + "Strands adapter runtime is not started", + ); + } + if (typeof request.input !== "string") { + return { + status: "failed", + output: null, + error: { + code: "strands_unsupported_input", + message: "The Strands adapter accepts only plain-text input", + retryable: false, + }, + }; + } + const session = this.session; + if ( + context.deadline_millis !== undefined && + context.deadline_millis !== null && + context.deadline_millis <= Date.now() + ) { + return { + status: "failed", + output: null, + error: { + code: "strands_deadline_exceeded", + message: "The Strands invocation deadline has already elapsed", + retryable: false, + }, + }; + } + const timeoutMs = + context.deadline_millis === undefined || context.deadline_millis === null + ? undefined + : context.deadline_millis - Date.now(); + if (this.invocations >= this.maxInvocations) { + return failed( + "strands_session_limit", + "Start a new runtime after the Strands invocation limit", + {}, + ); + } + this.invocations++; + let outcome: StrandsPromptOutcome; + try { + outcome = await session.prompt(request.input, timeoutMs); + } catch (error) { + await this.invalidate(session); + if (error instanceof LifecycleError) throw error; + throw new LifecycleError( + "strands_session_failed", + "Strands ACP session communication failed", + ); + } + const extensions = resultExtensions(session, outcome); + if (outcome.stopReason === "cancelled") { + return { + status: "cancelled", + output: null, + error: { + code: "strands_cancelled", + message: "Strands cancelled the invocation", + retryable: false, + }, + extensions, + ...(outcome.usage === undefined ? {} : { usage: outcome.usage }), + }; + } + if (outcome.stopReason !== "end_turn") { + return failed( + `strands_${outcome.stopReason}`, + `Strands stopped the invocation with ${outcome.stopReason}`, + extensions, + outcome.usage, + ); + } + if (outcome.text.length === 0) { + return failed( + "strands_no_assistant_response", + "Strands completed without a final assistant text response", + extensions, + outcome.usage, + ); + } + return { + status: "succeeded", + output: { response: outcome.text }, + extensions, + ...(outcome.usage === undefined ? {} : { usage: outcome.usage }), + }; + } + + async stop(): Promise { + const session = this.session; + this.session = undefined; + if (session !== undefined) await session.stop(); + } +} diff --git a/adapters/typescript/strands/src/strands-acp.ts b/adapters/typescript/strands/src/strands-acp.ts new file mode 100644 index 000000000..da357af16 --- /dev/null +++ b/adapters/typescript/strands/src/strands-acp.ts @@ -0,0 +1,334 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + access, + mkdir, + mkdtemp, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { delimiter, join, resolve } from "node:path"; + +import type { AgentUsage } from "nemo-fabric-adapter-contract"; +import { + AcpRunnerError, + LifecycleError, + startAcpSession, + type AcpPromptResult, + type AcpSession, + type AdapterStartInput, +} from "nemo-fabric-adapters-common"; + +import { + selectMcpServers, + selectModel, + selectSettings, + systemInstructions, + workspacePath, +} from "./configuration.js"; + +export interface StrandsPromptOutcome { + text: string; + stopReason: AcpPromptResult["stopReason"]; + usage?: AgentUsage; +} + +export interface StrandsSessionHandle { + readonly id: string; + prompt(text: string, timeoutMs?: number): Promise; + stop(): Promise; +} + +export interface StrandsSessionFactory { + create(input: AdapterStartInput): Promise; +} + +const INHERITED_ENVIRONMENT_NAMES = [ + "APPDATA", + "COMSPEC", + "ComSpec", + "HOME", + "HTTP_PROXY", + "HTTPS_PROXY", + "LANG", + "LC_ALL", + "LC_CTYPE", + "LOCALAPPDATA", + "NO_PROXY", + "PATH", + "PATHEXT", + "Path", + "PathExt", + "SHELL", + "SSL_CERT_DIR", + "SSL_CERT_FILE", + "SYSTEMROOT", + "SystemRoot", + "TEMP", + "TMP", + "TMPDIR", + "USER", + "USERPROFILE", + "http_proxy", + "https_proxy", + "no_proxy", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_PROFILE", + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_CONFIG_FILE", + "AWS_SHARED_CREDENTIALS_FILE", + "AWS_CA_BUNDLE", + "AWS_SDK_LOAD_CONFIG", + "AWS_ROLE_ARN", + "AWS_ROLE_SESSION_NAME", + "AWS_WEB_IDENTITY_TOKEN_FILE", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "AWS_CONTAINER_AUTHORIZATION_TOKEN", + "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", + "AWS_EC2_METADATA_DISABLED", + "AWS_BEARER_TOKEN_BEDROCK", +]; + +function childEnvironment(input: AdapterStartInput): NodeJS.ProcessEnv { + const environment: NodeJS.ProcessEnv = {}; + for (const name of INHERITED_ENVIRONMENT_NAMES) { + if (process.env[name] !== undefined) environment[name] = process.env[name]; + } + Object.assign(environment, input.runtimeContext.environment.env ?? {}); + return environment; +} + +function translateRunnerError( + error: unknown, + stage: "start" | "prompt", +): LifecycleError { + if (error instanceof LifecycleError) return error; + if ( + error instanceof AcpRunnerError && + error.code === "acp_process_unavailable" + ) { + return new LifecycleError( + "strands_harness_unavailable", + "The installed Strands CLI could not be started", + ); + } + if (error instanceof AcpRunnerError && error.code === "acp_prompt_timeout") { + return new LifecycleError( + "strands_prompt_timeout", + "Strands did not finish the invocation before the deadline", + ); + } + // Do not expose target error text: it may contain credentials or prompt content. + return new LifecycleError( + stage === "start" ? "strands_start_failed" : "strands_session_failed", + stage === "start" + ? "Strands ACP initialization failed" + : "Strands ACP session communication failed", + ); +} + +function validCounter(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +export function normalizeUsage( + usage: AcpPromptResult["usage"], +): AgentUsage | undefined { + if ( + !usage || + ![usage.inputTokens, usage.outputTokens, usage.totalTokens].every( + validCounter, + ) + ) + return undefined; + const { + inputTokens: input, + outputTokens: output, + totalTokens: total, + } = usage; + if (input + output !== total) return undefined; + const cache = usage.cachedReadTokens; + const write = usage.cachedWriteTokens; + if ( + (cache != null && !validCounter(cache)) || + (write != null && !validCounter(write)) + ) + return undefined; + if ((cache ?? 0) + (write ?? 0) > input) return undefined; + // The pinned Strands CLI normalizes provider-specific cache semantics into inclusive input tokens. + return { + input_tokens: input, + output_tokens: output, + total_tokens: total, + input_tokens_include_cache: true, + ...(cache == null ? {} : { cached_input_tokens: cache }), + extensions: { + "nemo_fabric.detailed_token_usage.v1": { + input_tokens: input, + output_tokens: output, + total_tokens: total, + input_tokens_include_cache: true, + ...(cache == null ? {} : { cached_input_tokens: cache }), + ...(write == null ? {} : { cache_write_input_tokens: write }), + }, + }, + }; +} + +class StrandsAcpSession implements StrandsSessionHandle { + readonly id: string; + constructor( + private readonly session: AcpSession, + private readonly home: string, + ) { + this.id = session.sessionId; + } + + async prompt( + text: string, + timeoutMs?: number, + ): Promise { + try { + const result = await this.session.prompt(text, timeoutMs); + const usage = normalizeUsage(result.usage); + return { + text: result.text, + stopReason: result.stopReason, + ...(usage === undefined ? {} : { usage }), + }; + } catch (error) { + throw translateRunnerError(error, "prompt"); + } + } + + async stop(): Promise { + try { + await this.session.stop(); + } finally { + await rm(this.home, { recursive: true, force: true }); + } + } +} + +async function command( + environment: NodeJS.ProcessEnv, +): Promise<{ command: string; args: string[] }> { + if (process.platform === "win32") { + // npm creates .cmd shims on Windows; ACP uses direct spawn without a shell. + for (const bin of (environment.PATH ?? environment.Path ?? "").split( + delimiter, + )) { + for (const candidate of [ + join(bin, "node_modules/@strands-agents/cli/bin/strands.js"), + resolve(bin, "../@strands-agents/cli/bin/strands.js"), + ]) { + try { + await access(candidate); + return { command: process.execPath, args: [candidate] }; + } catch { + /* Try the next npm installation location. */ + } + } + } + } + return { command: "strands", args: [] }; +} + +export class StrandsAcpSessionFactory implements StrandsSessionFactory { + async create(input: AdapterStartInput): Promise { + const workspace = await workspacePath(input); + const settings = selectSettings(input.config); + const model = selectModel(input.config, settings); + const instructions = systemInstructions(input.config); + const configured = input.runtimeContext.environment.env ?? {}; + const mcpServers = selectMcpServers(input.config, configured, process.env); + const environment = childEnvironment(input); + if (model?.endpoint) { + const key = + model.keyEnvironment === undefined + ? "unused" + : (configured[model.keyEnvironment] ?? + process.env[model.keyEnvironment]); + if (!key) + throw new LifecycleError( + "strands_credential_missing", + "The configured Strands model credential is unavailable", + ); + environment.LITELLM_BASE_URL = model.endpoint; + environment.LITELLM_API_KEY = key; + } + const home = await mkdtemp(join(tmpdir(), "fabric-strands-")); + try { + const sourceHome = + environment.HOME ?? environment.USERPROFILE ?? homedir(); + // Keep the standard AWS SDK credential chain (including SSO caches) while isolating CLI state. + try { + await access(join(sourceHome, ".aws")); + await symlink( + resolve(sourceHome, ".aws"), + join(home, ".aws"), + process.platform === "win32" ? "junction" : "dir", + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + environment.HOME = home; + environment.USERPROFILE = home; + environment.NO_COLOR = "1"; + environment.STRANDS_CLI_TELEMETRY = "off"; + environment.STRANDS_CLI_MCP_DISCOVERY = "off"; + await mkdir(join(home, ".strands/cli"), { recursive: true }); + await writeFile( + join(home, ".strands/cli/config.json"), + JSON.stringify({ + settings: { + mcpDiscovery: false, + skillDiscovery: false, + telemetry: false, + }, + }), + { mode: 0o600 }, + ); + const executable = await command(environment); + const session = await startAcpSession({ + command: executable.command, + args: [ + ...executable.args, + "--acp-server", + "--set", + "agentConfig.backgroundTasks=false", + "--session", + "off", + "--memory", + "off", + "--skills", + "off", + "--builtin-plugins", + "environment", + "--builtin-tools", + settings.builtinTools.join(","), + ...(model === undefined ? [] : ["--model", model.name]), + ...(instructions === undefined + ? [] + : [`--instructions=${instructions}`]), + ], + cwd: workspace, + env: environment, + mcpServers, + clientName: "nemo-fabric-strands", + clientVersion: "0.6.0", + }); + return new StrandsAcpSession(session, home); + } catch (error) { + await rm(home, { recursive: true, force: true }); + throw translateRunnerError(error, "start"); + } + } +} diff --git a/adapters/typescript/strands/strands.fabric-adapter.json b/adapters/typescript/strands/strands.fabric-adapter.json new file mode 100644 index 000000000..6d9289cd9 --- /dev/null +++ b/adapters/typescript/strands/strands.fabric-adapter.json @@ -0,0 +1,180 @@ +{ + "contract_version": "fabric.adapter/v1alpha2", + "adapter_id": "nvidia.fabric.strands", + "adapter_kind": "process", + "runner": { + "command": "node", + "script": "dist/cli.js" + }, + "requirements": { + "binaries": [ + "node", + "strands" + ] + }, + "config": { + "accepts": [ + "models", + "mcp", + "instructions.system", + "models.base_url" + ], + "system_instruction_modes": [ + "append" + ] + }, + "settings_schema": { + "type": "object", + "properties": { + "builtin_tools": { + "type": "array", + "items": { + "enum": [ + "shell", + "read", + "write", + "edit", + "web_fetch" + ] + }, + "uniqueItems": true + }, + "max_invocations": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "default": 100 + }, + "insecure_http_model_hosts": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false + }, + "model_schema": { + "oneOf": [ + { + "type": "object", + "properties": { + "provider": { + "const": "bedrock" + }, + "model": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "enum": [ + "openai", + "nvidia" + ] + }, + "model": { + "type": "string", + "minLength": 1 + }, + "base_url": { + "type": "string", + "minLength": 1 + }, + "api_key_env": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + } + }, + "required": [ + "provider", + "model", + "base_url" + ], + "additionalProperties": false + } + ] + }, + "extension_schemas": { + "run_result": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "session_id": { + "type": "string", + "minLength": 1 + }, + "stop_reason": { + "type": "string", + "enum": [ + "end_turn", + "max_tokens", + "max_turn_requests", + "refusal", + "cancelled" + ] + } + }, + "required": [ + "session_id", + "stop_reason" + ], + "additionalProperties": false + }, + "usage": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "nemo_fabric.detailed_token_usage.v1": { + "type": "object", + "properties": { + "input_tokens": { + "type": "integer", + "minimum": 0 + }, + "cached_input_tokens": { + "type": "integer", + "minimum": 0 + }, + "cache_write_input_tokens": { + "type": "integer", + "minimum": 0 + }, + "input_tokens_include_cache": { + "type": "boolean" + }, + "output_tokens": { + "type": "integer", + "minimum": 0 + }, + "output_tokens_include_reasoning": { + "type": "boolean" + }, + "total_tokens": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "capabilities": { + "streaming": false, + "cancellation": false, + "updates": false, + "service": false + } +} diff --git a/adapters/typescript/strands/test/configuration.test.mjs b/adapters/typescript/strands/test/configuration.test.mjs new file mode 100644 index 000000000..241c79c11 --- /dev/null +++ b/adapters/typescript/strands/test/configuration.test.mjs @@ -0,0 +1,211 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + selectMcpServers, + selectModel, + selectSettings, + systemInstructions, +} from "../dist/configuration.js"; + +test("maps ACP-native MCP transports and expands configured headers", () => { + const servers = selectMcpServers( + { + mcp: { + servers: { + local: { + transport: "stdio", + url: "/usr/bin/node", + args: ["server.mjs"], + env: { TOKEN: "scoped" }, + }, + remote: { + transport: "streamable-http", + url: "https://mcp.example.test", + custom_headers: { Authorization: "Bearer ${MCP_TOKEN}" }, + }, + }, + }, + }, + { MCP_TOKEN: "secret" }, + {}, + ); + assert.deepEqual(servers, [ + { + name: "local", + command: "/usr/bin/node", + args: ["server.mjs"], + env: [{ name: "TOKEN", value: "scoped" }], + }, + { + type: "http", + name: "remote", + url: "https://mcp.example.test", + headers: [{ name: "Authorization", value: "Bearer secret" }], + }, + ]); +}); + +test("rejects MCP fields Strands ACP cannot enforce", () => { + assert.throws( + () => + selectMcpServers( + { + mcp: { + servers: { + filtered: { + transport: "stdio", + url: "/bin/echo", + allowed_tools: ["read"], + }, + }, + }, + }, + {}, + {}, + ), + (error) => error.code === "strands_mcp_tool_filters_unsupported", + ); + assert.throws( + () => + selectMcpServers( + { + mcp: { + servers: { + insecure: { + transport: "streamable-http", + url: "http://example.test", + }, + }, + }, + }, + {}, + {}, + ), + (error) => error.code === "strands_mcp_invalid_server", + ); +}); + +test("maps Bedrock and explicit Chat Completions endpoints without changing model IDs", () => { + const settings = selectSettings({}); + assert.equal( + selectModel( + { + models: { + default: { provider: "bedrock", model: "us.amazon.nova-pro-v1:0" }, + }, + }, + settings, + ).name, + "bedrock/us.amazon.nova-pro-v1:0", + ); + assert.deepEqual( + selectModel( + { + models: { + default: { + provider: "nvidia", + model: "nvidia/nemotron", + base_url: "https://integrate.api.nvidia.com/v1", + api_key_env: "NVIDIA_API_KEY", + }, + }, + }, + settings, + ), + { + name: "litellm/nvidia/nemotron", + endpoint: "https://integrate.api.nvidia.com/v1", + keyEnvironment: "NVIDIA_API_KEY", + }, + ); + assert.equal( + selectModel( + { + models: { + default: { + provider: "openai", + model: "local", + base_url: "http://localhost:8000/v1", + }, + }, + }, + settings, + ).name, + "litellm/local", + ); + assert.throws( + () => + selectModel( + { + models: { + default: { + provider: "openai", + model: "local", + base_url: "http://10.1.2.3:8000/v1", + }, + }, + }, + settings, + ), + { code: "strands_model_invalid" }, + ); + const trusted = selectSettings({ + harness: { settings: { insecure_http_model_hosts: ["10.1.2.3"] } }, + }); + assert.ok( + selectModel( + { + models: { + default: { + provider: "openai", + model: "local", + base_url: "http://10.1.2.3:8000/v1", + }, + }, + }, + trusted, + ), + ); +}); + +test("rejects unsupported model roles, credential URLs, settings, and instruction replacement", () => { + for (const models of [ + { other: { provider: "bedrock", model: "x" } }, + { + default: { + provider: "openai", + model: "x", + base_url: "https://user:secret@example.test/v1", + }, + }, + { default: { provider: "bedrock", model: "x", api_key_env: "KEY" } }, + ]) { + assert.throws(() => selectModel({ models }, selectSettings({}))); + } + for (const settings of [ + { unknown: true }, + { builtin_tools: ["subagent"] }, + { max_invocations: 0 }, + ]) { + assert.throws(() => selectSettings({ harness: { settings } }), { + code: "strands_settings_invalid", + }); + } + assert.throws( + () => + systemInstructions({ + instructions: { system: { mode: "replace", content: "x" } }, + }), + { code: "strands_instruction_mode_unsupported" }, + ); + assert.equal( + systemInstructions({ + instructions: { system: { mode: "append", content: "review" } }, + }), + "review", + ); +}); diff --git a/adapters/typescript/strands/test/fixtures/acp-agent.mjs b/adapters/typescript/strands/test/fixtures/acp-agent.mjs new file mode 100644 index 000000000..4a733d8a8 --- /dev/null +++ b/adapters/typescript/strands/test/fixtures/acp-agent.mjs @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { randomUUID } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { Readable, Writable } from "node:stream"; +import { + agent, + methods, + ndJsonStream, + PROTOCOL_VERSION, +} from "@agentclientprotocol/sdk"; + +let history = []; +let servers = []; +const id = randomUUID(); +if (process.env.TEST_STRANDS_STATE) { + await writeFile( + process.env.TEST_STRANDS_STATE, + JSON.stringify({ + pid: process.pid, + home: process.env.HOME, + args: process.argv.slice(2), + endpoint: process.env.LITELLM_BASE_URL, + hasKey: Boolean(process.env.LITELLM_API_KEY), + unrelatedSecret: process.env.TEST_UNRELATED_SECRET, + }), + ); +} +const app = agent({ name: "strands-fixture" }); +app.onRequest(methods.agent.initialize, () => ({ + protocolVersion: PROTOCOL_VERSION, + agentCapabilities: { mcpCapabilities: { http: true, sse: true } }, +})); +app.onRequest(methods.agent.session.new, ({ params }) => { + if (process.env.TEST_STRANDS_FAIL_START === "1") + throw new Error("PRIVATE_STARTUP_DETAIL"); + servers = params.mcpServers; + return { sessionId: id }; +}); +app.onRequest(methods.agent.session.prompt, async ({ params, client }) => { + const text = params.prompt[0].text; + if (text === "hang") return await new Promise(() => {}); + if (text === "crash") process.exit(2); + const response = + text === "recall" + ? (history[0] ?? "EMPTY") + : text === "mcp" + ? JSON.stringify(servers) + : text; + history.push(text); + await client.notify(methods.client.session.update, { + sessionId: id, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: response }, + }, + }); + // Deliberately unequal per-turn counters expose accidental cumulative differencing. + return { + stopReason: text === "refuse" ? "refusal" : "end_turn", + usage: { + inputTokens: 10 + history.length, + outputTokens: 3, + totalTokens: 13 + history.length, + cachedReadTokens: 2, + cachedWriteTokens: 1, + }, + }; +}); +await app.connect( + ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)), +).closed; diff --git a/adapters/typescript/strands/test/runtime.test.mjs b/adapters/typescript/strands/test/runtime.test.mjs new file mode 100644 index 000000000..1f39981b4 --- /dev/null +++ b/adapters/typescript/strands/test/runtime.test.mjs @@ -0,0 +1,193 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { StrandsAdapterRuntime } from "../dist/runtime.js"; + +const context = { + runtime_id: "runtime", + invocation_id: "invocation", + request_id: "request", + artifacts: {}, + environment: { + environment_id: "environment", + provider: "local", + ownership: "caller_owned", + control_location: "external_control", + env: {}, + }, +}; + +test("keeps one Strands session across ordered invocations", async () => { + const prompts = []; + let stopped = 0; + const runtime = new StrandsAdapterRuntime({ + create: async () => ({ + id: "session", + prompt: async (text) => { + prompts.push(text); + return { + text: `answer:${text}`, + stopReason: "end_turn", + usage: { input_tokens: 1, output_tokens: 2, total_tokens: 3 }, + }; + }, + stop: async () => { + stopped += 1; + }, + }), + }); + await runtime.start({ + agentName: "agent", + baseDir: ".", + config: {}, + runtimeContext: context, + }); + assert.equal( + (await runtime.invoke({ input: "one" }, context)).output.response, + "answer:one", + ); + assert.equal( + (await runtime.invoke({ input: "two" }, context)).output.response, + "answer:two", + ); + await runtime.stop(); + assert.deepEqual(prompts, ["one", "two"]); + assert.equal(stopped, 1); +}); + +test("normalizes terminal stop reasons and malformed output", async () => { + const outcomes = [ + { text: "", stopReason: "refusal" }, + { text: "", stopReason: "end_turn" }, + { text: "", stopReason: "cancelled" }, + ]; + const runtime = new StrandsAdapterRuntime({ + create: async () => ({ + id: "session", + prompt: async () => outcomes.shift(), + stop: async () => {}, + }), + }); + await runtime.start({ + agentName: "agent", + baseDir: ".", + config: {}, + runtimeContext: context, + }); + assert.equal( + (await runtime.invoke({ input: "one" }, context)).error.code, + "strands_refusal", + ); + assert.equal( + (await runtime.invoke({ input: "two" }, context)).error.code, + "strands_no_assistant_response", + ); + assert.equal( + (await runtime.invoke({ input: "three" }, context)).status, + "cancelled", + ); +}); + +test("invalidates a Strands session after a transport failure", async () => { + let stopped = 0; + const runtime = new StrandsAdapterRuntime({ + create: async () => ({ + id: "session", + prompt: async () => { + throw new Error("private transport detail"); + }, + stop: async () => { + stopped += 1; + }, + }), + }); + await runtime.start({ + agentName: "agent", + baseDir: ".", + config: {}, + runtimeContext: context, + }); + await assert.rejects( + runtime.invoke({ input: "one" }, context), + (error) => + error.code === "strands_session_failed" && error.retryable === false, + ); + await assert.rejects( + runtime.invoke({ input: "two" }, context), + (error) => error.code === "strands_not_started", + ); + assert.equal(stopped, 1); +}); + +test("rejects an expired deadline before dispatch and keeps the session usable", async () => { + const prompts = []; + const runtime = new StrandsAdapterRuntime({ + create: async () => ({ + id: "session", + prompt: async (text) => { + prompts.push(text); + return { text: `answer:${text}`, stopReason: "end_turn" }; + }, + stop: async () => {}, + }), + }); + await runtime.start({ + agentName: "agent", + baseDir: ".", + config: {}, + runtimeContext: context, + }); + + const expired = await runtime.invoke( + { input: "expired" }, + { ...context, deadline_millis: Date.now() - 1 }, + ); + assert.deepEqual(expired, { + status: "failed", + output: null, + error: { + code: "strands_deadline_exceeded", + message: "The Strands invocation deadline has already elapsed", + retryable: false, + }, + }); + assert.deepEqual(prompts, []); + assert.equal( + (await runtime.invoke({ input: "next" }, context)).output.response, + "answer:next", + ); + assert.deepEqual(prompts, ["next"]); +}); + +test("bounds retained warm conversation without silently discarding history", async () => { + let invoked = 0; + const runtime = new StrandsAdapterRuntime({ + create: async () => ({ + id: "session", + prompt: async () => { + invoked++; + return { text: "answer", stopReason: "end_turn" }; + }, + stop: async () => {}, + }), + }); + await runtime.start({ + agentName: "test", + baseDir: ".", + config: { harness: { settings: { max_invocations: 1 } } }, + runtimeContext: context, + }); + assert.equal( + (await runtime.invoke({ input: "one" }, context)).status, + "succeeded", + ); + assert.equal( + (await runtime.invoke({ input: "two" }, context)).error.code, + "strands_session_limit", + ); + assert.equal(invoked, 1); + await runtime.stop(); +}); diff --git a/adapters/typescript/strands/test/strands-acp.test.mjs b/adapters/typescript/strands/test/strands-acp.test.mjs new file mode 100644 index 000000000..84964cab2 --- /dev/null +++ b/adapters/typescript/strands/test/strands-acp.test.mjs @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, + access, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { + normalizeUsage, + StrandsAcpSessionFactory, +} from "../dist/strands-acp.js"; + +async function fixture(t) { + const bin = await mkdtemp(join(tmpdir(), "fabric-strands-test-")); + t.after(() => rm(bin, { recursive: true, force: true })); + const source = fileURLToPath( + new URL("./fixtures/acp-agent.mjs", import.meta.url), + ); + const script = `import(${JSON.stringify(new URL("./fixtures/acp-agent.mjs", import.meta.url).href)});\n`; + if (process.platform === "win32") { + const root = join(bin, "node_modules/@strands-agents/cli/bin"); + await mkdir(root, { recursive: true }); + await writeFile(join(root, "strands.js"), script); + } else { + await writeFile( + join(bin, "strands"), + '#!/bin/sh\nexec "$TEST_NODE_EXECUTABLE" "$TEST_STRANDS_FIXTURE" "$@"\n', + ); + await chmod(join(bin, "strands"), 0o755); + } + return { + agentName: "test", + baseDir: bin, + config: { + models: { + default: { + provider: "openai", + model: "local", + base_url: "http://localhost:8000/v1", + }, + }, + harness: { settings: { builtin_tools: [] } }, + }, + runtimeContext: { + runtime_id: "runtime", + invocation_id: "start", + request_id: "request", + artifacts: {}, + environment: { + environment_id: "environment", + provider: "local", + ownership: "caller_owned", + control_location: "external_control", + workspace: bin, + env: { + PATH: `${bin}${delimiter}${process.env.PATH ?? ""}`, + TEST_NODE_EXECUTABLE: process.execPath, + TEST_STRANDS_FIXTURE: source, + TEST_STRANDS_STATE: join(bin, "state.json"), + }, + }, + }, + }; +} + +test("preserves real ACP session history, isolates runtime state, and uses per-invocation usage", async (t) => { + const input = await fixture(t); + input.config.instructions = { + system: { mode: "append", content: "--instruction-text" }, + }; + const factory = new StrandsAcpSessionFactory(); + const first = await factory.create(input); + t.after(() => first.stop()); + const state = JSON.parse( + await readFile( + input.runtimeContext.environment.env.TEST_STRANDS_STATE, + "utf8", + ), + ); + assert.notEqual(state.home, process.env.HOME); + assert.equal(state.endpoint, "http://localhost:8000/v1"); + assert.equal(state.hasKey, true); + assert.equal(state.unrelatedSecret, undefined); + assert.ok(state.args.includes("litellm/local")); + assert.ok(state.args.includes("--instructions=--instruction-text")); + assert.ok(state.args.includes("agentConfig.backgroundTasks=false")); + const one = await first.prompt("secret-token"); + const two = await first.prompt("recall"); + assert.equal(two.text, "secret-token"); + assert.equal(one.usage.input_tokens, 11); + assert.equal(two.usage.input_tokens, 12); + assert.equal( + two.usage.extensions["nemo_fabric.detailed_token_usage.v1"] + .cache_write_input_tokens, + 1, + ); + const second = await factory.create(input); + t.after(() => second.stop()); + assert.notEqual(second.id, first.id); + assert.equal((await second.prompt("recall")).text, "EMPTY"); + await first.stop(); + await first.stop(); + await assert.rejects(access(state.home), { code: "ENOENT" }); +}); + +test("passes MCP servers into the first ACP session and cleans up on timeout", async (t) => { + const input = await fixture(t); + input.config.mcp = { + servers: { + probe: { transport: "stdio", url: process.execPath, args: ["probe.mjs"] }, + }, + }; + const session = await new StrandsAcpSessionFactory().create(input); + t.after(() => session.stop()); + const result = await session.prompt("mcp"); + assert.equal(JSON.parse(result.text)[0].name, "probe"); + await assert.rejects(session.prompt("hang", 25), { + code: "strands_prompt_timeout", + }); + await session.stop(); +}); + +test("keeps missing and invalid usage unknown without rejecting the completed result", () => { + for (const usage of [ + undefined, + { inputTokens: 1, outputTokens: 1, totalTokens: 3 }, + { inputTokens: 1.5, outputTokens: 2, totalTokens: 3.5 }, + { inputTokens: 1, outputTokens: 2, totalTokens: 3, cachedReadTokens: 5 }, + ]) + assert.equal(normalizeUsage(usage), undefined); + assert.equal( + normalizeUsage({ inputTokens: 2, outputTokens: 1, totalTokens: 3 }) + .cached_input_tokens, + undefined, + ); +}); + +test("fails before launch when an explicitly named model credential is missing", async (t) => { + const input = await fixture(t); + input.config.models.default.api_key_env = "FABRIC_TEST_STRANDS_MISSING_KEY"; + await assert.rejects(new StrandsAcpSessionFactory().create(input), { + code: "strands_credential_missing", + }); +}); + +test("removes temporary CLI state after partial startup and keeps diagnostics private", async (t) => { + const input = await fixture(t); + input.runtimeContext.environment.env.TEST_STRANDS_FAIL_START = "1"; + await assert.rejects( + new StrandsAcpSessionFactory().create(input), + (error) => + error.code === "strands_start_failed" && + !error.message.includes("PRIVATE_STARTUP_DETAIL"), + ); + const state = JSON.parse( + await readFile( + input.runtimeContext.environment.env.TEST_STRANDS_STATE, + "utf8", + ), + ); + await assert.rejects(access(state.home), { code: "ENOENT" }); +}); diff --git a/adapters/typescript/strands/tsconfig.build.json b/adapters/typescript/strands/tsconfig.build.json new file mode 100644 index 000000000..b0a2ed237 --- /dev/null +++ b/adapters/typescript/strands/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "declaration": true, + "esModuleInterop": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noUncheckedIndexedAccess": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true, + "strict": true, + "target": "ES2022" + }, + "include": ["src/**/*.ts"] +} diff --git a/docs/index.yml b/docs/index.yml index 0e6ee7828..a83e3d7a0 100644 --- a/docs/index.yml +++ b/docs/index.yml @@ -76,6 +76,8 @@ navigation: path: ./integrations/harness/kilo.mdx - page: Kiro path: ./integrations/harness/kiro.mdx + - page: Strands + path: ./integrations/harness/strands.mdx - page: mini-SWE-agent path: ./integrations/harness/mini-swe-agent.mdx - page: OpenClaw diff --git a/docs/integrations/harness/overview.mdx b/docs/integrations/harness/overview.mdx index 32f00e3a7..50bf9184c 100644 --- a/docs/integrations/harness/overview.mdx +++ b/docs/integrations/harness/overview.mdx @@ -23,6 +23,7 @@ NeMo Fabric includes the following harness integrations: | LangChain Deep Agents | [Use the LangChain Deep Agents adapter](./deepagents.mdx) | | Kilo Code | [Use the Kilo Code adapter](./kilo.mdx) | | Kiro | [Use the Kiro adapter](./kiro.mdx) | +| Strands | [Use the Strands adapter](./strands.mdx) | | mini-SWE-agent | [Use the mini-SWE-agent adapter](./mini-swe-agent.mdx) | | OpenClaw | [Use the OpenClaw adapter](./openclaw.mdx) | | OpenHands | [Use the OpenHands adapter](./openhands.mdx) | diff --git a/docs/integrations/harness/strands.mdx b/docs/integrations/harness/strands.mdx new file mode 100644 index 000000000..cef8a53c0 --- /dev/null +++ b/docs/integrations/harness/strands.mdx @@ -0,0 +1,31 @@ +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +# Strands + +Use `nvidia.fabric.strands` to run the standard Strands harness through NVIDIA NeMo Fabric's shared ACP runner. One Fabric runtime owns a Strands CLI child process and a persistent conversation. + +Install Node.js 22.19 or newer and the separately managed CLI: + +```bash +npm install --global @strands-agents/cli@0.2.1 +just install-typescript-strands +npm run build --prefix adapters/typescript --workspace nemo-fabric-adapters-common +npm run build --prefix adapters/typescript --workspace nemo-fabric-adapters-strands +``` + +From a source checkout, try the code-review example with an NVIDIA-hosted model: + +```bash +export NVIDIA_API_KEY="" +.venv/bin/python -m examples.code_review_agent --variant strands --plan +.venv/bin/python -m examples.code_review_agent --variant strands --input "Review the workspace changes." +``` + +The adapter accepts the default model role, append-only system instructions, and normalized MCP servers over stdio, streamable HTTP, or SSE. Use provider `bedrock` for the AWS SDK credential chain, or `openai`/`nvidia` with an explicit Chat Completions `base_url`. Omitting `api_key_env` supports a keyless endpoint. Non-loopback HTTP model endpoints require `harness.settings.insecure_http_model_hosts`. + +Select native tools with `harness.settings.builtin_tools`; the default is `shell`, `read`, `write`, and `edit`. These tools execute with the task environment's permissions. The standard ACP path does not expose an adapter permission mode. Each runtime uses an isolated CLI profile with personal configuration, discovery, disk session persistence, and long-term memory disabled. Conversation state lasts until stop and is limited to 100 dispatched invocations by default through `max_invocations`. + +The adapter supports text invocations, invocation-local token usage, deadline cleanup, and independent runtimes. It does not advertise custom source agents, native streaming, public cancellation, services, Relay telemetry, or normalized skills and tool policy. + +See the [Strands adapter guide](https://github.com/NVIDIA/NeMo-Fabric/blob/main/adapters/typescript/strands/README.md) for complete configuration, endpoint mapping, MCP restrictions, lifecycle behavior, and live E2E commands. diff --git a/examples/code_review_agent/README.md b/examples/code_review_agent/README.md index 69d17d663..361a8f14d 100644 --- a/examples/code_review_agent/README.md +++ b/examples/code_review_agent/README.md @@ -486,3 +486,19 @@ npm run build --prefix adapters/typescript --workspace nemo-fabric-adapters-qwen Set `NVIDIA_API_KEY` to run the example. The variant keeps the code-review skill, uses Qwen's non-interactive default approval mode, and blocks shell and edit tools. The variant does not configure an MCP server or Relay telemetry. + +### Strands (`strands`) + +Run the standard Strands harness through the shared ACP runner. From the repository root: + +```bash +npm install --global @strands-agents/cli@0.2.1 +just install-typescript-strands +npm run build --prefix adapters/typescript --workspace nemo-fabric-adapters-common +npm run build --prefix adapters/typescript --workspace nemo-fabric-adapters-strands +export NVIDIA_API_KEY="" +.venv/bin/python -m examples.code_review_agent --variant strands --plan +.venv/bin/python -m examples.code_review_agent --variant strands --input "Review the workspace changes." +``` + +This variant uses the NVIDIA model configured in `strands_config()`, append-only review instructions, and native shell/read/write/edit tools. Tool calls run with the task environment's permissions. Normalized skills and Relay telemetry are unsupported. For Bedrock or a keyless custom endpoint, update the default `ModelConfig` as shown in the [adapter guide](../../adapters/typescript/strands/README.md). No Dockerfile is required. diff --git a/examples/code_review_agent/__init__.py b/examples/code_review_agent/__init__.py index 9cf80c6e1..0e98a0700 100644 --- a/examples/code_review_agent/__init__.py +++ b/examples/code_review_agent/__init__.py @@ -14,6 +14,7 @@ hermes_config, kilo_config, kiro_config, + strands_config, nooa_config, openclaw_config, openhands_config, @@ -39,6 +40,7 @@ "hermes_config", "kilo_config", "kiro_config", + "strands_config", "nooa_config", "openclaw_config", "openhands_config", diff --git a/examples/code_review_agent/__main__.py b/examples/code_review_agent/__main__.py index 73614937b..41c815cee 100644 --- a/examples/code_review_agent/__main__.py +++ b/examples/code_review_agent/__main__.py @@ -26,6 +26,7 @@ hermes_config, kilo_config, kiro_config, + strands_config, nooa_config, openclaw_config, openhands_config, @@ -39,6 +40,7 @@ "hermes": hermes_config, "kilo": kilo_config, "kiro": kiro_config, + "strands": strands_config, "claude": claude_config, "cline": cline_config, "codex": codex_config, @@ -166,7 +168,16 @@ async def main() -> None: parser.error("--stream cannot be combined with --plan") if ( args.variant - in {"cline", "droid", "kilo", "kiro", "openclaw", "openhands", "qwen"} + in { + "cline", + "droid", + "kilo", + "kiro", + "strands", + "openclaw", + "openhands", + "qwen", + } and args.relay ): display_name = { @@ -174,6 +185,7 @@ async def main() -> None: "droid": "Factory Droid", "kilo": "Kilo Code", "kiro": "Kiro", + "strands": "Strands", "openclaw": "OpenClaw", "openhands": "OpenHands", "qwen": "Qwen Code", diff --git a/examples/code_review_agent/config.py b/examples/code_review_agent/config.py index 3d5807a4d..5655c02fb 100644 --- a/examples/code_review_agent/config.py +++ b/examples/code_review_agent/config.py @@ -35,6 +35,7 @@ DROID_DESCRIPTOR = "../../adapters/typescript/droid/droid.fabric-adapter.json" QWEN_DESCRIPTOR = "../../adapters/typescript/qwen/qwen.fabric-adapter.json" KILO_DESCRIPTOR = "../../adapters/typescript/kilo/kilo.fabric-adapter.json" +STRANDS_DESCRIPTOR = "../../adapters/typescript/strands/strands.fabric-adapter.json" KIRO_DESCRIPTOR = "../../adapters/typescript/kiro/kiro.fabric-adapter.json" CODE_REVIEW_INSTRUCTION = ( "You are a concise code reviewer. Read the relevant workspace files before " @@ -269,6 +270,22 @@ def kiro_config() -> FabricConfig: return config +def strands_config() -> FabricConfig: + """Return the standard Strands harness through the shared ACP runner.""" + config = kiro_config() + config.discovery = DiscoveryConfig(local_paths=[STRANDS_DESCRIPTOR]) + config.harness = HarnessConfig( + adapter_id="nvidia.fabric.strands", resolution="preinstalled" + ) + config.models = {"default": _nvidia_code_review_model()} + config.instructions = InstructionsConfig( + system=InstructionConfig(mode="append", content=CODE_REVIEW_INSTRUCTION) + ) + config.runtime.artifacts = "./artifacts/strands" + config.environment.artifacts = "./artifacts/strands" + return config + + def qwen_config() -> FabricConfig: """Return the direct Qwen Code SDK adapter variant.""" diff --git a/justfile b/justfile index 49c0a90d9..cb1211a6b 100644 --- a/justfile +++ b/justfile @@ -294,6 +294,7 @@ clean: adapters/typescript/qwen/dist \ adapters/typescript/kilo/dist \ adapters/typescript/kiro/dist \ + adapters/typescript/strands/dist \ adapters/python/*/build \ adapters/python/*/dist \ sdk/python/*/build \ @@ -306,7 +307,8 @@ clean: adapters/typescript/opencode/*.tgz \ adapters/typescript/qwen/*.tgz \ adapters/typescript/kilo/*.tgz \ - adapters/typescript/kiro/*.tgz + adapters/typescript/kiro/*.tgz \ + adapters/typescript/strands/*.tgz find . \ \( -path './.venv' -o -path './.git' \) -prune -o \ -type d \( \ @@ -383,6 +385,9 @@ install-typescript-kilo: install-typescript-contract install-typescript-kiro: install-typescript-contract npm ci --prefix adapters/typescript --workspace nemo-fabric-adapters-kiro --include-workspace-root --ignore-scripts +install-typescript-strands: install-typescript-contract + npm ci --prefix adapters/typescript --workspace nemo-fabric-adapters-strands --include-workspace-root --ignore-scripts + # Install every maintained TypeScript package. install-typescript: install-typescript-contract install-typescript-adapters diff --git a/scripts/ci/set_typescript_project_version.py b/scripts/ci/set_typescript_project_version.py index 341b356d1..a1eb2b871 100644 --- a/scripts/ci/set_typescript_project_version.py +++ b/scripts/ci/set_typescript_project_version.py @@ -29,6 +29,7 @@ QWEN_PACKAGE = "nemo-fabric-adapters-qwen" KILO_PACKAGE = "nemo-fabric-adapters-kilo" KIRO_PACKAGE = "nemo-fabric-adapters-kiro" +STRANDS_PACKAGE = "nemo-fabric-adapters-strands" def _read_json_object(path: Path) -> dict[str, Any]: @@ -84,6 +85,7 @@ def set_typescript_project_version(root: Path, version: str) -> None: qwen_package_path = root / ADAPTERS_DIRECTORY / "qwen" / "package.json" kilo_package_path = root / ADAPTERS_DIRECTORY / "kilo" / "package.json" kiro_package_path = root / ADAPTERS_DIRECTORY / "kiro" / "package.json" + strands_package_path = root / ADAPTERS_DIRECTORY / "strands" / "package.json" contract_package = _read_json_object(contract_package_path) contract_lock = _read_json_object(contract_lock_path) @@ -97,6 +99,7 @@ def set_typescript_project_version(root: Path, version: str) -> None: qwen_package = _read_json_object(qwen_package_path) kilo_package = _read_json_object(kilo_package_path) kiro_package = _read_json_object(kiro_package_path) + strands_package = _read_json_object(strands_package_path) contract_lock_packages = _require_object( contract_lock.get("packages"), f"a packages object in {contract_lock_path}" @@ -146,6 +149,10 @@ def set_typescript_project_version(root: Path, version: str) -> None: adapters_lock_packages.get("kiro"), f"{KIRO_PACKAGE} metadata in {adapters_lock_path}", ) + adapters_lock_strands = _require_object( + adapters_lock_packages.get("strands"), + f"{STRANDS_PACKAGE} metadata in {adapters_lock_path}", + ) _require_named_package( contract_package, CONTRACT_PACKAGE, str(contract_package_path) @@ -200,9 +207,13 @@ def set_typescript_project_version(root: Path, version: str) -> None: adapters_lock_kilo, KILO_PACKAGE, f"kilo entry of {adapters_lock_path}" ) _require_named_package(kiro_package, KIRO_PACKAGE, str(kiro_package_path)) + _require_named_package(strands_package, STRANDS_PACKAGE, str(strands_package_path)) _require_named_package( adapters_lock_kiro, KIRO_PACKAGE, f"kiro entry of {adapters_lock_path}" ) + _require_named_package( + adapters_lock_strands, STRANDS_PACKAGE, f"strands entry of {adapters_lock_path}" + ) source_values = { path: json.dumps(value, sort_keys=True) @@ -219,6 +230,7 @@ def set_typescript_project_version(root: Path, version: str) -> None: (qwen_package_path, qwen_package), (kilo_package_path, kilo_package), (kiro_package_path, kiro_package), + (strands_package_path, strands_package), ) } @@ -245,7 +257,9 @@ def set_typescript_project_version(root: Path, version: str) -> None: kilo_package, adapters_lock_kilo, kiro_package, + strands_package, adapters_lock_kiro, + adapters_lock_strands, ): package["version"] = version @@ -265,7 +279,9 @@ def set_typescript_project_version(root: Path, version: str) -> None: (kilo_package, str(kilo_package_path)), (adapters_lock_kilo, f"kilo entry of {adapters_lock_path}"), (kiro_package, str(kiro_package_path)), + (strands_package, str(strands_package_path)), (adapters_lock_kiro, f"kiro entry of {adapters_lock_path}"), + (adapters_lock_strands, f"strands entry of {adapters_lock_path}"), ): _set_exact_dependency(package, CONTRACT_PACKAGE, version, description) for package, description in ( @@ -282,7 +298,9 @@ def set_typescript_project_version(root: Path, version: str) -> None: (kilo_package, str(kilo_package_path)), (adapters_lock_kilo, f"kilo entry of {adapters_lock_path}"), (kiro_package, str(kiro_package_path)), + (strands_package, str(strands_package_path)), (adapters_lock_kiro, f"kiro entry of {adapters_lock_path}"), + (adapters_lock_strands, f"strands entry of {adapters_lock_path}"), ): _set_exact_dependency(package, COMMON_PACKAGE, version, description) @@ -299,6 +317,7 @@ def set_typescript_project_version(root: Path, version: str) -> None: qwen_package_path: qwen_package, kilo_package_path: kilo_package, kiro_package_path: kiro_package, + strands_package_path: strands_package, } changed_paths = [ path diff --git a/sdk/python/nemo-fabric-adapter-catalog/src/nemo_fabric_adapter_catalog/catalog.json b/sdk/python/nemo-fabric-adapter-catalog/src/nemo_fabric_adapter_catalog/catalog.json index a39e2a3fe..c688ed411 100644 --- a/sdk/python/nemo-fabric-adapter-catalog/src/nemo_fabric_adapter_catalog/catalog.json +++ b/sdk/python/nemo-fabric-adapter-catalog/src/nemo_fabric_adapter_catalog/catalog.json @@ -2460,6 +2460,195 @@ }, "sha256": "39870c056b7aa15b8706b6cabf9190029f6c9165c5c9ce4b427f5f18bd7ba70a", "source": "adapters/python/remote-agent/remote-agent.fabric-adapter.json" + }, + "nvidia.fabric.strands": { + "descriptor": { + "adapter_id": "nvidia.fabric.strands", + "adapter_kind": "process", + "capabilities": { + "cancellation": false, + "service": false, + "streaming": false, + "updates": false + }, + "config": { + "accepts": [ + "models", + "mcp", + "instructions.system", + "models.base_url" + ], + "system_instruction_modes": [ + "append" + ] + }, + "contract_version": "fabric.adapter/v1alpha2", + "extension_schemas": { + "run_result": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "session_id": { + "minLength": 1, + "type": "string" + }, + "stop_reason": { + "enum": [ + "end_turn", + "max_tokens", + "max_turn_requests", + "refusal", + "cancelled" + ], + "type": "string" + } + }, + "required": [ + "session_id", + "stop_reason" + ], + "type": "object" + }, + "usage": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "nemo_fabric.detailed_token_usage.v1": { + "additionalProperties": false, + "properties": { + "cache_write_input_tokens": { + "minimum": 0, + "type": "integer" + }, + "cached_input_tokens": { + "minimum": 0, + "type": "integer" + }, + "input_tokens": { + "minimum": 0, + "type": "integer" + }, + "input_tokens_include_cache": { + "type": "boolean" + }, + "output_tokens": { + "minimum": 0, + "type": "integer" + }, + "output_tokens_include_reasoning": { + "type": "boolean" + }, + "total_tokens": { + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "model_schema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "model": { + "minLength": 1, + "type": "string" + }, + "provider": { + "const": "bedrock" + } + }, + "required": [ + "provider", + "model" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "api_key_env": { + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "type": "string" + }, + "base_url": { + "minLength": 1, + "type": "string" + }, + "model": { + "minLength": 1, + "type": "string" + }, + "provider": { + "enum": [ + "openai", + "nvidia" + ] + } + }, + "required": [ + "provider", + "model", + "base_url" + ], + "type": "object" + } + ] + }, + "requirements": { + "binaries": [ + "node", + "strands" + ] + }, + "runner": { + "command": "node", + "script": "dist/cli.js" + }, + "settings_schema": { + "additionalProperties": false, + "properties": { + "builtin_tools": { + "items": { + "enum": [ + "shell", + "read", + "write", + "edit", + "web_fetch" + ] + }, + "type": "array", + "uniqueItems": true + }, + "insecure_http_model_hosts": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "max_invocations": { + "default": 100, + "maximum": 10000, + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + } + }, + "ecosystem": "typescript", + "package": { + "name": "nemo-fabric-adapters-strands", + "version": "0.6.0" + }, + "sha256": "09cbfc363b9f89f9e66b39dceea2c70f58c6bce5de8f51ecd1071c64413719ce", + "source": "adapters/typescript/strands/strands.fabric-adapter.json" } }, "catalog_version": "0.6.0", diff --git a/tests/adapters/test_strands_adapter.py b/tests/adapters/test_strands_adapter.py new file mode 100644 index 000000000..5953ff2d2 --- /dev/null +++ b/tests/adapters/test_strands_adapter.py @@ -0,0 +1,119 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Planning and requirements coverage for the standard Strands ACP harness.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +from nemo_fabric import ( + DiscoveryConfig, + Fabric, + FabricConfig, + FabricConfigError, + HarnessConfig, + InstructionConfig, + InstructionsConfig, + MetadataConfig, + ModelConfig, +) + +ROOT = Path(__file__).resolve().parents[2] +DESCRIPTOR = ROOT / "adapters/typescript/strands/strands.fabric-adapter.json" + + +@pytest.fixture(name="strands_config") +def strands_config_fixture(): + return FabricConfig( + metadata=MetadataConfig(name="strands-test"), + discovery=DiscoveryConfig(local_paths=[DESCRIPTOR]), + harness=HarnessConfig(adapter_id="nvidia.fabric.strands"), + models={ + "default": ModelConfig( + provider="openai", + model="test-model", + base_url="http://localhost:8000/v1", + ) + }, + instructions=InstructionsConfig( + system=InstructionConfig(mode="append", content="Review carefully.") + ), + ) + + +def test_strands_plans_models_instructions_and_mcp(strands_config): + strands_config.add_mcp_server( + "probe", transport="stdio", url="/usr/bin/node", args=["probe.mjs"] + ) + plan = Fabric().plan(strands_config, base_dir=ROOT) + projected = plan.to_mapping()["agent_config"] + assert projected["models"]["default"]["base_url"] == "http://localhost:8000/v1" + assert projected["instructions"]["system"] == { + "mode": "append", + "content": "Review carefully.", + } + assert projected["mcp"]["servers"]["probe"]["transport"] == "stdio" + descriptor = plan.adapter_descriptor["descriptor"] + assert descriptor == {**json.loads(DESCRIPTOR.read_text()), "telemetry": {}} + assert descriptor["capabilities"] == { + "streaming": False, + "cancellation": False, + "updates": False, + "service": False, + } + + +@pytest.mark.parametrize("provider", ["bedrock", "nvidia"]) +def test_strands_plans_each_supported_provider(strands_config, provider): + strands_config.models["default"] = ModelConfig( + provider=provider, + model="test-model", + base_url="https://integrate.api.nvidia.com/v1" + if provider == "nvidia" + else None, + api_key_env="NVIDIA_API_KEY" if provider == "nvidia" else None, + ) + assert ( + Fabric() + .plan(strands_config, base_dir=ROOT) + .to_mapping()["agent_config"]["models"]["default"]["provider"] + == provider + ) + + +@pytest.mark.parametrize( + "settings", + [{"unknown": True}, {"builtin_tools": ["subagent"]}, {"max_invocations": 0}], +) +def test_strands_rejects_unsupported_settings(strands_config, settings): + strands_config.harness.settings = settings + with pytest.raises(FabricConfigError): + Fabric().plan(strands_config, base_dir=ROOT) + + +def test_strands_rejects_replacement_and_unsupported_model_fields(strands_config): + strands_config.instructions.system.mode = "replace" + with pytest.raises(FabricConfigError, match="replace"): + Fabric().plan(strands_config, base_dir=ROOT) + strands_config.instructions.system.mode = "append" + strands_config.models["default"].temperature = 0.3 + with pytest.raises(FabricConfigError): + Fabric().plan(strands_config, base_dir=ROOT) + + +async def test_strands_doctor_reports_missing_binaries(strands_config): + os.environ["PATH"] = "" + report = await Fabric().doctor(strands_config, base_dir=ROOT) + assert report.status == "fail" + failures = [ + c.message + for c in report.checks + if c.name == "requirement.binary" and c.status == "fail" + ] + assert any("node" in message for message in failures) + assert any("strands" in message for message in failures) diff --git a/tests/e2e/test_strands.py b/tests/e2e/test_strands.py new file mode 100644 index 000000000..36be1d3cf --- /dev/null +++ b/tests/e2e/test_strands.py @@ -0,0 +1,163 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Opt-in tests against an installed Strands CLI and a real model. + +RUN_FABRIC_STRANDS_INTEGRATION=1 STRANDS_TEST_MODEL= uv run pytest tests/e2e/test_strands.py +Set STRANDS_TEST_BASE_URL for Chat Completions; otherwise the model uses Bedrock. +Set STRANDS_TEST_API_KEY_ENV to name an existing credential, or omit it for a keyless endpoint. +""" + +from __future__ import annotations + +import os +import sys +import uuid +from pathlib import Path +from urllib.parse import urlsplit + +import pytest + + +@pytest.fixture(name="strands_config") +def strands_config_fixture(tmp_path): + if os.environ.get("RUN_FABRIC_STRANDS_INTEGRATION") != "1": + pytest.skip("set RUN_FABRIC_STRANDS_INTEGRATION=1 to run") + from nemo_fabric import ( + DiscoveryConfig, + EnvironmentConfig, + FabricConfig, + HarnessConfig, + MetadataConfig, + ModelConfig, + RuntimeConfig, + ) + + root = Path(__file__).resolve().parents[2] + endpoint = os.environ.get("STRANDS_TEST_BASE_URL") + model = os.environ["STRANDS_TEST_MODEL"] + return FabricConfig( + metadata=MetadataConfig(name="strands-e2e"), + discovery=DiscoveryConfig( + local_paths=[ + root / "adapters/typescript/strands/strands.fabric-adapter.json" + ] + ), + harness=HarnessConfig( + adapter_id="nvidia.fabric.strands", + settings={ + "builtin_tools": [], + "insecure_http_model_hosts": [urlsplit(endpoint).hostname] + if endpoint + else [], + }, + ), + models={ + "default": ModelConfig( + provider="openai" if endpoint else "bedrock", + model=model, + base_url=endpoint, + api_key_env=os.environ.get("STRANDS_TEST_API_KEY_ENV"), + ) + }, + environment=EnvironmentConfig( + provider="local", workspace=tmp_path, artifacts=tmp_path / "artifacts" + ), + runtime=RuntimeConfig( + timeout_seconds=60, + input_schema="text", + output_schema="message", + artifacts=tmp_path / "artifacts", + ), + ) + + +async def test_strands_live_conversation_and_runtime_isolation( + strands_config, tmp_path +): + from nemo_fabric import Fabric + + token = uuid.uuid4().hex + async with await Fabric().start_runtime(strands_config, base_dir=tmp_path) as first: + remembered = await first.invoke( + input=f"Remember this exact token: {token}. Reply only ACK." + ) + recalled = await first.invoke( + input="Reply only with the exact token from my previous message." + ) + async with await Fabric().start_runtime( + strands_config, base_dir=tmp_path + ) as second: + isolated = await second.invoke( + input="What token did I previously give you? If none, reply only NONE." + ) + assert remembered.status == "succeeded", remembered.to_mapping() + assert recalled.status == "succeeded", recalled.to_mapping() + assert recalled.output.response.strip() == token + assert recalled.usage.input_tokens > 0 + assert ( + recalled.usage.total_tokens + == recalled.usage.input_tokens + recalled.usage.output_tokens + ) + assert ( + recalled.usage.metadata["nemo_fabric.detailed_token_usage.v1"][ + "input_tokens_include_cache" + ] + is True + ) + assert isolated.status == "succeeded", isolated.to_mapping() + assert isolated.output.response.strip() == "NONE" + assert ( + remembered.metadata["adapter"]["session_id"] + == recalled.metadata["adapter"]["session_id"] + ) + assert ( + isolated.metadata["adapter"]["session_id"] + != recalled.metadata["adapter"]["session_id"] + ) + assert not (tmp_path / ".agent" / "sessions").exists() + + +async def test_strands_live_mcp_available_on_first_turn(strands_config, tmp_path): + from nemo_fabric import Fabric + + marker = tmp_path / "probe-called" + token = uuid.uuid4().hex + root = Path(__file__).resolve().parents[2] + strands_config.add_mcp_server( + "probe", + transport="stdio", + url=sys.executable, + args=[ + str(root / "tests/_utils/probe_mcp_server.py"), + "--marker", + str(marker), + "--token", + token, + ], + ) + async with await Fabric().start_runtime( + strands_config, base_dir=tmp_path + ) as runtime: + result = await runtime.invoke( + input="Call the probe MCP tool now. Reply only with the token it returns." + ) + assert result.status == "succeeded", result.to_mapping() + assert marker.read_text(encoding="utf-8") == token + assert token in result.output.response + + +async def test_strands_live_harness_reads_workspace(strands_config, tmp_path): + from nemo_fabric import Fabric + + token = uuid.uuid4().hex + (tmp_path / "review-note.txt").write_text(token, encoding="utf-8") + strands_config.harness.settings["builtin_tools"] = ["read"] + async with await Fabric().start_runtime( + strands_config, base_dir=tmp_path + ) as runtime: + result = await runtime.invoke( + input="Read review-note.txt in the workspace. Reply only with its contents." + ) + assert result.status == "succeeded", result.to_mapping() + assert result.output.response.strip() == token diff --git a/tests/scripts/test_publish_typescript_workflow.py b/tests/scripts/test_publish_typescript_workflow.py index 951ec4e09..5ed054541 100644 --- a/tests/scripts/test_publish_typescript_workflow.py +++ b/tests/scripts/test_publish_typescript_workflow.py @@ -50,6 +50,7 @@ def test_publisher_only_triggers_for_public_release_channels(): "nemo-fabric-adapters-droid", "nemo-fabric-adapters-kilo", "nemo-fabric-adapters-kiro", + "nemo-fabric-adapters-strands", "nemo-fabric-adapters-opencode", "nemo-fabric-adapters-pi", "nemo-fabric-adapters-qwen", @@ -104,6 +105,7 @@ def test_publisher_publishes_packages_in_dependency_order(): "adapters/typescript/qwen", "adapters/typescript/kilo", "adapters/typescript/kiro", + "adapters/typescript/strands", ) positions = [run.index(directory) for directory in directories] assert positions == sorted(positions) diff --git a/tests/scripts/test_set_typescript_project_version.py b/tests/scripts/test_set_typescript_project_version.py index 3ba6750fc..4ae5e86c8 100644 --- a/tests/scripts/test_set_typescript_project_version.py +++ b/tests/scripts/test_set_typescript_project_version.py @@ -138,6 +138,17 @@ def typescript_project_fixture(tmp_path: Path) -> Path: }, }, ) + _write_json( + tmp_path / "adapters/typescript/strands/package.json", + { + "name": "nemo-fabric-adapters-strands", + "version": "0.2.0", + "dependencies": { + "nemo-fabric-adapter-contract": "0.2.0", + "nemo-fabric-adapters-common": "0.2.0", + }, + }, + ) _write_json( tmp_path / "adapters/typescript/package-lock.json", { @@ -213,6 +224,14 @@ def typescript_project_fixture(tmp_path: Path) -> Path: "nemo-fabric-adapters-common": "0.2.0", }, }, + "strands": { + "name": "nemo-fabric-adapters-strands", + "version": "0.2.0", + "dependencies": { + "nemo-fabric-adapter-contract": "0.2.0", + "nemo-fabric-adapters-common": "0.2.0", + }, + }, "node_modules/ajv": {"version": "8.20.0"}, }, }, @@ -291,6 +310,7 @@ def test_updates_the_complete_typescript_package_graph( "qwen", "kilo", "kiro", + "strands", ): assert adapters_lock["packages"][workspace]["version"] == version assert common["version"] == version From 564eb12845657386f5d9386a192ae46ee83d950d Mon Sep 17 00:00:00 2001 From: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:58:40 -0700 Subject: [PATCH 2/8] fix: restrict Strands code review tools and address review Signed-off-by: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> --- adapters/README.md | 2 +- adapters/typescript/strands/README.md | 14 +++++++------- .../typescript/strands/test/strands-acp.test.mjs | 6 ++++++ docs/integrations/harness/strands.mdx | 6 +++--- examples/code_review_agent/README.md | 2 +- examples/code_review_agent/config.py | 4 +++- tests/python/test_code_review_example.py | 11 +++++++++++ .../scripts/test_set_typescript_project_version.py | 10 ++++++++++ 8 files changed, 42 insertions(+), 13 deletions(-) diff --git a/adapters/README.md b/adapters/README.md index d56b98dbb..512ec3b26 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -105,7 +105,7 @@ integration shape and implement the minimum lifecycle. | [Kilo Code](typescript/kilo/README.md) | Local Kilo server and one Kilo session | Not supported | Reuses the session and submits one SDK prompt for ordered text input | Deletes the session, terminates the server, and removes the isolated profile | Adapter-owned loopback service | | [Kilo Code](typescript/kilo/README.md) | Configured Kilo provider and model with an optional OpenAI-compatible base URL | `tools.enabled` and `tools.blocked` map to native Kilo tool names | Normalized: stdio, streamable HTTP, and SSE | Normalized `skills.paths` | Not exposed | | [Kiro](typescript/kiro/README.md) | Optional model selected from the Kiro ACP session catalog | ACP permission requests use the closed adapter setting; normalized tool policy is not exposed | Normalized ACP session inputs: stdio, streamable HTTP, and SSE | Not exposed | Kiro native behavior | -| [Strands](typescript/strands/README.md) | Bedrock or explicit OpenAI-compatible Chat Completions endpoint | Built-in tool selection; stock harness permissions | Stdio, streamable HTTP, and SSE | Not exposed | Append system instructions | +| [Strands](typescript/strands/README.md) | Bedrock or explicit OpenAI-compatible Chat Completions endpoint | Built-in tool selection; stock harness permissions | Stdio, streamable HTTP, and SSE | Not exposed | Not exposed | | [OpenCode](typescript/opencode/README.md) | Configured OpenCode provider and model with an optional OpenAI-compatible base URL | Not exposed | Normalized: stdio and streamable HTTP | Normalized `skills.paths` | Not exposed | | [Pi](typescript/pi/README.md) | One Pi-catalog provider and model with an optional base URL override | `tools.definitions`, `tools.enabled`, and `tools.blocked` cover built-ins, trusted local modules, and explicit extension tools | Not exposed | Normalized `skills.paths` | Not exposed | | [Qwen Code](typescript/qwen/README.md) | OpenAI-compatible provider and model | Native `tools.blocked` deny list | Normalized: stdio and streamable HTTP with per-server tool filters | Explicit `skills.paths` | Not exposed | diff --git a/adapters/typescript/strands/README.md b/adapters/typescript/strands/README.md index 97f06607c..296236c89 100644 --- a/adapters/typescript/strands/README.md +++ b/adapters/typescript/strands/README.md @@ -5,7 +5,7 @@ SPDX-License-Identifier: Apache-2.0 # Strands ACP Adapter -Run the standard [Strands harness](https://github.com/strands-agents/harness-sdk/tree/main/strands-cli) through NVIDIA NeMo Fabric. The `nvidia.fabric.strands` adapter launches `strands --acp-server` using the [shared ACP runner](../common/acp-runner.md), retains one conversation per Fabric runtime, and submits ordered text invocations. +Run the standard [Strands harness](https://github.com/strands-agents/harness-sdk/tree/main/strands-cli) through NVIDIA NeMo Fabric. The `nvidia.fabric.strands` adapter launches `strands --acp-server` using the [shared ACP runner](../common/acp-runner.md), retains one conversation per NeMo Fabric runtime, and submits ordered text invocations. ```mermaid flowchart LR @@ -34,7 +34,7 @@ npm run build --prefix adapters/typescript --workspace nemo-fabric-adapters-comm npm run build --prefix adapters/typescript --workspace nemo-fabric-adapters-strands ``` -The published adapter package is `nemo-fabric-adapters-strands`. Its descriptor is `strands.fabric-adapter.json` in the installed package. Fabric's Python adapter catalog also includes the descriptor; source development can select it explicitly as below. +The published adapter package is `nemo-fabric-adapters-strands`. Its descriptor is `strands.fabric-adapter.json` in the installed package. NeMo Fabric's Python adapter catalog also includes the descriptor; source development can select it explicitly as below. ## Configure an Agent Invocation @@ -53,7 +53,7 @@ config = FabricConfig( discovery=DiscoveryConfig(local_paths=[ root / "adapters/typescript/strands/strands.fabric-adapter.json", ]), - harness=HarnessConfig(adapter_id="nvidia.fabric.strands"), + harness=HarnessConfig(adapter_id="nvidia.fabric.strands", settings={"builtin_tools": ["read"]}), models={"default": ModelConfig( provider="nvidia", model="nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", @@ -75,7 +75,7 @@ async def main(): asyncio.run(main()) ``` -Only the `default` model role is supported. With no configured model, Strands selects its native Bedrock default. To select a Bedrock model explicitly, use `ModelConfig(provider="bedrock", model="")` without `base_url` or `api_key_env`. Configure the AWS SDK credential chain and region before starting Fabric. AWS profiles and SSO caches remain accessible through the runtime's isolated home. +Only the `default` model role is supported. With no configured model, Strands selects its native Bedrock default. To select a Bedrock model explicitly, use `ModelConfig(provider="bedrock", model="")` without `base_url` or `api_key_env`. Configure the AWS SDK credential chain and region before starting NeMo Fabric. AWS profiles and SSO caches remain accessible through the runtime's isolated home. For Chat Completions endpoints, use provider `openai` or `nvidia` and an explicit `base_url`. Both map to Strands' native `litellm/` profile, which creates a direct OpenAI-compatible Chat Completions client; a LiteLLM proxy is not required. The model ID is passed unchanged after the profile prefix. The CLI's `openai/` profile uses the Responses API, so it is not used for this mapping. @@ -96,13 +96,13 @@ Query the endpoint's model catalog for its served ID. Strands does not provide a | Harness setting | Default | Behavior | | --- | --- | --- | -| `builtin_tools` | `shell`, `read`, `write`, `edit` | Select from these tools and `web_fetch`; an empty list disables built-in tools. | +| `builtin_tools` | `shell`, `read`, `write`, `edit` | Select from these tools and `web_fetch`; an empty list disables these tools. | | `max_invocations` | `100` | Maximum dispatched invocations in one conversation, from 1 to 10,000. Start a new runtime when exhausted; history is not silently reset. | | `insecure_http_model_hosts` | `[]` | Explicit hostnames or IPs allowed for HTTP model endpoints outside loopback. | -The standard Strands ACP server executes enabled tools with the task environment's permissions. There is no adapter permission mode or normalized tool-policy support. Tool selection is not an operating-system sandbox; use an appropriate Fabric environment when running untrusted tasks. +The standard Strands ACP server executes enabled tools with the task environment's permissions. There is no adapter permission mode or normalized tool-policy support. Tool selection is not an operating-system sandbox; use an appropriate NeMo Fabric environment when running untrusted tasks. -Configure MCP through Fabric's normalized `mcp.servers`, not harness settings: +Configure MCP through NeMo Fabric's normalized `mcp.servers`, not harness settings: ```python import sys diff --git a/adapters/typescript/strands/test/strands-acp.test.mjs b/adapters/typescript/strands/test/strands-acp.test.mjs index 84964cab2..5160a67ae 100644 --- a/adapters/typescript/strands/test/strands-acp.test.mjs +++ b/adapters/typescript/strands/test/strands-acp.test.mjs @@ -78,6 +78,12 @@ test("preserves real ACP session history, isolates runtime state, and uses per-i input.config.instructions = { system: { mode: "append", content: "--instruction-text" }, }; + const previousSecret = process.env.TEST_UNRELATED_SECRET; + process.env.TEST_UNRELATED_SECRET = "must-not-leak"; + t.after(() => { + if (previousSecret === undefined) delete process.env.TEST_UNRELATED_SECRET; + else process.env.TEST_UNRELATED_SECRET = previousSecret; + }); const factory = new StrandsAcpSessionFactory(); const first = await factory.create(input); t.after(() => first.stop()); diff --git a/docs/integrations/harness/strands.mdx b/docs/integrations/harness/strands.mdx index cef8a53c0..5f623ba71 100644 --- a/docs/integrations/harness/strands.mdx +++ b/docs/integrations/harness/strands.mdx @@ -3,7 +3,7 @@ SPDX-License-Identifier: Apache-2.0 */} # Strands -Use `nvidia.fabric.strands` to run the standard Strands harness through NVIDIA NeMo Fabric's shared ACP runner. One Fabric runtime owns a Strands CLI child process and a persistent conversation. +Use `nvidia.fabric.strands` to run the standard Strands harness through NVIDIA NeMo Fabric's shared ACP runner. One NeMo Fabric runtime owns a Strands CLI child process and a persistent conversation. Install Node.js 22.19 or newer and the separately managed CLI: @@ -24,8 +24,8 @@ export NVIDIA_API_KEY="" The adapter accepts the default model role, append-only system instructions, and normalized MCP servers over stdio, streamable HTTP, or SSE. Use provider `bedrock` for the AWS SDK credential chain, or `openai`/`nvidia` with an explicit Chat Completions `base_url`. Omitting `api_key_env` supports a keyless endpoint. Non-loopback HTTP model endpoints require `harness.settings.insecure_http_model_hosts`. -Select native tools with `harness.settings.builtin_tools`; the default is `shell`, `read`, `write`, and `edit`. These tools execute with the task environment's permissions. The standard ACP path does not expose an adapter permission mode. Each runtime uses an isolated CLI profile with personal configuration, discovery, disk session persistence, and long-term memory disabled. Conversation state lasts until stop and is limited to 100 dispatched invocations by default through `max_invocations`. +The code-review example enables only `read`. Select native tools for other applications with `harness.settings.builtin_tools`; the default is `shell`, `read`, `write`, and `edit`. These tools execute with the task environment's permissions. The standard ACP path does not expose an adapter permission mode. Each runtime uses an isolated CLI profile with personal configuration, discovery, disk session persistence, and long-term memory disabled. Conversation state lasts until stop and is limited to 100 dispatched invocations by default through `max_invocations`. The adapter supports text invocations, invocation-local token usage, deadline cleanup, and independent runtimes. It does not advertise custom source agents, native streaming, public cancellation, services, Relay telemetry, or normalized skills and tool policy. -See the [Strands adapter guide](https://github.com/NVIDIA/NeMo-Fabric/blob/main/adapters/typescript/strands/README.md) for complete configuration, endpoint mapping, MCP restrictions, lifecycle behavior, and live E2E commands. +See the [Strands adapter guide](https://github.com/NVIDIA/NeMo-NeMo Fabric/blob/main/adapters/typescript/strands/README.md) for complete configuration, endpoint mapping, MCP restrictions, lifecycle behavior, and live E2E commands. diff --git a/examples/code_review_agent/README.md b/examples/code_review_agent/README.md index 361a8f14d..6250506f3 100644 --- a/examples/code_review_agent/README.md +++ b/examples/code_review_agent/README.md @@ -501,4 +501,4 @@ export NVIDIA_API_KEY="" .venv/bin/python -m examples.code_review_agent --variant strands --input "Review the workspace changes." ``` -This variant uses the NVIDIA model configured in `strands_config()`, append-only review instructions, and native shell/read/write/edit tools. Tool calls run with the task environment's permissions. Normalized skills and Relay telemetry are unsupported. For Bedrock or a keyless custom endpoint, update the default `ModelConfig` as shown in the [adapter guide](../../adapters/typescript/strands/README.md). No Dockerfile is required. +This variant uses the NVIDIA model configured in `strands_config()`, append-only review instructions, and the native read tool. Tool calls run with the task environment's permissions. Normalized skills and Relay telemetry are unsupported. For Bedrock or a keyless custom endpoint, update the default `ModelConfig` as shown in the [adapter guide](../../adapters/typescript/strands/README.md). No Dockerfile is required. diff --git a/examples/code_review_agent/config.py b/examples/code_review_agent/config.py index 5655c02fb..e4f0c128a 100644 --- a/examples/code_review_agent/config.py +++ b/examples/code_review_agent/config.py @@ -275,7 +275,9 @@ def strands_config() -> FabricConfig: config = kiro_config() config.discovery = DiscoveryConfig(local_paths=[STRANDS_DESCRIPTOR]) config.harness = HarnessConfig( - adapter_id="nvidia.fabric.strands", resolution="preinstalled" + adapter_id="nvidia.fabric.strands", + resolution="preinstalled", + settings={"builtin_tools": ["read"]}, ) config.models = {"default": _nvidia_code_review_model()} config.instructions = InstructionsConfig( diff --git a/tests/python/test_code_review_example.py b/tests/python/test_code_review_example.py index 12a88392c..8f2d85380 100644 --- a/tests/python/test_code_review_example.py +++ b/tests/python/test_code_review_example.py @@ -1019,3 +1019,14 @@ async def test_example_entrypoint_streams_relay_records_and_terminal_result( runtime.invoke_stream.assert_called_once_with(input="review this") stream.result.assert_awaited_once_with() runtime_context.__aexit__.assert_awaited_once() + + +def test_strands_variant_projects_read_only_harness_tools(): + from examples.code_review_agent import strands_config + + plan = Fabric().plan(strands_config(), base_dir=BASE_DIR) + agent_config = plan.to_mapping()["agent_config"] + assert agent_config["harness"]["settings"]["builtin_tools"] == ["read"] + assert agent_config["instructions"]["system"]["mode"] == "append" + assert agent_config.get("tools") is None + assert agent_config.get("skills") is None diff --git a/tests/scripts/test_set_typescript_project_version.py b/tests/scripts/test_set_typescript_project_version.py index 4ae5e86c8..94ca5576b 100644 --- a/tests/scripts/test_set_typescript_project_version.py +++ b/tests/scripts/test_set_typescript_project_version.py @@ -293,6 +293,9 @@ def test_updates_the_complete_typescript_package_graph( kiro = json.loads( (typescript_project / "adapters/typescript/kiro/package.json").read_text() ) + strands = json.loads( + (typescript_project / "adapters/typescript/strands/package.json").read_text() + ) assert contract["version"] == version assert contract_lock["version"] == version @@ -336,6 +339,9 @@ def test_updates_the_complete_typescript_package_graph( assert kiro["version"] == version assert kiro["dependencies"]["nemo-fabric-adapter-contract"] == version assert kiro["dependencies"]["nemo-fabric-adapters-common"] == version + assert strands["version"] == version + assert strands["dependencies"]["nemo-fabric-adapter-contract"] == version + assert strands["dependencies"]["nemo-fabric-adapters-common"] == version assert ( adapters_lock["packages"]["common"]["dependencies"][ "nemo-fabric-adapter-contract" @@ -370,6 +376,10 @@ def test_updates_the_complete_typescript_package_graph( "nemo-fabric-adapter-contract": version, "nemo-fabric-adapters-common": version, } + assert adapters_lock["packages"]["strands"]["dependencies"] == { + "nemo-fabric-adapter-contract": version, + "nemo-fabric-adapters-common": version, + } assert contract_lock["packages"]["node_modules/typescript"]["version"] == "5.9.3" assert adapters_lock["packages"]["node_modules/ajv"]["version"] == "8.20.0" From 07afa0b3ddf675a69558acc1cd26074d7c1dab12 Mon Sep 17 00:00:00 2001 From: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:07:15 -0700 Subject: [PATCH 3/8] docs: complete Strands support matrix and repair guide link Signed-off-by: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> --- adapters/README.md | 68 +++++++++++++-------------- docs/integrations/harness/strands.mdx | 2 +- 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/adapters/README.md b/adapters/README.md index 512ec3b26..f95c6e39d 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -141,42 +141,42 @@ its harness. `No` means an explicitly configured value fails planning instead of being ignored. The following table groups provider-specific Relay subfields and additive extension maps because their support does not vary by adapter: -| `FabricConfig` Field | Claude | Codex | Deep Agents | Hermes Agent | mini-SWE-agent | NOOA | OpenClaw | OpenHands | Cline | Factory Droid | OpenCode | Pi | Qwen Code | Kilo Code | Kiro | Remote Agent | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| `schema_version` | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | -| `metadata.name`, `.description` | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | -| `harness.adapter_id`, `.resolution` | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | -| `harness.settings` | Closed adapter schema | Closed adapter schema | Closed adapter schema | Closed adapter schema | Closed `timeout` schema | Closed schema | Closed command, port-range, and timeout schema | Closed `condenser` schema | Closed schema with no properties | Closed schema with no properties | No settings declared | Closed local-extension and Relay-extension schema | Closed `permission_mode` schema | Closed schema | Closed mode, effort, and permission schema | Closed `base_url`, `api_type`, transport-timeout, and `relay_streaming` schema | -| `workflow.target_id`, `.settings` | No | No | No | No | No | No | No | No | No | No | No | No | No | No | No | No | -| `models..provider` | `anthropic` uses native auth; custom names require an Anthropic Messages-compatible `base_url` and `api_key_env` | `openai` uses native auth; custom names require a Responses-compatible `base_url` and `api_key_env` | Dynamic LangChain provider; custom OpenAI-compatible endpoints require `base_url` and `api_key_env` | Dynamic Hermes provider | Configured provider | Configured provider | OpenClaw provider; custom providers require an OpenAI Chat Completions-compatible `base_url` | Prefixes unqualified model names for LiteLLM | Cline provider | `factory` | OpenCode provider | Pi catalog provider | `openai` | Kilo provider | `kiro` | Configured provider | -| `models..model` | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes; Factory-managed model ID | Yes; passed to OpenCode | Yes; must exist in the Pi catalog | Yes | Yes | Yes; must be advertised by Kiro | Yes | -| `models..api_key_env` | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | `KIRO_API_KEY` only | Yes | -| `models..base_url` | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes; compatible endpoint | No; use external Droid settings | Yes; OpenAI-compatible Chat Completions endpoint | Yes; known catalog models only | Yes; OpenAI-compatible Chat Completions endpoint | Yes; OpenAI-compatible endpoint | No | No; use `harness.settings.base_url` | -| `models..temperature` | No | No | Yes | Yes | Yes | Yes | Yes | Yes | No | No | Yes: OpenAI-compatible `base_url` only | No | Yes | Yes | No | Yes | -| `models..settings.` | No keys declared | No keys declared | No keys declared | No keys declared | No keys declared | `client_type` | No keys declared | No keys declared | No keys declared | No keys declared | No keys declared | No keys declared | No keys declared | No keys declared | No keys declared | `max_tokens` for Anthropic Messages | -| `models..top_p` | No | No | Yes | Yes | Yes: passed through LiteLLM | No | Yes | Yes | No | No | Yes: OpenAI-compatible `base_url` only | No | Yes | Yes | No | Yes: translated to the selected API protocol | -| `models..max_tokens` | No | No | Yes | Yes | Yes: passed through LiteLLM | No | Yes | Yes | No | No | No | No | No | No | No | Yes: translated to the selected API protocol | -| `instructions.system` | `replace`, `append` | `replace` with base instructions | `replace` | `replace` | `replace` | `replace` | `replace` | `replace`, `append` | `replace` with Cline override prompt | `replace`, `append` | `replace` with OpenCode base instructions | `replace` with Pi base instructions | `replace`, `append` | `replace` | No | `replace` | -| `runtime.input_schema`, `.output_schema` | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | -| `runtime.artifacts`, `.timeout_seconds` | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | -| `runtime.max_turns` | Yes | No | Yes; maps to LangGraph supersteps | Yes; iteration limit | Yes | No | No | Yes; per invocation | No | No | No | No | No | Yes; maps to build-agent steps | No | No | -| `environment.provider`, `.control_location`, `.ownership` | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | -| `environment.workspace`, `.artifacts`, `.env`, `.env_names` | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | -| `environment.connection`, `.metadata`, `.settings` | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | -| `tools.definitions` | No | No | No | No | No | No | No | No | No | No | No | Yes; trusted local module factories | No | No | No | No | -| `tools.enabled`, `.blocked` | Yes | No | Yes | Yes; native selectors are Hermes toolset names | No | No | Yes | Yes; terminal and file editor | Yes; Cline built-in names | Yes; Droid built-in IDs | No | Yes | `tools.blocked`: Yes; `tools.enabled`: No | Yes; native Kilo tool names | No | No | -| `skills.paths` | Yes | Yes | Yes | Yes | No | InteractiveAgent: Yes, BenchAgent: No | Yes | Yes | Yes; Agent Plugins v1 | Yes; isolated personal compatibility directory | Yes | Yes | Yes | Yes | No | No | -| `mcp.servers..transport`, `.url` with `harness_native` exposure | Yes | Yes | Yes | Yes | No | InteractiveAgent: Yes, BenchAgent: No | Yes | Yes; no authentication or per-server tool filters | Yes; unauthenticated only | Yes; unauthenticated stdio, streamable HTTP, and SSE | Yes: stdio `args` and `env`, streamable HTTP `custom_headers` | No | Yes: stdio and streamable HTTP with per-server tool filters | Yes: stdio, streamable HTTP, and SSE | Yes: stdio, streamable HTTP, and SSE without authentication or tool filters | No | -| `mcp.servers..exposure = "fabric_managed"` | No; not implemented | No; not implemented | No; not implemented | No; not implemented | No | No; not implemented | No; not implemented | No | No | No | No | No | No | No | No | No | -| `telemetry.providers.relay` | Yes | Yes | Yes | Yes | Yes | Yes | No | No | No | No | No | Yes, supports embedded collector-backed Agent Trajectory Observability Format (ATOF) streaming | No | No | No | Yes, supports collector-backed ATOF streaming | -| `telemetry.providers.native` | No | Yes; OpenTelemetry | Yes; OpenTelemetry and OpenInference | No | No | No | No | No | No | No | No | No | No | No | No | No | -| `telemetry.providers..config` | Declared-provider pass-through | Declared-provider pass-through | Declared-provider pass-through | Declared-provider pass-through | Declared-provider pass-through | Declared-provider pass-through | No | No | No | No | No | Declared-provider pass-through | No | No | No | No | -| `relay.project`, `.output_dir`, `.observability` | Yes | Yes | Yes | Yes | Yes | Yes | No | No | No | No | No | Yes | No | No | No | Uses the named external collector sink when selected; config is not sent to the remote service | -| `relay.components`, `.policy` | Yes | Yes | Yes | Yes | Yes | Yes | No | No | No | No | No | Yes | No | No | No | Not sent to the remote service | -| Other additive `extensions` on typed config objects | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | +| `FabricConfig` Field | Claude | Codex | Deep Agents | Hermes Agent | mini-SWE-agent | NOOA | OpenClaw | OpenHands | Cline | Factory Droid | OpenCode | Pi | Qwen Code | Kilo Code | Kiro | Strands | Remote Agent | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `schema_version` | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | +| `metadata.name`, `.description` | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | +| `harness.adapter_id`, `.resolution` | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | +| `harness.settings` | Closed adapter schema | Closed adapter schema | Closed adapter schema | Closed adapter schema | Closed `timeout` schema | Closed schema | Closed command, port-range, and timeout schema | Closed `condenser` schema | Closed schema with no properties | Closed schema with no properties | No settings declared | Closed local-extension and Relay-extension schema | Closed `permission_mode` schema | Closed schema | Closed mode, effort, and permission schema | Closed tool-selection, invocation-limit, and HTTP model-host schema | Closed `base_url`, `api_type`, transport-timeout, and `relay_streaming` schema | +| `workflow.target_id`, `.settings` | No | No | No | No | No | No | No | No | No | No | No | No | No | No | No | No | No | +| `models..provider` | `anthropic` uses native auth; custom names require an Anthropic Messages-compatible `base_url` and `api_key_env` | `openai` uses native auth; custom names require a Responses-compatible `base_url` and `api_key_env` | Dynamic LangChain provider; custom OpenAI-compatible endpoints require `base_url` and `api_key_env` | Dynamic Hermes provider | Configured provider | Configured provider | OpenClaw provider; custom providers require an OpenAI Chat Completions-compatible `base_url` | Prefixes unqualified model names for LiteLLM | Cline provider | `factory` | OpenCode provider | Pi catalog provider | `openai` | Kilo provider | `kiro` | `bedrock`; `openai` or `nvidia` with an explicit Chat Completions `base_url` | Configured provider | +| `models..model` | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes; Factory-managed model ID | Yes; passed to OpenCode | Yes; must exist in the Pi catalog | Yes | Yes | Yes; must be advertised by Kiro | Yes; `default` role only | Yes | +| `models..api_key_env` | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | `KIRO_API_KEY` only | Yes; Chat Completions endpoints only; omit for keyless | Yes | +| `models..base_url` | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes; compatible endpoint | No; use external Droid settings | Yes; OpenAI-compatible Chat Completions endpoint | Yes; known catalog models only | Yes; OpenAI-compatible Chat Completions endpoint | Yes; OpenAI-compatible endpoint | No | Yes; Chat Completions endpoints only | No; use `harness.settings.base_url` | +| `models..temperature` | No | No | Yes | Yes | Yes | Yes | Yes | Yes | No | No | Yes: OpenAI-compatible `base_url` only | No | Yes | Yes | No | No | Yes | +| `models..settings.` | No keys declared | No keys declared | No keys declared | No keys declared | No keys declared | `client_type` | No keys declared | No keys declared | No keys declared | No keys declared | No keys declared | No keys declared | No keys declared | No keys declared | No keys declared | No keys declared | `max_tokens` for Anthropic Messages | +| `models..top_p` | No | No | Yes | Yes | Yes: passed through LiteLLM | No | Yes | Yes | No | No | Yes: OpenAI-compatible `base_url` only | No | Yes | Yes | No | No | Yes: translated to the selected API protocol | +| `models..max_tokens` | No | No | Yes | Yes | Yes: passed through LiteLLM | No | Yes | Yes | No | No | No | No | No | No | No | No | Yes: translated to the selected API protocol | +| `instructions.system` | `replace`, `append` | `replace` with base instructions | `replace` | `replace` | `replace` | `replace` | `replace` | `replace`, `append` | `replace` with Cline override prompt | `replace`, `append` | `replace` with OpenCode base instructions | `replace` with Pi base instructions | `replace`, `append` | `replace` | No | `append` | `replace` | +| `runtime.input_schema`, `.output_schema` | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | +| `runtime.artifacts`, `.timeout_seconds` | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | +| `runtime.max_turns` | Yes | No | Yes; maps to LangGraph supersteps | Yes; iteration limit | Yes | No | No | Yes; per invocation | No | No | No | No | No | Yes; maps to build-agent steps | No | No | No | +| `environment.provider`, `.control_location`, `.ownership` | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | +| `environment.workspace`, `.artifacts`, `.env`, `.env_names` | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | Core | +| `environment.connection`, `.metadata`, `.settings` | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | Environment-provider-owned | +| `tools.definitions` | No | No | No | No | No | No | No | No | No | No | No | Yes; trusted local module factories | No | No | No | No | No | +| `tools.enabled`, `.blocked` | Yes | No | Yes | Yes; native selectors are Hermes toolset names | No | No | Yes | Yes; terminal and file editor | Yes; Cline built-in names | Yes; Droid built-in IDs | No | Yes | `tools.blocked`: Yes; `tools.enabled`: No | Yes; native Kilo tool names | No | No | No | +| `skills.paths` | Yes | Yes | Yes | Yes | No | InteractiveAgent: Yes, BenchAgent: No | Yes | Yes | Yes; Agent Plugins v1 | Yes; isolated personal compatibility directory | Yes | Yes | Yes | Yes | No | No | No | +| `mcp.servers..transport`, `.url` with `harness_native` exposure | Yes | Yes | Yes | Yes | No | InteractiveAgent: Yes, BenchAgent: No | Yes | Yes; no authentication or per-server tool filters | Yes; unauthenticated only | Yes; unauthenticated stdio, streamable HTTP, and SSE | Yes: stdio `args` and `env`, streamable HTTP `custom_headers` | No | Yes: stdio and streamable HTTP with per-server tool filters | Yes: stdio, streamable HTTP, and SSE | Yes: stdio, streamable HTTP, and SSE without authentication or tool filters | Yes; stdio, streamable HTTP, and SSE; no normalized authentication or tool filters | No | +| `mcp.servers..exposure = "fabric_managed"` | No; not implemented | No; not implemented | No; not implemented | No; not implemented | No | No; not implemented | No; not implemented | No | No | No | No | No | No | No | No | No | No | +| `telemetry.providers.relay` | Yes | Yes | Yes | Yes | Yes | Yes | No | No | No | No | No | Yes, supports embedded collector-backed Agent Trajectory Observability Format (ATOF) streaming | No | No | No | No | Yes, supports collector-backed ATOF streaming | +| `telemetry.providers.native` | No | Yes; OpenTelemetry | Yes; OpenTelemetry and OpenInference | No | No | No | No | No | No | No | No | No | No | No | No | No | No | +| `telemetry.providers..config` | Declared-provider pass-through | Declared-provider pass-through | Declared-provider pass-through | Declared-provider pass-through | Declared-provider pass-through | Declared-provider pass-through | No | No | No | No | No | Declared-provider pass-through | No | No | No | No | No | +| `relay.project`, `.output_dir`, `.observability` | Yes | Yes | Yes | Yes | Yes | Yes | No | No | No | No | No | Yes | No | No | No | No | Uses the named external collector sink when selected; config is not sent to the remote service | +| `relay.components`, `.policy` | Yes | Yes | Yes | Yes | Yes | Yes | No | No | No | No | No | Yes | No | No | No | No | Not sent to the remote service | +| Other additive `extensions` on typed config objects | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | Rejected unless declared by the descriptor | The selected model role is `default`, or the sole configured role when no -`default` exists. More than one role without `default` fails planning. +`default` exists. More than one role without `default` fails planning. Kiro and Strands accept only the `default` role. All bundled adapters except Hermes publish a descriptor-owned `model_schema` for every configured model role. The Claude and Codex native providers (`anthropic` and `openai`, respectively) keep the existing authentication path. diff --git a/docs/integrations/harness/strands.mdx b/docs/integrations/harness/strands.mdx index 5f623ba71..0113a82f4 100644 --- a/docs/integrations/harness/strands.mdx +++ b/docs/integrations/harness/strands.mdx @@ -28,4 +28,4 @@ The code-review example enables only `read`. Select native tools for other appli The adapter supports text invocations, invocation-local token usage, deadline cleanup, and independent runtimes. It does not advertise custom source agents, native streaming, public cancellation, services, Relay telemetry, or normalized skills and tool policy. -See the [Strands adapter guide](https://github.com/NVIDIA/NeMo-NeMo Fabric/blob/main/adapters/typescript/strands/README.md) for complete configuration, endpoint mapping, MCP restrictions, lifecycle behavior, and live E2E commands. +See the [Strands adapter guide](https://github.com/NVIDIA/NeMo-Fabric/blob/main/adapters/typescript/strands/README.md) for complete configuration, endpoint mapping, MCP restrictions, lifecycle behavior, and live E2E commands. From e2fa1dcfabee9d0ef986dea8183f3a27af4bd5f9 Mon Sep 17 00:00:00 2001 From: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:53:25 -0700 Subject: [PATCH 4/8] docs: clarify AWS adapter naming and Strands limitations Signed-off-by: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> --- README.md | 4 ++-- adapters/README.md | 2 +- adapters/typescript/README.md | 2 +- adapters/typescript/strands/README.md | 4 ++-- docs/index.yml | 2 +- docs/integrations/harness/overview.mdx | 2 +- docs/integrations/harness/strands.mdx | 10 +++++++--- examples/code_review_agent/README.md | 4 ++-- 8 files changed, 17 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index a4a3d1a7b..83b9e94e3 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,8 @@ NeMo Fabric includes adapters for the following harnesses and execution targets. | [Factory Droid](adapters/typescript/droid/README.md) | ✅ | ✅ | ✅ | — | | [Hermes Agent](adapters/python/hermes/README.md) | ✅ | ✅ | ✅ | ✅ | | [Kilo Code](adapters/typescript/kilo/README.md) | ✅ | ✅ | ✅ | — | -| [Kiro](adapters/typescript/kiro/README.md) | — | ✅ | — | — | -| [Strands](adapters/typescript/strands/README.md) | — | ✅ | — | — | +| [AWS Kiro](adapters/typescript/kiro/README.md) | — | ✅ | — | — | +| [AWS Strands](adapters/typescript/strands/README.md) | — | ✅ | — | — | | [LangChain Deep Agents](adapters/python/deepagents/README.md) | ✅ | ✅ | ✅ | ✅ | | [mini-SWE-agent](adapters/python/mini-swe-agent/README.md) | — | — | — | ✅ | | [NOOA](adapters/python/nooa/README.md) | — | ✅ | ✅ | ✅ | diff --git a/adapters/README.md b/adapters/README.md index f95c6e39d..e9232e3e7 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -62,7 +62,7 @@ build, and packaging conventions. | [Factory Droid](typescript/droid/README.md) | `nvidia.fabric.droid` | `nemo-fabric-adapters-droid` | Node.js 22.19+ | | [Kilo Code](typescript/kilo/README.md) | `nvidia.fabric.kilo` | `nemo-fabric-adapters-kilo` | Node.js 22.19+ | | [Kiro](typescript/kiro/README.md) | `nvidia.fabric.kiro` | `nemo-fabric-adapters-kiro` | Node.js 22.19+ | -| [Strands](typescript/strands/README.md) | `nvidia.fabric.strands` | `nemo-fabric-adapters-strands` | Node.js 22.19+ | +| [AWS Strands](typescript/strands/README.md) | `nvidia.fabric.strands` | `nemo-fabric-adapters-strands` | Node.js 22.19+ | | [OpenCode](typescript/opencode/README.md) | `nvidia.fabric.opencode` | `nemo-fabric-adapters-opencode` | Bun 1.4.2+ | | [Pi](typescript/pi/README.md) | `nvidia.fabric.pi` | `nemo-fabric-adapters-pi` | Node.js 22.19+ | | [Qwen Code](typescript/qwen/README.md) | `nvidia.fabric.qwen` | `nemo-fabric-adapters-qwen` | Node.js 22.19+ | diff --git a/adapters/typescript/README.md b/adapters/typescript/README.md index b21d1c52a..e6a929206 100644 --- a/adapters/typescript/README.md +++ b/adapters/typescript/README.md @@ -77,4 +77,4 @@ The OpenCode adapter runs with Bun 1.4.2 or later. Source and package checks run its process lifecycle under Bun while using Node.js for TypeScript builds and the shared workspace tooling. -The [Strands adapter](strands/README.md) runs the standard Strands harness through the shared ACP runner. Install the CLI separately, then use `just install-typescript-strands` to install the adapter workspace dependencies. +The [AWS Strands adapter](strands/README.md) runs the standard Strands harness through the shared ACP runner. Install the CLI separately, then use `just install-typescript-strands` to install the adapter workspace dependencies. diff --git a/adapters/typescript/strands/README.md b/adapters/typescript/strands/README.md index 296236c89..14b76e733 100644 --- a/adapters/typescript/strands/README.md +++ b/adapters/typescript/strands/README.md @@ -3,9 +3,9 @@ SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All SPDX-License-Identifier: Apache-2.0 --> -# Strands ACP Adapter +# AWS Strands ACP Adapter -Run the standard [Strands harness](https://github.com/strands-agents/harness-sdk/tree/main/strands-cli) through NVIDIA NeMo Fabric. The `nvidia.fabric.strands` adapter launches `strands --acp-server` using the [shared ACP runner](../common/acp-runner.md), retains one conversation per NeMo Fabric runtime, and submits ordered text invocations. +Run the standard [AWS Strands harness](https://github.com/strands-agents/harness-sdk/tree/main/strands-cli) through NVIDIA NeMo Fabric. The `nvidia.fabric.strands` adapter launches `strands --acp-server` using the [shared ACP runner](../common/acp-runner.md), retains one conversation per NeMo Fabric runtime, and submits ordered text invocations. ```mermaid flowchart LR diff --git a/docs/index.yml b/docs/index.yml index a83e3d7a0..f17226d75 100644 --- a/docs/index.yml +++ b/docs/index.yml @@ -76,7 +76,7 @@ navigation: path: ./integrations/harness/kilo.mdx - page: Kiro path: ./integrations/harness/kiro.mdx - - page: Strands + - page: AWS Strands path: ./integrations/harness/strands.mdx - page: mini-SWE-agent path: ./integrations/harness/mini-swe-agent.mdx diff --git a/docs/integrations/harness/overview.mdx b/docs/integrations/harness/overview.mdx index 50bf9184c..7b53b29d4 100644 --- a/docs/integrations/harness/overview.mdx +++ b/docs/integrations/harness/overview.mdx @@ -23,7 +23,7 @@ NeMo Fabric includes the following harness integrations: | LangChain Deep Agents | [Use the LangChain Deep Agents adapter](./deepagents.mdx) | | Kilo Code | [Use the Kilo Code adapter](./kilo.mdx) | | Kiro | [Use the Kiro adapter](./kiro.mdx) | -| Strands | [Use the Strands adapter](./strands.mdx) | +| AWS Strands | [Use the Strands adapter](./strands.mdx) | | mini-SWE-agent | [Use the mini-SWE-agent adapter](./mini-swe-agent.mdx) | | OpenClaw | [Use the OpenClaw adapter](./openclaw.mdx) | | OpenHands | [Use the OpenHands adapter](./openhands.mdx) | diff --git a/docs/integrations/harness/strands.mdx b/docs/integrations/harness/strands.mdx index 0113a82f4..0e9a67120 100644 --- a/docs/integrations/harness/strands.mdx +++ b/docs/integrations/harness/strands.mdx @@ -1,9 +1,9 @@ {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} -# Strands +# AWS Strands -Use `nvidia.fabric.strands` to run the standard Strands harness through NVIDIA NeMo Fabric's shared ACP runner. One NeMo Fabric runtime owns a Strands CLI child process and a persistent conversation. +Use `nvidia.fabric.strands` to run the standard AWS Strands harness through NVIDIA NeMo Fabric's shared ACP runner. One NeMo Fabric runtime owns a Strands CLI child process and a persistent conversation. Install Node.js 22.19 or newer and the separately managed CLI: @@ -26,6 +26,10 @@ The adapter accepts the default model role, append-only system instructions, and The code-review example enables only `read`. Select native tools for other applications with `harness.settings.builtin_tools`; the default is `shell`, `read`, `write`, and `edit`. These tools execute with the task environment's permissions. The standard ACP path does not expose an adapter permission mode. Each runtime uses an isolated CLI profile with personal configuration, discovery, disk session persistence, and long-term memory disabled. Conversation state lasts until stop and is limited to 100 dispatched invocations by default through `max_invocations`. -The adapter supports text invocations, invocation-local token usage, deadline cleanup, and independent runtimes. It does not advertise custom source agents, native streaming, public cancellation, services, Relay telemetry, or normalized skills and tool policy. +The adapter supports text invocations, invocation-local token usage, deadline cleanup, and independent runtimes. + +## Limitations + +The adapter does not advertise custom source agents, native streaming, public cancellation, services, Relay telemetry, or normalized skills and tool policy. See the [Strands adapter guide](https://github.com/NVIDIA/NeMo-Fabric/blob/main/adapters/typescript/strands/README.md) for complete configuration, endpoint mapping, MCP restrictions, lifecycle behavior, and live E2E commands. diff --git a/examples/code_review_agent/README.md b/examples/code_review_agent/README.md index 6250506f3..d35a96976 100644 --- a/examples/code_review_agent/README.md +++ b/examples/code_review_agent/README.md @@ -487,9 +487,9 @@ Set `NVIDIA_API_KEY` to run the example. The variant keeps the code-review skill uses Qwen's non-interactive default approval mode, and blocks shell and edit tools. The variant does not configure an MCP server or Relay telemetry. -### Strands (`strands`) +### AWS Strands (`strands`) -Run the standard Strands harness through the shared ACP runner. From the repository root: +Run the standard AWS Strands harness through the shared ACP runner. From the repository root: ```bash npm install --global @strands-agents/cli@0.2.1 From 4deb097c7d580c3a66cec3b85ae453245f2831c1 Mon Sep 17 00:00:00 2001 From: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:10:56 -0700 Subject: [PATCH 5/8] fix: retain Strands session after failed cleanup Signed-off-by: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> --- RELEASING.md | 8 ++++-- adapters/typescript/README.md | 2 +- adapters/typescript/strands/src/runtime.ts | 6 ++-- .../typescript/strands/test/runtime.test.mjs | 28 +++++++++++++++++++ 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 889fccbf1..54e64f877 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -32,7 +32,7 @@ package-specific tag pushes: | Ecosystem | Published Surface | |---|---| | crates.io | `nemo-fabric-core`, `nemo-fabric-cli` | -| npm | `nemo-fabric-adapter-contract`, `nemo-fabric-adapters-common`, `nemo-fabric-adapters-droid`, `nemo-fabric-adapters-pi`, `nemo-fabric-adapters-opencode`, `nemo-fabric-adapters-qwen`, `nemo-fabric-adapters-kiro` | +| npm | `nemo-fabric-adapter-contract`, `nemo-fabric-adapters-common`, `nemo-fabric-adapters-droid`, `nemo-fabric-adapters-pi`, `nemo-fabric-adapters-opencode`, `nemo-fabric-adapters-qwen`, `nemo-fabric-adapters-kiro`, `nemo-fabric-adapters-strands` | | GitHub Actions | `nemo-fabric`, `nemo-fabric-runtime`, `nemo-fabric-collector`, `nemo-fabric-adapter-catalog`, `nemo-fabric-adapters-common`, `nemo-fabric-adapters-claude`, `nemo-fabric-adapters-codex`, `nemo-fabric-adapters-deepagents`, `nemo-fabric-adapters-hermes`, and `nemo-fabric-adapters-nooa` wheel artifacts | | Fern | The documentation site | @@ -544,7 +544,8 @@ After the release is live, verify: nemo-fabric-adapters-pi \ nemo-fabric-adapters-opencode \ nemo-fabric-adapters-qwen \ - nemo-fabric-adapters-kiro; do + nemo-fabric-adapters-kiro \ + nemo-fabric-adapters-strands; do npm view "${package}@" version \ --registry="$npmjs_registry" npm view "$package" dist-tags --registry="$npmjs_registry" @@ -561,7 +562,8 @@ After the release is live, verify: "nemo-fabric-adapters-pi@" \ "nemo-fabric-adapters-opencode@" \ "nemo-fabric-adapters-qwen@" \ - "nemo-fabric-adapters-kiro@" + "nemo-fabric-adapters-kiro@" \ + "nemo-fabric-adapters-strands@" npm audit signatures --registry="$npmjs_registry" ) ``` diff --git a/adapters/typescript/README.md b/adapters/typescript/README.md index e6a929206..45c9acb6c 100644 --- a/adapters/typescript/README.md +++ b/adapters/typescript/README.md @@ -6,7 +6,7 @@ SPDX-License-Identifier: Apache-2.0 # NVIDIA NeMo Fabric TypeScript Adapter Workspace This private npm workspace coordinates the independently published common, -Cline, Pi, OpenCode, Qwen Code, Kilo Code, and Kiro adapter packages. It +Cline, Pi, OpenCode, Qwen Code, Kilo Code, AWS Kiro, and AWS Strands adapter packages. It provides shared build, test, package-content, and consumer-install checks without becoming a published package itself. diff --git a/adapters/typescript/strands/src/runtime.ts b/adapters/typescript/strands/src/runtime.ts index 115061ac7..579f31107 100644 --- a/adapters/typescript/strands/src/runtime.ts +++ b/adapters/typescript/strands/src/runtime.ts @@ -177,7 +177,9 @@ export class StrandsAdapterRuntime implements AdapterRuntime { async stop(): Promise { const session = this.session; - this.session = undefined; - if (session !== undefined) await session.stop(); + if (session !== undefined) { + await session.stop(); + this.session = undefined; + } } } diff --git a/adapters/typescript/strands/test/runtime.test.mjs b/adapters/typescript/strands/test/runtime.test.mjs index 1f39981b4..d37d70cd7 100644 --- a/adapters/typescript/strands/test/runtime.test.mjs +++ b/adapters/typescript/strands/test/runtime.test.mjs @@ -191,3 +191,31 @@ test("bounds retained warm conversation without silently discarding history", as assert.equal(invoked, 1); await runtime.stop(); }); + +test("retains the session after failed stop so cleanup can be retried", async () => { + let attempts = 0; + const runtime = new StrandsAdapterRuntime({ + create: async () => ({ + id: "session", + prompt: async () => ({ text: "answer", stopReason: "end_turn" }), + stop: async () => { + attempts += 1; + if (attempts === 1) throw new Error("cleanup failed"); + }, + }), + }); + await runtime.start({ + agentName: "agent", + baseDir: ".", + config: {}, + runtimeContext: context, + }); + await assert.rejects(runtime.stop(), /cleanup failed/); + await runtime.stop(); + await runtime.stop(); + assert.equal(attempts, 2); + await assert.rejects( + runtime.invoke({ input: "after stop" }, context), + (error) => error.code === "strands_not_started", + ); +}); From dcd3c3afd3186e4b079fb588771c9783139aff54 Mon Sep 17 00:00:00 2001 From: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:04:05 -0700 Subject: [PATCH 6/8] fix: address Strands setup, cache usage and cleanup review Signed-off-by: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> --- adapters/typescript/common/acp-runner.md | 2 +- adapters/typescript/common/src/acp-runner.ts | 43 +++++++++++++------ .../common/test/acp-runner.test.mjs | 42 ++++++++++++++++++ adapters/typescript/strands/README.md | 3 +- .../typescript/strands/src/strands-acp.ts | 9 ++-- .../strands/test/strands-acp.test.mjs | 22 ++++++++++ docs/integrations/harness/strands.mdx | 3 ++ examples/code_review_agent/README.md | 1 + 8 files changed, 105 insertions(+), 20 deletions(-) diff --git a/adapters/typescript/common/acp-runner.md b/adapters/typescript/common/acp-runner.md index dbbca7cc0..93eb6406e 100644 --- a/adapters/typescript/common/acp-runner.md +++ b/adapters/typescript/common/acp-runner.md @@ -83,7 +83,7 @@ When an agent invocation exceeds its deadline, the runner attempts to send `sess ## Stop a Session -Shutdown is idempotent. The runner disposes its active-session observer, requests `session/close` when the agent advertises that capability, and closes the ACP connection. On POSIX systems, the agent runs in a dedicated process group: cleanup sends `SIGTERM` to the group, waits for the agent to exit within the shutdown timeout, then sends `SIGKILL` to any remaining group members. On Windows, cleanup uses `taskkill /T /F` to terminate the agent and its descendants. The runner waits for the agent process to exit before returning. +Shutdown is idempotent. The runner disposes its active-session observer, requests `session/close` when the agent advertises that capability, and closes the ACP connection. On POSIX systems, the agent runs in a dedicated process group: cleanup sends `SIGTERM` to the group, waits for the agent to exit within the shutdown timeout, then sends `SIGKILL` to any remaining group members. On Windows, cleanup uses `taskkill /T /F` to terminate the agent and its descendants. The runner waits for the agent process to exit before returning. If process termination fails, a later `stop()` retries termination while prompts remain disabled. Concurrent stop calls share the same cleanup attempt; calls after successful termination do nothing. Refer to the [Kiro adapter](../kiro/README.md) for the first integration that uses the shared ACP runner. diff --git a/adapters/typescript/common/src/acp-runner.ts b/adapters/typescript/common/src/acp-runner.ts index 862ca3560..109f85cbf 100644 --- a/adapters/typescript/common/src/acp-runner.ts +++ b/adapters/typescript/common/src/acp-runner.ts @@ -213,6 +213,8 @@ class SpawnedAcpSession implements AcpSession { readonly initializeResponse: InitializeResponse; private stopped = false; + private processTerminated = false; + private stopPromise: Promise | undefined; private readonly child: ChildProcessWithoutNullStreams; private readonly connection: ClientConnection; private readonly activeSession: ActiveSession; @@ -267,23 +269,36 @@ class SpawnedAcpSession implements AcpSession { } async stop(): Promise { - if (this.stopped) return; - this.stopped = true; - this.activeSession.dispose(); - - if (this.initializeResponse.agentCapabilities?.sessionCapabilities?.close != null) { - try { - await timeout( - this.connection.agent.request(methods.agent.session.close, { sessionId: this.sessionId }), - this.shutdownTimeoutMs, - new AcpRunnerError("acp_shutdown_timeout", "The ACP agent did not close the session before the deadline"), - ); - } catch { - // Process termination below is the final cleanup boundary. + if (this.processTerminated) return; + if (this.stopPromise !== undefined) return this.stopPromise; + this.stopPromise = this.stopAttempt(); + try { + await this.stopPromise; + } finally { + this.stopPromise = undefined; + } + } + + private async stopAttempt(): Promise { + if (!this.stopped) { + this.stopped = true; + this.activeSession.dispose(); + + if (this.initializeResponse.agentCapabilities?.sessionCapabilities?.close != null) { + try { + await timeout( + this.connection.agent.request(methods.agent.session.close, { sessionId: this.sessionId }), + this.shutdownTimeoutMs, + new AcpRunnerError("acp_shutdown_timeout", "The ACP agent did not close the session before the deadline"), + ); + } catch { + // Process termination below is the final cleanup boundary. + } } + this.connection.close(); } - this.connection.close(); await terminateProcessTree(this.child, this.shutdownTimeoutMs); + this.processTerminated = true; } } diff --git a/adapters/typescript/common/test/acp-runner.test.mjs b/adapters/typescript/common/test/acp-runner.test.mjs index 849ac39de..263066394 100644 --- a/adapters/typescript/common/test/acp-runner.test.mjs +++ b/adapters/typescript/common/test/acp-runner.test.mjs @@ -200,3 +200,45 @@ for (const scenario of ["parent exits", "forced shutdown", "partial startup"]) { } }); } + +test("retries process-group termination after a failed stop", { skip: process.platform === "win32" }, async (t) => { + const directory = await mkdtemp(join(tmpdir(), "fabric-acp-retry-")); + const marker = join(directory, "pids.json"); + const session = await startAcpSession({ + command: process.execPath, + args: [fixture], + cwd: process.cwd(), + env: { ...process.env, TEST_DESCENDANT_MARKER: marker, TEST_IGNORE_SIGTERM: "1" }, + shutdownTimeoutMs: 100, + }); + const pids = JSON.parse(await readFile(marker, "utf8")); + const originalKill = process.kill.bind(process); + let failTermination = true; + const kill = t.mock.method(process, "kill", (pid, signal) => { + if (pid === -pids.parent && signal === "SIGTERM" && failTermination) { + failTermination = false; + throw Object.assign(new Error("termination denied"), { code: "EPERM" }); + } + return originalKill(pid, signal); + }); + try { + await assert.rejects(session.stop(), { code: "EPERM" }); + originalKill(pids.child, 0); + await assert.rejects(session.prompt("after failed stop"), { code: "acp_session_stopped" }); + await Promise.all([session.stop(), session.stop()]); + await waitForProcessToStop(pids.parent); + await waitForProcessToStop(pids.child); + assert.equal(kill.mock.calls.filter(({ arguments: [pid, signal] }) => pid === -pids.parent && signal === "SIGTERM").length, 2); + const signalCount = kill.mock.callCount(); + await session.stop(); + assert.equal(kill.mock.callCount(), signalCount); + } finally { + kill.mock.restore(); + for (const pid of [pids.parent, pids.child]) { + try { originalKill(pid, "SIGKILL"); } catch (error) { + if (error.code !== "ESRCH") throw error; + } + } + await session.stop(); + } +}); diff --git a/adapters/typescript/strands/README.md b/adapters/typescript/strands/README.md index 14b76e733..efae986d5 100644 --- a/adapters/typescript/strands/README.md +++ b/adapters/typescript/strands/README.md @@ -30,6 +30,7 @@ For a source checkout, run from the repository root: ```bash just install-typescript-strands +npm run build --prefix adapter-contract/typescript npm run build --prefix adapters/typescript --workspace nemo-fabric-adapters-common npm run build --prefix adapters/typescript --workspace nemo-fabric-adapters-strands ``` @@ -121,7 +122,7 @@ Each runtime owns one child process, an isolated temporary CLI home, and one liv Warm invocations retain conversation history without saving it to the workspace. Deadline expiry attempts ACP cancellation, invalidates the session, and terminates its process group. Stop is idempotent. The adapter supports text input and returns `{ "response": "..." }`, with ACP session ID and stop reason in result metadata. Non-success stop reasons become failed or cancelled results. -Strands CLI usage is invocation-local and is not differenced across turns. Inclusive input, output, total, and cached-read counters are normalized when valid. Cache writes are retained in `nemo_fabric.detailed_token_usage.v1`. Missing or malformed usage remains unknown without discarding a completed response. Cost is not estimated. Public cancellation, native OpenAI streaming, live updates, services, Relay telemetry, normalized runtime limits, and normalized skills are not advertised. +Strands CLI usage is invocation-local and is not differenced across turns. Inclusive input, output, total, and cached-read counters are normalized when valid. Cache writes are retained in `nemo_fabric.detailed_token_usage.v1`. Missing or malformed usage remains unknown without discarding a completed response. Strands CLI 0.2.1 reports zero cache counters even when the provider omitted them. The adapter therefore omits zero cache-read and cache-write counts, including genuine zeros, preserves positive counts, and retains inclusive input/output totals. This workaround is specific to Strands; it does not change other adapters’ handling of reported zeros. Cost is not estimated. Public cancellation, native OpenAI streaming, live updates, services, Relay telemetry, normalized runtime limits, and normalized skills are not advertised. ## Validate diff --git a/adapters/typescript/strands/src/strands-acp.ts b/adapters/typescript/strands/src/strands-acp.ts index da357af16..a9c89f86e 100644 --- a/adapters/typescript/strands/src/strands-acp.ts +++ b/adapters/typescript/strands/src/strands-acp.ts @@ -162,21 +162,22 @@ export function normalizeUsage( ) return undefined; if ((cache ?? 0) + (write ?? 0) > input) return undefined; - // The pinned Strands CLI normalizes provider-specific cache semantics into inclusive input tokens. + // Strands CLI 0.2.1 defaults missing cache counts to zero; only positive counts are known. + // Keep inclusive input/output totals even when cache details are unknown. return { input_tokens: input, output_tokens: output, total_tokens: total, input_tokens_include_cache: true, - ...(cache == null ? {} : { cached_input_tokens: cache }), + ...((cache ?? 0) > 0 ? { cached_input_tokens: cache } : {}), extensions: { "nemo_fabric.detailed_token_usage.v1": { input_tokens: input, output_tokens: output, total_tokens: total, input_tokens_include_cache: true, - ...(cache == null ? {} : { cached_input_tokens: cache }), - ...(write == null ? {} : { cache_write_input_tokens: write }), + ...((cache ?? 0) > 0 ? { cached_input_tokens: cache } : {}), + ...((write ?? 0) > 0 ? { cache_write_input_tokens: write } : {}), }, }, }; diff --git a/adapters/typescript/strands/test/strands-acp.test.mjs b/adapters/typescript/strands/test/strands-acp.test.mjs index 5160a67ae..9d11a0bfd 100644 --- a/adapters/typescript/strands/test/strands-acp.test.mjs +++ b/adapters/typescript/strands/test/strands-acp.test.mjs @@ -176,3 +176,25 @@ test("removes temporary CLI state after partial startup and keeps diagnostics pr ); await assert.rejects(access(state.home), { code: "ENOENT" }); }); + +test("omits ambiguous zero cache counts while preserving positive counts and token totals", () => { + for (const [read, write] of [[0, 0], [2, 0], [0, 3], [2, 3]]) { + const usage = normalizeUsage({ + inputTokens: 10, outputTokens: 5, totalTokens: 15, + cachedReadTokens: read, cachedWriteTokens: write, + }); + const detail = usage.extensions["nemo_fabric.detailed_token_usage.v1"]; + assert.equal(usage.input_tokens, 10); + assert.equal(usage.output_tokens, 5); + assert.equal(usage.total_tokens, 15); + assert.equal(usage.input_tokens_include_cache, true); + assert.equal(Object.hasOwn(usage, "cached_input_tokens"), read > 0); + assert.equal(Object.hasOwn(detail, "cached_input_tokens"), read > 0); + assert.equal(Object.hasOwn(detail, "cache_write_input_tokens"), write > 0); + if (read > 0) { + assert.equal(usage.cached_input_tokens, read); + assert.equal(detail.cached_input_tokens, read); + } + if (write > 0) assert.equal(detail.cache_write_input_tokens, write); + } +}); diff --git a/docs/integrations/harness/strands.mdx b/docs/integrations/harness/strands.mdx index 0e9a67120..72f1f4385 100644 --- a/docs/integrations/harness/strands.mdx +++ b/docs/integrations/harness/strands.mdx @@ -10,6 +10,7 @@ Install Node.js 22.19 or newer and the separately managed CLI: ```bash npm install --global @strands-agents/cli@0.2.1 just install-typescript-strands +npm run build --prefix adapter-contract/typescript npm run build --prefix adapters/typescript --workspace nemo-fabric-adapters-common npm run build --prefix adapters/typescript --workspace nemo-fabric-adapters-strands ``` @@ -30,6 +31,8 @@ The adapter supports text invocations, invocation-local token usage, deadline cl ## Limitations +Strands CLI 0.2.1 reports zero cache counters even when the provider did not report them. The adapter omits these ambiguous zero cache counts and preserves positive counts and inclusive input/output totals. Genuine zero cache counts cannot be distinguished from missing counts. + The adapter does not advertise custom source agents, native streaming, public cancellation, services, Relay telemetry, or normalized skills and tool policy. See the [Strands adapter guide](https://github.com/NVIDIA/NeMo-Fabric/blob/main/adapters/typescript/strands/README.md) for complete configuration, endpoint mapping, MCP restrictions, lifecycle behavior, and live E2E commands. diff --git a/examples/code_review_agent/README.md b/examples/code_review_agent/README.md index d35a96976..b00b442bb 100644 --- a/examples/code_review_agent/README.md +++ b/examples/code_review_agent/README.md @@ -494,6 +494,7 @@ Run the standard AWS Strands harness through the shared ACP runner. From the rep ```bash npm install --global @strands-agents/cli@0.2.1 just install-typescript-strands +npm run build --prefix adapter-contract/typescript npm run build --prefix adapters/typescript --workspace nemo-fabric-adapters-common npm run build --prefix adapters/typescript --workspace nemo-fabric-adapters-strands export NVIDIA_API_KEY="" From 09b39ad7f4aeb41d4123269bf6e5ed0a6b208a35 Mon Sep 17 00:00:00 2001 From: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:18:33 -0700 Subject: [PATCH 7/8] test: preserve ACP retry assertions during cleanup Signed-off-by: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> --- .../typescript/common/test/acp-runner.test.mjs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/adapters/typescript/common/test/acp-runner.test.mjs b/adapters/typescript/common/test/acp-runner.test.mjs index 263066394..db8da8019 100644 --- a/adapters/typescript/common/test/acp-runner.test.mjs +++ b/adapters/typescript/common/test/acp-runner.test.mjs @@ -221,6 +221,7 @@ test("retries process-group termination after a failed stop", { skip: process.pl } return originalKill(pid, signal); }); + let cleanupError; try { await assert.rejects(session.stop(), { code: "EPERM" }); originalKill(pids.child, 0); @@ -233,12 +234,21 @@ test("retries process-group termination after a failed stop", { skip: process.pl await session.stop(); assert.equal(kill.mock.callCount(), signalCount); } finally { - kill.mock.restore(); + try { + kill.mock.restore(); + } catch (error) { + cleanupError ??= error; + } for (const pid of [pids.parent, pids.child]) { try { originalKill(pid, "SIGKILL"); } catch (error) { - if (error.code !== "ESRCH") throw error; + if (error.code !== "ESRCH") cleanupError ??= error; } } - await session.stop(); + try { + await session.stop(); + } catch (error) { + cleanupError ??= error; + } } + if (cleanupError !== undefined) throw cleanupError; }); From ed86363ad50dfbe2efd5853909a8f4ec995f03b0 Mon Sep 17 00:00:00 2001 From: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:41:48 -0700 Subject: [PATCH 8/8] fix: retain invalidated Strands session for cleanup Signed-off-by: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> --- adapters/typescript/strands/README.md | 2 +- adapters/typescript/strands/src/runtime.ts | 8 ++- .../strands/test/fixtures/acp-agent.mjs | 11 ++++ .../strands/test/strands-acp.test.mjs | 66 +++++++++++++++++++ 4 files changed, 83 insertions(+), 4 deletions(-) diff --git a/adapters/typescript/strands/README.md b/adapters/typescript/strands/README.md index efae986d5..b58ff7a4a 100644 --- a/adapters/typescript/strands/README.md +++ b/adapters/typescript/strands/README.md @@ -120,7 +120,7 @@ The adapter supports stdio, streamable HTTP, and SSE. Stdio requires an absolute Each runtime owns one child process, an isolated temporary CLI home, and one live ACP session. Personal CLI profiles, MCP discovery, skill discovery, session persistence, long-term memory, and the todos plugin are disabled. The native environment plugin supplies workspace context. Background tool execution is disabled so tool work stays within its invocation. The adapter inherits a small process/AWS environment and applies explicit task environment values. It preserves access to the caller's `.aws` directory for native credential resolution. The temporary home is removed after stop or startup failure. -Warm invocations retain conversation history without saving it to the workspace. Deadline expiry attempts ACP cancellation, invalidates the session, and terminates its process group. Stop is idempotent. The adapter supports text input and returns `{ "response": "..." }`, with ACP session ID and stop reason in result metadata. Non-success stop reasons become failed or cancelled results. +Warm invocations retain conversation history without saving it to the workspace. Deadline expiry attempts ACP cancellation, invalidates the session, and terminates its process group. If cleanup fails after an invocation error, the runtime retains the session for a later `stop()` retry while rejecting further invocations and replacement sessions. Stop is idempotent. The adapter supports text input and returns `{ "response": "..." }`, with ACP session ID and stop reason in result metadata. Non-success stop reasons become failed or cancelled results. Strands CLI usage is invocation-local and is not differenced across turns. Inclusive input, output, total, and cached-read counters are normalized when valid. Cache writes are retained in `nemo_fabric.detailed_token_usage.v1`. Missing or malformed usage remains unknown without discarding a completed response. Strands CLI 0.2.1 reports zero cache counters even when the provider omitted them. The adapter therefore omits zero cache-read and cache-write counts, including genuine zeros, preserves positive counts, and retains inclusive input/output totals. This workaround is specific to Strands; it does not change other adapters’ handling of reported zeros. Cost is not estimated. Public cancellation, native OpenAI streaming, live updates, services, Relay telemetry, normalized runtime limits, and normalized skills are not advertised. diff --git a/adapters/typescript/strands/src/runtime.ts b/adapters/typescript/strands/src/runtime.ts index 579f31107..88ccd1b50 100644 --- a/adapters/typescript/strands/src/runtime.ts +++ b/adapters/typescript/strands/src/runtime.ts @@ -48,6 +48,7 @@ function failed( export class StrandsAdapterRuntime implements AdapterRuntime { private readonly factory: StrandsSessionFactory; private session?: StrandsSessionHandle; + private invalidated = false; private invocations = 0; private maxInvocations = 100; @@ -64,14 +65,14 @@ export class StrandsAdapterRuntime implements AdapterRuntime { } this.maxInvocations = selectSettings(input.config).maxInvocations; this.session = await this.factory.create(input); + this.invalidated = false; this.invocations = 0; } private async invalidate(session: StrandsSessionHandle): Promise { if (this.session !== session) return; - this.session = undefined; try { - await session.stop(); + await this.stop(); } catch { // Preserve the invocation failure that made the session unusable. } @@ -81,7 +82,7 @@ export class StrandsAdapterRuntime implements AdapterRuntime { request: AgentRunRequest, context: RuntimeContext, ): Promise { - if (this.session === undefined) { + if (this.session === undefined || this.invalidated) { throw new LifecycleError( "strands_not_started", "Strands adapter runtime is not started", @@ -178,6 +179,7 @@ export class StrandsAdapterRuntime implements AdapterRuntime { async stop(): Promise { const session = this.session; if (session !== undefined) { + this.invalidated = true; await session.stop(); this.session = undefined; } diff --git a/adapters/typescript/strands/test/fixtures/acp-agent.mjs b/adapters/typescript/strands/test/fixtures/acp-agent.mjs index 4a733d8a8..d2ac491b2 100644 --- a/adapters/typescript/strands/test/fixtures/acp-agent.mjs +++ b/adapters/typescript/strands/test/fixtures/acp-agent.mjs @@ -1,7 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; +import { once } from "node:events"; import { writeFile } from "node:fs/promises"; import { Readable, Writable } from "node:stream"; import { @@ -14,6 +16,15 @@ import { let history = []; let servers = []; const id = randomUUID(); +if (process.env.TEST_STRANDS_DESCENDANT_STATE) { + const child = spawn(process.execPath, ["-e", ` + process.on("SIGTERM", () => {}); + process.send(process.pid); + setInterval(() => {}, 1000); + `], { stdio: ["ignore", "ignore", "ignore", "ipc"] }); + const [pid] = await once(child, "message"); + await writeFile(process.env.TEST_STRANDS_DESCENDANT_STATE, JSON.stringify({ pid })); +} if (process.env.TEST_STRANDS_STATE) { await writeFile( process.env.TEST_STRANDS_STATE, diff --git a/adapters/typescript/strands/test/strands-acp.test.mjs b/adapters/typescript/strands/test/strands-acp.test.mjs index 9d11a0bfd..df9e94661 100644 --- a/adapters/typescript/strands/test/strands-acp.test.mjs +++ b/adapters/typescript/strands/test/strands-acp.test.mjs @@ -15,6 +15,8 @@ import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; +import { setTimeout as delay } from "node:timers/promises"; +import { StrandsAdapterRuntime } from "../dist/runtime.js"; import { normalizeUsage, StrandsAcpSessionFactory, @@ -198,3 +200,67 @@ test("omits ambiguous zero cache counts while preserving positive counts and tok if (write > 0) assert.equal(detail.cache_write_input_tokens, write); } }); + +test("retains a timed-out runtime session until process cleanup succeeds", { skip: process.platform === "win32" }, async (t) => { + const input = await fixture(t); + const descendantState = join(input.baseDir, "descendant.json"); + input.runtimeContext.environment.env.TEST_STRANDS_DESCENDANT_STATE = descendantState; + const runtime = new StrandsAdapterRuntime(new StrandsAcpSessionFactory()); + await runtime.start(input); + const { pid: parent } = JSON.parse(await readFile(input.runtimeContext.environment.env.TEST_STRANDS_STATE, "utf8")); + const { pid: child } = JSON.parse(await readFile(descendantState, "utf8")); + const originalKill = process.kill.bind(process); + let failTermination = true; + const kill = t.mock.method(process, "kill", (pid, signal) => { + if (pid === -parent && signal === "SIGTERM" && failTermination) { + failTermination = false; + throw Object.assign(new Error("termination denied"), { code: "EPERM" }); + } + return originalKill(pid, signal); + }); + t.after(async () => { + const cleanup = [ + () => kill.mock.restore(), + ...[parent, child].map((pid) => () => { + try { originalKill(pid, "SIGKILL"); } catch (error) { + if (error.code !== "ESRCH") throw error; + } + }), + () => runtime.stop(), + ]; + const results = await Promise.allSettled(cleanup.map(async (step) => step())); + const failures = results.filter((result) => result.status === "rejected"); + if (failures.length > 0) { + throw new AggregateError(failures.map((result) => result.reason), "Test process cleanup failed"); + } + }); + await assert.rejects( + runtime.invoke({ input: "hang" }, { ...input.runtimeContext, deadline_millis: Date.now() + 50 }), + { code: "strands_prompt_timeout" }, + ); + originalKill(child, 0); + await assert.rejects(runtime.invoke({ input: "next" }, input.runtimeContext), { code: "strands_not_started" }); + await assert.rejects(runtime.start(input), { code: "strands_already_started" }); + await runtime.stop(); + for (const pid of [parent, child]) { + let running = true; + for (let attempt = 0; attempt < 200; attempt++) { + try { + originalKill(pid, 0); + if (process.platform === "linux") { + const status = await readFile(`/proc/${pid}/stat`, "utf8"); + if (status.slice(status.lastIndexOf(")") + 2).startsWith("Z")) { running = false; break; } + } + } catch (error) { + if (error.code !== "ESRCH" && error.code !== "ENOENT") throw error; + running = false; + break; + } + await delay(10); + } + assert.equal(running, false, `Process ${pid} survived runtime.stop()`); + } + const attempts = kill.mock.callCount(); + await runtime.stop(); + assert.equal(kill.mock.callCount(), attempts); +});