diff --git a/.github/workflows/e2e-branch-validation.yaml b/.github/workflows/e2e-branch-validation.yaml
index d80d1ee877..5170363dfb 100644
--- a/.github/workflows/e2e-branch-validation.yaml
+++ b/.github/workflows/e2e-branch-validation.yaml
@@ -46,13 +46,17 @@ name: E2E / Branch Validation
# OpenAI-compatible endpoint. Creates its own sandbox
# (e2e-msg-compat) on a separate fresh instance.
# dashboard-remote-bind — Verifies opt-in remote dashboard forwards bind 0.0.0.0.
+# tool-disclosure-performance-smoke — Runs the claim-ineligible hosted-inference
+# performance smoke on an ephemeral Brev CPU VM and
+# retains its sanitized evidence. (~60 min)
# gpu — Provisions a Brev GPU VM and runs the Ollama GPU E2E
# sandbox proof suite from source. (~45 min)
# all — Runs credential-sanitization + telegram-injection, each with
# its own sandbox lifecycle (NOT the independent full journey).
#
# Required secrets: BREV_API_KEY + BREV_ORG_ID (or legacy BREV_API_TOKEN), NVIDIA_INFERENCE_API_KEY
-# Instance cost: Brev CPU credits (~$0.10/run for 4x16 instance)
+# Instance cost: Brev CPU credits; most suites use 4x16, while the performance
+# smoke requests 8 vCPU, 32 GB RAM, and 100 GB disk.
on:
workflow_dispatch:
@@ -73,6 +77,7 @@ on:
- messaging-providers
- messaging-compatible-endpoint
- dashboard-remote-bind
+ - tool-disclosure-performance-smoke
- gpu
- all
use_launchable:
@@ -188,7 +193,7 @@ jobs:
TEST_SUITE: ${{ inputs.test_suite }}
run: |
case "$TEST_SUITE" in
- full|credential-sanitization|telegram-injection|messaging-providers|messaging-compatible-endpoint|dashboard-remote-bind|gpu|all) ;;
+ full|credential-sanitization|telegram-injection|messaging-providers|messaging-compatible-endpoint|dashboard-remote-bind|tool-disclosure-performance-smoke|gpu|all) ;;
*)
echo "::error::test_suite is not one of the supported Brev E2E suites"
exit 1
@@ -213,7 +218,7 @@ jobs:
- name: Checkout target branch
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
- ref: ${{ env.RESOLVED_BRANCH || inputs.branch || 'main' }}
+ ref: ${{ env.RESOLVED_BRANCH || inputs.branch || github.ref }}
persist-credentials: false
- id: tested-ref
@@ -267,7 +272,28 @@ jobs:
- name: Build CLI
run: npm run build:cli
- - name: Run ephemeral Brev E2E
+ - name: Install and verify Brev performance smoke tunnel prerequisite
+ if: inputs.test_suite == 'tool-disclosure-performance-smoke'
+ env:
+ CLOUDFLARED_VERSION: "2026.6.1"
+ CLOUDFLARED_DEB_SHA256: "ccd02ec216c62bfa573395d8f72cb2e91e95cbdf8726a8acc06b3e2d9aa31526"
+ run: |
+ set -euo pipefail
+ cloudflared_deb="${RUNNER_TEMP}/cloudflared-${CLOUDFLARED_VERSION}-linux-amd64.deb"
+ curl -fL "https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-amd64.deb" -o "${cloudflared_deb}"
+ printf '%s %s\n' "${CLOUDFLARED_DEB_SHA256}" "${cloudflared_deb}" | sha256sum -c -
+ package="$(dpkg-deb -f "${cloudflared_deb}" Package)"
+ version="$(dpkg-deb -f "${cloudflared_deb}" Version)"
+ architecture="$(dpkg-deb -f "${cloudflared_deb}" Architecture)"
+ if [[ "${package}" != "cloudflared" || "${version}" != "${CLOUDFLARED_VERSION}" || "${architecture}" != "amd64" ]]; then
+ printf 'Unexpected cloudflared package metadata: package=%s version=%s architecture=%s\n' "${package}" "${version}" "${architecture}" >&2
+ exit 1
+ fi
+ sudo dpkg -i "${cloudflared_deb}"
+ cloudflared --version | grep -F "cloudflared version ${CLOUDFLARED_VERSION}"
+
+ - id: brev-test
+ name: Run ephemeral Brev E2E
env:
NEMOCLAW_RUN_BRANCH_VALIDATION_E2E: "1"
NEMOCLAW_BREV_WORKFLOW_OWNS_INSTANCE: "1"
@@ -277,6 +303,9 @@ jobs:
INSTANCE_NAME: ${{ env.BREV_E2E_INSTANCE_NAME }}
TEST_SUITE: ${{ inputs.test_suite }}
USE_LAUNCHABLE: ${{ inputs.use_launchable && '1' || '0' }}
+ BREV_MIN_VCPU: ${{ inputs.test_suite == 'tool-disclosure-performance-smoke' && '8' || '' }}
+ BREV_MIN_RAM: ${{ inputs.test_suite == 'tool-disclosure-performance-smoke' && '32' || '' }}
+ BREV_MIN_DISK: ${{ inputs.test_suite == 'tool-disclosure-performance-smoke' && '100' || '' }}
BREV_PROVIDER: ${{ inputs.brev_provider || vars.BREV_PROVIDER || '' }}
BREV_GPU_TYPE: ${{ inputs.brev_gpu_type || vars.BREV_GPU_TYPE || '' }}
BREV_GPU_NAME: ${{ inputs.brev_gpu_name || vars.BREV_GPU_NAME || '' }}
@@ -286,6 +315,63 @@ jobs:
KEEP_ALIVE: ${{ inputs.keep_alive }}
run: npx vitest run --project e2e-branch-validation --silent=false --reporter=default
+ - name: Collect Brev tool-disclosure performance smoke artifacts
+ if: always() && inputs.test_suite == 'tool-disclosure-performance-smoke'
+ env:
+ INSTANCE: ${{ env.BREV_E2E_INSTANCE_NAME }}
+ TEST_OUTCOME: ${{ steps.brev-test.outcome }}
+ run: |
+ set -euo pipefail
+ artifact_dir="brev-tool-disclosure-performance-smoke-artifacts"
+ remote_dir="/tmp/nemoclaw-tool-disclosure-performance-smoke-artifacts"
+ archive="${RUNNER_TEMP}/brev-tool-disclosure-performance-smoke-artifacts.tar.gz"
+ mkdir -p "$artifact_dir"
+ brev refresh >/dev/null
+ if ! ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR \
+ -o ConnectTimeout=10 "$INSTANCE" \
+ "test -d '$remote_dir' && tar -C '$remote_dir' -czf - ." \
+ >"$archive"; then
+ if [ "$TEST_OUTCOME" = "success" ]; then
+ echo "::error::Successful Brev performance smoke did not produce an artifact archive"
+ exit 1
+ fi
+ echo "::warning::Failed Brev performance smoke produced no artifact archive"
+ exit 0
+ fi
+ tar -C "$artifact_dir" -xzf "$archive"
+ result="$artifact_dir/tool-disclosure-hosted-inference-performance-smoke-completes-one-frozen-task-in-both-modes/tool-disclosure-performance-smoke.json"
+ if [ ! -f "$result" ]; then
+ if [ "$TEST_OUTCOME" = "success" ]; then
+ echo "::error::Successful Brev performance smoke artifact is missing its result JSON"
+ exit 1
+ fi
+ echo "::warning::Failed Brev performance smoke has only partial artifacts"
+ fi
+
+ - name: Scan Brev tool-disclosure performance smoke artifacts
+ if: always() && inputs.test_suite == 'tool-disclosure-performance-smoke'
+ env:
+ NVIDIA_INFERENCE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }}
+ run: |
+ set -euo pipefail
+ artifact_dir="brev-tool-disclosure-performance-smoke-artifacts"
+ test -d "$artifact_dir"
+ if [ -n "${NVIDIA_INFERENCE_API_KEY:-}" ] && \
+ grep -R -I -l -F -- "$NVIDIA_INFERENCE_API_KEY" "$artifact_dir" >/dev/null; then
+ echo "::error::Brev performance smoke artifacts contain an unredacted inference credential"
+ exit 1
+ fi
+
+ - name: Upload Brev tool-disclosure performance smoke artifacts
+ if: always() && inputs.test_suite == 'tool-disclosure-performance-smoke'
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: e2e-brev-tool-disclosure-performance-smoke-${{ github.run_attempt }}
+ path: brev-tool-disclosure-performance-smoke-artifacts/
+ if-no-files-found: ignore
+ include-hidden-files: false
+ retention-days: 14
+
# Collect debugging artifacts from the Brev VM on failure before the
# instance gets torn down. Captures the onboard log, sandbox list,
# docker state, and gateway status so downstream onboard failures are
@@ -306,6 +392,11 @@ jobs:
timeout 15s openshell sandbox list > /tmp/nc-debug/sandbox-list.txt 2>&1
timeout 15s openshell gateway status > /tmp/nc-debug/gateway-status.txt 2>&1
timeout 15s docker ps -a > /tmp/nc-debug/docker-ps.txt 2>&1
+ free -h > /tmp/nc-debug/memory.txt 2>&1
+ df -h > /tmp/nc-debug/disk.txt 2>&1
+ timeout 15s sudo systemctl status docker --no-pager > /tmp/nc-debug/docker-service.txt 2>&1
+ timeout 15s sudo journalctl -u docker --since "15 minutes ago" --no-pager > /tmp/nc-debug/docker-journal.txt 2>&1
+ timeout 15s sudo dmesg | tail -200 > /tmp/nc-debug/kernel-tail.txt 2>&1
timeout 30s tar -C /tmp -czf /tmp/nc-debug.tar.gz nc-debug
' || true
scp -o StrictHostKeyChecking=no -o LogLevel=ERROR \
diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml
index 926e44ba42..a258b15756 100644
--- a/.github/workflows/e2e.yaml
+++ b/.github/workflows/e2e.yaml
@@ -14,7 +14,7 @@ on:
default: ""
type: string
jobs:
- description: "Optional comma-separated free-standing live E2E job ids. Empty runs default-enabled jobs only when targets is also empty; explicit-only jobs openshell-gateway-auth-contract, mcp-bridge-dev, hermes-gpu-startup, sandbox-rlimits-connect, and jetson-nvmap-gpu are skipped unless selected."
+ description: "Optional comma-separated free-standing live E2E job ids. Empty runs default-enabled jobs only when targets is also empty; explicit-only jobs openshell-gateway-auth-contract, mcp-bridge-dev, tool-disclosure-performance-smoke, hermes-gpu-startup, sandbox-rlimits-connect, and jetson-nvmap-gpu are skipped unless selected."
required: false
default: ""
type: string
@@ -723,6 +723,96 @@ jobs:
shell: bash
run: bash .github/scripts/docker-auth-cleanup.sh
+ tool-disclosure-performance-smoke:
+ needs: generate-matrix
+ # This smoke provisions real agent sandboxes and consumes hosted inference.
+ # Keep it opt-in: it validates performance-test plumbing on ordinary CI hardware,
+ # but it is not the complete two-campaign performance-test procedure.
+ if: ${{ contains(format(',{0},', inputs.jobs), ',tool-disclosure-performance-smoke,') || contains(format(',{0},', inputs.targets), ',tool-disclosure-performance-smoke,') }}
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ timeout-minutes: 60
+ env:
+ E2E_JOB: "1"
+ E2E_DEFAULT_ENABLED: "0"
+ E2E_TARGET_ID: "tool-disclosure-performance-smoke"
+ E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/live/tool-disclosure-performance-smoke
+ NEMOCLAW_CLI_BIN: ${{ github.workspace }}/bin/nemoclaw.js
+ NEMOCLAW_RUN_LIVE_E2E: "1"
+ NEMOCLAW_E2E_USE_HOSTED_INFERENCE: "1"
+ NEMOCLAW_NON_INTERACTIVE: "1"
+ NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1"
+ NEMOCLAW_OPENSHELL_CHANNEL: stable
+ NEMOCLAW_PROVIDER: custom
+ NEMOCLAW_ENDPOINT_URL: https://inference-api.nvidia.com/v1
+ NEMOCLAW_MODEL: nvidia/nvidia/nemotron-3-ultra
+ NEMOCLAW_COMPAT_MODEL: nvidia/nvidia/nemotron-3-ultra
+ NEMOCLAW_PREFERRED_API: openai-completions
+ OPENSHELL_GATEWAY: nemoclaw
+ steps:
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ with:
+ persist-credentials: false
+
+ - *dockerhub-auth
+
+ - name: Prepare E2E workspace
+ uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@50281ee84c4a6fc759da95ea28fc0b7d9c378a28
+
+ - name: Install and verify cloudflared prerequisite
+ env:
+ CLOUDFLARED_VERSION: "2026.6.1"
+ CLOUDFLARED_DEB_SHA256: "ccd02ec216c62bfa573395d8f72cb2e91e95cbdf8726a8acc06b3e2d9aa31526"
+ run: |
+ set -euo pipefail
+ cloudflared_deb="${RUNNER_TEMP}/cloudflared-${CLOUDFLARED_VERSION}-linux-amd64.deb"
+ curl -fL "https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-amd64.deb" -o "${cloudflared_deb}"
+ printf '%s %s\n' "${CLOUDFLARED_DEB_SHA256}" "${cloudflared_deb}" | sha256sum -c -
+ package="$(dpkg-deb -f "${cloudflared_deb}" Package)"
+ version="$(dpkg-deb -f "${cloudflared_deb}" Version)"
+ architecture="$(dpkg-deb -f "${cloudflared_deb}" Architecture)"
+ if [[ "${package}" != "cloudflared" || "${version}" != "${CLOUDFLARED_VERSION}" || "${architecture}" != "amd64" ]]; then
+ printf 'Unexpected cloudflared package metadata: package=%s version=%s architecture=%s\n' "${package}" "${version}" "${architecture}" >&2
+ exit 1
+ fi
+ sudo dpkg -i "${cloudflared_deb}"
+ cloudflared --version | grep -F "cloudflared version ${CLOUDFLARED_VERSION}"
+
+ - name: Install OpenShell CLI
+ env:
+ NEMOCLAW_OPENSHELL_FORCE_INSTALL: "1"
+ run: bash scripts/install-openshell.sh
+
+ - name: Run tool-disclosure performance smoke live test
+ env:
+ NVIDIA_INFERENCE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }}
+ run: |
+ set -euo pipefail
+ export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:$PATH"
+ if command -v openshell >/dev/null 2>&1; then
+ OPENSHELL_BIN="$(command -v openshell)"
+ elif [ -x "$HOME/.local/bin/openshell" ]; then
+ OPENSHELL_BIN="$HOME/.local/bin/openshell"
+ else
+ echo "::error::OpenShell CLI not found after install"
+ exit 1
+ fi
+ export OPENSHELL_BIN
+ "$OPENSHELL_BIN" --version
+ npx vitest run --project e2e-live \
+ test/e2e/live/tool-disclosure-performance-smoke.test.ts \
+ --silent=false --reporter=default
+
+ - name: Upload tool-disclosure performance smoke artifacts
+ if: always()
+ uses: NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@7768e15eb90d3ee2d33432f481dfe8747e4f6d57
+
+ - name: Clean up Docker auth
+ if: always()
+ shell: bash
+ run: bash .github/scripts/docker-auth-cleanup.sh
+
onboard-negative-paths:
needs: generate-matrix
if: ${{ (github.event_name != 'workflow_dispatch' || (inputs.jobs == '' && inputs.targets == '')) || contains(format(',{0},', inputs.jobs), ',onboard-negative-paths,') || contains(format(',{0},', inputs.targets), ',onboard-negative-paths,') }}
@@ -4613,6 +4703,7 @@ jobs:
openshell-gateway-auth-contract,
mcp-bridge,
mcp-bridge-dev,
+ tool-disclosure-performance-smoke,
onboard-negative-paths,
skill-agent,
openclaw-skill-cli,
@@ -4718,6 +4809,11 @@ jobs:
target: 'mcp-bridge-dev',
reason: 'default dispatch excludes moving OpenShell dev artifacts unless explicitly selected',
},
+ 'tool-disclosure-performance-smoke': {
+ job: 'tool-disclosure-performance-smoke',
+ target: 'tool-disclosure-performance-smoke',
+ reason: 'default dispatch excludes the hosted-inference performance smoke test unless explicitly selected',
+ },
'jetson-nvmap-gpu': {
job: 'jetson-nvmap-gpu',
target: 'jetson-nvmap-gpu',
@@ -4848,7 +4944,7 @@ jobs:
? '**Requested jobs:** _(selector rejected by workflow validation)_'
: requestedJobs
? `**Requested jobs:** \`${requestedJobs}\``
- : '**Requested jobs:** _(default — all default-enabled free-standing jobs; explicit-only jobs `openshell-gateway-auth-contract`, `mcp-bridge-dev`, `hermes-gpu-startup`, `sandbox-rlimits-connect`, and `jetson-nvmap-gpu` are skipped unless selected)_',
+ : '**Requested jobs:** _(default — all default-enabled free-standing jobs; explicit-only jobs `openshell-gateway-auth-contract`, `mcp-bridge-dev`, `tool-disclosure-performance-smoke`, `hermes-gpu-startup`, `sandbox-rlimits-connect`, and `jetson-nvmap-gpu` are skipped unless selected)_',
`**Summary:** ${passed.length} passed, ${failed.length} failed, ${cancelled.length} cancelled, ${skipped.length} skipped`,
'',
'| Job | Result |',
diff --git a/agents/langchain-deepagents-code/Dockerfile b/agents/langchain-deepagents-code/Dockerfile
index 3b2e62774a..9ef5e26243 100644
--- a/agents/langchain-deepagents-code/Dockerfile
+++ b/agents/langchain-deepagents-code/Dockerfile
@@ -59,14 +59,16 @@ RUN case "$NEMOCLAW_TOOL_DISCLOSURE" in \
esac
# The launcher and startup script read these root-owned files instead of
-# trusting process-level environment overrides for inference routing. Invoking
-# each launcher validates the build args before the image can complete.
+# trusting process-level environment overrides for inference routing or tool
+# disclosure. Invoking each launcher validates the build args before the image
+# can complete.
RUN install -d -m 0755 /usr/local/share/nemoclaw \
&& printf '%s\n' "$NEMOCLAW_PROXY_HOST" > /usr/local/share/nemoclaw/dcode-proxy-host \
&& printf '%s\n' "$NEMOCLAW_PROXY_PORT" > /usr/local/share/nemoclaw/dcode-proxy-port \
+ && printf '%s\n' "$NEMOCLAW_TOOL_DISCLOSURE" > /usr/local/share/nemoclaw/dcode-tool-disclosure \
&& printf '%s\n' "$NEMOCLAW_INFERENCE_BASE_URL" > /usr/local/share/nemoclaw/dcode-inference-base-url \
- && chown root:root /usr/local/share/nemoclaw/dcode-proxy-host /usr/local/share/nemoclaw/dcode-proxy-port /usr/local/share/nemoclaw/dcode-inference-base-url \
- && chmod 0444 /usr/local/share/nemoclaw/dcode-proxy-host /usr/local/share/nemoclaw/dcode-proxy-port /usr/local/share/nemoclaw/dcode-inference-base-url \
+ && chown root:root /usr/local/share/nemoclaw/dcode-proxy-host /usr/local/share/nemoclaw/dcode-proxy-port /usr/local/share/nemoclaw/dcode-tool-disclosure /usr/local/share/nemoclaw/dcode-inference-base-url \
+ && chmod 0444 /usr/local/share/nemoclaw/dcode-proxy-host /usr/local/share/nemoclaw/dcode-proxy-port /usr/local/share/nemoclaw/dcode-tool-disclosure /usr/local/share/nemoclaw/dcode-inference-base-url \
&& /usr/local/bin/dcode --version \
&& /usr/local/bin/dcode.real --version \
&& /usr/local/bin/deepagents-code --version
diff --git a/agents/langchain-deepagents-code/Dockerfile.base b/agents/langchain-deepagents-code/Dockerfile.base
index bc9d9883e2..2c6f41fdee 100644
--- a/agents/langchain-deepagents-code/Dockerfile.base
+++ b/agents/langchain-deepagents-code/Dockerfile.base
@@ -42,7 +42,7 @@ RUN groupadd -r sandbox \
# Pre-create shell init files for the sandbox user. OpenShell/NemoClaw writes
# /tmp/nemoclaw-proxy-env.sh at startup so interactive sessions and dcode share
-# the same proxy, CA, inference, HOME, and update-check posture.
+# the same proxy, CA, inference, tool-disclosure, HOME, and update-check posture.
# hadolint ignore=SC2016
RUN printf '%s\n' \
'# Source runtime proxy + Deep Agents Code config' \
diff --git a/agents/langchain-deepagents-code/dcode-launcher.sh b/agents/langchain-deepagents-code/dcode-launcher.sh
index 2e92280e62..76c8001707 100755
--- a/agents/langchain-deepagents-code/dcode-launcher.sh
+++ b/agents/langchain-deepagents-code/dcode-launcher.sh
@@ -20,6 +20,7 @@ export PATH="/usr/local/bin:/opt/venv/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sb
# dcode no longer uses inference.local.
readonly MANAGED_PROXY_HOST_FILE="/usr/local/share/nemoclaw/dcode-proxy-host"
readonly MANAGED_PROXY_PORT_FILE="/usr/local/share/nemoclaw/dcode-proxy-port"
+readonly MANAGED_TOOL_DISCLOSURE_FILE="/usr/local/share/nemoclaw/dcode-tool-disclosure"
readonly MANAGED_PROXY_OWNER_UID=0
managed_proxy_file_metadata() {
@@ -32,21 +33,21 @@ managed_proxy_file_metadata() {
fi
}
-read_managed_proxy_value() {
+read_managed_image_value() {
local file="$1"
local name="$2"
local metadata
local value
if [ ! -f "$file" ] || [ -L "$file" ] || [ ! -r "$file" ]; then
- printf 'Missing or unsafe trusted managed proxy %s file.\n' "$name" >&2
+ printf 'Missing or unsafe trusted managed %s file.\n' "$name" >&2
return 1
fi
metadata="$(managed_proxy_file_metadata "$file")" || {
- printf 'Cannot inspect trusted managed proxy %s file.\n' "$name" >&2
+ printf 'Cannot inspect trusted managed %s file.\n' "$name" >&2
return 1
}
if [ "$metadata" != "${MANAGED_PROXY_OWNER_UID}:444" ]; then
- printf 'Unsafe ownership or mode on trusted managed proxy %s file.\n' "$name" >&2
+ printf 'Unsafe ownership or mode on trusted managed %s file.\n' "$name" >&2
return 1
fi
value="$(<"$file")"
@@ -55,8 +56,9 @@ read_managed_proxy_value() {
# Onboard validates the build args and the Dockerfile stores them in root-owned
# files. Runtime env is untrusted and cannot override those image-baked values.
-PROXY_HOST="$(read_managed_proxy_value "$MANAGED_PROXY_HOST_FILE" "host")"
-PROXY_PORT="$(read_managed_proxy_value "$MANAGED_PROXY_PORT_FILE" "port")"
+PROXY_HOST="$(read_managed_image_value "$MANAGED_PROXY_HOST_FILE" "proxy host")"
+PROXY_PORT="$(read_managed_image_value "$MANAGED_PROXY_PORT_FILE" "proxy port")"
+TOOL_DISCLOSURE="$(read_managed_image_value "$MANAGED_TOOL_DISCLOSURE_FILE" "tool-disclosure mode")"
unset NEMOCLAW_PROXY_HOST NEMOCLAW_PROXY_PORT
# Generic proxy fallbacks are outside the managed dcode contract and may carry
# host credentials even after the scheme-specific proxy values are normalized.
@@ -88,6 +90,14 @@ if ! is_valid_proxy_port "$PROXY_PORT"; then
printf '%s\n' 'Invalid NEMOCLAW_PROXY_PORT for the managed runtime proxy.' >&2
exit 1
fi
+case "$TOOL_DISCLOSURE" in
+ progressive | direct) ;;
+ *)
+ printf '%s\n' 'Invalid image-baked tool-disclosure mode for the managed runtime.' >&2
+ exit 1
+ ;;
+esac
+export NEMOCLAW_TOOL_DISCLOSURE="$TOOL_DISCLOSURE"
_PROXY_URL="http://${PROXY_HOST}:${PROXY_PORT}"
_NO_PROXY_VAL="localhost,127.0.0.1,::1,${PROXY_HOST}"
diff --git a/agents/langchain-deepagents-code/managed-dcode-runtime.py b/agents/langchain-deepagents-code/managed-dcode-runtime.py
index cf0abf2b5c..f1626b6ae3 100644
--- a/agents/langchain-deepagents-code/managed-dcode-runtime.py
+++ b/agents/langchain-deepagents-code/managed-dcode-runtime.py
@@ -52,7 +52,13 @@
_MCP_CHILD_BINDING_ENV = "NEMOCLAW_DCODE_MCP_BINDING"
_MCP_SEALED_KIND = "sealed-memfd"
_MCP_ANONYMOUS_KIND = "anonymous-otmpfile"
-_MCP_ANONYMOUS_DIRECTORY = Path("/tmp")
+_MCP_ANONYMOUS_DIRECTORIES = (Path("/tmp"), Path("/dev/shm"))
+_MCP_ANONYMOUS_DIRECTORY_FALLBACK_ERRNOS = {
+ errno.EINVAL,
+ errno.ENOENT,
+ errno.ENOSYS,
+ errno.EOPNOTSUPP,
+}
_MCP_FALLBACK_ERRNOS = {
errno.EACCES,
errno.EINVAL,
@@ -699,13 +705,29 @@ def _sealed_managed_mcp_snapshot(payload: bytes) -> int:
raise
+def _open_anonymous_managed_mcp_writer(flags: int) -> int:
+ for index, directory in enumerate(_MCP_ANONYMOUS_DIRECTORIES):
+ try:
+ return os.open(directory, flags, 0o600)
+ except OSError as exc:
+ is_last = index == len(_MCP_ANONYMOUS_DIRECTORIES) - 1
+ if (
+ is_last
+ or exc.errno not in _MCP_ANONYMOUS_DIRECTORY_FALLBACK_ERRNOS
+ ):
+ raise
+ raise RuntimeError(
+ "managed MCP config requires anonymous O_TMPFILE support"
+ )
+
+
def _anonymous_managed_mcp_snapshot(payload: bytes) -> int:
writer: int | None = None
reader: int | None = None
complete = False
try:
flags = os.O_TMPFILE | os.O_EXCL | os.O_RDWR | os.O_CLOEXEC
- writer = os.open(_MCP_ANONYMOUS_DIRECTORY, flags, 0o600)
+ writer = _open_anonymous_managed_mcp_writer(flags)
remaining = memoryview(payload)
while remaining:
written = os.write(writer, remaining)
diff --git a/agents/langchain-deepagents-code/policy-additions.yaml b/agents/langchain-deepagents-code/policy-additions.yaml
index 129f4501e1..d0a12c182e 100644
--- a/agents/langchain-deepagents-code/policy-additions.yaml
+++ b/agents/langchain-deepagents-code/policy-additions.yaml
@@ -24,6 +24,10 @@ filesystem_policy:
- /sandbox
- /sandbox/.deepagents
- /tmp
+ # OpenShell blocks memfd_create; managed MCP config uses an unlinked,
+ # read-only O_TMPFILE snapshot on this sandbox-local tmpfs when /tmp's
+ # backing filesystem does not support anonymous files.
+ - /dev/shm
- /dev/null
landlock:
diff --git a/agents/langchain-deepagents-code/start.sh b/agents/langchain-deepagents-code/start.sh
index 65cd2a0af0..ae19c955e7 100755
--- a/agents/langchain-deepagents-code/start.sh
+++ b/agents/langchain-deepagents-code/start.sh
@@ -42,6 +42,7 @@ export OPENAI_BASE_URL="${OPENAI_BASE_URL:-https://inference.local/v1}"
# or when dcode no longer uses inference.local.
readonly MANAGED_PROXY_HOST_FILE="/usr/local/share/nemoclaw/dcode-proxy-host"
readonly MANAGED_PROXY_PORT_FILE="/usr/local/share/nemoclaw/dcode-proxy-port"
+readonly MANAGED_TOOL_DISCLOSURE_FILE="/usr/local/share/nemoclaw/dcode-tool-disclosure"
readonly MANAGED_PROXY_OWNER_UID=0
managed_proxy_file_metadata() {
@@ -54,31 +55,32 @@ managed_proxy_file_metadata() {
fi
}
-read_managed_proxy_value() {
+read_managed_image_value() {
local file="$1"
local name="$2"
local metadata
local value
if [ ! -f "$file" ] || [ -L "$file" ] || [ ! -r "$file" ]; then
- printf 'Missing or unsafe trusted managed proxy %s file.\n' "$name" >&2
+ printf 'Missing or unsafe trusted managed %s file.\n' "$name" >&2
return 1
fi
metadata="$(managed_proxy_file_metadata "$file")" || {
- printf 'Cannot inspect trusted managed proxy %s file.\n' "$name" >&2
+ printf 'Cannot inspect trusted managed %s file.\n' "$name" >&2
return 1
}
if [ "$metadata" != "${MANAGED_PROXY_OWNER_UID}:444" ]; then
- printf 'Unsafe ownership or mode on trusted managed proxy %s file.\n' "$name" >&2
+ printf 'Unsafe ownership or mode on trusted managed %s file.\n' "$name" >&2
return 1
fi
value="$(<"$file")"
printf '%s' "$value"
}
-# Fail closed if the root-owned image contract is missing. Process-level
-# NEMOCLAW_PROXY_* values are not a trusted runtime routing source.
-PROXY_HOST="$(read_managed_proxy_value "$MANAGED_PROXY_HOST_FILE" "host")"
-PROXY_PORT="$(read_managed_proxy_value "$MANAGED_PROXY_PORT_FILE" "port")"
+# Fail closed if the root-owned image contract is missing. Process-level proxy
+# and tool-disclosure values are not trusted runtime configuration sources.
+PROXY_HOST="$(read_managed_image_value "$MANAGED_PROXY_HOST_FILE" "proxy host")"
+PROXY_PORT="$(read_managed_image_value "$MANAGED_PROXY_PORT_FILE" "proxy port")"
+TOOL_DISCLOSURE="$(read_managed_image_value "$MANAGED_TOOL_DISCLOSURE_FILE" "tool-disclosure mode")"
unset NEMOCLAW_PROXY_HOST NEMOCLAW_PROXY_PORT
# Generic proxy fallbacks are outside the managed dcode contract and may carry
# host credentials even after the scheme-specific proxy values are normalized.
@@ -110,6 +112,14 @@ if ! is_valid_proxy_port "$PROXY_PORT"; then
printf '%s\n' 'Invalid NEMOCLAW_PROXY_PORT for the managed runtime proxy.' >&2
exit 1
fi
+case "$TOOL_DISCLOSURE" in
+ progressive | direct) ;;
+ *)
+ printf '%s\n' 'Invalid image-baked tool-disclosure mode for the managed runtime.' >&2
+ exit 1
+ ;;
+esac
+export NEMOCLAW_TOOL_DISCLOSURE="$TOOL_DISCLOSURE"
_PROXY_URL="http://${PROXY_HOST}:${PROXY_PORT}"
_NO_PROXY_VAL="localhost,127.0.0.1,::1,${PROXY_HOST}"
@@ -165,6 +175,7 @@ prepare_runtime_env() {
# LangSmith values are intentionally excluded because any inherited variable
# can be misconfigured with a token and this shared file is readable.
write_export_if_set NEMOCLAW_SANDBOX_NAME
+ write_export_if_set NEMOCLAW_TOOL_DISCLOSURE
} >"$tmp"
# Dcode intentionally runs as the non-root sandbox user, unlike the
# root-supervised OpenClaw/Hermes startup path. This atomic, sandbox-user-owned
diff --git a/docs/index.yml b/docs/index.yml
index 8eea7f133f..25a1525d97 100644
--- a/docs/index.yml
+++ b/docs/index.yml
@@ -66,6 +66,9 @@ navigation:
- page: "Model Capability Audit"
path: _build/agent-variants/inference/model-capability-audit.openclaw.generated.mdx
slug: model-capability-audit
+ - page: "Progressive Tool Disclosure Performance Test"
+ path: _build/agent-variants/inference/progressive-tool-disclosure-performance-test.openclaw.generated.mdx
+ slug: progressive-tool-disclosure-performance-test
- page: "Switch Inference Providers"
path: _build/agent-variants/inference/switch-inference-providers.openclaw.generated.mdx
slug: switch-inference-providers
@@ -249,6 +252,9 @@ navigation:
- page: "Model Capability Audit"
path: _build/agent-variants/inference/model-capability-audit.hermes.generated.mdx
slug: model-capability-audit
+ - page: "Progressive Tool Disclosure Performance Test"
+ path: _build/agent-variants/inference/progressive-tool-disclosure-performance-test.hermes.generated.mdx
+ slug: progressive-tool-disclosure-performance-test
- page: "Switch Inference Providers"
path: _build/agent-variants/inference/switch-inference-providers.hermes.generated.mdx
slug: switch-inference-providers
diff --git a/docs/inference/progressive-tool-disclosure-performance-test.mdx b/docs/inference/progressive-tool-disclosure-performance-test.mdx
new file mode 100644
index 0000000000..e652bd08ac
--- /dev/null
+++ b/docs/inference/progressive-tool-disclosure-performance-test.mdx
@@ -0,0 +1,327 @@
+---
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+title: "Progressive Tool Disclosure Performance Test"
+sidebar-title: "Progressive Tool Disclosure Performance Test"
+description: "Run the reproducible two-campaign performance test that measures progressive tool disclosure across NemoClaw agent surfaces."
+description-agent: "Explains how to prepare, run, and summarize the hardware-neutral progressive tool-disclosure performance test. Use when measuring tool-schema tokens, task success, or latency for OpenClaw, Hermes, and LangChain Deep Agents Code."
+keywords: "progressive tool disclosure performance test, hardware-neutral performance test, tool schema tokens, MCP performance test"
+position: 5
+---
+
+This protocol compares direct tool exposure with progressive disclosure for OpenClaw, Hermes, and LangChain Deep Agents Code.
+It produces checksummed evidence and only emits claim text that clears the configured statistical gates.
+
+
+The repository does not include measured results.
+Run both campaigns and retain their evidence before you make a performance or quality claim.
+
+
+The explicit-only `tool-disclosure-performance-smoke` E2E job is a smaller operational check for ordinary GitHub-hosted runners.
+It runs the route-only acceptance corpus, one frozen task in progressive and direct modes with fresh Deep Agents sandboxes, and a separate routed replay of that task through the request transformer.
+The routed replay keeps its recorder on a host-local private Docker bridge within the trusted runner boundary, rejects requests before transformation unless they carry a fresh ephemeral credential, and replaces that credential with the hosted provider credential only on the runner side.
+Its output validates live wiring only: it is not a complete campaign, cannot be summarized by this protocol, and does not support performance claims.
+
+## Exercise the compositional routing experiment
+
+The performance-test package also contains a test-scoped, independently implemented compositional tool router.
+It is an experimental reference path, not a new NemoClaw runtime mode.
+Enabling it in tests does not change the meaning of the existing `direct` or `progressive` modes.
+
+The implementation performs these steps:
+
+1. Ask an injected decomposer for an ordered list of atomic actions.
+2. L2-normalize tool and action embeddings, then perform deterministic exact inner-product retrieval.
+3. Merge the first-pass candidates into at most `12` tool-name hints.
+4. Ask the decomposer to refine the action boundaries with those hints.
+5. Retrieve the top `10` candidates per refined action, select a bounded ordered tool set, and filter only the schemas forwarded in a copied model request.
+
+The tested request-transform contract leaves the caller-owned executor and policy registry unchanged.
+Its integration tests verify that only the copied model request is filtered and that the recording proxy measures the transformed schemas.
+This transform is not installed in the frozen direct/progressive campaign.
+Malformed decomposition, embedding, or routing results fail open to the original unfiltered request, and the route records counts, safe tool names, fallback class, and timings without retaining prompts or subtask text.
+
+The model adapters support an OpenAI-compatible chat decomposer and an OpenAI-compatible semantic embeddings endpoint.
+Record the exact embedding model and revision for any measured run.
+The dependency-free hashing adapter is only for deterministic tests and portable smoke checks; it is lexical and is not a substitute for a recorded semantic retrieval model.
+
+Run the deterministic routing acceptance suite on an ordinary CPU runner:
+
+```bash
+npx vitest run --project integration \
+ test/performance/tool-disclosure-compositional-*.test.ts \
+ test/performance/tool-disclosure-recorder.test.ts
+```
+
+Run the same route-only acceptance corpus with a real OpenAI-compatible decomposer by creating a private configuration file:
+
+```json
+{
+ "decomposer": {
+ "base_url": "https://models.example/v1",
+ "model": "recorded-decomposer-model",
+ "revision": "immutable-model-revision",
+ "api_key_env": "MODEL_API_KEY",
+ "allow_remote": true,
+ "reasoning_control": "enable_thinking_false",
+ "json_object_response": true,
+ "max_attempts": 2
+ },
+ "embedding": {
+ "kind": "portable",
+ "dimensions": 1024
+ },
+ "timeout_ms": 120000,
+ "run_timeout_ms": 900000
+}
+```
+
+Then run:
+
+```bash
+npm run performance:tool-disclosure -- route-acceptance \
+ --output-dir \
+ --config
+```
+
+The command writes `compositional-routing-acceptance.json` and `SHA256SUMS`, exits nonzero when a strict gate fails, and never writes endpoint URLs, credentials, prompts, or decomposed subtask text.
+Set `reasoning_control` only when the endpoint supports the matching chat-template extension: `enable_thinking_false` sends `chat_template_kwargs.enable_thinking=false`, while `thinking_false` sends `chat_template_kwargs.thinking=false`.
+Set `json_object_response` only when the endpoint supports OpenAI-compatible JSON-object response mode.
+Both choices are recorded in the artifact; omit them to use endpoint defaults and prompt-only JSON formatting.
+`max_attempts` is the per-decomposition attempt count and must be at most `3`.
+`timeout_ms` applies to each remote request and must be at most `300000`; the product of `timeout_ms` and `max_attempts` must not exceed `600000`.
+`run_timeout_ms` is the overall route-only deadline across index construction and every corpus case and must be at most `2700000`.
+The overall deadline aborts outstanding remote work, exits nonzero, and is recorded with the per-request and retry limits.
+Timed-out runs record an explicit execution status, completed-case count, and `run-deadline-exceeded` case evidence instead of attributing unattempted work to a model or embedding failure.
+Every attempt contributes to the request and duration counts, and any provider token usage returned for an attempt is aggregated.
+The portable embedding mode is a CPU smoke path and is always claim-ineligible.
+For a semantic retrieval run, set `embedding.kind` to `openai` and provide its `base_url`, `model`, immutable `revision`, optional `api_key_env`, and remote opt-in.
+Remote opt-in sends the acceptance prompts and synthetic tool metadata to the configured HTTPS services; HTTP is accepted only on loopback.
+
+The route-only corpus contains `20` distinct tools and eight implicit requests spanning zero through five required capabilities.
+Its strict gates require every route to complete without fallback, exact decomposition count, decomposition within one step, exact tool recall at `1` and `10`, ordered-chain forwarding, forwarded-schema count, and no-tool behavior.
+Passthrough is evaluated as forwarding all `20` schemas, so a failed-open no-tool route cannot be scored as a successful empty route.
+The deterministic suite holds decomposition fixed, so passing it proves the router, retriever, transformer, evidence, and fallback contracts; it does not prove that tool hints improve a real model's decomposition.
+
+For a model-level evaluation, record a paired initial-versus-one-refinement run on the same implicit corpus and report decomposition and embedding usage separately alongside decomposition accuracy, decomposition within one step, exact recall at `1` and `10`, ordered-chain selection, selected-schema count, tokens, and latency.
+Then use the existing agent performance test below for execution success, arguments, ordering, schema tokens, and end-to-end latency.
+Treat latency as a measurement rather than an acceptance gate because the refinement adds a second decomposition call.
+
+This experiment routes callable tool schemas in its own synthetic corpus.
+It does not implement or claim a dependency-graph planner or compatibility composer.
+The route-only command does not execute an agent or a tool; use the full performance procedure below to validate task execution and end-to-end behavior.
+It also does not install the router into the frozen direct/progressive campaign.
+The opt-in smoke job exercises the transform in one separate, claim-ineligible agent replay and requires successful task execution, the expected tool call, no routing fallback, and fewer model-visible schemas.
+That ordinary-runner bridge is test-only; the complete two-campaign procedure keeps the recorder on its documented local host boundary and does not route measurements through the smoke bridge.
+A broader agent-level routing comparison must use a separate initial/refined schedule in which both agent cells expose the same complete catalog before the request transformer runs.
+
+## Understand the frozen protocol
+
+The preparation command generates one deterministic catalog, task corpus, and paired execution schedule.
+Use the generated files without adding, removing, or reordering runs.
+
+| Phase | Catalog tools | Tasks and repetitions per agent and mode | Purpose |
+| --- | ---: | --- | --- |
+| `static-visibility` | `16`, `64`, `256`, `512`, and `2209` | One exact capture at each size. | Measure the initial serialized tool-schema bytes, tokens, and visible tool count. |
+| `small-control` | `64` | `24` tasks, one repetition. | Check behavior before context pressure becomes large. |
+| `primary` | `512` | `24` tasks, five repetitions. | Supply the paired observations used by the reported claim gates. |
+| `large-stress` | `2209` | `8` tasks, one repetition. | Exercise the largest generated catalog without widening the primary claim. |
+
+The schedule contains `942` runs in each campaign and `1884` runs in total.
+It keeps each progressive and direct pair adjacent while deterministically balancing which mode runs first.
+The primary task set includes single-tool, ordered-chain, near-match, and no-tool cases.
+
+## Prepare the evidence directory
+
+Run preparation from a clean NemoClaw checkout at the exact system-under-test revision.
+The sandbox base must use an immutable OCI digest; mutable tags such as `latest` are rejected.
+Place the evidence directory outside the Git worktree so generated artifacts cannot change the recorded cleanliness state.
+
+```bash
+npm run performance:tool-disclosure -- prepare \
+ --output-dir \
+ --sandbox-base ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:<64-hex-digest>
+```
+
+Preparation writes these inputs:
+
+- `catalog.json` contains the deterministic synthetic tool catalog.
+- `primary-tasks.json` and `stress-tasks.json` contain the frozen task definitions.
+- `schedule.json` contains both campaigns in execution order.
+- `manifest.template.json` contains the shareable evidence schema fields that you must complete.
+- The generated OpenClaw fixture directories contain native tool plugins for the scheduled catalog sizes.
+
+Copy `manifest.template.json` to `manifest.json` and replace every placeholder before the first run.
+
+```bash
+cp /manifest.template.json /manifest.json
+```
+
+Record the full NemoClaw system-under-test and harness commit SHAs, exact model ID and revision, vLLM image digest and version, serving flags, parser names, OpenShell version, agent versions, accelerator and CPU description, accelerator driver and runtime versions, RAM, and the observed power or performance state.
+For a CPU-only run, set `accelerator_count` to `0`, `accelerator_type` to `none`, and the remaining accelerator fields to `not-applicable`.
+Keep credentials, endpoint URLs, hostnames, usernames, and local paths out of the manifest.
+
+
+The performance test commands do not provision hardware, start vLLM, or create sandboxes.
+Prepare the runtime environment first; the `execute` command starts the loopback recorder and synthetic MCP fixtures, drives the frozen schedule, and writes sanitized raw evidence plus derived run records.
+
+
+## Provision and preflight the performance test host
+
+In principle, the performance test can run on any hardware with a compatible host environment that can serve the recorded model and complete the frozen schedule.
+The required runtime consists of Node.js and a POSIX-compatible shell, an OpenAI-compatible vLLM endpoint with `/metrics` and `/tokenize`, Docker-backed OpenShell sandboxes, `cloudflared` with outbound network access, and working OpenClaw, Hermes, and LangChain Deep Agents Code runtimes.
+Provisioning and access procedures are outside the performance test protocol.
+
+Run host-appropriate preflight checks before starting inference.
+The following examples cover common Linux and macOS environments; run only the commands available on the performance test host.
+
+```bash
+uname -a
+uname -m
+lscpu # Linux CPU inventory
+sysctl -n machdep.cpu.brand_string # macOS CPU inventory
+free -h # Linux memory inventory
+vm_stat # macOS memory inventory
+docker version
+openshell --version
+nemoclaw --version
+git rev-parse HEAD
+```
+
+Accelerator inspection is optional and platform-specific; use the hardware vendor's supported inspection utility when applicable.
+Confirm that the host matches the recorded processor and accelerator configuration, has enough available memory for the frozen model, has the required container image by digest, and has no competing workload that could affect the measurements.
+Set and record the intended power or performance state before each campaign.
+Do not publish raw preflight logs because they can contain hostnames, usernames, or local paths.
+
+## Route inference through the recorder
+
+Run one vLLM server and one performance-test recording proxy on the performance test host.
+Configure the NemoClaw managed inference route so every model request from every performance-test sandbox passes through the proxy before reaching vLLM.
+After vLLM starts, verify its local health endpoint before beginning a campaign.
+
+```bash
+curl --fail --silent --show-error http://127.0.0.1:/health
+```
+
+```text
+OpenClaw, Hermes, and LangChain Deep Agents Code sandboxes
+ |
+ NemoClaw managed inference route
+ |
+ recording proxy on 127.0.0.1, forwarding /v1/*
+ |
+ vLLM
+ /metrics and /tokenize
+```
+
+The recorder must bind to `127.0.0.1` and must not be exposed on a non-loopback network interface.
+It forwards OpenAI-compatible `/v1` requests without following redirects and records content-free request metadata, including the canonical tool-schema byte count, schema hash, visible tool count, tool names, status, monotonic timing, and run sequence.
+It does not retain prompts, messages, headers, request bodies, response bodies, model identifiers, endpoints, or errors from upstream bodies.
+
+Use vLLM `/tokenize` to count initial serialized tool-schema tokens with the exact served model tokenizer.
+Read prompt and generation token counters from vLLM `/metrics` immediately before and after each scheduled run.
+Keep performance-test concurrency at `1` so those process-level counter deltas remain attributable to one run.
+Use temperature `0`, the manifest's frozen parser configuration, and the same recorded vLLM flags in both modes and both campaigns.
+
+## Run two fresh campaigns
+
+Treat the two campaigns as independent executions of the same prepared schedule.
+Do not reuse an inference process, sandbox, model session, or recorder event stream across campaigns.
+
+Copy `execute-config.template.json` outside the evidence directory and fill in the campaign number, loopback vLLM and telemetry URLs, tokenizer model, fixed recorder port, live `vllm_container_name` and `vllm_container_id`, binary overrides when needed, plus `sandbox_names`, `sandbox_container_names`, and live `sandbox_instance_ids` mappings keyed as `::`.
+The sandboxes' managed inference route must already target that recorder port.
+Do not publish this file because it can contain local routing and sandbox identifiers.
+Execution verifies the live vLLM container ID, Docker image ID, model and revision, parser names, public flags, and process start time.
+It also verifies each live sandbox status against its expected agent, disclosure mode, immutable image ID, and instance ID.
+Only hashes of private container identities and reviewed public configuration enter the evidence bundle.
+Each campaign generates a new MCP bearer token and shares it only among that campaign's five short-lived catalog servers; the token is never written to the public evidence artifacts.
+The quick-tunnel process disables config-file discovery and receives an isolated temporary `HOME` and `XDG_CONFIG_HOME`, removed when the tunnel closes, so it cannot load the operator's ambient Cloudflare account configuration or credentials.
+This attestation path expects vLLM and the OpenShell sandboxes to use the Docker driver so `docker inspect` can verify the live state.
+
+```bash
+npm run performance:tool-disclosure -- execute \
+ --output-dir \
+ --config
+```
+
+For campaign 1:
+
+1. Start a fresh vLLM process from the recorded image digest and flags.
+2. Start a fresh loopback recorder and reset its event stream.
+3. Create fresh OpenClaw, Hermes, and LangChain Deep Agents Code sandboxes for the scheduled modes and fixtures.
+4. Run `execute` for campaign 1. It appends each attempt atomically to `attempt-journal.jsonl`, records hashed live identities in `campaign-attestations.json`, and materializes content-free `raw-events.jsonl` plus one derived `nemoclaw.tool_disclosure_performance_test.v1` row per entry in `runs.jsonl`.
+5. Stop vLLM, stop the recorder, and destroy the campaign sandboxes.
+
+For campaign 2, repeat the same five steps with another fresh vLLM process, recorder event stream, and sandbox set.
+Use the same hardware configuration, system-under-test and harness revisions, model revision, image digest, parser configuration, and task artifacts.
+If any of those controlled values changes, prepare a new performance test ID and restart both campaigns.
+
+The agent adapters must use the generated deterministic handlers and verify expected tool names, order, argument hashes, call count, unnecessary calls, and the returned nonce.
+Direct and progressive modes must differ only in their disclosure configuration.
+Follow the retry allowance in `manifest.json` for setup failures, and do not convert task failures into setup failures.
+Once an agent invocation has begun, any incomplete or inconsistent evidence aborts the campaign instead of consuming a setup retry; discard that campaign and restart it with fresh resources.
+
+## Summarize and verify artifacts
+
+After all scheduled records are present, run the summarizer in the same evidence directory.
+
+```bash
+npm run performance:tool-disclosure -- summarize --output-dir
+```
+
+The command consumes `manifest.json` and `runs.jsonl` and writes:
+
+- `summary.json` with paired aggregates, confidence intervals, and claim-gate decisions.
+- `raw-events.jsonl` with content-free measurements from which every run record is re-derived.
+- `attempt-journal.jsonl` as the crash-recoverable source for raw and derived run rows.
+- `campaign-attestations.json` with vLLM process start times, hashed inference identities and configuration, and hashed live sandbox identities.
+- `report.md` with environment metadata, measurements, gate status, and mechanically generated claim text.
+- `evidence.json` with the public artifact inventory and SHA-256 values.
+- `SHA256SUMS` with the checksum manifest.
+
+Verify the bundle on the performance test host before you move it.
+
+```bash
+cd
+sha256sum -c SHA256SUMS # GNU/Linux
+shasum -a 256 -c SHA256SUMS # macOS
+```
+
+Run the command available on the performance test host.
+
+Treat every output as untrusted until you review it for credentials, endpoint URLs, hostnames, usernames, local paths, raw prompts, tool arguments, and response text.
+Do not hand-edit `summary.json` or the validated claims in `report.md`.
+If a correction changes an input or run record, regenerate the summary and checksums.
+
+## Apply the claim gates
+
+The summarizer uses a deterministic paired bootstrap with `10,000` samples.
+It averages repetitions within each task first, then resamples whole progressive and direct task pairs, so the task is the statistical unit.
+
+The report can emit each claim only when its gate passes for OpenClaw, Hermes, and LangChain Deep Agents Code in both campaigns at the `512`-tool primary size.
+
+| Claim | Required gate in every campaign and agent cell |
+| --- | --- |
+| Initial serialized tool-schema tokens improved. | The paired progressive-minus-direct token difference has a `95%` confidence-interval upper bound below `0`, and the exact static capture has a positive token reduction. |
+| Task success is noninferior. | The paired progressive-minus-direct success difference has a `95%` confidence-interval lower bound of at least `-5` percentage points. |
+| End-to-end latency improved. | The paired progressive-minus-direct latency difference has a `95%` confidence-interval upper bound below `0`. |
+
+Repeated static captures must agree exactly on visible tool count, serialized bytes, and tokenizer tokens.
+The summarizer excludes setup failures, but scores timeouts, incorrect tool behavior, model errors, and context overflows as task failures.
+A blocked gate remains visible in `report.md`, and the report does not emit the corresponding cross-agent claim.
+
+## Interpret the scope
+
+The performance test supports claims about the recorded commits, agents, model, serving configuration, hardware configuration, deterministic synthetic catalog, and task corpus.
+It does not establish a universal result for other models, hardware, tool distributions, prompts, concurrency levels, or production workloads.
+Evaluate another hardware configuration with a separate complete two-campaign run and report that result with its own recorded environment metadata.
+
+Progressive disclosure is a context optimization, not an authorization boundary.
+The OpenClaw fixture measures its native catalog path and does not establish that mcporter-managed MCP schemas are compacted.
+The `2209`-tool stress phase supplies boundary evidence but does not widen claims beyond the `512`-tool primary phase.
+Two fresh campaigns on one hardware configuration demonstrate repeatability for that configuration, not cross-hardware generality.
+
+## Related topics
+
+- [Model Capability Audit](model-capability-audit) explains the evidence fields for model and agent compatibility claims.
+- [Tool-Calling Reliability](tool-calling-reliability) separates tool-use behavior from endpoint connectivity failures.
diff --git a/package.json b/package.json
index 86df0f0dd8..363a256823 100644
--- a/package.json
+++ b/package.json
@@ -35,6 +35,7 @@
"test:projects:check": "tsx scripts/checks/vitest-project-overlap.ts",
"test:titles:check": "tsx scripts/checks/test-title-style.ts",
"bench": "tsx scripts/bench/run.ts",
+ "performance:tool-disclosure": "tsx scripts/performance/tool-disclosure/run.ts",
"check": "npx prek run --all-files --stage pre-commit && npx prek run --all-files --stage manual",
"check:diff": "npx prek run --from-ref origin/main --to-ref HEAD --stage pre-commit && npx commitlint --from origin/main --to HEAD && npx prek run --from-ref origin/main --to-ref HEAD --stage pre-push",
"checks": "tsx scripts/checks/run.ts",
diff --git a/scripts/checks/check-cloudflared-update.sh b/scripts/checks/check-cloudflared-update.sh
index 3f25aa2fc6..8d99d38a41 100755
--- a/scripts/checks/check-cloudflared-update.sh
+++ b/scripts/checks/check-cloudflared-update.sh
@@ -2,14 +2,14 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
-# invalidState: the three reviewed E2E consumers drift to different cloudflared
+# invalidState: the four reviewed E2E consumers drift to different cloudflared
# versions/digests, or their shared pin no longer matches the upstream asset.
-# sourceBoundary: Cloudflare owns the release asset; NemoClaw owns all three
+# sourceBoundary: Cloudflare owns the release asset; NemoClaw owns all four
# workflow pins and independently verifies the downloaded bytes.
# whyNotSourceFix: upstream cannot enforce which release NemoClaw workflows use.
-# regressionTest: cloudflared-update-check-workflow.test.ts covers three-pin
+# regressionTest: cloudflared-update-check-workflow.test.ts covers four-pin
# parity, asset URL identity, digest mismatch, and update instructions.
-# removalCondition: remove this checker when the three consumers share one
+# removalCondition: remove this checker when the four consumers share one
# machine-readable dependency manifest with equivalent live asset verification.
set -euo pipefail
@@ -48,10 +48,10 @@ done < <(
"${E2E_WORKFLOW}"
)
-[[ "${#version_pins[@]}" -eq 3 ]] \
- || fail "expected exactly three CLOUDFLARED_VERSION pins in ${E2E_WORKFLOW}; found ${#version_pins[@]}"
-[[ "${#sha_pins[@]}" -eq 3 ]] \
- || fail "expected exactly three CLOUDFLARED_DEB_SHA256 pins in ${E2E_WORKFLOW}; found ${#sha_pins[@]}"
+[[ "${#version_pins[@]}" -eq 4 ]] \
+ || fail "expected exactly four CLOUDFLARED_VERSION pins in ${E2E_WORKFLOW}; found ${#version_pins[@]}"
+[[ "${#sha_pins[@]}" -eq 4 ]] \
+ || fail "expected exactly four CLOUDFLARED_DEB_SHA256 pins in ${E2E_WORKFLOW}; found ${#sha_pins[@]}"
pinned_version="${version_pins[0]}"
pinned_sha="$(printf '%s' "${sha_pins[0]}" | tr '[:upper:]' '[:lower:]')"
@@ -127,7 +127,7 @@ print_update_instructions() {
'Update locations:' \
" ${workflow_display} CLOUDFLARED_VERSION lines: ${version_lines}" \
" ${workflow_display} CLOUDFLARED_DEB_SHA256 lines: ${sha_lines}" \
- 'Set all three version/SHA256 pairs to the latest reviewed values, then rerun this check.' >&2
+ 'Set all four version/SHA256 pairs to the latest reviewed values, then rerun this check.' >&2
}
if [[ "${latest_version}" != "${pinned_version}" ]]; then
diff --git a/scripts/performance/README.md b/scripts/performance/README.md
new file mode 100644
index 0000000000..3a96a398eb
--- /dev/null
+++ b/scripts/performance/README.md
@@ -0,0 +1,101 @@
+
+
+# Progressive tool-disclosure performance test
+
+The progressive tool-disclosure performance test compares direct and
+progressive tool visibility for OpenClaw, Hermes, and LangChain Deep Agents
+Code against one deterministic synthetic catalog and task corpus. It is the
+repeatable comparison harness for this feature.
+
+In principle, it can run on any hardware with a compatible host environment
+that supports the recorded model, Node.js and a POSIX-compatible shell, an
+OpenAI-compatible vLLM endpoint with `/metrics` and `/tokenize`, Docker-backed
+OpenShell sandboxes, `cloudflared` with outbound network access, and the three
+agent runtimes. Results apply to the recorded hardware and configuration;
+evaluating another hardware configuration requires a separate complete run.
+
+Prepare a new evidence directory with the frozen catalog, task sets, schedule,
+manifest template, and generated OpenClaw fixtures:
+
+```bash
+npm run performance:tool-disclosure -- prepare \
+ --output-dir \
+ --sandbox-base ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:<64-hex-digest>
+```
+
+Run the emitted schedule as two fresh campaigns on the same recorded hardware
+and inference configuration. Each campaign requires a fresh vLLM process and
+fresh sandboxes. Populate `manifest.json` from `manifest.template.json`, and
+append the public-safe run records to `runs.jsonl`.
+
+Drive each prepared campaign with a private configuration that identifies the
+fresh vLLM container and maps the frozen agent/mode/catalog cells to fresh
+sandbox names, container names, and instance IDs:
+
+```bash
+npm run performance:tool-disclosure -- execute \
+ --output-dir --config
+```
+
+Execution uses a recoverable `attempt-journal.jsonl`, verifies the live vLLM
+image/configuration and Docker-backed sandbox identities, writes only hashed
+private identities to the attestation artifact, materializes content-free
+`raw-events.jsonl`, and derives `runs.jsonl`; the summarizer rejects records
+that cannot be reproduced from the journal and raw evidence.
+
+Summarize the completed evidence set:
+
+```bash
+npm run performance:tool-disclosure -- summarize --output-dir
+```
+
+The summarizer writes `summary.json`, `report.md`, `evidence.json`, and
+`SHA256SUMS`. It derives public claim text mechanically and leaves claims
+blocked unless the required confidence-interval gate passes for every agent in
+both campaigns.
+
+The same package includes a test-scoped independent compositional tool routing
+experiment. It runs two-pass atomic decomposition, normalized exact
+inner-product retrieval, and a bounded tool-hint refinement. Integration tests
+exercise its request-transform contract through the recorder while leaving the
+caller-owned executor registry unchanged. The transform is not enabled in the
+frozen direct/progressive campaign. Run its deterministic CPU acceptance suite
+with:
+
+```bash
+npx vitest run --project integration \
+ test/performance/tool-disclosure-compositional-*.test.ts \
+ test/performance/tool-disclosure-recorder.test.ts
+```
+
+Those tests prove routing mechanics and strict route-quality gates with frozen
+decomposition inputs. They do not establish a model-level decomposition
+improvement. The full protocol explains the separate paired initial/refined
+evidence required for that result.
+
+The explicit live smoke also runs a separate routed replay after its frozen
+direct/progressive cells. That replay must preserve task correctness and the
+expected tool call while reducing model-visible schemas without fallback. It
+does not alter either frozen cell and remains claim-ineligible. On an ordinary
+GitHub runner, its recorder uses an authenticated, host-local private Docker
+bridge inside the trusted runner boundary. Its ephemeral ingress credential is
+replaced before upstream forwarding; full campaigns retain the protocol's
+local recorder topology.
+
+Run the route-only corpus with a real decomposer using:
+
+```bash
+npm run performance:tool-disclosure -- route-acceptance \
+ --output-dir \
+ --config
+```
+
+The protocol documents the private config, semantic and portable embedding
+options, public-safe output, remote-content boundary, and strict exit gates.
+
+See the [progressive tool-disclosure performance-test protocol](../../docs/inference/progressive-tool-disclosure-performance-test.mdx)
+for the hardware-neutral workflow, recorder topology, artifact rules, claim
+gates, and limitations.
diff --git a/scripts/performance/tool-disclosure/artifacts.ts b/scripts/performance/tool-disclosure/artifacts.ts
new file mode 100644
index 0000000000..a3ed0bc4ce
--- /dev/null
+++ b/scripts/performance/tool-disclosure/artifacts.ts
@@ -0,0 +1,113 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { createHash } from "node:crypto";
+import fs from "node:fs";
+import path from "node:path";
+
+const SAFE_ARTIFACT_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
+
+export function ensureCampaignDirectory(outputDir: string, resume: boolean): string {
+ const resolved = path.resolve(outputDir);
+ if (fs.existsSync(resolved)) {
+ const stat = fs.lstatSync(resolved);
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
+ throw new Error("performance test output path must be a real directory");
+ }
+ const entries = fs.readdirSync(resolved);
+ if (!resume && entries.length > 0) {
+ throw new Error("performance test output directory is not empty; pass --resume to reuse it");
+ }
+ } else {
+ fs.mkdirSync(resolved, { recursive: true, mode: 0o700 });
+ }
+ return resolved;
+}
+
+export function artifactPath(outputDir: string, name: string): string {
+ if (!SAFE_ARTIFACT_NAME.test(name))
+ throw new Error(`invalid performance test artifact name: ${name}`);
+ const root = path.resolve(outputDir);
+ const candidate = path.resolve(root, name);
+ if (path.dirname(candidate) !== root)
+ throw new Error("performance test artifact escaped output directory");
+ if (fs.existsSync(candidate) && fs.lstatSync(candidate).isSymbolicLink()) {
+ throw new Error(`performance test artifact must not be a symlink: ${name}`);
+ }
+ return candidate;
+}
+
+export function writeJsonArtifact(outputDir: string, name: string, value: unknown): string {
+ const destination = artifactPath(outputDir, name);
+ const temporary = `${destination}.${process.pid}.${Date.now()}.tmp`;
+ fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, {
+ encoding: "utf8",
+ flag: "wx",
+ mode: 0o600,
+ });
+ fs.renameSync(temporary, destination);
+ return destination;
+}
+
+export function writeTextArtifact(outputDir: string, name: string, value: string): string {
+ const destination = artifactPath(outputDir, name);
+ const temporary = `${destination}.${process.pid}.${Date.now()}.tmp`;
+ fs.writeFileSync(temporary, value, {
+ encoding: "utf8",
+ flag: "wx",
+ mode: 0o600,
+ });
+ fs.renameSync(temporary, destination);
+ return destination;
+}
+
+export function appendJsonLine(outputDir: string, name: string, value: unknown): void {
+ const destination = artifactPath(outputDir, name);
+ fs.appendFileSync(destination, `${JSON.stringify(value)}\n`, {
+ encoding: "utf8",
+ mode: 0o600,
+ });
+}
+
+export function readJsonLines(filePath: string): T[] {
+ const raw = fs.readFileSync(filePath, "utf8");
+ return raw
+ .split(/\r?\n/u)
+ .filter((line) => line.trim().length > 0)
+ .map((line, index) => {
+ try {
+ return JSON.parse(line) as T;
+ } catch {
+ throw new Error(`invalid JSONL at line ${index + 1}`);
+ }
+ });
+}
+
+export function sha256File(filePath: string): string {
+ return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
+}
+
+export function writeChecksumManifest(outputDir: string, names: readonly string[]): string {
+ const unique = [...new Set(names)].sort();
+ const lines = unique.map((name) => {
+ const file = artifactPath(outputDir, name);
+ if (!fs.statSync(file).isFile()) throw new Error(`cannot checksum non-file artifact: ${name}`);
+ return `${sha256File(file)} ${name}`;
+ });
+ const destination = artifactPath(outputDir, "SHA256SUMS");
+ fs.writeFileSync(destination, `${lines.join("\n")}\n`, { encoding: "utf8", mode: 0o600 });
+ return destination;
+}
+
+export function scanArtifactsForForbiddenValues(
+ outputDir: string,
+ names: readonly string[],
+ forbiddenValues: readonly string[],
+): void {
+ const forbidden = forbiddenValues.filter((value) => value.length > 0);
+ for (const name of names) {
+ const content = fs.readFileSync(artifactPath(outputDir, name), "utf8");
+ const leaked = forbidden.find((value) => content.includes(value));
+ if (leaked) throw new Error(`performance test artifact ${name} contains a forbidden value`);
+ }
+}
diff --git a/scripts/performance/tool-disclosure/assemble-run.ts b/scripts/performance/tool-disclosure/assemble-run.ts
new file mode 100644
index 0000000000..c43c399e30
--- /dev/null
+++ b/scripts/performance/tool-disclosure/assemble-run.ts
@@ -0,0 +1,143 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { gradeTaskRun, type RecordedSyntheticCall } from "./grading";
+import type { ToolDisclosureRecordingEvent } from "./recorder";
+import type { ScheduledToolDisclosureRun } from "./schedule";
+import type { SyntheticPerformanceTask } from "./tasks";
+import {
+ type PerformanceTestRunOutcome,
+ type RunCorrectness,
+ TOOL_DISCLOSURE_PERFORMANCE_TEST_SCHEMA_VERSION,
+ type ToolDisclosureManifest,
+ type ToolDisclosureRun,
+} from "./types";
+
+export interface RunInvocationResult {
+ exit_code: number | null;
+ timed_out: boolean;
+ elapsed_ms: number;
+ final_output: string;
+}
+
+function successfulStaticCorrectness(): RunCorrectness {
+ return {
+ task_success: true,
+ expected_tool_names: true,
+ expected_tool_order: true,
+ expected_arguments: true,
+ expected_call_count: true,
+ nonce_present: true,
+ unnecessary_tool_calls: 0,
+ };
+}
+
+function failedCorrectness(callCount: number): RunCorrectness {
+ return {
+ task_success: false,
+ expected_tool_names: false,
+ expected_tool_order: false,
+ expected_arguments: false,
+ expected_call_count: false,
+ nonce_present: false,
+ unnecessary_tool_calls: callCount,
+ };
+}
+
+/**
+ * Convert transient model output plus content-free call/recorder events into a
+ * public-safe run record. Raw output and tool arguments never cross this API.
+ */
+export function assembleToolDisclosureRun(options: {
+ manifest: ToolDisclosureManifest;
+ scheduled: ScheduledToolDisclosureRun;
+ task?: SyntheticPerformanceTask;
+ calls: readonly RecordedSyntheticCall[];
+ recorderEvents: readonly ToolDisclosureRecordingEvent[];
+ invocation: RunInvocationResult;
+ initialSchemaTokens: number;
+ promptTokens?: number;
+ completionTokens?: number;
+ failureOutcome?: Exclude;
+}): ToolDisclosureRun {
+ if (!Number.isSafeInteger(options.initialSchemaTokens) || options.initialSchemaTokens < 0) {
+ throw new Error("initial schema token count must be a non-negative integer");
+ }
+ const scheduledCampaign = `campaign-${options.scheduled.campaign}`;
+ if (!options.manifest.campaigns.some((item) => item.campaign_id === scheduledCampaign)) {
+ throw new Error(`scheduled campaign ${scheduledCampaign} is absent from the manifest`);
+ }
+ if (options.scheduled.phase !== "static-visibility" && !options.task) {
+ throw new Error("task runs require their frozen task definition");
+ }
+ if (options.task && options.scheduled.task_id !== options.task.id) {
+ throw new Error("scheduled task does not match the supplied task definition");
+ }
+
+ const modelEvents = options.recorderEvents.filter((event) => event.model_call_sequence !== null);
+ const initial = modelEvents.find((event) => event.model_call_sequence === 1);
+ let outcome: PerformanceTestRunOutcome;
+ let correctness: RunCorrectness;
+ if (options.failureOutcome) {
+ outcome = options.failureOutcome;
+ correctness = failedCorrectness(options.calls.length);
+ } else if (options.invocation.timed_out) {
+ outcome = "timeout";
+ correctness = failedCorrectness(options.calls.length);
+ } else if (options.invocation.exit_code !== 0 || !initial || initial.outcome !== "completed") {
+ outcome = "model-error";
+ correctness = failedCorrectness(options.calls.length);
+ } else if (options.scheduled.phase === "static-visibility") {
+ outcome = options.initialSchemaTokens > 0 ? "success" : "model-error";
+ correctness =
+ options.initialSchemaTokens > 0
+ ? successfulStaticCorrectness()
+ : failedCorrectness(options.calls.length);
+ } else {
+ const graded = gradeTaskRun(
+ options.task as SyntheticPerformanceTask,
+ options.calls,
+ options.invocation.final_output,
+ );
+ outcome = graded.outcome;
+ correctness = graded.correctness;
+ }
+
+ return {
+ schema_version: TOOL_DISCLOSURE_PERFORMANCE_TEST_SCHEMA_VERSION,
+ performance_test_id: options.manifest.performance_test_id,
+ campaign_id: scheduledCampaign,
+ run_id: options.scheduled.run_id,
+ phase: options.scheduled.phase,
+ agent: options.scheduled.agent,
+ mode: options.scheduled.mode,
+ catalog_size: options.scheduled.catalog_size,
+ task_id: options.task?.id ?? "static-capture",
+ ...(options.task ? { task_kind: options.task.kind } : {}),
+ repetition: options.scheduled.repetition,
+ execution_seed: options.manifest.protocol.execution_seed,
+ outcome,
+ scored: options.scheduled.phase !== "static-visibility" && outcome !== "setup-error",
+ correctness,
+ measurements: {
+ initial_tool_schema: {
+ tool_count: initial?.visible_tool_count ?? 0,
+ serialized_bytes: initial?.canonical_tools_json_bytes ?? 0,
+ tokenizer_tokens: options.initialSchemaTokens,
+ },
+ ...(options.promptTokens === undefined ? {} : { total_prompt_tokens: options.promptTokens }),
+ ...(options.completionTokens === undefined
+ ? {}
+ : { completion_tokens: options.completionTokens }),
+ ...(initial?.streaming !== true ||
+ initial.time_to_first_byte_ms === null ||
+ initial.time_to_first_byte_ms === undefined
+ ? {}
+ : { time_to_first_response_byte_ms: initial.time_to_first_byte_ms }),
+ inference_time_ms: modelEvents.reduce((total, event) => total + event.duration_ms, 0),
+ end_to_end_time_ms: options.invocation.elapsed_ms,
+ model_calls: modelEvents.length,
+ discovery_calls: Math.max(0, modelEvents.length - options.calls.length - 1),
+ },
+ };
+}
diff --git a/scripts/performance/tool-disclosure/catalog.ts b/scripts/performance/tool-disclosure/catalog.ts
new file mode 100644
index 0000000000..f55429cca7
--- /dev/null
+++ b/scripts/performance/tool-disclosure/catalog.ts
@@ -0,0 +1,735 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { createHash } from "node:crypto";
+
+export const TOOL_DISCLOSURE_PERFORMANCE_TEST_CATALOG_SCHEMA_VERSION =
+ "nemoclaw.tool_disclosure.performance_test.catalog.v1" as const;
+export const DEFAULT_SYNTHETIC_PERFORMANCE_TEST_CATALOG_SEED =
+ "nemoclaw-tool-disclosure-performance-test-v1";
+export const MAX_SYNTHETIC_TOOLS = 2_209;
+
+export type JsonPrimitive = string | number | boolean | null;
+export type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
+export interface JsonObject {
+ [key: string]: JsonValue;
+}
+
+export interface JsonSchema {
+ type: "object" | "array" | "string" | "integer" | "number" | "boolean";
+ description?: string;
+ properties?: Readonly>;
+ required?: readonly string[];
+ additionalProperties?: boolean;
+ items?: JsonSchema;
+ enum?: readonly JsonPrimitive[];
+ minLength?: number;
+ maxLength?: number;
+ pattern?: string;
+ minimum?: number;
+ maximum?: number;
+ minItems?: number;
+ maxItems?: number;
+ uniqueItems?: boolean;
+}
+
+export interface OpenAIFunctionTool {
+ type: "function";
+ function: {
+ name: string;
+ description: string;
+ parameters: JsonSchema;
+ };
+}
+
+/**
+ * The categories are deliberately synthetic and vendor-neutral. Their labels,
+ * resources, and operations make every generated tool understandable without
+ * implying access to a real service or copying a production API.
+ */
+export const SYNTHETIC_CATEGORIES = [
+ {
+ id: "calendar",
+ label: "calendar",
+ resource: "event",
+ purpose: "appointments, availability, and event logistics",
+ operations: [
+ "find",
+ "schedule",
+ "reschedule",
+ "cancel",
+ "list_attendees",
+ "check_availability",
+ ],
+ },
+ {
+ id: "contacts",
+ label: "contacts",
+ resource: "contact",
+ purpose: "people and organization directory records",
+ operations: ["find", "create", "update", "merge", "list_groups", "verify"],
+ },
+ {
+ id: "email",
+ label: "email",
+ resource: "message",
+ purpose: "mailbox messages and threads",
+ operations: ["search", "draft", "send", "archive", "summarize", "list_attachments"],
+ },
+ {
+ id: "documents",
+ label: "documents",
+ resource: "document",
+ purpose: "text documents and their revisions",
+ operations: ["search", "create", "update", "compare", "export", "list_revisions"],
+ },
+ {
+ id: "storage",
+ label: "storage",
+ resource: "file",
+ purpose: "files, folders, and object metadata",
+ operations: ["find", "upload", "copy", "move", "share", "inspect_metadata"],
+ },
+ {
+ id: "messaging",
+ label: "messaging",
+ resource: "conversation",
+ purpose: "chat conversations and channel messages",
+ operations: ["search", "post", "reply", "list_members", "summarize", "pin"],
+ },
+ {
+ id: "projects",
+ label: "projects",
+ resource: "project",
+ purpose: "project plans, milestones, and ownership",
+ operations: ["find", "create", "update", "list_milestones", "assign_owner", "summarize"],
+ },
+ {
+ id: "issues",
+ label: "issues",
+ resource: "issue",
+ purpose: "work items, defects, and status tracking",
+ operations: ["search", "create", "update", "link", "list_blockers", "triage"],
+ },
+ {
+ id: "source_control",
+ label: "source control",
+ resource: "revision",
+ purpose: "repositories, revisions, and code review metadata",
+ operations: ["search", "inspect", "compare", "list_changes", "list_reviews", "summarize"],
+ },
+ {
+ id: "ci_cd",
+ label: "continuous integration",
+ resource: "pipeline",
+ purpose: "build pipelines, jobs, and deployment runs",
+ operations: ["find", "inspect", "retry", "cancel", "list_jobs", "summarize"],
+ },
+ {
+ id: "observability",
+ label: "observability",
+ resource: "signal",
+ purpose: "metrics, logs, traces, and service health",
+ operations: ["query", "inspect", "compare", "list_alerts", "summarize", "correlate"],
+ },
+ {
+ id: "incidents",
+ label: "incidents",
+ resource: "incident",
+ purpose: "operational incidents and response coordination",
+ operations: ["find", "create", "update", "list_responders", "summarize", "close"],
+ },
+ {
+ id: "infrastructure",
+ label: "infrastructure",
+ resource: "resource",
+ purpose: "deployed infrastructure and configuration state",
+ operations: ["find", "inspect", "compare", "list_dependencies", "validate", "summarize"],
+ },
+ {
+ id: "compute",
+ label: "compute",
+ resource: "compute allocation",
+ purpose: "compute capacity, allocations, and runtime state",
+ operations: ["find", "inspect", "list_capacity", "compare", "validate", "summarize"],
+ },
+ {
+ id: "databases",
+ label: "databases",
+ resource: "dataset",
+ purpose: "database objects, queries, and metadata",
+ operations: ["find", "describe", "query", "list_columns", "validate", "summarize"],
+ },
+ {
+ id: "analytics",
+ label: "analytics",
+ resource: "analysis",
+ purpose: "reports, measures, and analytical results",
+ operations: ["find", "calculate", "compare", "list_dimensions", "forecast", "summarize"],
+ },
+ {
+ id: "finance",
+ label: "finance",
+ resource: "financial record",
+ purpose: "budgets, transactions, and financial summaries",
+ operations: ["find", "calculate", "compare", "list_transactions", "validate", "summarize"],
+ },
+ {
+ id: "commerce",
+ label: "commerce",
+ resource: "order",
+ purpose: "orders, products, and fulfillment records",
+ operations: ["find", "create", "update", "list_items", "track", "summarize"],
+ },
+ {
+ id: "travel",
+ label: "travel",
+ resource: "itinerary",
+ purpose: "bookings, itineraries, and travel options",
+ operations: ["search", "inspect", "compare", "list_segments", "validate", "summarize"],
+ },
+ {
+ id: "maps",
+ label: "maps",
+ resource: "place",
+ purpose: "places, routes, and geographic information",
+ operations: ["search", "inspect", "route", "list_nearby", "compare", "summarize"],
+ },
+ {
+ id: "weather",
+ label: "weather",
+ resource: "forecast",
+ purpose: "forecasts, conditions, and weather alerts",
+ operations: ["find", "inspect", "compare", "list_alerts", "validate", "summarize"],
+ },
+ {
+ id: "research",
+ label: "research",
+ resource: "source",
+ purpose: "research sources, evidence, and citations",
+ operations: ["search", "inspect", "compare", "list_citations", "validate", "summarize"],
+ },
+ {
+ id: "knowledge",
+ label: "knowledge",
+ resource: "knowledge entry",
+ purpose: "knowledge-base entries and linked concepts",
+ operations: ["search", "inspect", "create", "update", "list_links", "summarize"],
+ },
+ {
+ id: "media",
+ label: "media",
+ resource: "media asset",
+ purpose: "audio, image, and video asset metadata",
+ operations: ["search", "inspect", "transcode", "list_variants", "compare", "summarize"],
+ },
+] as const;
+
+export type SyntheticCategoryId = (typeof SYNTHETIC_CATEGORIES)[number]["id"];
+export type SchemaComplexity = "small" | "medium" | "large";
+
+export interface SyntheticTool {
+ index: number;
+ category: SyntheticCategoryId;
+ operation: string;
+ complexity: SchemaComplexity;
+ definition: OpenAIFunctionTool;
+ handler: {
+ kind: "deterministic_fixture_v1";
+ key: string;
+ };
+}
+
+export interface SyntheticCatalog {
+ schema_version: typeof TOOL_DISCLOSURE_PERFORMANCE_TEST_CATALOG_SCHEMA_VERSION;
+ seed: string;
+ size: number;
+ max_size: typeof MAX_SYNTHETIC_TOOLS;
+ tools_sha256: string;
+ tools: SyntheticTool[];
+}
+
+export interface SyntheticCatalogOptions {
+ size?: number;
+ seed?: string | number;
+}
+
+export interface SyntheticToolResult extends JsonObject {
+ ok: true;
+ tool_name: string;
+ category: SyntheticCategoryId;
+ operation: string;
+ nonce: string;
+ arguments_sha256: string;
+ summary: string;
+}
+
+export class SyntheticToolInvocationError extends Error {}
+
+const TOOL_NAME_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
+// A 25/50/25 cycle makes the medium schema the catalog median while retaining
+// substantial small and large populations for scaling comparisons. The medium
+// definition is intentionally near the performance test's roughly 400-token target;
+// authoritative token counts still come from the pinned model tokenizer.
+const COMPLEXITY_CYCLE: readonly SchemaComplexity[] = [
+ "small",
+ "small",
+ "small",
+ "small",
+ "small",
+ "medium",
+ "medium",
+ "medium",
+ "medium",
+ "medium",
+ "medium",
+ "medium",
+ "medium",
+ "medium",
+ "medium",
+ "large",
+ "large",
+ "large",
+ "large",
+ "large",
+];
+
+export function canonicalJson(value: JsonValue): string {
+ return canonicalize(value, new Set