diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 2181115..5f52d4c 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -11,14 +11,140 @@ permissions: jobs: check: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + python: ['3.11', '3.14'] steps: - uses: actions/checkout@v7 with: persist-credentials: false - uses: actions/setup-python@v7 with: - python-version: '3.11' + python-version: ${{ matrix.python }} + - uses: dtolnay/rust-toolchain@stable - run: python -m pip install -e ".[dev]" - run: ruff check . - run: python -m pytest + - run: python -m pytest + env: + AGENTCHAT_BACKEND: python + + rust: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: '3.11' + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - run: cargo fmt --all --check + - run: cargo test --workspace --locked + - run: cargo clippy --workspace --all-targets --locked -- -D warnings + - run: cargo check -p agentchat --no-default-features --locked + - run: cargo check -p agentchat --features slack --examples --locked + + rust-minimum: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: '3.11' + - uses: dtolnay/rust-toolchain@1.88.0 + - run: cargo +1.88.0 check --workspace --all-targets --locked + + node: + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + node: ['22', '24'] + defaults: + run: + working-directory: packages/node + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node }} + cache: npm + cache-dependency-path: packages/node/package-lock.json + - uses: dtolnay/rust-toolchain@stable + - run: npm ci + - run: npm run build + - run: npm run typecheck + - run: npm test + - run: npm run smoke + - run: npm ci + working-directory: examples/node + - run: npm run typecheck + working-directory: examples/node + - run: npm test + working-directory: examples/node + + packages: + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, ubuntu-24.04-arm, macos-15, macos-15-intel, windows-latest] + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: '3.11' + - uses: actions/setup-node@v7 + with: + node-version: '24' + cache: npm + cache-dependency-path: packages/node/package-lock.json + - uses: dtolnay/rust-toolchain@stable + - run: python -m pip install 'maturin>=1.9,<2' + - uses: PyO3/maturin-action@v1 + with: + command: build + args: --release --locked --out dist + manylinux: auto + - name: Install and exercise built wheel + shell: bash + run: | + python -m pip install dist/*.whl + python scripts/python-package-smoke.py + - run: npm ci + working-directory: packages/node + - run: npm run build + working-directory: packages/node + - run: npm run smoke + working-directory: packages/node + + source-distribution: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: '3.11' + - uses: dtolnay/rust-toolchain@stable + - run: python -m pip install 'maturin>=1.9,<2' + - run: maturin sdist --out dist + - run: python -m pip install dist/*.tar.gz + - run: python scripts/python-package-smoke.py diff --git a/.gitignore b/.gitignore index 2e933e2..440f564 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,13 @@ __pycache__/ .pytest_cache/ .ruff_cache/ .venv/ +.venv-*/ build/ dist/ *.egg-info/ +target/ +node_modules/ +*.node +*.so +*.dylib +*.tgz diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ec23839 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2295 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "agentchat" +version = "0.1.0-alpha.1" +dependencies = [ + "agentchat-core", + "agentchat-slack", + "async-trait", + "serde_json", + "tokio", +] + +[[package]] +name = "agentchat-core" +version = "0.1.0-alpha.1" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "thiserror", + "tokio", +] + +[[package]] +name = "agentchat-node" +version = "0.1.0-alpha.1" +dependencies = [ + "agentchat-core", + "agentchat-slack", + "napi", + "napi-build", + "napi-derive", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "agentchat-python" +version = "0.1.0-alpha.1" +dependencies = [ + "agentchat-core", + "pyo3", +] + +[[package]] +name = "agentchat-slack" +version = "0.1.0-alpha.1" +dependencies = [ + "agentchat-core", + "async-trait", + "axum", + "futures-util", + "reqwest", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite 0.30.0", + "tokio-util", + "tracing", + "unicode-general-category", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "aws-lc-rs" +version = "1.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "base64 0.22.1", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1 0.10.7", + "sync_wrapper", + "tokio", + "tokio-tungstenite 0.29.0", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "convert_case" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1af709f1f33454bf52eadfc8c78b3b9ef9cb26fb54d16dc9cd9a7299f899fd1b" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "napi" +version = "3.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e2cf85c02d9b057898c485b824c3c416b65193689e2b7f517efc94b86ed24c" +dependencies = [ + "bitflags", + "ctor", + "futures", + "libc", + "napi-build", + "napi-sys", + "nohash-hasher", + "rustc-hash", + "tokio", +] + +[[package]] +name = "napi-build" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b899b545d3aa6dca985939059f258c5488d34e4ecf39c274e20009748f4b846d" + +[[package]] +name = "napi-derive" +version = "3.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92cec6048ac04041ac8055bdce1ec4f353416cd443ba9222bf73af790c3d4495" +dependencies = [ + "convert_case", + "ctor", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "napi-derive-backend" +version = "6.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fae2444f4ba7333754bb73414ff8059dec2cb23b83a95bd1a0bf08169eed1be0" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "semver", + "syn 3.0.9", +] + +[[package]] +name = "napi-sys" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22a4f25c16a5c5411d987cd6fbd48313522ae1789ade2d6dd3efdc6d40a0fc8" +dependencies = [ + "libloading", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "700d18fa267b73b9b521fd7e13580e2f446916f176cacee1ab63fcc8191f1655" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-build-config" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b3fc0c4d08f6bb10e71fe39dfb9e2f59c6eb6854e22ec8092f50c69a4499adb" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc0b8e19df29aad7086cf977bb0c2a2f143e30567eb113e9cf72b62ca698330" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6100e8a4b5eba53afaa5ed078364851a0b2499c553a44c31026b929049b49dc6" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6143877a16e82b5a727b7127ff4cd86858a24a28d745d72f43e6f227c7b1bdb3" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.29.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite 0.30.0", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1 0.10.7", + "thiserror", +] + +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.10.3", + "rustls", + "rustls-pki-types", + "sha1 0.11.0", + "thiserror", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..62c611e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,25 @@ +[workspace] +resolver = "2" +members = [ + "crates/agentchat", + "crates/agentchat-core", + "crates/agentchat-slack", + "bindings/python", + "bindings/node", +] + +[workspace.package] +version = "0.1.0-alpha.1" +edition = "2021" +rust-version = "1.88" +license = "MIT" +repository = "https://github.com/BerriAI/agentchat" + +[workspace.dependencies] +agentchat-core = { version = "=0.1.0-alpha.1", path = "crates/agentchat-core" } +agentchat-slack = { version = "=0.1.0-alpha.1", path = "crates/agentchat-slack" } +async-trait = "0.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "signal"] } diff --git a/README.md b/README.md index 643826f..e36e29b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # agentchat +AgentChat has a shared Rust core with compatible Python, Node.js/TypeScript and +Rust interfaces. The native Node/Rust channel currently targets Slack, including +the [Claude Agent SDK example](examples/node/README.md). Existing Python channel +integrations remain supported. See the [migration and support guide](docs/rust-core.md). + a minimal sdk to connect your agents to slack, teams, imessage, whatsapp, telegram. use any SDK (Claude Agents, OpenAI Agents, Langchain DeepAgents, Pydantic Agents) and instantly connect it to all supported channels. @@ -9,6 +14,9 @@ use any SDK (Claude Agents, OpenAI Agents, Langchain DeepAgents, Pydantic Agents ## Quickstart ### 1. Install + +Installing from this checkout requires a Rust toolchain. Published binary wheels +do not require a compiler. ```sh git clone https://github.com/BerriAI/agentchat.git cd agentchat diff --git a/bindings/node/Cargo.toml b/bindings/node/Cargo.toml new file mode 100644 index 0000000..8d0c8f5 --- /dev/null +++ b/bindings/node/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "agentchat-node" +version = "0.1.0-alpha.1" +edition = "2021" +rust-version = "1.88" +license = "MIT" +publish = false + +[lib] +name = "agentchat_node" +crate-type = ["cdylib"] +test = false +doctest = false + +[dependencies] +agentchat-core = { path = "../../crates/agentchat-core" } +agentchat-slack = { path = "../../crates/agentchat-slack" } +napi = { version = "3", features = ["napi8", "tokio_rt"] } +napi-derive = "3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["sync", "rt", "time"] } + +[build-dependencies] +napi-build = "2" diff --git a/bindings/node/build.rs b/bindings/node/build.rs new file mode 100644 index 0000000..0f1b010 --- /dev/null +++ b/bindings/node/build.rs @@ -0,0 +1,3 @@ +fn main() { + napi_build::setup(); +} diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs new file mode 100644 index 0000000..13e9505 --- /dev/null +++ b/bindings/node/src/lib.rs @@ -0,0 +1,473 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Weak}; +use std::time::Duration; + +use agentchat_core::{ + Attachment, Channel, ConversationId, Error as CoreError, Message, RichReply, Runtime, + UploadFile, +}; +use agentchat_slack::{Slack, SlackConfig}; +use napi::bindgen_prelude::*; +use napi::threadsafe_function::ThreadsafeFunction; +use napi_derive::napi; +use serde::Deserialize; +use tokio::sync::Mutex; +use tokio::task::JoinSet; + +type Handler = ThreadsafeFunction< + FnArgs<(u32, String)>, + Promise>, + FnArgs<(u32, String)>, + Status, + false, +>; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SlackOptions { + bot_token: String, + app_token: String, + workspace_id: Option, + bot_user_id: Option, + #[serde(default = "default_ack_emoji")] + ack_emoji: Option, + max_pending_events: Option, + shutdown_timeout_ms: Option, + api_base_url: Option, +} + +fn default_ack_emoji() -> Option { + Some("eyes".into()) +} + +#[napi(object)] +pub struct NativeUploadFile { + pub filename: String, + pub content: Buffer, + pub title: Option, +} + +#[napi(object)] +pub struct NativeDownloadedFile { + pub id: String, + pub name: String, + pub content: Buffer, + pub media_type: Option, +} + +#[napi] +pub struct NativeWorkingStatus { + inner: Arc>, +} + +#[napi] +impl NativeWorkingStatus { + #[napi] + pub async fn update(&self, status: String) -> bool { + self.inner.lock().await.update(&status).await + } + + #[napi] + pub async fn close(&self) { + self.inner.lock().await.close().await; + } +} + +fn native_error(error: impl std::fmt::Display) -> napi::Error { + napi::Error::from_reason(error.to_string()) +} + +fn decode_message(value: &str) -> napi::Result { + serde_json::from_str(value).map_err(native_error) +} + +fn encode_message(value: &Message) -> napi::Result { + serde_json::to_string(value).map_err(native_error) +} + +#[napi] +pub struct NativeAgentChat { + runtime: Arc, + channels: Vec>, + running: Arc, + closed: Arc, + closing: AtomicBool, + close_gate: Mutex<()>, + working: Mutex>>>, +} + +#[napi] +impl NativeAgentChat { + #[napi(constructor)] + pub fn new(channels_json: String) -> napi::Result { + let options: Vec = + serde_json::from_str(&channels_json).map_err(native_error)?; + if options.is_empty() { + return Err(native_error("AgentChat requires at least one channel")); + } + let channels = options + .into_iter() + .map(|options| { + let mut config = SlackConfig::new(options.bot_token, options.app_token); + config.workspace_id = options.workspace_id; + config.bot_user_id = options.bot_user_id; + config.ack_emoji = options.ack_emoji; + if let Some(limit) = options.max_pending_events { + config.max_pending_events = limit; + } + if let Some(timeout) = options.shutdown_timeout_ms { + config.shutdown_timeout = Duration::from_millis(timeout); + } + if let Some(url) = options.api_base_url { + config.api_base_url = url; + } + Slack::new(config).map(Arc::new).map_err(native_error) + }) + .collect::>>()?; + Ok(Self { + runtime: Arc::new(Runtime::new()), + channels, + running: Arc::new(AtomicBool::new(false)), + closed: Arc::new(AtomicBool::new(false)), + closing: AtomicBool::new(false), + close_gate: Mutex::new(()), + working: Mutex::new(Vec::new()), + }) + } + + #[napi( + ts_args_type = "handler: (channelIndex: number, messageJson: string) => Promise" + )] + pub async fn run(&self, handler: Arc) -> napi::Result<()> { + if self.closing.load(Ordering::Acquire) || self.closed.load(Ordering::Acquire) { + return Err(native_error("AgentChat is closed")); + } + if self.running.swap(true, Ordering::AcqRel) { + return Err(native_error("AgentChat is already running")); + } + let mut tasks = JoinSet::new(); + for (index, slack) in self.channels.iter().enumerate() { + let slack = Arc::clone(slack); + let runtime = Arc::clone(&self.runtime); + let handler = Arc::clone(&handler); + tasks.spawn(async move { + let channel: Arc = slack.clone(); + slack + .run(move |message| { + let runtime = Arc::clone(&runtime); + let channel = Arc::clone(&channel); + let handler = Arc::clone(&handler); + async move { + runtime + .receive(channel, message, move |context| async move { + let json = serde_json::to_string(&context.message) + .map_err(|error| CoreError::Handler(error.to_string()))?; + let promise = handler + .call_async_catch((index as u32, json).into()) + .await + .map_err(|error| CoreError::Handler(error.to_string()))?; + promise + .await + .map_err(|error| CoreError::Handler(error.to_string())) + }) + .await + } + }) + .await + }); + } + let mut first_error = None; + while let Some(result) = tasks.join_next().await { + match result { + Ok(Ok(())) => {} + Ok(Err(error)) => { + first_error = Some(native_error(error)); + break; + } + Err(error) => { + first_error = Some(native_error(error)); + break; + } + } + } + if first_error.is_some() { + let _ = self.close().await; + tasks.abort_all(); + while tasks.join_next().await.is_some() {} + } + self.running.store(false, Ordering::Release); + first_error.map_or(Ok(()), Err) + } + + #[napi] + pub async fn close(&self) -> napi::Result<()> { + self.closing.store(true, Ordering::Release); + for channel in &self.channels { + channel.stop_admission(); + } + let _close_guard = self.close_gate.lock().await; + if self.closed.load(Ordering::Acquire) { + return Ok(()); + } + let mut first_error = None; + let working: Vec<_> = self + .working + .lock() + .await + .drain(..) + .filter_map(|status| status.upgrade()) + .collect(); + let mut status_closures = JoinSet::new(); + for status in working { + status_closures.spawn(async move { status.lock().await.close().await }); + } + while let Some(result) = status_closures.join_next().await { + if let Err(error) = result { + first_error.get_or_insert_with(|| native_error(error)); + } + } + let mut closures = JoinSet::new(); + for channel in &self.channels { + let channel = Arc::clone(channel); + closures.spawn(async move { channel.close().await }); + } + while let Some(result) = closures.join_next().await { + match result { + Ok(Ok(())) => {} + Ok(Err(error)) => { + first_error.get_or_insert_with(|| native_error(error)); + } + Err(error) => { + first_error.get_or_insert_with(|| native_error(error)); + } + } + } + self.closed.store(true, Ordering::Release); + first_error.map_or(Ok(()), Err) + } + + #[napi] + pub async fn reply( + &self, + channel_index: u32, + source_json: String, + text: String, + ) -> napi::Result { + if self.closed.load(Ordering::Acquire) { + return Err(native_error("AgentChat is closed")); + } + let source = decode_message(&source_json)?; + let slack = self.channel(channel_index)?; + let channel: Arc = slack.clone(); + let reply = slack + .with_admitted_source(&source, self.runtime.reply(channel, &source, &text)) + .await + .map_err(native_error)?; + encode_message(&reply) + } + + #[napi] + pub async fn history( + &self, + conversation_id: String, + limit: Option, + ) -> napi::Result { + let history = self + .runtime + .history( + &ConversationId(conversation_id), + limit.map(|value| value as usize), + ) + .await + .map_err(native_error)?; + serde_json::to_string(&history).map_err(native_error) + } + + #[napi] + pub async fn thread_history( + &self, + channel_index: u32, + source_json: String, + limit: u32, + ) -> napi::Result { + let source = decode_message(&source_json)?; + let slack = self.channel(channel_index)?; + let channel: Arc = slack.clone(); + let context = + agentchat_core::MessageContext::new(Arc::clone(&self.runtime), channel, source.clone()); + let history = slack + .with_admitted_source(&source, context.history(limit as usize)) + .await + .map_err(native_error)?; + serde_json::to_string(&history).map_err(native_error) + } + + #[napi] + pub async fn subscribe(&self, channel_index: u32, source_json: String) -> napi::Result<()> { + let source = decode_message(&source_json)?; + let slack = self.channel(channel_index)?; + slack + .with_admitted_source(&source, slack.subscribe(&source)) + .await + .map_err(native_error) + } + + #[napi] + pub async fn reply_rich( + &self, + channel_index: u32, + source_json: String, + content_json: String, + ) -> napi::Result { + let source = decode_message(&source_json)?; + let content: RichReply = serde_json::from_str(&content_json).map_err(native_error)?; + let slack = self.channel(channel_index)?; + let channel: Arc = slack.clone(); + let reply = slack + .with_admitted_source(&source, self.runtime.reply_rich(channel, &source, &content)) + .await + .map_err(native_error)?; + encode_message(&reply) + } + + #[napi] + pub async fn upload_files( + &self, + channel_index: u32, + source_json: String, + files: Vec, + ) -> napi::Result { + let source = decode_message(&source_json)?; + let files: Vec<_> = files + .into_iter() + .map(|file| UploadFile { + filename: file.filename, + content: file.content.to_vec(), + title: file.title, + }) + .collect(); + let slack = self.channel(channel_index)?; + let channel: Arc = slack.clone(); + let uploaded = slack + .with_admitted_source(&source, self.runtime.upload_files(channel, &source, &files)) + .await + .map_err(native_error)?; + serde_json::to_string(&uploaded).map_err(native_error) + } + + #[napi] + pub async fn download_attachment( + &self, + channel_index: u32, + source_json: String, + attachment_json: String, + max_bytes: u32, + ) -> napi::Result { + let source = decode_message(&source_json)?; + let attachment: Attachment = + serde_json::from_str(&attachment_json).map_err(native_error)?; + let slack = self.channel(channel_index)?; + let channel: Arc = slack.clone(); + let file = slack + .with_admitted_source( + &source, + self.runtime + .download_attachment(channel, &source, &attachment, max_bytes as usize), + ) + .await + .map_err(native_error)?; + Ok(NativeDownloadedFile { + id: file.id, + name: file.name, + content: file.content.into(), + media_type: file.media_type, + }) + } + + #[napi] + pub async fn set_status( + &self, + channel_index: u32, + source_json: String, + status: String, + ) -> napi::Result { + let source = decode_message(&source_json)?; + let slack = self.channel(channel_index)?; + let channel: Arc = slack.clone(); + slack + .with_admitted_source(&source, self.runtime.set_status(channel, &source, &status)) + .await + .map_err(native_error) + } + + #[napi] + pub async fn working( + &self, + channel_index: u32, + source_json: String, + status: String, + refresh_interval_ms: u32, + ) -> napi::Result { + // Serialize creation with the shutdown snapshot so a pending status cannot + // appear after its channel transport has been closed. + let mut working = self.working.lock().await; + if self.closing.load(Ordering::Acquire) { + return Err(native_error("AgentChat is closed")); + } + let source = decode_message(&source_json)?; + let slack = self.channel(channel_index)?; + let channel: Arc = slack.clone(); + let context = + agentchat_core::MessageContext::new(Arc::clone(&self.runtime), channel, source.clone()); + let inner = Arc::new(Mutex::new( + slack + .with_admitted_source( + &source, + context.working(&status, Duration::from_millis(refresh_interval_ms.into())), + ) + .await + .map_err(native_error)?, + )); + if self.closing.load(Ordering::Acquire) { + inner.lock().await.close().await; + return Err(native_error("AgentChat is closed")); + } + working.retain(|status| status.strong_count() > 0); + working.push(Arc::downgrade(&inner)); + Ok(NativeWorkingStatus { inner }) + } + + #[napi] + pub async fn mirror( + &self, + channel_index: u32, + source_json: String, + message_json: String, + origin: String, + ) -> napi::Result { + let source = decode_message(&source_json)?; + let message = decode_message(&message_json)?; + let slack = self.channel(channel_index)?; + let channel: Arc = slack.clone(); + let mirrored = slack + .with_admitted_source( + &source, + self.runtime.mirror(channel, &source, &message, &origin), + ) + .await + .map_err(native_error)?; + encode_message(&mirrored) + } +} + +impl NativeAgentChat { + fn channel(&self, index: u32) -> napi::Result> { + if self.closed.load(Ordering::Acquire) { + return Err(native_error("AgentChat is closed")); + } + self.channels + .get(index as usize) + .cloned() + .ok_or_else(|| native_error("Unknown channel")) + } +} diff --git a/bindings/python/Cargo.toml b/bindings/python/Cargo.toml new file mode 100644 index 0000000..13e186c --- /dev/null +++ b/bindings/python/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "agentchat-python" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish = false + +[lib] +name = "_native" +crate-type = ["cdylib"] +test = false +doctest = false + +[dependencies] +agentchat-core = { path = "../../crates/agentchat-core" } +pyo3 = { version = "0.29", features = ["abi3-py311"] } diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs new file mode 100644 index 0000000..242dc06 --- /dev/null +++ b/bindings/python/src/lib.rs @@ -0,0 +1,163 @@ +//! Python drives host effects on its existing task; Rust owns turn transitions. +//! +//! Keeping host objects outside this boundary preserves dataclasses, arbitrary +//! metadata, custom State context managers, and the original exception traceback. + +use std::sync::Arc; + +use agentchat_core::{MemoryStore, Turn, TurnEffect, TurnResult}; +use pyo3::exceptions::{PyRuntimeError, PyValueError}; +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyTuple}; +use pyo3::{PyTraverseError, PyVisit}; + +#[pyclass(eq, eq_int, skip_from_py_object, module = "agentchat._native")] +#[derive(Clone, Copy, PartialEq, Eq)] +enum Effect { + Claim, + AcquireConversation, + AppendIncoming, + InvokeHandler, + SendReply, + AppendReply, + Complete, +} + +#[pyclass(module = "agentchat._native")] +struct NativeTurn { + turn: Turn, +} + +impl NativeTurn { + fn resolve(&mut self, result: TurnResult) -> PyResult<()> { + self.turn + .resolve(result) + .map_err(|error| PyRuntimeError::new_err(error.to_string())) + } +} + +#[pymethods] +impl NativeTurn { + #[new] + fn new() -> Self { + Self { turn: Turn::new() } + } + + #[getter] + fn effect(&self) -> Effect { + match self.turn.effect() { + TurnEffect::Claim => Effect::Claim, + TurnEffect::AcquireConversation => Effect::AcquireConversation, + TurnEffect::AppendIncoming => Effect::AppendIncoming, + TurnEffect::InvokeHandler => Effect::InvokeHandler, + TurnEffect::SendReply(_) => Effect::SendReply, + TurnEffect::AppendReply => Effect::AppendReply, + TurnEffect::Complete => Effect::Complete, + } + } + + #[getter] + fn reply_text(&self) -> Option { + match self.turn.effect() { + TurnEffect::SendReply(text) => Some(text.clone()), + _ => None, + } + } + + fn claimed(&mut self, accepted: bool) -> PyResult<()> { + self.resolve(TurnResult::Claimed(accepted)) + } + + fn acquired(&mut self) -> PyResult<()> { + self.resolve(TurnResult::ConversationAcquired) + } + + fn incoming_appended(&mut self) -> PyResult<()> { + self.resolve(TurnResult::IncomingAppended) + } + + #[pyo3(signature = (content=None))] + fn handler_completed(&mut self, content: Option) -> PyResult<()> { + self.resolve(TurnResult::HandlerCompleted(content)) + } + + fn reply_sent(&mut self) -> PyResult<()> { + self.resolve(TurnResult::ReplySent) + } + + fn reply_appended(&mut self) -> PyResult<()> { + self.resolve(TurnResult::ReplyAppended) + } +} + +#[pyfunction] +fn thread_history_indices(length: usize, limit: usize) -> PyResult> { + agentchat_core::thread_history_indices(length, limit) + .map_err(|error| PyValueError::new_err(error.to_string())) +} + +/// The core retains owned Python objects without converting their metadata. +/// The dictionary is a live compatibility view used by MemoryState.messages. +#[pyclass(module = "agentchat._native")] +struct NativeMemoryStore { + store: MemoryStore>>, + view: Option>, +} + +#[pymethods] +impl NativeMemoryStore { + #[new] + fn new(py: Python<'_>) -> Self { + Self { + store: MemoryStore::new(), + view: Some(PyDict::new(py).unbind()), + } + } + + fn claim(&mut self, message_id: &str) -> bool { + self.store.claim(&message_id.into()) + } + + fn append(&mut self, py: Python<'_>, conversation_id: &str, value: Py) -> PyResult<()> { + let conversation = conversation_id.into(); + self.store.append(&conversation, Arc::new(value)); + let values = self.store.history(&conversation, None); + let tuple = PyTuple::new(py, values.iter().map(|value| value.bind(py)))?; + self.view + .as_ref() + .ok_or_else(|| PyRuntimeError::new_err("Memory state has been cleared"))? + .bind(py) + .set_item(conversation_id, tuple) + } + + #[getter] + fn messages(&self, py: Python<'_>) -> PyResult> { + self.view + .as_ref() + .map(|view| view.clone_ref(py)) + .ok_or_else(|| PyRuntimeError::new_err("Memory state has been cleared")) + } + + fn __traverse__(&self, visit: PyVisit<'_>) -> std::result::Result<(), PyTraverseError> { + visit.call(&self.view)?; + for value in self.store.values() { + visit.call(value.as_ref())?; + } + Ok(()) + } + + fn __clear__(&mut self) { + self.store = MemoryStore::new(); + self.view = None; + } +} + +#[pymodule] +fn _native(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_function(wrap_pyfunction!(thread_history_indices, module)?)?; + module.add("core_version", env!("CARGO_PKG_VERSION"))?; + Ok(()) +} diff --git a/conformance/slack-events.json b/conformance/slack-events.json new file mode 100644 index 0000000..2e6e7d9 --- /dev/null +++ b/conformance/slack-events.json @@ -0,0 +1,529 @@ +[ + { + "name": "channel mention", + "workspace_id": "T1", + "bot_user_id": "UBOT", + "payload": { + "team_id": "T1", + "event_id": "Ev1", + "event": { + "type": "app_mention", + "user": "U1", + "channel": "C1", + "ts": "1730000000.000001", + "text": "<@UBOT> hello" + } + }, + "expected": { + "id": "slack:T1:C1:1730000000.000001", + "conversation_id": "slack:T1:C1:1730000000.000001", + "channel": "slack", + "sender": { + "id": "U1", + "display_name": null + }, + "text": "hello", + "role": "user", + "metadata": { + "channel_id": "C1", + "team_id": "T1", + "event_id": "Ev1", + "channel_type": "channel", + "reply_thread_timestamp": "1730000000.000001", + "message_timestamp": "1730000000.000001", + "requires_subscription": false + }, + "thread_id": "1730000000.000001", + "addressed": true, + "attachments": [] + } + }, + { + "name": "direct message", + "workspace_id": "T1", + "bot_user_id": "UBOT", + "payload": { + "team_id": "T1", + "event_id": "Ev1", + "event": { + "type": "message", + "user": "U1", + "channel": "D1", + "ts": "1730000000.000001", + "text": "hello", + "channel_type": "im" + } + }, + "expected": { + "id": "slack:T1:D1:1730000000.000001", + "conversation_id": "slack:T1:D1", + "channel": "slack", + "sender": { + "id": "U1", + "display_name": null + }, + "text": "hello", + "role": "user", + "metadata": { + "channel_id": "D1", + "team_id": "T1", + "event_id": "Ev1", + "channel_type": "im", + "reply_thread_timestamp": null, + "message_timestamp": "1730000000.000001", + "requires_subscription": false + }, + "thread_id": null, + "addressed": true, + "attachments": [] + } + }, + { + "name": "threaded direct message", + "workspace_id": "T1", + "bot_user_id": "UBOT", + "payload": { + "team_id": "T1", + "event_id": "Ev1", + "event": { + "type": "message", + "user": "U1", + "channel": "D1", + "ts": "1730000000.000001", + "text": "hello", + "channel_type": "im", + "thread_ts": "1729999999.123456" + } + }, + "expected": { + "id": "slack:T1:D1:1730000000.000001", + "conversation_id": "slack:T1:D1", + "channel": "slack", + "sender": { + "id": "U1", + "display_name": null + }, + "text": "hello", + "role": "user", + "metadata": { + "channel_id": "D1", + "team_id": "T1", + "event_id": "Ev1", + "channel_type": "im", + "reply_thread_timestamp": "1729999999.123456", + "message_timestamp": "1730000000.000001", + "requires_subscription": false + }, + "thread_id": "1729999999.123456", + "addressed": true, + "attachments": [] + } + }, + { + "name": "followed thread candidate", + "workspace_id": "T1", + "bot_user_id": "UBOT", + "payload": { + "team_id": "T1", + "event_id": "Ev1", + "event": { + "type": "message", + "user": "U1", + "channel": "C1", + "ts": "1730000000.000001", + "text": "continue", + "channel_type": "channel", + "thread_ts": "1729999999.123456" + } + }, + "expected": { + "id": "slack:T1:C1:1730000000.000001", + "conversation_id": "slack:T1:C1:1729999999.123456", + "channel": "slack", + "sender": { + "id": "U1", + "display_name": null + }, + "text": "continue", + "role": "user", + "metadata": { + "channel_id": "C1", + "team_id": "T1", + "event_id": "Ev1", + "channel_type": "channel", + "reply_thread_timestamp": "1729999999.123456", + "message_timestamp": "1730000000.000001", + "requires_subscription": true + }, + "thread_id": "1729999999.123456", + "addressed": false, + "attachments": [] + } + }, + { + "name": "group explicit mention", + "workspace_id": "T1", + "bot_user_id": "UBOT", + "payload": { + "team_id": "T1", + "event_id": "Ev1", + "event": { + "type": "message", + "user": "U1", + "channel": "C1", + "ts": "1730000000.000001", + "text": "hi <@UBOT> again", + "channel_type": "group" + } + }, + "expected": { + "id": "slack:T1:C1:1730000000.000001", + "conversation_id": "slack:T1:C1:1730000000.000001", + "channel": "slack", + "sender": { + "id": "U1", + "display_name": null + }, + "text": "hi again", + "role": "user", + "metadata": { + "channel_id": "C1", + "team_id": "T1", + "event_id": "Ev1", + "channel_type": "group", + "reply_thread_timestamp": "1730000000.000001", + "message_timestamp": "1730000000.000001", + "requires_subscription": false + }, + "thread_id": "1730000000.000001", + "addressed": true, + "attachments": [] + } + }, + { + "name": "workspace isolation", + "workspace_id": "T1", + "bot_user_id": "UBOT", + "payload": { + "team_id": "T2", + "event_id": "Ev1", + "event": { + "type": "app_mention", + "user": "U1", + "channel": "C1", + "ts": "1730000000.000001", + "text": "<@UBOT> hello" + } + }, + "expected": null + }, + { + "name": "bot echo ignored", + "workspace_id": "T1", + "bot_user_id": "UBOT", + "payload": { + "team_id": "T1", + "event_id": "Ev1", + "event": { + "type": "app_mention", + "user": "U1", + "channel": "C1", + "ts": "1730000000.000001", + "text": "<@UBOT> hello", + "bot_id": "B1" + } + }, + "expected": null + }, + { + "name": "own message ignored", + "workspace_id": "T1", + "bot_user_id": "UBOT", + "payload": { + "team_id": "T1", + "event_id": "Ev1", + "event": { + "type": "app_mention", + "user": "UBOT", + "channel": "C1", + "ts": "1730000000.000001", + "text": "<@UBOT> hello" + } + }, + "expected": null + }, + { + "name": "edit ignored", + "workspace_id": "T1", + "bot_user_id": "UBOT", + "payload": { + "team_id": "T1", + "event_id": "Ev1", + "event": { + "type": "app_mention", + "user": "U1", + "channel": "C1", + "ts": "1730000000.000001", + "text": "<@UBOT> hello", + "subtype": "message_changed" + } + }, + "expected": null + }, + { + "name": "malformed thread ignored", + "workspace_id": "T1", + "bot_user_id": "UBOT", + "payload": { + "team_id": "T1", + "event_id": "Ev1", + "event": { + "type": "app_mention", + "user": "U1", + "channel": "C1", + "ts": "1730000000.000001", + "text": "<@UBOT> hello", + "thread_ts": false + } + }, + "expected": null + }, + { + "name": "unaddressed root ignored", + "workspace_id": "T1", + "bot_user_id": "UBOT", + "payload": { + "team_id": "T1", + "event_id": "Ev1", + "event": { + "type": "message", + "user": "U1", + "channel": "C1", + "ts": "1730000000.000001", + "text": "hello", + "channel_type": "channel" + } + }, + "expected": null + }, + { + "name": "attachment only and sanitized names", + "workspace_id": "T1", + "bot_user_id": "UBOT", + "payload": { + "team_id": "T1", + "event_id": "Ev1", + "event": { + "type": "app_mention", + "user": "U1", + "channel": "C1", + "ts": "1730000000.000001", + "text": "<@UBOT>", + "subtype": "file_share", + "files": [ + { + "id": "F123", + "name": "../one\u0000.txt", + "mimetype": "text/plain", + "size": true + }, + { + "id": "F123", + "name": "duplicate" + }, + { + "id": "bad", + "name": "ignored" + } + ] + } + }, + "expected": { + "id": "slack:T1:C1:1730000000.000001", + "conversation_id": "slack:T1:C1:1730000000.000001", + "channel": "slack", + "sender": { + "id": "U1", + "display_name": null + }, + "text": "", + "role": "user", + "metadata": { + "channel_id": "C1", + "team_id": "T1", + "event_id": "Ev1", + "channel_type": "channel", + "reply_thread_timestamp": "1730000000.000001", + "message_timestamp": "1730000000.000001", + "requires_subscription": false + }, + "thread_id": "1730000000.000001", + "addressed": true, + "attachments": [ + { + "id": "F123", + "name": "one.txt", + "media_type": "text/plain", + "size": null + } + ] + } + }, + { + "name": "null channel type preserved", + "workspace_id": "T1", + "bot_user_id": "UBOT", + "payload": { + "team_id": "T1", + "event_id": "Ev1", + "event": { + "type": "app_mention", + "user": "U1", + "channel": "C1", + "ts": "1730000000.000001", + "text": "<@UBOT> hello", + "channel_type": null + } + }, + "expected": { + "id": "slack:T1:C1:1730000000.000001", + "conversation_id": "slack:T1:C1:1730000000.000001", + "channel": "slack", + "sender": { + "id": "U1", + "display_name": null + }, + "text": "hello", + "role": "user", + "metadata": { + "channel_id": "C1", + "team_id": "T1", + "event_id": "Ev1", + "channel_type": null, + "reply_thread_timestamp": "1730000000.000001", + "message_timestamp": "1730000000.000001", + "requires_subscription": false + }, + "thread_id": "1730000000.000001", + "addressed": true, + "attachments": [] + } + }, + { + "name": "python whitespace normalization", + "workspace_id": "T1", + "bot_user_id": "UBOT", + "payload": { + "team_id": "T1", + "event_id": "Ev1", + "event": { + "type": "app_mention", + "user": "U1", + "channel": "C1", + "ts": "1730000000.000001", + "text": "\u001c<@UBOT> hello\u001f" + } + }, + "expected": { + "id": "slack:T1:C1:1730000000.000001", + "conversation_id": "slack:T1:C1:1730000000.000001", + "channel": "slack", + "sender": { + "id": "U1", + "display_name": null + }, + "text": "hello", + "role": "user", + "metadata": { + "channel_id": "C1", + "team_id": "T1", + "event_id": "Ev1", + "channel_type": "channel", + "reply_thread_timestamp": "1730000000.000001", + "message_timestamp": "1730000000.000001", + "requires_subscription": false + }, + "thread_id": "1730000000.000001", + "addressed": true, + "attachments": [] + } + }, + { + "name": "timestamps stay lossless", + "workspace_id": "T1", + "bot_user_id": "UBOT", + "payload": { + "team_id": "T1", + "event_id": "Ev1", + "event": { + "type": "app_mention", + "user": "U1", + "channel": "C1", + "ts": "99999999999999999.000001", + "text": "<@UBOT> hello" + } + }, + "expected": { + "id": "slack:T1:C1:99999999999999999.000001", + "conversation_id": "slack:T1:C1:99999999999999999.000001", + "channel": "slack", + "sender": { + "id": "U1", + "display_name": null + }, + "text": "hello", + "role": "user", + "metadata": { + "channel_id": "C1", + "team_id": "T1", + "event_id": "Ev1", + "channel_type": "channel", + "reply_thread_timestamp": "99999999999999999.000001", + "message_timestamp": "99999999999999999.000001", + "requires_subscription": false + }, + "thread_id": "99999999999999999.000001", + "addressed": true, + "attachments": [] + } + }, + { + "name": "unicode text", + "workspace_id": "T1", + "bot_user_id": "UBOT", + "payload": { + "team_id": "T1", + "event_id": "Ev1", + "event": { + "type": "app_mention", + "user": "U1", + "channel": "C1", + "ts": "1730000000.000001", + "text": "<@UBOT> hi \ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d\udc66\n\u05e9\u05dc\u05d5\u05dd" + } + }, + "expected": { + "id": "slack:T1:C1:1730000000.000001", + "conversation_id": "slack:T1:C1:1730000000.000001", + "channel": "slack", + "sender": { + "id": "U1", + "display_name": null + }, + "text": "hi \ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d\udc66\n\u05e9\u05dc\u05d5\u05dd", + "role": "user", + "metadata": { + "channel_id": "C1", + "team_id": "T1", + "event_id": "Ev1", + "channel_type": "channel", + "reply_thread_timestamp": "1730000000.000001", + "message_timestamp": "1730000000.000001", + "requires_subscription": false + }, + "thread_id": "1730000000.000001", + "addressed": true, + "attachments": [] + } + } +] diff --git a/crates/agentchat-core/Cargo.toml b/crates/agentchat-core/Cargo.toml new file mode 100644 index 0000000..4d601c9 --- /dev/null +++ b/crates/agentchat-core/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "agentchat-core" +description = "Shared typed message and conversation runtime for AgentChat" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +async-trait.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/agentchat-core/src/activity.rs b/crates/agentchat-core/src/activity.rs new file mode 100644 index 0000000..4c4568b --- /dev/null +++ b/crates/agentchat-core/src/activity.rs @@ -0,0 +1,107 @@ +use std::{sync::Arc, time::Duration}; + +use tokio::{sync::Mutex, task::JoinHandle}; + +use crate::{Channel, Error, Message, Result}; + +const STATUS_TIMEOUT: Duration = Duration::from_secs(3); + +/// Refreshes a transient indicator without putting it in message history. +/// +/// Call [`Self::close`] to await clearing the indicator before shutting down its +/// transport. Rust cannot await in `Drop`: dropping aborts refresh and schedules +/// best-effort clearing only when a Tokio runtime remains available. Transport +/// failures never replace an application error or prevent an answer. +#[must_use = "call close().await to ensure the working indicator is cleared"] +pub struct WorkingStatus { + channel: Arc, + source: Message, + status: Arc>, + worker: Option>, + closed: bool, +} + +async fn send(channel: &dyn Channel, source: &Message, status: &str) -> bool { + matches!( + tokio::time::timeout(STATUS_TIMEOUT, channel.set_status(source, status)).await, + Ok(Ok(true)) + ) +} + +impl WorkingStatus { + pub async fn start( + channel: Arc, + source: Message, + status: String, + refresh_interval: Duration, + ) -> Result { + if refresh_interval.is_zero() || refresh_interval > Duration::from_secs(90) { + return Err(Error::InvalidInput( + "Refresh interval must be greater than 0 and at most 90 seconds".into(), + )); + } + let status = Arc::new(Mutex::new(status)); + send(channel.as_ref(), &source, &status.lock().await).await; + let worker_channel = channel.clone(); + let worker_source = source.clone(); + let worker_status = status.clone(); + let worker = tokio::spawn(async move { + loop { + tokio::time::sleep(refresh_interval).await; + let current = worker_status.lock().await; + send(worker_channel.as_ref(), &worker_source, ¤t).await; + } + }); + Ok(Self { + channel, + source, + status, + worker: Some(worker), + closed: false, + }) + } + + /// Update the label used for future refreshes. Unsupported/outage returns false. + pub async fn update(&self, status: &str) -> bool { + if self.closed { + return false; + } + let mut current = self.status.lock().await; + *current = status.to_owned(); + send(self.channel.as_ref(), &self.source, ¤t).await + } + + pub async fn close(&mut self) { + if self.closed { + return; + } + if let Some(worker) = self.worker.take() { + worker.abort(); + let _ = worker.await; + } + self.update("").await; + self.closed = true; + } +} + +impl Drop for WorkingStatus { + fn drop(&mut self) { + if self.closed { + return; + } + let worker = self.worker.take(); + if let Some(worker) = &worker { + worker.abort(); + } + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + let channel = self.channel.clone(); + let source = self.source.clone(); + runtime.spawn(async move { + if let Some(worker) = worker { + let _ = worker.await; + } + send(channel.as_ref(), &source, "").await; + }); + } + } +} diff --git a/crates/agentchat-core/src/channel.rs b/crates/agentchat-core/src/channel.rs new file mode 100644 index 0000000..62593cb --- /dev/null +++ b/crates/agentchat-core/src/channel.rs @@ -0,0 +1,61 @@ +use async_trait::async_trait; + +use crate::{ + Attachment, DownloadedFile, Error, Message, Result, RichReply, UploadFile, UploadedFile, +}; + +/// A transport returns a message only after provider confirmation. +/// Failed or ambiguous sends must not be transparently retried. +#[async_trait] +pub trait Channel: Send + Sync { + fn name(&self) -> &str; + + async fn reply(&self, source: &Message, content: &str) -> Result; + + async fn close(&self) -> Result<()> { + Ok(()) + } + + /// `None` means unsupported; errors from supported history must propagate. + async fn thread_history( + &self, + _source: &Message, + _limit: usize, + ) -> Result>> { + Ok(None) + } + + async fn reply_rich(&self, _source: &Message, _content: &RichReply) -> Result { + Err(Error::Unsupported("rich replies")) + } + + async fn upload_files( + &self, + _source: &Message, + _files: &[UploadFile], + ) -> Result> { + Err(Error::Unsupported("file uploads")) + } + + async fn download_attachment( + &self, + _source: &Message, + _attachment: &Attachment, + _max_bytes: usize, + ) -> Result { + Err(Error::Unsupported("attachment downloads")) + } + + async fn set_status(&self, _source: &Message, _status: &str) -> Result { + Ok(false) + } + + async fn mirror( + &self, + _source: &Message, + _message: &Message, + _origin: &str, + ) -> Result { + Err(Error::Unsupported("mirroring")) + } +} diff --git a/crates/agentchat-core/src/context.rs b/crates/agentchat-core/src/context.rs new file mode 100644 index 0000000..061abc3 --- /dev/null +++ b/crates/agentchat-core/src/context.rs @@ -0,0 +1,106 @@ +use std::sync::Arc; + +use crate::{ + AgentChat, Attachment, Channel, ConversationId, DownloadedFile, HistoryLimit, Message, Result, + RichReply, Sender, UploadFile, UploadedFile, +}; + +#[derive(Clone)] +pub struct Conversation { + pub id: ConversationId, + pub(crate) app: Arc, +} + +impl Conversation { + pub async fn history(&self, limit: Option) -> Result> { + self.app.history(&self.id, limit).await + } +} + +#[derive(Clone)] +pub struct MessageContext { + pub message: Message, + pub sender: Sender, + pub conversation: Conversation, + pub(crate) app: Arc, + pub(crate) channel: Arc, +} + +impl MessageContext { + /// Begin an indicator; await its `close()` before closing the channel. + pub async fn working( + &self, + status: &str, + refresh_interval: std::time::Duration, + ) -> Result { + crate::WorkingStatus::start( + self.channel.clone(), + self.message.clone(), + status.to_owned(), + refresh_interval, + ) + .await + } + + pub fn new(app: Arc, channel: Arc, message: Message) -> Self { + Self { + sender: message.sender.clone(), + conversation: Conversation { + id: message.conversation_id.clone(), + app: app.clone(), + }, + message, + app, + channel, + } + } + + pub async fn history(&self, limit: usize) -> Result> { + let limit = HistoryLimit::new(limit)?; + if self.message.thread_id.is_some() { + if let Some(mut previous) = self + .channel + .thread_history(&self.message, limit.get()) + .await? + { + previous.push(self.message.clone()); + return Ok(limit.select(&previous)); + } + } + self.conversation.history(Some(limit.get())).await + } + + pub async fn reply(&self, content: &str) -> Result { + self.app + .reply(self.channel.clone(), &self.message, content) + .await + } + + pub async fn reply_rich(&self, content: &RichReply) -> Result { + self.app + .reply_rich(self.channel.clone(), &self.message, content) + .await + } + + pub async fn upload_files(&self, files: &[UploadFile]) -> Result> { + self.app + .upload_files(self.channel.clone(), &self.message, files) + .await + } + + pub async fn download_attachment( + &self, + attachment: &Attachment, + max_bytes: usize, + ) -> Result { + self.app + .download_attachment(self.channel.clone(), &self.message, attachment, max_bytes) + .await + } + + pub async fn set_status(&self, status: &str) -> Result { + self.app + .set_status(self.channel.clone(), &self.message, status) + .await + } +} diff --git a/crates/agentchat-core/src/error.rs b/crates/agentchat-core/src/error.rs new file mode 100644 index 0000000..727c9b1 --- /dev/null +++ b/crates/agentchat-core/src/error.rs @@ -0,0 +1,18 @@ +/// Errors native to AgentChat. Host exceptions remain owned by the language bridge. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("{0}")] + InvalidInput(String), + #[error("This channel does not support {0}")] + Unsupported(&'static str), + #[error("{0}")] + Transport(String), + #[error("{0}")] + Handler(String), + #[error("Channel is closed")] + Closed, + #[error("Invalid turn transition: {0}")] + InvalidTransition(String), +} + +pub type Result = std::result::Result; diff --git a/crates/agentchat-core/src/history.rs b/crates/agentchat-core/src/history.rs new file mode 100644 index 0000000..fd09538 --- /dev/null +++ b/crates/agentchat-core/src/history.rs @@ -0,0 +1,46 @@ +use crate::{Error, Result}; + +/// Validated context-history bound, shared by runtimes and provider adapters. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HistoryLimit(usize); + +impl HistoryLimit { + pub fn new(limit: usize) -> Result { + if !(2..=100).contains(&limit) { + return Err(Error::InvalidInput( + "History limit must be between 2 and 100".into(), + )); + } + Ok(Self(limit)) + } + + pub fn get(self) -> usize { + self.0 + } + + pub fn indices(self, length: usize) -> Vec { + if length <= self.0 { + return (0..length).collect(); + } + std::iter::once(0) + .chain(length - (self.0 - 1)..length) + .collect() + } + + pub fn select(self, messages: &[T]) -> Vec { + self.indices(messages.len()) + .into_iter() + .map(|index| messages[index].clone()) + .collect() + } +} + +/// Indices into provider history plus the current turn. Keep the root and tail. +/// Hosts use these indices without copying opaque language objects through Rust. +pub fn thread_history_indices(length: usize, limit: usize) -> Result> { + Ok(HistoryLimit::new(limit)?.indices(length)) +} + +pub fn select_history(messages: &[T], limit: usize) -> Result> { + Ok(HistoryLimit::new(limit)?.select(messages)) +} diff --git a/crates/agentchat-core/src/lib.rs b/crates/agentchat-core/src/lib.rs new file mode 100644 index 0000000..0351784 --- /dev/null +++ b/crates/agentchat-core/src/lib.rs @@ -0,0 +1,24 @@ +//! Shared policy and an asynchronous runtime for application-owned agents. +//! +//! The turn state machine is also driven by foreign-language hosts. It owns the +//! ordering policy while effects and user callbacks execute on their host loop. + +mod activity; +mod channel; +mod context; +mod error; +mod history; +mod models; +mod runtime; +mod state; +mod turn; + +pub use activity::WorkingStatus; +pub use channel::Channel; +pub use context::{Conversation, MessageContext}; +pub use error::{Error, Result}; +pub use history::{select_history, thread_history_indices, HistoryLimit}; +pub use models::*; +pub use runtime::{AgentChat, Handler, Runtime}; +pub use state::{ConversationPermit, MemoryState, MemoryStore, State}; +pub use turn::{Turn, TurnEffect, TurnResult}; diff --git a/crates/agentchat-core/src/models.rs b/crates/agentchat-core/src/models.rs new file mode 100644 index 0000000..25c0e77 --- /dev/null +++ b/crates/agentchat-core/src/models.rs @@ -0,0 +1,134 @@ +use std::fmt; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +macro_rules! identifier { + ($name:ident) => { + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] + #[serde(transparent)] + pub struct $name(pub String); + + impl From for $name { + fn from(value: String) -> Self { + Self(value) + } + } + impl From<&str> for $name { + fn from(value: &str) -> Self { + Self(value.to_owned()) + } + } + impl AsRef for $name { + fn as_ref(&self) -> &str { + &self.0 + } + } + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } + } + }; +} + +identifier!(MessageId); +identifier!(ConversationId); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Role { + User, + Assistant, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Sender { + pub id: String, + #[serde(default)] + pub display_name: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Attachment { + pub id: String, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub media_type: Option, + #[serde(default)] + pub size: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Message { + pub id: MessageId, + pub conversation_id: ConversationId, + pub channel: String, + pub sender: Sender, + pub text: String, + pub role: Role, + #[serde(default)] + pub metadata: Map, + #[serde(default)] + pub thread_id: Option, + #[serde(default = "addressed_by_default")] + pub addressed: bool, + #[serde(default)] + pub attachments: Vec, +} + +fn addressed_by_default() -> bool { + true +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RichReply { + pub text: String, + #[serde(default)] + pub blocks: Vec>, + #[serde(default)] + pub attachments: Vec>, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct UploadFile { + pub filename: String, + pub content: Vec, + pub title: Option, +} + +impl fmt::Debug for UploadFile { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("UploadFile") + .field("filename", &self.filename) + .field("content_length", &self.content.len()) + .field("title", &self.title) + .finish_non_exhaustive() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UploadedFile { + pub id: String, + pub permalink: Option, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct DownloadedFile { + pub id: String, + pub name: String, + pub content: Vec, + pub media_type: Option, +} + +impl fmt::Debug for DownloadedFile { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DownloadedFile") + .field("id", &self.id) + .field("name", &self.name) + .field("content_length", &self.content.len()) + .field("media_type", &self.media_type) + .finish_non_exhaustive() + } +} diff --git a/crates/agentchat-core/src/runtime.rs b/crates/agentchat-core/src/runtime.rs new file mode 100644 index 0000000..c9dc14a --- /dev/null +++ b/crates/agentchat-core/src/runtime.rs @@ -0,0 +1,196 @@ +use std::{future::Future, sync::Arc}; + +use async_trait::async_trait; + +use crate::{ + Attachment, Channel, ConversationId, ConversationPermit, DownloadedFile, Error, MemoryState, + Message, MessageContext, Result, RichReply, Role, State, Turn, TurnEffect, TurnResult, + UploadFile, UploadedFile, +}; + +/// An application handler; callbacks remain application-owned, never model-owned. +#[async_trait] +pub trait Handler: Send + Sync { + async fn handle(&self, context: MessageContext) -> Result>; +} + +#[async_trait] +impl Handler for F +where + F: Fn(MessageContext) -> Fut + Send + Sync, + Fut: Future>> + Send, +{ + async fn handle(&self, context: MessageContext) -> Result> { + self(context).await + } +} + +/// The runtime deliberately does not own the Tokio runtime or agent framework. +pub struct AgentChat { + state: Arc, +} + +pub type Runtime = AgentChat; + +impl Default for AgentChat { + fn default() -> Self { + Self::new() + } +} + +impl AgentChat { + pub fn new() -> Self { + Self::with_state(Arc::new(MemoryState::new())) + } + + pub fn with_state(state: Arc) -> Self { + Self { state } + } + + pub fn state(&self) -> &Arc { + &self.state + } + + pub async fn receive( + self: &Arc, + channel: Arc, + message: Message, + handler: F, + ) -> Result<()> + where + F: FnOnce(MessageContext) -> Fut, + Fut: Future>>, + { + let mut turn = Turn::new(); + let mut handler = Some(handler); + let mut confirmed_reply = None; + // Keep the permit until every effect completes, including reply append. + let mut _permit: Option> = None; + loop { + let outcome = match turn.effect() { + TurnEffect::Claim => TurnResult::Claimed(self.state.claim(&message.id).await?), + TurnEffect::AcquireConversation => { + _permit = Some(self.state.acquire(&message.conversation_id).await?); + TurnResult::ConversationAcquired + } + TurnEffect::AppendIncoming => { + self.state.append(message.clone()).await?; + TurnResult::IncomingAppended + } + TurnEffect::InvokeHandler => { + let handler = handler.take().ok_or_else(|| { + Error::InvalidTransition("handler already invoked".into()) + })?; + let context = + MessageContext::new(self.clone(), channel.clone(), message.clone()); + TurnResult::HandlerCompleted(handler(context).await?) + } + TurnEffect::SendReply(content) => { + confirmed_reply = Some(channel.reply(&message, content).await?); + TurnResult::ReplySent + } + TurnEffect::AppendReply => { + let reply = confirmed_reply.take().ok_or_else(|| { + Error::InvalidTransition("reply was not confirmed".into()) + })?; + self.state.append(reply).await?; + TurnResult::ReplyAppended + } + TurnEffect::Complete => return Ok(()), + }; + turn.resolve(outcome)?; + } + } + + pub async fn receive_with_handler( + self: &Arc, + channel: Arc, + message: Message, + handler: &dyn Handler, + ) -> Result<()> { + self.receive(channel, message, |context| handler.handle(context)) + .await + } + + pub async fn reply( + &self, + channel: Arc, + source: &Message, + content: &str, + ) -> Result { + let reply = channel.reply(source, content).await?; + self.state.append(reply.clone()).await?; + Ok(reply) + } + + pub async fn reply_rich( + &self, + channel: Arc, + source: &Message, + content: &RichReply, + ) -> Result { + let reply = channel.reply_rich(source, content).await?; + self.state.append(reply.clone()).await?; + Ok(reply) + } + + pub async fn upload_files( + &self, + channel: Arc, + source: &Message, + files: &[UploadFile], + ) -> Result> { + channel.upload_files(source, files).await + } + + pub async fn download_attachment( + &self, + channel: Arc, + source: &Message, + attachment: &Attachment, + max_bytes: usize, + ) -> Result { + if !source.attachments.contains(attachment) { + return Err(Error::InvalidInput( + "Attachment does not belong to the source message".into(), + )); + } + channel + .download_attachment(source, attachment, max_bytes) + .await + } + + pub async fn set_status( + &self, + channel: Arc, + source: &Message, + status: &str, + ) -> Result { + channel.set_status(source, status).await + } + + pub async fn mirror( + &self, + channel: Arc, + source: &Message, + message: &Message, + origin: &str, + ) -> Result { + if message.role != Role::User { + return Err(Error::InvalidInput( + "Only user inputs can be mirrored".into(), + )); + } + let mirrored = channel.mirror(source, message, origin).await?; + self.state.append(mirrored.clone()).await?; + Ok(mirrored) + } + + pub async fn history( + &self, + conversation: &ConversationId, + limit: Option, + ) -> Result> { + self.state.history(conversation, limit).await + } +} diff --git a/crates/agentchat-core/src/state.rs b/crates/agentchat-core/src/state.rs new file mode 100644 index 0000000..e5cfb36 --- /dev/null +++ b/crates/agentchat-core/src/state.rs @@ -0,0 +1,135 @@ +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; + +use async_trait::async_trait; +use tokio::sync::{Mutex, OwnedMutexGuard}; + +use crate::{ConversationId, Message, MessageId, Result}; + +/// Value-independent memory storage. Claims and history deliberately have no TTL, +/// matching the original in-process state. Synchronization belongs to the caller. +#[derive(Debug)] +pub struct MemoryStore { + claims: HashSet, + messages: HashMap>, +} + +impl Default for MemoryStore { + fn default() -> Self { + Self::new() + } +} + +impl MemoryStore { + pub fn new() -> Self { + Self { + claims: HashSet::new(), + messages: HashMap::new(), + } + } + + pub fn claim(&mut self, id: &MessageId) -> bool { + self.claims.insert(id.clone()) + } + + pub fn append(&mut self, conversation: &ConversationId, value: T) { + self.messages + .entry(conversation.clone()) + .or_default() + .push(value); + } + + /// Visit host-owned values so bindings can participate in garbage collection. + pub fn values(&self) -> impl Iterator { + self.messages.values().flat_map(|messages| messages.iter()) + } + + pub fn clear(&mut self) { + self.claims.clear(); + self.messages.clear(); + } + + pub fn history(&self, conversation: &ConversationId, limit: Option) -> Vec { + let messages = self + .messages + .get(conversation) + .map(Vec::as_slice) + .unwrap_or_default(); + // Python's messages[-0:] returns the entire history. + let start = match limit { + Some(limit) if limit > 0 => messages.len().saturating_sub(limit), + _ => 0, + }; + messages[start..].to_vec() + } +} + +/// Dropping this permit must release the conversation lock. +pub trait ConversationPermit: Send {} +impl ConversationPermit for OwnedMutexGuard<()> {} + +#[async_trait] +pub trait State: Send + Sync { + async fn claim(&self, message_id: &MessageId) -> Result; + async fn acquire( + &self, + conversation_id: &ConversationId, + ) -> Result>; + async fn append(&self, message: Message) -> Result<()>; + async fn history( + &self, + conversation_id: &ConversationId, + limit: Option, + ) -> Result>; +} + +#[derive(Default)] +pub struct MemoryState { + store: Mutex>, + locks: Mutex>>>, +} + +impl MemoryState { + pub fn new() -> Self { + Self::default() + } +} + +#[async_trait] +impl State for MemoryState { + async fn claim(&self, message_id: &MessageId) -> Result { + Ok(self.store.lock().await.claim(message_id)) + } + + async fn acquire( + &self, + conversation_id: &ConversationId, + ) -> Result> { + let lock = self + .locks + .lock() + .await + .entry(conversation_id.clone()) + .or_default() + .clone(); + Ok(Box::new(lock.lock_owned().await)) + } + + async fn append(&self, message: Message) -> Result<()> { + self.store + .lock() + .await + .append(&message.conversation_id.clone(), message); + Ok(()) + } + + async fn history( + &self, + conversation_id: &ConversationId, + limit: Option, + ) -> Result> { + Ok(self.store.lock().await.history(conversation_id, limit)) + } +} diff --git a/crates/agentchat-core/src/turn.rs b/crates/agentchat-core/src/turn.rs new file mode 100644 index 0000000..ac2c195 --- /dev/null +++ b/crates/agentchat-core/src/turn.rs @@ -0,0 +1,70 @@ +use crate::{Error, Result}; + +/// The next host effect. A turn never performs I/O itself. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TurnEffect { + Claim, + AcquireConversation, + AppendIncoming, + InvokeHandler, + SendReply(String), + AppendReply, + Complete, +} + +/// Successful effect outcomes. A failed effect stops the driver without retry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TurnResult { + Claimed(bool), + ConversationAcquired, + IncomingAppended, + HandlerCompleted(Option), + ReplySent, + ReplyAppended, +} + +#[derive(Debug, Clone)] +pub struct Turn { + effect: TurnEffect, +} + +impl Default for Turn { + fn default() -> Self { + Self::new() + } +} + +impl Turn { + pub fn new() -> Self { + Self { + effect: TurnEffect::Claim, + } + } + + pub fn effect(&self) -> &TurnEffect { + &self.effect + } + + pub fn resolve(&mut self, result: TurnResult) -> Result<()> { + self.effect = match (&self.effect, result) { + (TurnEffect::Claim, TurnResult::Claimed(true)) => TurnEffect::AcquireConversation, + (TurnEffect::Claim, TurnResult::Claimed(false)) => TurnEffect::Complete, + (TurnEffect::AcquireConversation, TurnResult::ConversationAcquired) => { + TurnEffect::AppendIncoming + } + (TurnEffect::AppendIncoming, TurnResult::IncomingAppended) => TurnEffect::InvokeHandler, + (TurnEffect::InvokeHandler, TurnResult::HandlerCompleted(Some(text))) => { + TurnEffect::SendReply(text) + } + (TurnEffect::InvokeHandler, TurnResult::HandlerCompleted(None)) => TurnEffect::Complete, + (TurnEffect::SendReply(_), TurnResult::ReplySent) => TurnEffect::AppendReply, + (TurnEffect::AppendReply, TurnResult::ReplyAppended) => TurnEffect::Complete, + (effect, result) => { + return Err(Error::InvalidTransition(format!( + "{effect:?} cannot accept {result:?}" + ))) + } + }; + Ok(()) + } +} diff --git a/crates/agentchat-core/tests/contracts.rs b/crates/agentchat-core/tests/contracts.rs new file mode 100644 index 0000000..973ee3d --- /dev/null +++ b/crates/agentchat-core/tests/contracts.rs @@ -0,0 +1,474 @@ +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; +use std::time::Duration; + +use agentchat_core::*; +use async_trait::async_trait; +use tokio::sync::{Mutex, Semaphore}; + +fn incoming(id: &str, conversation: &str) -> Message { + Message { + id: id.into(), + conversation_id: conversation.into(), + channel: "fake".into(), + sender: Sender { + id: "alice".into(), + display_name: None, + }, + text: id.into(), + role: Role::User, + metadata: Default::default(), + thread_id: None, + addressed: true, + attachments: vec![], + } +} + +#[derive(Default)] +struct FakeChannel { + calls: AtomicUsize, + sent: Mutex>, + uncertain: bool, + history: Option>, + history_fails: bool, + statuses: Mutex>, + status_supported: bool, + status_fails: bool, +} + +#[async_trait] +impl Channel for FakeChannel { + fn name(&self) -> &str { + "fake" + } + + async fn set_status(&self, _source: &Message, status: &str) -> Result { + if !self.status_supported { + return Ok(false); + } + self.statuses.lock().await.push(status.to_owned()); + if self.status_fails { + return Err(Error::Transport("Status unavailable".into())); + } + Ok(true) + } + + async fn reply(&self, source: &Message, content: &str) -> Result { + let sequence = self.calls.fetch_add(1, Ordering::SeqCst) + 1; + self.sent.lock().await.push(content.to_owned()); + if self.uncertain { + return Err(Error::Transport("Delivery outcome is unknown".into())); + } + Ok(Message { + id: format!("reply:{sequence}").into(), + sender: Sender { + id: "bot".into(), + display_name: None, + }, + text: content.into(), + role: Role::Assistant, + ..source.clone() + }) + } + + async fn thread_history( + &self, + _source: &Message, + _limit: usize, + ) -> Result>> { + if self.history_fails { + return Err(Error::Transport("History unavailable".into())); + } + Ok(self.history.clone()) + } +} + +#[test] +fn typed_turn_enforces_each_effect_and_keeps_empty_replies() { + let mut turn = Turn::new(); + assert_eq!(turn.effect(), &TurnEffect::Claim); + assert!(turn.resolve(TurnResult::ReplySent).is_err()); + assert_eq!(turn.effect(), &TurnEffect::Claim); + turn.resolve(TurnResult::Claimed(true)).unwrap(); + assert_eq!(turn.effect(), &TurnEffect::AcquireConversation); + turn.resolve(TurnResult::ConversationAcquired).unwrap(); + turn.resolve(TurnResult::IncomingAppended).unwrap(); + turn.resolve(TurnResult::HandlerCompleted(Some(String::new()))) + .unwrap(); + assert_eq!(turn.effect(), &TurnEffect::SendReply(String::new())); + turn.resolve(TurnResult::ReplySent).unwrap(); + turn.resolve(TurnResult::ReplyAppended).unwrap(); + assert_eq!(turn.effect(), &TurnEffect::Complete); + assert!(turn.resolve(TurnResult::Claimed(true)).is_err()); +} + +#[test] +fn duplicate_finishes_without_any_other_effect() { + let mut turn = Turn::new(); + turn.resolve(TurnResult::Claimed(false)).unwrap(); + assert_eq!(turn.effect(), &TurnEffect::Complete); +} + +#[test] +fn history_selection_preserves_root_and_current_turn() { + assert_eq!(thread_history_indices(5, 3).unwrap(), vec![0, 3, 4]); + assert_eq!(thread_history_indices(1, 3).unwrap(), vec![0]); + assert!(thread_history_indices(5, 1).is_err()); + assert!(thread_history_indices(5, 101).is_err()); +} + +#[test] +fn memory_values_preserve_identity_and_zero_limit_matches_python() { + let mut store = MemoryStore::new(); + let value = Arc::new(vec![1, 2, 3]); + let conversation = ConversationId::from("example"); + assert!(store.claim(&MessageId::from("one"))); + assert!(!store.claim(&MessageId::from("one"))); + store.append(&conversation, value.clone()); + assert!(Arc::ptr_eq( + &store.history(&conversation, Some(0))[0], + &value + )); + assert!(store + .history(&ConversationId::from("missing"), None) + .is_empty()); +} + +#[test] +fn portable_ids_are_strings_and_optional_message_fields_have_defaults() { + let message: Message = serde_json::from_value(serde_json::json!({ + "id": "discord:999999999999999999", "conversation_id": "slack:T:C:1.000001", + "channel": "custom", "sender": {"id": "person"}, "text": "hello", "role": "user" + })) + .unwrap(); + assert!(message.addressed); + assert_eq!(message.id.as_ref(), "discord:999999999999999999"); + let value = serde_json::to_value(&message).unwrap(); + assert_eq!(value["id"], "discord:999999999999999999"); + assert_eq!(value["role"], "user"); +} + +#[test] +fn file_debug_output_does_not_include_bytes() { + let content = b"private attachment".to_vec(); + let upload = UploadFile { + filename: "report.txt".into(), + content: content.clone(), + title: None, + }; + let download = DownloadedFile { + id: "file".into(), + name: "report.txt".into(), + content: content.clone(), + media_type: None, + }; + for debug in [format!("{upload:?}"), format!("{download:?}")] { + assert!(debug.contains("content_length: 18")); + assert!(!debug.contains("private attachment")); + assert!(!debug.contains(&format!("{content:?}"))); + } +} + +#[tokio::test] +async fn nested_reply_and_history_do_not_reacquire_the_conversation_lock() { + let app = Arc::new(Runtime::new()); + let channel = Arc::new(FakeChannel::default()); + tokio::time::timeout( + Duration::from_secs(1), + app.receive( + channel.clone(), + incoming("one", "chat"), + |context| async move { + assert_eq!(context.history(30).await?.len(), 1); + context.reply("manual reply").await?; + assert_eq!(context.conversation.history(None).await?.len(), 2); + Ok(None) + }, + ), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(channel.calls.load(Ordering::SeqCst), 1); + assert_eq!(app.history(&"chat".into(), None).await.unwrap().len(), 2); +} + +#[tokio::test] +async fn uncertain_send_never_appends_success_or_retries_claimed_input() { + let app = Arc::new(Runtime::new()); + let channel = Arc::new(FakeChannel { + uncertain: true, + ..Default::default() + }); + let message = incoming("one", "chat"); + let result = app + .receive(channel.clone(), message.clone(), |_| async { + Ok(Some("answer".into())) + }) + .await; + assert!(matches!(result, Err(Error::Transport(_)))); + app.receive(channel.clone(), message, |_| async { + panic!("a claimed message must not run again") + }) + .await + .unwrap(); + assert_eq!(channel.calls.load(Ordering::SeqCst), 1); + assert_eq!(app.history(&"chat".into(), None).await.unwrap().len(), 1); +} + +#[tokio::test] +async fn handler_failure_keeps_input_and_claim_and_releases_permit() { + let app = Arc::new(Runtime::new()); + let channel = Arc::new(FakeChannel::default()); + assert!(app + .receive(channel.clone(), incoming("one", "chat"), |_| async { + Err(Error::Handler("failed".into())) + }) + .await + .is_err()); + app.receive(channel.clone(), incoming("one", "chat"), |_| async { + panic!("duplicate") + }) + .await + .unwrap(); + app.receive(channel, incoming("two", "chat"), |_| async { Ok(None) }) + .await + .unwrap(); + assert_eq!(app.history(&"chat".into(), None).await.unwrap().len(), 2); +} + +#[tokio::test] +async fn conversations_serialize_independently() { + let app = Arc::new(Runtime::new()); + let channel = Arc::new(FakeChannel::default()); + let started = Arc::new(Semaphore::new(0)); + let release = Arc::new(Semaphore::new(0)); + let app1 = app.clone(); + let channel1 = channel.clone(); + let started1 = started.clone(); + let release1 = release.clone(); + let first = tokio::spawn(async move { + app1.receive(channel1, incoming("one", "chat"), |_| async move { + started1.add_permits(1); + release1.acquire().await.unwrap().forget(); + Ok(Some("first".into())) + }) + .await + }); + started.acquire().await.unwrap().forget(); + let app2 = app.clone(); + let channel2 = channel.clone(); + let second = tokio::spawn(async move { + app2.receive(channel2, incoming("two", "chat"), |context| async move { + assert_eq!(context.conversation.history(None).await?.len(), 3); + Ok(Some("second".into())) + }) + .await + }); + tokio::time::timeout( + Duration::from_secs(1), + app.receive(channel.clone(), incoming("other", "separate"), |_| async { + Ok(None) + }), + ) + .await + .unwrap() + .unwrap(); + release.add_permits(1); + first.await.unwrap().unwrap(); + second.await.unwrap().unwrap(); + assert_eq!(*channel.sent.lock().await, vec!["first", "second"]); +} + +#[tokio::test] +async fn cancellation_releases_lock_without_releasing_claim() { + let app = Arc::new(Runtime::new()); + let channel = Arc::new(FakeChannel::default()); + let started = Arc::new(Semaphore::new(0)); + let app1 = app.clone(); + let channel1 = channel.clone(); + let started1 = started.clone(); + let task = tokio::spawn(async move { + app1.receive(channel1, incoming("cancelled", "chat"), |_| async move { + started1.add_permits(1); + std::future::pending::>>().await + }) + .await + }); + started.acquire().await.unwrap().forget(); + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + app.receive(channel.clone(), incoming("cancelled", "chat"), |_| async { + panic!("duplicate") + }) + .await + .unwrap(); + tokio::time::timeout( + Duration::from_secs(1), + app.receive(channel, incoming("next", "chat"), |_| async { Ok(None) }), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(app.history(&"chat".into(), None).await.unwrap().len(), 2); +} + +#[tokio::test] +async fn native_history_keeps_root_and_tail_without_replaying_past_turns() { + let app = Arc::new(Runtime::new()); + let channel = Arc::new(FakeChannel { + history: Some(vec![ + incoming("root", "chat"), + incoming("middle", "chat"), + incoming("last", "chat"), + ]), + ..Default::default() + }); + let message = Message { + thread_id: Some("thread".into()), + ..incoming("current", "chat") + }; + app.receive(channel, message, |context| async move { + let history = context.history(3).await?; + assert_eq!( + history.iter().map(|m| m.id.as_ref()).collect::>(), + vec!["root", "last", "current"] + ); + assert_eq!(context.conversation.history(None).await?.len(), 1); + Ok(None) + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn native_history_failure_does_not_fall_back_to_local_history() { + let app = Arc::new(Runtime::new()); + let channel = Arc::new(FakeChannel { + history_fails: true, + ..Default::default() + }); + let message = Message { + thread_id: Some("thread".into()), + ..incoming("current", "chat") + }; + let result = app + .receive(channel.clone(), message, |context| async move { + context.history(30).await?; + Ok(Some("must not send".into())) + }) + .await; + assert!(matches!(result, Err(Error::Transport(_)))); + assert_eq!(channel.calls.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn optional_operations_fail_without_sending_or_inventing_history() { + let app = Arc::new(Runtime::new()); + let channel = Arc::new(FakeChannel::default()); + let message = incoming("one", "chat"); + assert!(!app + .set_status(channel.clone(), &message, "working") + .await + .unwrap()); + assert!(matches!( + app.upload_files(channel.clone(), &message, &[]).await, + Err(Error::Unsupported("file uploads")) + )); + assert!(matches!( + app.mirror(channel.clone(), &message, &message, "web").await, + Err(Error::Unsupported("mirroring")) + )); + assert!(app + .history(&message.conversation_id, None) + .await + .unwrap() + .is_empty()); + assert_eq!(channel.calls.load(Ordering::SeqCst), 0); +} + +#[tokio::test(start_paused = true)] +async fn activity_refreshes_latest_label_and_explicit_close_clears_once() { + let app = Arc::new(Runtime::new()); + let channel = Arc::new(FakeChannel { + status_supported: true, + ..Default::default() + }); + let context = MessageContext::new(app.clone(), channel.clone(), incoming("status", "chat")); + let mut working = context + .working("working", Duration::from_secs(10)) + .await + .unwrap(); + assert!(working.update("finishing").await); + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_secs(10)).await; + tokio::task::yield_now().await; + working.close().await; + working.close().await; + drop(working); + assert_eq!( + *channel.statuses.lock().await, + vec!["working", "finishing", "finishing", ""] + ); + assert!(app.history(&"chat".into(), None).await.unwrap().is_empty()); +} + +#[tokio::test] +async fn activity_transport_failure_does_not_prevent_an_answer() { + let app = Arc::new(Runtime::new()); + let channel = Arc::new(FakeChannel { + status_supported: true, + status_fails: true, + ..Default::default() + }); + app.receive( + channel.clone(), + incoming("status", "chat"), + |context| async move { + let mut working = context.working("working", Duration::from_secs(60)).await?; + assert!(!working.update("still working").await); + working.close().await; + Ok(Some("answer".into())) + }, + ) + .await + .unwrap(); + assert_eq!(channel.calls.load(Ordering::SeqCst), 1); + assert_eq!(app.history(&"chat".into(), None).await.unwrap().len(), 2); +} + +#[tokio::test] +async fn activity_drop_schedules_best_effort_clear() { + let channel = Arc::new(FakeChannel { + status_supported: true, + ..Default::default() + }); + let working = WorkingStatus::start( + channel.clone(), + incoming("status", "chat"), + "working".into(), + Duration::from_secs(60), + ) + .await + .unwrap(); + drop(working); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if channel + .statuses + .lock() + .await + .last() + .is_some_and(String::is_empty) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(*channel.statuses.lock().await, vec!["working", ""]); +} diff --git a/crates/agentchat-slack/Cargo.toml b/crates/agentchat-slack/Cargo.toml new file mode 100644 index 0000000..cdf9909 --- /dev/null +++ b/crates/agentchat-slack/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "agentchat-slack" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +description = "Native Slack Socket Mode and Web API adapter for AgentChat" +license = "MIT" + +[dependencies] +agentchat-core.workspace = true +async-trait = "0.1" +futures-util = { version = "0.3", features = ["sink"] } +reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "form", "query", "stream"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["rt", "sync", "time", "net", "macros"] } +tokio-tungstenite = { version = "0.30", features = ["rustls-tls-webpki-roots"] } +tokio-util = { version = "0.7", features = ["rt"] } +tracing = "0.1" +unicode-general-category = "1" + +[dev-dependencies] +axum = { version = "0.8", features = ["ws"] } +tokio = { version = "1", features = ["rt-multi-thread", "test-util"] } diff --git a/crates/agentchat-slack/src/events.rs b/crates/agentchat-slack/src/events.rs new file mode 100644 index 0000000..1d34e0d --- /dev/null +++ b/crates/agentchat-slack/src/events.rs @@ -0,0 +1,144 @@ +//! Pure Slack event normalization. No authentication, downloads, or network I/O. + +use agentchat_core::{Attachment, Message, Role, Sender}; +use serde_json::{json, Value}; +use unicode_general_category::{get_general_category, GeneralCategory}; + +pub(crate) fn nonempty(value: &Value) -> Option<&str> { + value.as_str().filter(|text| !text.is_empty()) +} + +pub(crate) fn trim_python(text: &str) -> &str { + text.trim_matches(|c: char| c.is_whitespace() || matches!(c, '\u{001c}'..='\u{001f}')) +} + +pub(crate) fn valid_file_id(id: &str) -> bool { + (2..=31).contains(&id.len()) + && id.starts_with('F') + && id + .bytes() + .skip(1) + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) +} + +pub(crate) fn filename(value: &str) -> String { + let normalized = value.replace('\\', "/"); + let name: String = normalized + .rsplit('/') + .next() + .unwrap_or_default() + .chars() + .filter(|c| { + !matches!( + get_general_category(*c), + GeneralCategory::Control + | GeneralCategory::Format + | GeneralCategory::Surrogate + | GeneralCategory::PrivateUse + | GeneralCategory::Unassigned + ) + }) + .collect(); + let name: String = name.trim_matches([' ', '.']).chars().take(180).collect(); + if name.is_empty() { + "attachment".into() + } else { + name + } +} + +/// Normalize at most five file references. Event URLs and credentials are discarded. +pub fn attachments(files: &Value) -> Vec { + let Some(files) = files.as_array() else { + return Vec::new(); + }; + let mut selected: Vec = Vec::new(); + for file in files.iter().take(5) { + let Some(id) = nonempty(&file["id"]).filter(|id| valid_file_id(id)) else { + continue; + }; + if selected.iter().any(|item| item.id == id) { + continue; + } + selected.push(Attachment { + id: id.into(), + name: file["name"].as_str().map(filename), + media_type: file["mimetype"] + .as_str() + .map(|v| v.chars().take(255).collect()), + size: file["size"].as_u64(), + }); + } + selected +} + +/// Decode an authenticated Events API payload without doing I/O. +/// Untagged thread replies are marked for the adapter's subscription check. +pub fn normalize(payload: &Value, bot_user_id: &str, workspace_id: &str) -> Option { + let event = payload.get("event")?.as_object()?; + let get = |key: &str| event.get(key).unwrap_or(&Value::Null); + let event_type = get("type").as_str()?; + let direct = event_type == "message" && get("channel_type") == "im"; + if !get("bot_id").is_null() + || !get("bot_profile").is_null() + || (!get("subtype").is_null() && get("subtype") != "file_share") + { + return None; + } + let user = nonempty(get("user"))?; + if user == bot_user_id { + return None; + } + let channel = nonempty(get("channel"))?; + let timestamp = nonempty(get("ts"))?; + let text = event.get("text").map_or(Some(""), Value::as_str)?; + let files = attachments(get("files")); + if trim_python(text).is_empty() && files.is_empty() { + return None; + } + let team = nonempty(&payload["team_id"])?; + if team != workspace_id { + return None; + } + let supplied_thread = if get("thread_ts").is_null() { + None + } else { + Some(nonempty(get("thread_ts"))?) + }; + let root = supplied_thread.unwrap_or(timestamp); + let mention = format!("<@{bot_user_id}>"); + let channel_message = event_type == "message" + && matches!(get("channel_type").as_str(), Some("channel" | "group")); + let mentioned = event_type == "app_mention" || (channel_message && text.contains(&mention)); + if !(direct || mentioned || (channel_message && supplied_thread.is_some())) { + return None; + } + let without_mention = text.replace(&mention, ""); + let normalized_text = trim_python(&without_mention).to_owned(); + if normalized_text.is_empty() && files.is_empty() { + return None; + } + let thread = if direct { supplied_thread } else { Some(root) }; + let event_id = payload["event_id"] + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| format!("{channel}:{timestamp}")); + Some(Message { + id: format!("slack:{team}:{channel}:{timestamp}").into(), + conversation_id: if direct { format!("slack:{team}:{channel}") } + else { format!("slack:{team}:{channel}:{root}") }.into(), + channel: "slack".into(), + sender: Sender { id: user.into(), display_name: None }, + text: normalized_text, + role: Role::User, + thread_id: thread.map(str::to_owned), + addressed: direct || mentioned, + attachments: files, + metadata: json!({ + "channel_id": channel, "team_id": team, "event_id": event_id, + "channel_type": if direct { Value::String("im".into()) } else { event.get("channel_type").cloned().unwrap_or_else(|| Value::String("channel".into())) }, + "reply_thread_timestamp": thread, "message_timestamp": timestamp, + "requires_subscription": !direct && !mentioned, + }).as_object()?.clone(), + }) +} diff --git a/crates/agentchat-slack/src/files.rs b/crates/agentchat-slack/src/files.rs new file mode 100644 index 0000000..33e0a71 --- /dev/null +++ b/crates/agentchat-slack/src/files.rs @@ -0,0 +1,206 @@ +//! Explicit, bounded file transfer. Events never cause an automatic download. + +use std::collections::{HashMap, HashSet}; + +use agentchat_core::{ + Attachment, DownloadedFile, Error, Message, Result, Role, UploadFile, UploadedFile, +}; +use futures_util::StreamExt; +use serde_json::{json, Value}; + +use super::{events, Slack}; + +impl Slack { + pub async fn upload_files( + &self, + source: &Message, + files: &[UploadFile], + ) -> Result> { + let destination = self.destination(source).await?; + if files.is_empty() { + return Err(Error::InvalidInput("Expected at least one file".into())); + } + for file in files { + if file.filename.is_empty() + || file.filename.len() > 255 + || matches!(file.filename.as_str(), "." | "..") + || file.filename.contains(['/', '\\']) + || file.filename.chars().any(|c| (c as u32) < 32) + { + return Err(Error::InvalidInput("Expected a plain filename".into())); + } + if file.content.is_empty() { + return Err(Error::InvalidInput("Expected nonempty file bytes".into())); + } + } + let mut pending = Vec::new(); + let mut seen = HashSet::new(); + for file in files { + self.check_open()?; + let allocation = self + .inner + .client + .form( + "files.getUploadURLExternal", + &json!({ + "filename":file.filename,"length":file.content.len()}), + ) + .await?; + let id = events::nonempty(&allocation["file_id"]).ok_or_else(|| { + Error::Transport("Slack did not confirm an upload allocation".into()) + })?; + if !seen.insert(id.to_owned()) { + return Err(Error::Transport( + "Slack returned a duplicate upload allocation".into(), + )); + } + let url = allocation["upload_url"] + .as_str() + .ok_or_else(|| Error::Transport("Slack did not provide an upload URL".into()))?; + let url = self.inner.client.file_url(url, "/upload/")?; + self.check_open()?; + let uploaded = self + .inner + .client + .http + .post(url) + .header("Content-Type", "application/octet-stream") + .body(file.content.clone()) + .send() + .await + .map_err(|_| { + Error::Transport("Slack file transfer failed; delivery may be uncertain".into()) + })?; + if uploaded.status() != reqwest::StatusCode::OK { + return Err(Error::Transport( + "Slack did not confirm the file upload".into(), + )); + } + pending.push(json!({"id":id,"title":file.title.as_deref().filter(|title| !title.is_empty()).unwrap_or(&file.filename)})); + } + self.check_open()?; + let mut payload = json!({"files":pending,"channel_id":destination.channel_id}); + if let Some(thread) = destination.thread_timestamp { + payload["thread_ts"] = Value::String(thread.to_string()); + } + let response = self + .inner + .client + .call("files.completeUploadExternal", &payload) + .await?; + let completed = response["files"] + .as_array() + .ok_or_else(|| Error::Transport("Slack file delivery could not be confirmed".into()))?; + let by_id: HashMap<_, _> = completed + .iter() + .filter_map(|file| file["id"].as_str().map(|id| (id, file))) + .collect(); + if completed.len() != pending.len() + || by_id.len() != pending.len() + || seen.iter().any(|id| !by_id.contains_key(id.as_str())) + { + return Err(Error::Transport( + "Slack file delivery could not be confirmed".into(), + )); + } + pending + .iter() + .map(|file| { + let id = file["id"] + .as_str() + .ok_or_else(|| Error::Transport("Invalid upload allocation".into()))?; + Ok(UploadedFile { + id: id.into(), + permalink: by_id[id]["permalink"].as_str().map(str::to_owned), + }) + }) + .collect() + } + + pub async fn download_attachment( + &self, + source: &Message, + attachment: &Attachment, + max_bytes: usize, + ) -> Result { + self.destination(source).await?; + if source.role != Role::User || !source.attachments.contains(attachment) { + return Err(Error::InvalidInput( + "Attachment does not belong to the source user message".into(), + )); + } + if !events::valid_file_id(&attachment.id) { + return Err(Error::InvalidInput( + "Expected a Slack file identifier".into(), + )); + } + if max_bytes == 0 { + return Err(Error::InvalidInput( + "Expected a positive attachment byte limit".into(), + )); + } + let response = self + .inner + .client + .get("files.info", &json!({"file":attachment.id})) + .await?; + let file = &response["file"]; + if file["id"] != attachment.id || file["is_external"] == true { + return Err(Error::InvalidInput("This Slack file is unavailable".into())); + } + let size = file["size"] + .as_u64() + .ok_or_else(|| Error::Transport("Slack returned an invalid file size".into()))?; + let too_large = || { + Error::InvalidInput(format!( + "Slack file exceeds the {max_bytes} byte download limit" + )) + }; + if size > max_bytes as u64 { + return Err(too_large()); + } + let url = events::nonempty(&file["url_private_download"]) + .or_else(|| events::nonempty(&file["url_private"])) + .ok_or_else(|| { + Error::Transport("Slack did not return a private download URL".into()) + })?; + let url = self.inner.client.file_url(url, "/files-pri/")?; + self.check_open()?; + let response = self + .inner + .client + .http + .get(url) + .bearer_auth(self.inner.client.bot_token()) + .send() + .await + .map_err(|_| Error::Transport("Slack file download failed".into()))?; + if response.status() != reqwest::StatusCode::OK { + return Err(Error::Transport( + "Could not read this Slack attachment".into(), + )); + } + let mut chunks = response.bytes_stream(); + let mut content = Vec::new(); + while let Some(chunk) = chunks.next().await { + let chunk = chunk.map_err(|_| Error::Transport("Slack file download failed".into()))?; + if chunk.len() > max_bytes.saturating_sub(content.len()) { + return Err(too_large()); + } + content.extend_from_slice(&chunk); + } + self.check_open()?; + if content.is_empty() { + return Err(Error::InvalidInput("This Slack file was empty".into())); + } + Ok(DownloadedFile { + id: attachment.id.clone(), + name: file["name"] + .as_str() + .map(events::filename) + .unwrap_or_else(|| "attachment".into()), + content, + media_type: file["mimetype"].as_str().map(str::to_owned), + }) + } +} diff --git a/crates/agentchat-slack/src/lib.rs b/crates/agentchat-slack/src/lib.rs new file mode 100644 index 0000000..27d369e --- /dev/null +++ b/crates/agentchat-slack/src/lib.rs @@ -0,0 +1,694 @@ +//! Native Slack transport shared by Rust and Node applications. +//! +//! Channel methods make one outbound attempt. A successful result includes a +//! provider receipt; a failed write may have an uncertain delivery outcome. +//! Socket acknowledgements accept bounded in-memory work, not a durable queue. + +pub mod events; +mod files; +pub mod rendering; +mod route; +mod socket; +mod transport; + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fmt; +use std::future::Future; +use std::sync::{ + atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering}, + Arc, +}; +use std::time::Duration; + +use agentchat_core::{ + Attachment, Channel, ConversationId, DownloadedFile, Error, HistoryLimit, Message, MessageId, + Result, RichReply, Role, Sender, UploadFile, UploadedFile, +}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tokio::sync::{Mutex, RwLock}; +use tokio_util::sync::CancellationToken; + +use route::{Destination, SlackTimestamp}; +use transport::WebClient; + +const NEW: u8 = 0; +const RUNNING: u8 = 1; +const CLOSING: u8 = 2; +const CLOSED: u8 = 3; + +tokio::task_local! { static DELIVERY_SCOPE: Arc; } + +#[derive(Clone)] +pub struct SlackConfig { + pub bot_token: String, + pub app_token: String, + pub workspace_id: Option, + pub bot_user_id: Option, + pub ack_emoji: Option, + pub max_pending_events: usize, + pub shutdown_timeout: Duration, + /// Preserve the Python adapter's continue-on-handler-error behavior by default. + pub stop_on_handler_error: bool, + /// Override for a trusted API proxy or loopback test server. Never agent-controlled. + pub api_base_url: String, +} + +impl SlackConfig { + pub fn new(bot_token: String, app_token: String) -> Self { + Self { + bot_token, + app_token, + workspace_id: None, + bot_user_id: None, + ack_emoji: Some("eyes".into()), + max_pending_events: 64, + shutdown_timeout: Duration::from_secs(20), + stop_on_handler_error: false, + api_base_url: "https://slack.com/api/".into(), + } + } + + pub fn from_env() -> Result { + let bot_token = std::env::var("SLACK_BOT_TOKEN") + .map_err(|_| Error::InvalidInput("SLACK_BOT_TOKEN is required".into()))?; + let app_token = std::env::var("SLACK_APP_TOKEN") + .map_err(|_| Error::InvalidInput("SLACK_APP_TOKEN is required".into()))?; + Ok(Self::new(bot_token, app_token)) + } +} + +impl fmt::Debug for SlackConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SlackConfig") + .field("bot_token", &"[REDACTED]") + .field("app_token", &"[REDACTED]") + .field("workspace_id", &self.workspace_id) + .field("max_pending_events", &self.max_pending_events) + .finish_non_exhaustive() + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SlackIdentity { + pub workspace_id: String, + pub bot_user_id: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SlackUser { + pub id: String, + pub team_id: String, + pub display_name: Option, + pub email: Option, + pub is_bot: bool, + pub deleted: bool, +} + +/// Applications may persist or expire subscriptions in their own store. +#[async_trait] +pub trait ThreadSubscriptions: Send + Sync { + async fn contains(&self, conversation_id: &str) -> Result; + async fn add(&self, conversation_id: &str) -> Result<()>; +} + +#[derive(Default)] +pub struct MemoryThreadSubscriptions { + threads: Mutex>, +} + +#[async_trait] +impl ThreadSubscriptions for MemoryThreadSubscriptions { + async fn contains(&self, conversation_id: &str) -> Result { + Ok(self + .threads + .lock() + .await + .iter() + .any(|id| id == conversation_id)) + } + async fn add(&self, conversation_id: &str) -> Result<()> { + let mut threads = self.threads.lock().await; + threads.retain(|id| id != conversation_id); + threads.push_back(conversation_id.into()); + if threads.len() > 1000 { + threads.pop_front(); + } + Ok(()) + } +} + +struct Admission { + id: u64, + source: Message, + lifetime: CancellationToken, +} + +struct Inner { + config: SlackConfig, + client: WebClient, + identity: RwLock>, + authentication: Mutex<()>, + subscriptions: Arc, + lifecycle: AtomicU8, + connected: AtomicBool, + accepting: AtomicBool, + stop: CancellationToken, + done: CancellationToken, + admitted: std::sync::Mutex>>, + next_admission: AtomicU64, +} + +#[derive(Clone)] +pub struct Slack { + inner: Arc, +} + +impl Slack { + pub fn new(config: SlackConfig) -> Result { + Self::with_thread_subscriptions(config, Arc::new(MemoryThreadSubscriptions::default())) + } + + pub fn from_env() -> Result { + Self::new(SlackConfig::from_env()?) + } + + pub fn with_thread_subscriptions( + config: SlackConfig, + subscriptions: Arc, + ) -> Result { + if config.bot_token.trim().is_empty() || config.app_token.trim().is_empty() { + return Err(Error::InvalidInput( + "Slack bot_token and app_token are required".into(), + )); + } + if config.max_pending_events == 0 { + return Err(Error::InvalidInput( + "max_pending_events must be positive".into(), + )); + } + let client = WebClient::new( + &config.api_base_url, + config.bot_token.clone(), + config.app_token.clone(), + )?; + Ok(Self { + inner: Arc::new(Inner { + config, + client, + identity: RwLock::new(None), + authentication: Mutex::new(()), + subscriptions, + lifecycle: AtomicU8::new(NEW), + connected: AtomicBool::new(false), + accepting: AtomicBool::new(true), + stop: CancellationToken::new(), + done: CancellationToken::new(), + admitted: std::sync::Mutex::new(HashMap::new()), + next_admission: AtomicU64::new(0), + }), + }) + } + + /// Authenticate before accepting normalized events, including with host-owned ingress. + pub async fn authenticate(&self) -> Result { + self.check_open()?; + let _guard = self.inner.authentication.lock().await; + if let Some(identity) = self.inner.identity.read().await.clone() { + return Ok(identity); + } + let response = self.inner.client.call("auth.test", &json!({})).await?; + let workspace_id = events::nonempty(&response["team_id"]) + .ok_or_else(|| Error::Transport("Slack did not return a workspace identity".into()))?; + let bot_user_id = events::nonempty(&response["user_id"]) + .ok_or_else(|| Error::Transport("Slack did not return a bot identity".into()))?; + if self + .inner + .config + .workspace_id + .as_ref() + .is_some_and(|id| id != workspace_id) + || self + .inner + .config + .bot_user_id + .as_ref() + .is_some_and(|id| id != bot_user_id) + { + return Err(Error::InvalidInput( + "Slack token belongs to a different workspace or bot".into(), + )); + } + self.check_open()?; + let identity = SlackIdentity { + workspace_id: workspace_id.into(), + bot_user_id: bot_user_id.into(), + }; + *self.inner.identity.write().await = Some(identity.clone()); + Ok(identity) + } + + pub fn is_connected(&self) -> bool { + self.inner.connected.load(Ordering::Acquire) && !self.inner.stop.is_cancelled() + } + + fn check_open(&self) -> Result<()> { + let state = self.inner.lifecycle.load(Ordering::Acquire); + if state == CLOSED || (state == CLOSING && !self.in_delivery()) { + Err(Error::Closed) + } else { + Ok(()) + } + } + + fn in_delivery(&self) -> bool { + DELIVERY_SCOPE + .try_with(|inner| Arc::ptr_eq(inner, &self.inner)) + .unwrap_or(false) + } + + /// Restore an admitted receiver's operation scope across an FFI task boundary. + /// Outside a live receiver, normal lifecycle checks still apply. + pub async fn with_admitted_source(&self, source: &Message, operation: F) -> Result + where + F: Future>, + { + let lifetime = self + .inner + .admitted + .lock() + .map_err(|_| Error::Closed)? + .get(&(source.id.clone(), source.conversation_id.clone())) + .and_then(|entries| entries.iter().find(|entry| entry.source == *source)) + .map(|entry| entry.lifetime.clone()); + if let Some(lifetime) = lifetime { + DELIVERY_SCOPE.scope(self.inner.clone(), async { + tokio::select! { + biased; + _ = lifetime.cancelled() => Err(Error::Transport("Slack operation was cancelled; delivery may be uncertain".into())), + result = operation => result, + } + }).await + } else { + self.check_open()?; + operation.await + } + } + + async fn identity(&self) -> Result { + self.check_open()?; + self.inner.identity.read().await.clone().ok_or_else(|| { + Error::InvalidInput("Authenticate Slack before using this operation".into()) + }) + } + + async fn destination(&self, source: &Message) -> Result { + Destination::from_message(source, &self.identity().await?.workspace_id) + } + + /// The caller must authenticate HTTP requests before using this host-ingress hook. + pub async fn handle_event(&self, payload: &Value) -> Result> { + if self.inner.stop.is_cancelled() || !self.inner.accepting.load(Ordering::Acquire) { + return Err(Error::Closed); + } + self.prepare_event(payload).await + } + + async fn prepare_event(&self, payload: &Value) -> Result> { + let identity = self.identity().await?; + let Some(message) = + events::normalize(payload, &identity.bot_user_id, &identity.workspace_id) + else { + return Ok(None); + }; + if message.metadata.get("requires_subscription") == Some(&Value::Bool(true)) + && !self + .inner + .subscriptions + .contains(message.conversation_id.as_ref()) + .await? + { + return Ok(None); + } + Ok(Some(message)) + } + + /// Subscribe only after the application authorizes the sender and conversation. + pub async fn subscribe(&self, source: &Message) -> Result<()> { + self.destination(source).await?; + if source.metadata.get("channel_type") != Some(&Value::String("im".into())) { + self.inner + .subscriptions + .add(source.conversation_id.as_ref()) + .await?; + } + Ok(()) + } + + /// Run Socket Mode until closed. Dropping this future requests bounded cleanup. + pub async fn run(&self, receiver: F) -> Result<()> + where + F: Fn(Message) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + self.run_socket(receiver).await + } + + /// Stop intake immediately without waiting for the calling receiver to finish. + pub fn request_close(&self) { + self.stop_admission(); + loop { + match self.inner.lifecycle.load(Ordering::Acquire) { + NEW => { + if self + .inner + .lifecycle + .compare_exchange(NEW, CLOSED, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + self.inner.stop.cancel(); + self.inner.done.cancel(); + return; + } + } + RUNNING => { + if self + .inner + .lifecycle + .compare_exchange(RUNNING, CLOSING, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + self.inner.stop.cancel(); + break; + } + } + CLOSING => break, + _ => return, + } + } + } + + /// Stop accepting new events while existing operations finish. Hosts may clear + /// working indicators here before calling `close()` to release the transport. + pub fn stop_admission(&self) { + self.inner.accepting.store(false, Ordering::Release); + } + + pub async fn close(&self) -> Result<()> { + self.request_close(); + if self.in_delivery() { + return Ok(()); + } + self.inner.done.cancelled().await; + Ok(()) + } + + async fn post_message( + &self, + source: &Message, + destination: &Destination, + mut payload: Value, + content: &str, + ) -> Result { + payload["channel"] = Value::String(destination.channel_id.clone()); + if let Some(thread) = &destination.thread_timestamp { + payload["thread_ts"] = Value::String(thread.to_string()); + } + let response = self.inner.client.call("chat.postMessage", &payload).await?; + let timestamp = events::nonempty(&response["ts"]) + .and_then(SlackTimestamp::parse) + .ok_or_else(|| { + Error::Transport("Slack reply delivery could not be confirmed".into()) + })?; + Ok(Message { + id: format!("slack:{}:{timestamp}", destination.channel_id).into(), + conversation_id: source.conversation_id.clone(), + channel: "slack".into(), + sender: Sender { + id: "agentchat".into(), + display_name: Some("AgentChat".into()), + }, + text: content.into(), + role: Role::Assistant, + thread_id: source.thread_id.clone(), + addressed: true, + attachments: Vec::new(), + metadata: json!({"channel_id":destination.channel_id, + "team_id":destination.team_id,"reply_thread_timestamp":destination.thread_timestamp, + "message_timestamp":timestamp}) + .as_object() + .cloned() + .unwrap_or_default(), + }) + } + + pub async fn reply(&self, source: &Message, content: &str) -> Result { + let destination = self.destination(source).await?; + let blocks = rendering::text_blocks(content)?; + self.post_message( + source, + &destination, + json!({"text":content,"blocks":blocks, + "unfurl_links":false,"unfurl_media":false}), + content, + ) + .await + } + + pub async fn reply_rich(&self, source: &Message, content: &RichReply) -> Result { + let destination = self.destination(source).await?; + self.post_message( + source, + &destination, + rendering::rich_payload(content)?, + &content.text, + ) + .await + } + + pub async fn set_status(&self, source: &Message, status: &str) -> Result { + let destination = self.destination(source).await?; + let Some(thread) = destination.thread_timestamp else { + return Ok(false); + }; + self.inner + .client + .call( + "assistant.threads.setStatus", + &json!({"channel_id":destination.channel_id, + "thread_ts":thread,"status":status}), + ) + .await?; + Ok(true) + } + + pub async fn mirror( + &self, + source: &Message, + message: &Message, + origin: &str, + ) -> Result { + let destination = self.destination(source).await?; + let mut sent = self + .post_message( + source, + &destination, + rendering::mirror_payload(message, origin)?, + &message.text, + ) + .await?; + sent.role = Role::User; + sent.sender = message.sender.clone(); + sent.metadata.insert( + "mirrored_message_id".into(), + Value::String(message.id.to_string()), + ); + sent.metadata + .insert("origin".into(), Value::String(origin.into())); + Ok(sent) + } + + pub async fn get_user(&self, user_id: &str) -> Result { + self.identity().await?; + let response = self + .inner + .client + .get("users.info", &json!({"user":user_id})) + .await?; + let user = &response["user"]; + let invalid = || Error::Transport("Slack returned an invalid user profile".into()); + let profile = user["profile"].as_object().ok_or_else(invalid)?; + if user["id"] != user_id { + return Err(invalid()); + } + let text = |value: &Value| { + value + .as_str() + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_owned) + }; + Ok(SlackUser { + id: user_id.into(), + team_id: events::nonempty(&user["team_id"]) + .ok_or_else(invalid)? + .into(), + display_name: profile + .get("display_name") + .and_then(text) + .or_else(|| profile.get("real_name").and_then(text)) + .or_else(|| text(&user["name"])), + email: profile.get("email").and_then(text), + is_bot: user["is_bot"].as_bool().ok_or_else(invalid)? || user["is_app_user"] == true, + deleted: user["deleted"].as_bool().ok_or_else(invalid)?, + }) + } + + pub async fn thread_history(&self, source: &Message, limit: usize) -> Result> { + let limit = HistoryLimit::new(limit)?.get(); + let destination = self.destination(source).await?; + let thread = destination + .thread_timestamp + .as_ref() + .ok_or_else(|| Error::InvalidInput("Expected a Slack thread".into()))?; + if thread.as_str() == destination.message_timestamp.as_str() { + return Ok(Vec::new()); + } + let identity = self.identity().await?; + let mut root = None; + let mut recent = VecDeque::new(); + let mut cursor = String::new(); + let mut seen = HashSet::new(); + for _ in 0..5 { + let response = self + .inner + .client + .get( + "conversations.replies", + &json!({ + "channel":destination.channel_id,"ts":thread,"latest":destination.message_timestamp, + "inclusive":false,"limit":100,"cursor":cursor,"include_all_metadata":true}), + ) + .await?; + let records = response["messages"] + .as_array() + .ok_or_else(|| Error::Transport("Slack did not return thread history".into()))?; + for record in records { + let Some(ts) = events::nonempty(&record["ts"]).and_then(SlackTimestamp::parse) + else { + continue; + }; + let Some(sender_id) = events::nonempty(&record["user"]) + .or_else(|| events::nonempty(&record["bot_id"])) + else { + continue; + }; + let text = record.get("text").map_or(Some(""), Value::as_str); + let Some(text) = text else { continue }; + let files = events::attachments(&record["files"]); + if (events::trim_python(text).is_empty() && files.is_empty()) + || !ts.is_before(&destination.message_timestamp) + { + continue; + } + let mirror = rendering::read_mirror(record, &identity.bot_user_id); + let role = if sender_id == identity.bot_user_id && mirror.is_none() { + Role::Assistant + } else { + Role::User + }; + let input = mirror.unwrap_or_else(|| rendering::MirroredInput { + sender: Sender { + id: sender_id.into(), + display_name: None, + }, + text: text.into(), + }); + let message = Message { + id: format!( + "slack:{}:{}:{ts}", + identity.workspace_id, destination.channel_id + ) + .into(), + conversation_id: source.conversation_id.clone(), + channel: "slack".into(), + sender: input.sender, + text: input.text, + role, + thread_id: Some(thread.to_string()), + addressed: true, + attachments: files, + metadata: json!({"message_timestamp":ts,"team_id":identity.workspace_id, + "channel_id":destination.channel_id,"reply_thread_timestamp":thread}) + .as_object() + .cloned() + .unwrap_or_default(), + }; + if ts.as_str() == thread.as_str() { + root = Some(message); + } else { + recent.push_back(message); + if recent.len() > limit - 1 { + recent.pop_front(); + } + } + } + if response["has_more"] != true { + return Ok(root.into_iter().chain(recent).collect()); + } + let next = events::nonempty(&response["response_metadata"]["next_cursor"]).ok_or_else( + || Error::Transport("Slack returned invalid history pagination".into()), + )?; + if !seen.insert(next.to_owned()) { + return Err(Error::Transport( + "Slack returned repeated history pagination".into(), + )); + } + cursor = next.into(); + } + Err(Error::InvalidInput( + "Slack thread is too long; start a new thread".into(), + )) + } +} + +#[async_trait] +impl Channel for Slack { + fn name(&self) -> &str { + "slack" + } + async fn reply(&self, source: &Message, content: &str) -> Result { + Slack::reply(self, source, content).await + } + async fn close(&self) -> Result<()> { + Slack::close(self).await + } + async fn thread_history(&self, source: &Message, limit: usize) -> Result>> { + Slack::thread_history(self, source, limit).await.map(Some) + } + async fn reply_rich(&self, source: &Message, content: &RichReply) -> Result { + Slack::reply_rich(self, source, content).await + } + async fn set_status(&self, source: &Message, status: &str) -> Result { + Slack::set_status(self, source, status).await + } + async fn mirror(&self, source: &Message, message: &Message, origin: &str) -> Result { + Slack::mirror(self, source, message, origin).await + } + async fn upload_files( + &self, + source: &Message, + files: &[UploadFile], + ) -> Result> { + Slack::upload_files(self, source, files).await + } + async fn download_attachment( + &self, + source: &Message, + attachment: &Attachment, + max_bytes: usize, + ) -> Result { + Slack::download_attachment(self, source, attachment, max_bytes).await + } +} diff --git a/crates/agentchat-slack/src/rendering.rs b/crates/agentchat-slack/src/rendering.rs new file mode 100644 index 0000000..84ccfb0 --- /dev/null +++ b/crates/agentchat-slack/src/rendering.rs @@ -0,0 +1,185 @@ +//! Provider-owned formatting, preserving Unicode character boundaries. + +use agentchat_core::{Error, Message, Result, RichReply, Role, Sender}; +use serde_json::{json, Map, Value}; + +pub const MIRROR_EVENT_TYPE: &str = "agentchat_user_mirror_v1"; + +/// Split a single Slack message into expanded sections without breaking code fences. +pub fn text_blocks(text: &str) -> Result> { + if super::events::trim_python(text).is_empty() { + return Err(Error::InvalidInput( + "Slack replies require readable text".into(), + )); + } + let chars: Vec = text.chars().collect(); + let mut rest = chars.as_slice(); + let mut opened = false; + let mut blocks = Vec::new(); + while !rest.is_empty() { + let limit = 2992; + let mut end = if rest.len() <= limit { + rest.len() + } else { + rest[..limit] + .iter() + .rposition(|c| *c == '\n') + .map_or(0, |i| i + 1) + }; + if end < limit / 2 && rest.len() > limit { + end = limit; + } + for offset in [2, 1] { + if end >= offset + && rest.get(end - offset..end - offset + 3) == Some(&['`', '`', '`'][..]) + { + end -= offset; + break; + } + } + let part: String = rest[..end].iter().collect(); + let in_fence = opened ^ (part.matches("```").count() % 2 == 1); + let body = format!( + "{}{}{}", + if opened { "```\n" } else { "" }, + part, + if in_fence { "\n```" } else { "" } + ); + blocks.push(json!({"type":"section", "expand":true, + "text":{"type":"mrkdwn", "text":body, "verbatim":true}})); + if blocks.len() > 50 { + return Err(Error::InvalidInput( + "Slack replies support at most 50 sections; split the reply".into(), + )); + } + rest = &rest[end..]; + opened = in_fence; + } + Ok(blocks) +} + +fn expanded_blocks(blocks: &[Map]) -> Vec { + blocks + .iter() + .map(|block| { + let mut copied = block.clone(); + if copied.get("type").is_some_and(|v| v == "section") { + copied.entry("expand").or_insert(Value::Bool(true)); + } + Value::Object(copied) + }) + .collect() +} + +pub fn rich_payload(reply: &RichReply) -> Result { + if super::events::trim_python(&reply.text).is_empty() { + return Err(Error::InvalidInput( + "Rich replies require fallback text".into(), + )); + } + let blocks = if reply.blocks.is_empty() { + text_blocks(&reply.text)? + } else { + expanded_blocks(&reply.blocks) + }; + let mut payload = json!({"text":reply.text, "blocks":blocks, + "unfurl_links":false,"unfurl_media":false,"parse":"none","link_names":false}); + if !reply.attachments.is_empty() { + let attachments: Result> = reply + .attachments + .iter() + .map(|attachment| { + let mut attachment = attachment.clone(); + if let Some(blocks) = attachment.get("blocks") { + let blocks = blocks.as_array().ok_or_else(|| { + Error::InvalidInput("Attachment blocks must be an array".into()) + })?; + let blocks = blocks + .iter() + .map(|block| { + block.as_object().cloned().ok_or_else(|| { + Error::InvalidInput("Slack blocks must be objects".into()) + }) + }) + .collect::>>()?; + attachment.insert("blocks".into(), Value::Array(expanded_blocks(&blocks))); + } + Ok(Value::Object(attachment)) + }) + .collect(); + payload["attachments"] = Value::Array(attachments?); + } + Ok(payload) +} + +pub fn mirror_payload(message: &Message, origin: &str) -> Result { + if message.role != Role::User + || message.id.as_ref().is_empty() + || message.sender.id.is_empty() + || message.id.as_ref().chars().count() > 256 + || message.sender.id.chars().count() > 256 + { + return Err(Error::InvalidInput( + "A mirror requires a user input with stable IDs of at most 256 characters".into(), + )); + } + if message.text.is_empty() || message.text.chars().count() > 2800 { + return Err(Error::InvalidInput( + "Mirror text must contain 1-2800 characters".into(), + )); + } + if origin.trim().is_empty() || origin.chars().count() > 64 { + return Err(Error::InvalidInput( + "Mirror origin must contain 1-64 characters".into(), + )); + } + let name: String = message + .sender + .display_name + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or(&message.sender.id) + .split_whitespace() + .collect::>() + .join(" ") + .chars() + .take(160) + .collect(); + let origin = origin.split_whitespace().collect::>().join(" "); + let label = format!("{name} · via {origin}"); + let text = format!("{label}\n{}", message.text) + .replace('&', "&") + .replace('<', "<") + .replace('>', ">"); + Ok(json!({"text":text,"blocks":[ + {"type":"context","elements":[{"type":"plain_text","text":label,"emoji":false}]}, + {"type":"section","block_id":"agentchat_mirror_body","text":{"type":"plain_text","text":message.text,"emoji":false}}], + "metadata":{"event_type":MIRROR_EVENT_TYPE,"event_payload":{ + "message_id":message.id,"sender_id":message.sender.id,"sender_name":name,"origin":origin}}, + "mrkdwn":false,"parse":"none","link_names":false,"unfurl_links":false,"unfurl_media":false})) +} + +pub(crate) struct MirroredInput { + pub(crate) sender: Sender, + pub(crate) text: String, +} + +pub(crate) fn read_mirror(record: &Value, bot_user_id: &str) -> Option { + if record["user"] != bot_user_id || record["metadata"]["event_type"] != MIRROR_EVENT_TYPE { + return None; + } + let payload = &record["metadata"]["event_payload"]; + for key in ["message_id", "sender_id", "sender_name", "origin"] { + super::events::nonempty(&payload[key])?; + } + let body = record["blocks"].as_array()?.iter().find(|block| { + block["block_id"] == "agentchat_mirror_body" && block["text"]["type"] == "plain_text" + })?; + Some(MirroredInput { + sender: Sender { + id: payload["sender_id"].as_str()?.into(), + display_name: Some(payload["sender_name"].as_str()?.into()), + }, + text: body["text"]["text"].as_str()?.into(), + }) +} diff --git a/crates/agentchat-slack/src/route.rs b/crates/agentchat-slack/src/route.rs new file mode 100644 index 0000000..37988d2 --- /dev/null +++ b/crates/agentchat-slack/src/route.rs @@ -0,0 +1,106 @@ +//! Slack destinations are validated once here, then consumed by provider operations. + +use agentchat_core::{Error, Message, Result}; +use serde::{Serialize, Serializer}; +use serde_json::Value; + +#[derive(Clone, Debug)] +pub(crate) struct Destination { + pub(crate) channel_id: String, + pub(crate) thread_timestamp: Option, + pub(crate) message_timestamp: SlackTimestamp, + pub(crate) team_id: String, +} + +#[derive(Clone, Debug)] +pub(crate) struct SlackTimestamp { + raw: String, + seconds: String, + fraction: String, +} + +impl SlackTimestamp { + pub(crate) fn parse(value: &str) -> Option { + let (seconds, fraction) = value.split_once('.')?; + if seconds.is_empty() + || fraction.is_empty() + || !seconds.bytes().all(|c| c.is_ascii_digit()) + || !fraction.bytes().all(|c| c.is_ascii_digit()) + { + return None; + } + Some(Self { + raw: value.into(), + seconds: seconds.trim_start_matches('0').into(), + fraction: fraction.trim_end_matches('0').into(), + }) + } + pub(crate) fn as_str(&self) -> &str { + &self.raw + } + pub(crate) fn is_before(&self, other: &Self) -> bool { + self.seconds + .len() + .cmp(&other.seconds.len()) + .then_with(|| self.seconds.cmp(&other.seconds)) + .then_with(|| self.fraction.cmp(&other.fraction)) + .is_lt() + } +} + +impl std::fmt::Display for SlackTimestamp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.raw.fmt(f) + } +} + +impl Serialize for SlackTimestamp { + fn serialize(&self, serializer: S) -> std::result::Result { + serializer.serialize_str(&self.raw) + } +} + +impl Destination { + pub(crate) fn from_message(source: &Message, workspace_id: &str) -> Result { + let metadata = &source.metadata; + let text = |key| { + metadata + .get(key) + .and_then(Value::as_str) + .filter(|v| !v.is_empty()) + }; + let invalid = || { + Error::InvalidInput( + "Expected a source in the configured Slack workspace with a valid destination" + .into(), + ) + }; + if source.channel != "slack" || text("team_id") != Some(workspace_id) { + return Err(invalid()); + } + let channel_id = text("channel_id").ok_or_else(invalid)?; + if channel_id.len() < 2 + || !matches!(channel_id.as_bytes()[0], b'C' | b'D' | b'G') + || !channel_id + .bytes() + .skip(1) + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) + { + return Err(invalid()); + } + let message_timestamp = text("message_timestamp") + .and_then(SlackTimestamp::parse) + .ok_or_else(invalid)?; + let thread_timestamp = match metadata.get("reply_thread_timestamp") { + None | Some(Value::Null) => None, + Some(Value::String(thread)) => Some(SlackTimestamp::parse(thread).ok_or_else(invalid)?), + _ => return Err(invalid()), + }; + Ok(Self { + channel_id: channel_id.into(), + thread_timestamp, + message_timestamp, + team_id: workspace_id.into(), + }) + } +} diff --git a/crates/agentchat-slack/src/socket.rs b/crates/agentchat-slack/src/socket.rs new file mode 100644 index 0000000..f7dd0c0 --- /dev/null +++ b/crates/agentchat-slack/src/socket.rs @@ -0,0 +1,363 @@ +//! Socket admission, acknowledgements, reconnection, and owned task lifetime. + +use std::future::Future; +use std::sync::{atomic::Ordering, Arc}; +use std::time::Duration; + +use agentchat_core::{ConversationId, Error, Message, MessageId, Result}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{json, Value}; +use tokio::net::TcpStream; +use tokio::sync::oneshot; +use tokio::task::{JoinError, JoinSet}; +use tokio::time::{timeout, Instant, MissedTickBehavior}; +use tokio_tungstenite::{ + connect_async_with_config, + tungstenite::{protocol::WebSocketConfig, Message as Frame}, + MaybeTlsStream, WebSocketStream, +}; + +use super::{Admission, Inner, Slack, CLOSED, DELIVERY_SCOPE, NEW, RUNNING}; + +type Socket = WebSocketStream>; + +struct StopOnDrop(Arc); +impl Drop for StopOnDrop { + fn drop(&mut self) { + Slack { + inner: self.0.clone(), + } + .request_close(); + } +} + +struct Finished(Arc); +impl Drop for Finished { + fn drop(&mut self) { + self.0.connected.store(false, Ordering::Release); + self.0.lifecycle.store(CLOSED, Ordering::Release); + self.0.stop.cancel(); + self.0.done.cancel(); + } +} + +enum SocketExit { + Reconnect, + Shutdown, +} + +struct AdmittedMessage { + inner: Arc, + key: (MessageId, ConversationId), + id: u64, + lifetime: tokio_util::sync::CancellationToken, +} + +impl AdmittedMessage { + fn new(inner: Arc, message: &Message) -> Result { + let key = (message.id.clone(), message.conversation_id.clone()); + let id = inner.next_admission.fetch_add(1, Ordering::Relaxed); + let lifetime = tokio_util::sync::CancellationToken::new(); + inner + .admitted + .lock() + .map_err(|_| Error::Closed)? + .entry(key.clone()) + .or_default() + .push(Admission { + id, + source: message.clone(), + lifetime: lifetime.clone(), + }); + Ok(Self { + inner, + key, + id, + lifetime, + }) + } +} + +impl Drop for AdmittedMessage { + fn drop(&mut self) { + self.lifetime.cancel(); + let mut admitted = self + .inner + .admitted + .lock() + .unwrap_or_else(|error| error.into_inner()); + if let Some(entries) = admitted.get_mut(&self.key) { + entries.retain(|entry| entry.id != self.id); + if entries.is_empty() { + admitted.remove(&self.key); + } + } + } +} + +impl Slack { + pub(super) async fn run_socket(&self, receiver: F) -> Result<()> + where + F: Fn(Message) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + self.inner + .lifecycle + .compare_exchange(NEW, RUNNING, Ordering::AcqRel, Ordering::Acquire) + .map_err(|_| Error::InvalidInput("Slack has already been started or closed".into()))?; + let _stop = StopOnDrop(self.inner.clone()); + let slack = self.clone(); + let (send, receive) = oneshot::channel(); + tokio::spawn(async move { + let _finished = Finished(slack.inner.clone()); + let mut handlers = JoinSet::new(); + let result = slack.drive_socket(Arc::new(receiver), &mut handlers).await; + slack.inner.connected.store(false, Ordering::Release); + slack.request_close(); + let drained = timeout(slack.inner.config.shutdown_timeout, async { + let mut failure = None; + while let Some(completed) = handlers.join_next().await { + if let Err(error) = slack.receiver_result(completed) { + if failure.is_none() { + failure = Some(error); + } + } + } + failure + }) + .await; + let drain_error = match drained { + Ok(error) => error, + Err(_) => { + handlers.abort_all(); + while handlers.join_next().await.is_some() {} + None + } + }; + let result = result.and_then(|()| drain_error.map_or(Ok(()), Err)); + let _ = send.send(result); + }); + receive + .await + .map_err(|_| Error::Transport("Slack transport task stopped unexpectedly".into()))? + } + + async fn drive_socket( + &self, + receiver: Arc, + handlers: &mut JoinSet>, + ) -> Result<()> + where + F: Fn(Message) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + tokio::select! { + biased; + _ = self.inner.stop.cancelled() => return Ok(()), + result = self.authenticate() => { result?; } + } + let mut has_connected = false; + let mut retry_seconds = 1; + loop { + let open_request = json!({}); + let response = tokio::select! { + biased; + _ = self.inner.stop.cancelled() => return Ok(()), + result = self.inner.client.call("apps.connections.open", &open_request) => result, + }; + let response = match response { + Ok(response) => response, + Err(error) if has_connected && error.retryable_read() => { + if self.reconnect_delay(retry_seconds).await { + return Ok(()); + } + retry_seconds = (retry_seconds * 2).min(30); + continue; + } + Err(error) => return Err(error.into()), + }; + let url = response["url"] + .as_str() + .ok_or_else(|| Error::Transport("Slack did not return a socket URL".into()))?; + self.inner.client.validate_socket_url(url)?; + let config = WebSocketConfig::default() + .max_message_size(Some(2 * 1024 * 1024)) + .max_frame_size(Some(2 * 1024 * 1024)); + let connection = tokio::select! { + biased; + _ = self.inner.stop.cancelled() => return Ok(()), + result = timeout(Duration::from_secs(30), connect_async_with_config(url, Some(config), false)) => result, + }; + let mut socket = match connection { + Ok(Ok((socket, _))) => socket, + _ if has_connected => { + if self.reconnect_delay(retry_seconds).await { + return Ok(()); + } + retry_seconds = (retry_seconds * 2).min(30); + continue; + } + _ => { + return Err(Error::Transport( + "Slack Socket Mode connection failed".into(), + )) + } + }; + has_connected = true; + retry_seconds = 1; + self.inner.connected.store(true, Ordering::Release); + let outcome = self + .read_socket(&mut socket, receiver.clone(), handlers) + .await; + self.inner.connected.store(false, Ordering::Release); + // Drop the socket immediately when shutting down; a stalled close handshake must + // not consume the handler-drain budget. TCP/TLS resources are released on drop. + drop(socket); + match outcome? { + SocketExit::Shutdown => return Ok(()), + SocketExit::Reconnect => { + if self.reconnect_delay(retry_seconds).await { + return Ok(()); + } + } + } + } + } + + async fn reconnect_delay(&self, seconds: u64) -> bool { + tokio::select! { + biased; + _ = self.inner.stop.cancelled() => true, + _ = tokio::time::sleep(Duration::from_secs(seconds)) => false, + } + } + + async fn read_socket( + &self, + socket: &mut Socket, + receiver: Arc, + handlers: &mut JoinSet>, + ) -> Result + where + F: Fn(Message) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + let mut heartbeat = tokio::time::interval(Duration::from_secs(15)); + heartbeat.set_missed_tick_behavior(MissedTickBehavior::Delay); + heartbeat.tick().await; + let mut last_received = Instant::now(); + loop { + let frame = tokio::select! { + biased; + _ = self.inner.stop.cancelled() => return Ok(SocketExit::Shutdown), + completed = handlers.join_next(), if !handlers.is_empty() => { + if let Some(completed) = completed { self.receiver_result(completed)?; } + continue; + }, + _ = heartbeat.tick() => { + if last_received.elapsed() > Duration::from_secs(45) + || !self.send_frame(socket, Frame::Ping(Vec::new().into())).await { + return Ok(SocketExit::Reconnect); + } + continue; + }, + frame = socket.next() => frame, + }; + let Some(Ok(frame)) = frame else { + return Ok(SocketExit::Reconnect); + }; + last_received = Instant::now(); + let text = match frame { + Frame::Text(text) => text, + Frame::Ping(payload) => { + if !self.send_frame(socket, Frame::Pong(payload)).await { + return Ok(SocketExit::Reconnect); + } + continue; + } + Frame::Close(_) => return Ok(SocketExit::Reconnect), + _ => continue, + }; + let Ok(envelope) = serde_json::from_str::(&text) else { + continue; + }; + if envelope["type"] == "disconnect" { + if envelope["reason"] == "link_disabled" { + return Err(Error::Transport("Slack Socket Mode was disabled".into())); + } + return Ok(SocketExit::Reconnect); + } + if envelope["type"] != "events_api" { + continue; + } + let Some(envelope_id) = super::events::nonempty(&envelope["envelope_id"]) else { + continue; + }; + if !self.inner.accepting.load(Ordering::Acquire) + || handlers.len() >= self.inner.config.max_pending_events + { + // Leave overflow unacknowledged so Slack can apply its retry policy. + continue; + } + if !self + .send_frame( + socket, + Frame::Text(json!({"envelope_id":envelope_id}).to_string().into()), + ) + .await + { + return Ok(SocketExit::Reconnect); + } + let slack = self.clone(); + let payload = envelope["payload"].clone(); + let receive = receiver.clone(); + let scope = slack.inner.clone(); + handlers.spawn(DELIVERY_SCOPE.scope(scope, async move { + // Accepted work remains valid during shutdown and is drained before CLOSED. + if let Some(message) = slack.prepare_event(&payload).await? { + let _admitted = AdmittedMessage::new(slack.inner.clone(), &message)?; + if let Some(emoji) = slack + .inner + .config + .ack_emoji + .as_deref() + .filter(|v| !v.is_empty()) + { + let _ = slack + .inner + .client + .call( + "reactions.add", + &json!({ + "channel":message.metadata.get("channel_id"), "name":emoji, + "timestamp":message.metadata.get("message_timestamp")}), + ) + .await; + } + receive(message).await?; + } + Ok(()) + })); + } + } + + async fn send_frame(&self, socket: &mut Socket, frame: Frame) -> bool { + tokio::select! { + biased; + _ = self.inner.stop.cancelled() => false, + result = timeout(Duration::from_secs(5), socket.send(frame)) => matches!(result, Ok(Ok(()))), + } + } + + fn receiver_result(&self, result: std::result::Result, JoinError>) -> Result<()> { + let result = + result.unwrap_or_else(|_| Err(Error::Handler("Slack receiver task failed".into()))); + if self.inner.config.stop_on_handler_error { + return result; + } + if result.is_err() { + tracing::warn!("Slack receiver failed; continuing to accept events"); + } + Ok(()) + } +} diff --git a/crates/agentchat-slack/src/transport.rs b/crates/agentchat-slack/src/transport.rs new file mode 100644 index 0000000..62de07c --- /dev/null +++ b/crates/agentchat-slack/src/transport.rs @@ -0,0 +1,213 @@ +//! Single-attempt Web API transport. Errors deliberately omit credential-bearing URLs. + +use std::time::Duration; + +use agentchat_core::{Error, Result}; +use reqwest::{Client, Url}; +use serde_json::Value; + +#[derive(Debug)] +pub(crate) enum RequestError { + Http(u16), + Api(String), + Uncertain, + InvalidResponse, +} + +impl RequestError { + pub(crate) fn retryable_read(&self) -> bool { + matches!(self, Self::Http(429 | 500..=599) | Self::Uncertain) + || matches!(self, Self::Api(code) if matches!(code.as_str(), "ratelimited" | "internal_error" | "service_unavailable")) + } +} + +impl From for Error { + fn from(error: RequestError) -> Self { + let message = match error { + RequestError::Http(status) => format!("Slack API request failed (HTTP {status})"), + RequestError::Api(code) => format!("Slack API rejected the request ({code})"), + RequestError::Uncertain => "Slack request failed; delivery may be uncertain".into(), + RequestError::InvalidResponse => "Slack did not return a valid API response".into(), + }; + Error::Transport(message) + } +} + +#[derive(Clone)] +pub(crate) struct WebClient { + pub(crate) http: Client, + base_url: Url, + bot_token: String, + app_token: String, +} + +pub(crate) fn loopback(url: &Url) -> bool { + url.host_str() + .is_some_and(|host| matches!(host, "localhost" | "127.0.0.1" | "[::1]" | "::1")) +} + +impl WebClient { + pub(crate) fn new(base_url: &str, bot_token: String, app_token: String) -> Result { + let mut base_url = Url::parse(base_url) + .map_err(|_| Error::InvalidInput("Invalid Slack API base URL".into()))?; + if !(base_url.scheme() == "https" || (base_url.scheme() == "http" && loopback(&base_url))) + || base_url.host_str().is_none() + || !base_url.username().is_empty() + || base_url.password().is_some() + || base_url.query().is_some() + || base_url.fragment().is_some() + { + return Err(Error::InvalidInput("Slack API requires HTTPS (HTTP is allowed only on loopback) without credentials or query parameters".into())); + } + if !base_url.path().ends_with('/') { + base_url.set_path(&format!("{}/", base_url.path())); + } + let http = Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .retry(reqwest::retry::never()) + .timeout(Duration::from_secs(30)) + .build() + .map_err(|_| Error::Transport("Could not create Slack HTTP client".into()))?; + Ok(Self { + http, + base_url, + bot_token, + app_token, + }) + } + + pub(crate) fn bot_token(&self) -> &str { + &self.bot_token + } + + pub(crate) fn file_url(&self, value: &str, path_prefix: &str) -> Result { + let url = Url::parse(value) + .map_err(|_| Error::InvalidInput("Slack returned an invalid file URL".into()))?; + let trusted_loopback = loopback(&self.base_url) + && loopback(&url) + && url.host_str() == self.base_url.host_str() + && url.port_or_known_default() == self.base_url.port_or_known_default() + && url.scheme() == self.base_url.scheme(); + let trusted_slack = url.scheme() == "https" + && url.host_str() == Some("files.slack.com") + && matches!(url.port(), None | Some(443)); + if !(trusted_loopback || trusted_slack) + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + || !url.path().starts_with(path_prefix) + { + return Err(Error::InvalidInput( + "Slack returned an unsupported file destination".into(), + )); + } + Ok(url) + } + + pub(crate) async fn get( + &self, + method: &str, + payload: &Value, + ) -> std::result::Result { + let url = self + .base_url + .join(method) + .map_err(|_| RequestError::InvalidResponse)?; + let response = self + .http + .get(url) + .bearer_auth(&self.bot_token) + .query(payload) + .send() + .await + .map_err(|_| RequestError::Uncertain)?; + Self::response(response).await + } + + pub(crate) fn validate_socket_url(&self, url: &str) -> Result<()> { + let url = Url::parse(url) + .map_err(|_| Error::Transport("Slack returned an invalid socket URL".into()))?; + let valid = if loopback(&self.base_url) { + matches!(url.scheme(), "ws" | "wss") && loopback(&url) + } else { + url.scheme() == "wss" + && url + .host_str() + .is_some_and(|host| host.ends_with(".slack.com")) + }; + if !valid + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + { + return Err(Error::Transport( + "Slack returned an unsupported socket destination".into(), + )); + } + Ok(()) + } + + pub(crate) async fn call( + &self, + method: &str, + payload: &Value, + ) -> std::result::Result { + self.request(method, payload, false).await + } + + pub(crate) async fn form( + &self, + method: &str, + payload: &Value, + ) -> std::result::Result { + self.request(method, payload, true).await + } + + async fn request( + &self, + method: &str, + payload: &Value, + form: bool, + ) -> std::result::Result { + let url = self + .base_url + .join(method) + .map_err(|_| RequestError::InvalidResponse)?; + let token = if method == "apps.connections.open" { + &self.app_token + } else { + &self.bot_token + }; + let request = self.http.post(url).bearer_auth(token); + let response = if form { + request.form(payload) + } else { + request.json(payload) + } + .send() + .await + .map_err(|_| RequestError::Uncertain)?; + Self::response(response).await + } + + async fn response(response: reqwest::Response) -> std::result::Result { + if !response.status().is_success() { + return Err(RequestError::Http(response.status().as_u16())); + } + let result: Value = response + .json() + .await + .map_err(|_| RequestError::InvalidResponse)?; + if result["ok"] != true { + // Accept only Slack's machine-readable error code; never reflect arbitrary server text. + let code = result["error"] + .as_str() + .filter(|code| { + code.len() <= 80 && code.bytes().all(|c| c.is_ascii_lowercase() || c == b'_') + }) + .unwrap_or("unknown_error"); + return Err(RequestError::Api(code.into())); + } + Ok(result) + } +} diff --git a/crates/agentchat-slack/tests/normalization.rs b/crates/agentchat-slack/tests/normalization.rs new file mode 100644 index 0000000..50e4ea3 --- /dev/null +++ b/crates/agentchat-slack/tests/normalization.rs @@ -0,0 +1,170 @@ +use agentchat_core::{RichReply, Role}; +use agentchat_slack::{events, rendering}; +use serde_json::{json, Value}; + +fn event() -> Value { + json!({"team_id":"T1","event_id":"Ev1","event":{ + "type":"app_mention","channel":"C1","user":"U1","ts":"1.000001", + "text":"<@BOT> hi 🦀 <@OTHER>"}}) +} + +#[test] +fn shared_python_reference_fixtures_match_exactly() { + let fixtures: Value = + serde_json::from_str(include_str!("../../../conformance/slack-events.json")).unwrap(); + for case in fixtures.as_array().unwrap() { + let actual = events::normalize( + &case["payload"], + case["bot_user_id"].as_str().unwrap(), + case["workspace_id"].as_str().unwrap(), + ); + assert_eq!( + serde_json::to_value(actual).unwrap(), + case["expected"], + "{}", + case["name"] + ); + } +} + +#[test] +fn messages_preserve_identity_and_only_strip_our_mention() { + let payload = event(); + let message = events::normalize(&payload, "BOT", "T1").unwrap(); + assert_eq!(message.id.as_ref(), "slack:T1:C1:1.000001"); + assert_eq!(message.conversation_id.as_ref(), "slack:T1:C1:1.000001"); + assert_eq!(message.thread_id.as_deref(), Some("1.000001")); + assert_eq!(message.text, "hi 🦀 <@OTHER>"); + assert!(message.addressed); + assert_eq!(message.role, Role::User); + let mut duplicate = payload; + duplicate["event_id"] = json!("another-envelope"); + assert_eq!( + events::normalize(&duplicate, "BOT", "T1").unwrap().id, + message.id + ); +} + +#[test] +fn direct_messages_share_conversation_but_preserve_explicit_threads() { + let mut payload = event(); + payload["event"]["type"] = json!("message"); + payload["event"]["channel_type"] = json!("im"); + let first = events::normalize(&payload, "BOT", "T1").unwrap(); + assert_eq!(first.conversation_id.as_ref(), "slack:T1:C1"); + assert_eq!(first.thread_id, None); + payload["event"]["ts"] = json!("2.000002"); + payload["event"]["thread_ts"] = json!("1.000001"); + let reply = events::normalize(&payload, "BOT", "T1").unwrap(); + assert_eq!(reply.conversation_id, first.conversation_id); + assert_eq!(reply.thread_id.as_deref(), Some("1.000001")); +} + +#[test] +fn ignores_foreign_workspace_self_bots_and_mutations() { + assert!(events::normalize(&event(), "BOT", "T2").is_none()); + for (field, value) in [ + ("user", json!("BOT")), + ("bot_id", json!("B1")), + ("bot_profile", json!({})), + ("subtype", json!("message_changed")), + ("thread_ts", json!(123)), + ("text", json!(123)), + ("channel", json!("")), + ] { + let mut payload = event(); + payload["event"][field] = value; + assert!( + events::normalize(&payload, "BOT", "T1").is_none(), + "{field}" + ); + } +} + +#[test] +fn unaddressed_channel_threads_require_subscription() { + let mut payload = event(); + payload["event"]["type"] = json!("message"); + payload["event"]["channel_type"] = json!("channel"); + payload["event"]["text"] = json!("follow up"); + assert!(events::normalize(&payload, "BOT", "T1").is_none()); + payload["event"]["thread_ts"] = json!("0.001"); + let message = events::normalize(&payload, "BOT", "T1").unwrap(); + assert!(!message.addressed); + assert_eq!(message.metadata["requires_subscription"], true); + assert_eq!(message.conversation_id.as_ref(), "slack:T1:C1:0.001"); +} + +#[test] +fn file_only_messages_keep_bounded_sanitized_references() { + let mut payload = event(); + payload["event"]["text"] = json!("<@BOT>"); + payload["event"]["subtype"] = json!("file_share"); + payload["event"]["files"] = json!([ + {"id":"F1","name":"/tmp/..\\report\u{200d}\u{e000}.txt","size":10,"mimetype":"text/plain","url_private":"secret"}, + {"id":"F1","name":"duplicate"}, {"id":"../unsafe"}, {"id":"F2","size":true}, + {"id":"F3","name":"..."}, {"id":"F4"}]); + let message = events::normalize(&payload, "BOT", "T1").unwrap(); + assert_eq!(message.attachments.len(), 3); + assert_eq!(message.attachments[0].name.as_deref(), Some("report.txt")); + assert_eq!(message.attachments[1].size, None); + assert_eq!(message.attachments[2].name.as_deref(), Some("attachment")); + assert!(!serde_json::to_string(&message).unwrap().contains("secret")); +} + +#[test] +fn rendering_preserves_unicode_and_reopens_code_fences() { + let text = format!("```rust\n{}\n```", "🦀".repeat(7000)); + let blocks = rendering::text_blocks(&text).unwrap(); + assert_eq!(blocks.len(), 3); + for block in &blocks { + let section = block["text"]["text"].as_str().unwrap(); + assert!(section.chars().count() <= 3000); + assert_eq!(section.matches("```").count() % 2, 0); + assert_eq!(block["expand"], true); + assert_eq!(block["text"]["verbatim"], true); + } + assert_eq!( + blocks + .iter() + .map(|b| b["text"]["text"].as_str().unwrap().matches('🦀').count()) + .sum::(), + 7000 + ); + assert!(rendering::text_blocks(" ").is_err()); + assert!(rendering::text_blocks(&"x".repeat(2992 * 50 + 1)).is_err()); +} + +#[test] +fn rich_payload_expands_sections_without_overriding_explicit_behavior() { + let reply = RichReply { + text: "fallback".into(), + blocks: vec![ + json!({"type":"section","text":{"type":"plain_text","text":"one"}}) + .as_object() + .unwrap() + .clone(), + json!({"type":"section","expand":false}) + .as_object() + .unwrap() + .clone(), + ], + attachments: Vec::new(), + }; + let payload = rendering::rich_payload(&reply).unwrap(); + assert_eq!(payload["blocks"][0]["expand"], true); + assert_eq!(payload["blocks"][1]["expand"], false); + assert_eq!(payload["parse"], "none"); +} + +#[test] +fn mirror_uses_plain_text_and_escaped_fallback() { + let message = events::normalize(&event(), "BOT", "T1").unwrap(); + let payload = rendering::mirror_payload(&message, "web").unwrap(); + assert_eq!(payload["blocks"][1]["text"]["type"], "plain_text"); + assert!(payload["text"].as_str().unwrap().contains("<@OTHER>")); + assert_eq!(payload["mrkdwn"], false); + let mut too_long = message; + too_long.text = "🦀".repeat(2801); + assert!(rendering::mirror_payload(&too_long, "web").is_err()); +} diff --git a/crates/agentchat-slack/tests/support/mod.rs b/crates/agentchat-slack/tests/support/mod.rs new file mode 100644 index 0000000..2b1fa78 --- /dev/null +++ b/crates/agentchat-slack/tests/support/mod.rs @@ -0,0 +1,276 @@ +#![allow(dead_code)] + +use std::collections::{HashMap, VecDeque}; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; +use std::time::Duration; + +use agentchat_slack::{Slack, SlackConfig}; +use axum::{ + body::Bytes, + extract::{ + ws::{Message, WebSocket}, + Path, State, WebSocketUpgrade, + }, + http::{HeaderMap, StatusCode, Uri}, + response::{IntoResponse, Response}, + routing::{any, get}, + Router, +}; +use futures_util::StreamExt; +use serde_json::{json, Value}; +use tokio::sync::{mpsc, Mutex, Notify}; +use tokio::task::JoinHandle; + +#[derive(Clone, Debug)] +pub struct Request { + pub method: String, + pub payload: Value, + pub authorization: Option, + pub query: Option, +} + +#[derive(Clone)] +pub enum Reply { + Json(StatusCode, Value), + Gate(Arc, Value), + Bytes(Vec), + Redirect(String), +} + +#[derive(Clone)] +pub struct MockState { + pub base: String, + pub requests: Arc>>, + pub overrides: Arc>>>, + pub connections: Arc, + pub inbound: Arc>>, + pub acks: mpsc::UnboundedSender, +} + +pub struct MockSlack { + pub state: MockState, + pub sender: mpsc::UnboundedSender, + acks: Mutex>, + server: JoinHandle<()>, +} + +impl Drop for MockSlack { + fn drop(&mut self) { + self.server.abort(); + } +} + +impl MockSlack { + pub async fn start() -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let (sender, inbound) = mpsc::unbounded_channel(); + let (acks_sender, acks) = mpsc::unbounded_channel(); + let state = MockState { + base, + requests: Arc::new(Mutex::new(Vec::new())), + overrides: Arc::new(Mutex::new(HashMap::new())), + connections: Arc::new(AtomicUsize::new(0)), + inbound: Arc::new(Mutex::new(inbound)), + acks: acks_sender, + }; + let app = Router::new() + .route("/api/{method}", any(api)) + .route("/socket", get(socket_upgrade)) + .route("/upload/{id}", any(upload)) + .route("/files-pri/{id}", get(download)) + .with_state(state.clone()); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + Self { + state, + sender, + acks: Mutex::new(acks), + server, + } + } + + pub fn config(&self) -> SlackConfig { + let mut config = SlackConfig::new("xoxb-test-secret".into(), "xapp-test-secret".into()); + config.api_base_url = format!("{}/api/", self.state.base); + config.ack_emoji = None; + config.shutdown_timeout = Duration::from_millis(100); + config + } + pub fn slack(&self) -> Slack { + Slack::new(self.config()).unwrap() + } + pub async fn override_reply(&self, method: &str, reply: Reply) { + self.state + .overrides + .lock() + .await + .entry(method.into()) + .or_default() + .push_back(reply); + } + pub async fn calls(&self, method: &str) -> Vec { + self.state + .requests + .lock() + .await + .iter() + .filter(|request| request.method == method) + .cloned() + .collect() + } + pub fn send(&self, value: Value) { + self.sender + .send(Message::Text(value.to_string().into())) + .unwrap(); + } + pub async fn ack(&self) -> Value { + tokio::time::timeout(Duration::from_secs(3), self.acks.lock().await.recv()) + .await + .unwrap() + .unwrap() + } + pub async fn connected(&self, count: usize) { + tokio::time::timeout(Duration::from_secs(5), async { + while self.state.connections.load(Ordering::Acquire) < count { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + } +} + +pub fn event(timestamp: &str) -> Value { + json!({"team_id":"T1","event_id":format!("Ev{timestamp}"),"event":{ + "type":"app_mention","channel":"C1","user":"U1","ts":timestamp,"text":"<@BOT> hello"}}) +} + +pub fn envelope(id: &str, timestamp: &str) -> Value { + json!({"type":"events_api","envelope_id":id,"payload":event(timestamp)}) +} + +async fn api( + State(state): State, + Path(method): Path, + headers: HeaderMap, + uri: Uri, + body: Bytes, +) -> Response { + let payload = serde_json::from_slice(&body) + .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&body).into_owned())); + state.requests.lock().await.push(Request { + method: method.clone(), + payload, + authorization: headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned), + query: uri.query().map(str::to_owned), + }); + let response = state + .overrides + .lock() + .await + .get_mut(&method) + .and_then(VecDeque::pop_front); + if let Some(response) = response { + return render(response).await; + } + let value = match method.as_str() { + "auth.test" => json!({"ok":true,"team_id":"T1","user_id":"BOT"}), + "apps.connections.open" => { + json!({"ok":true,"url":format!("{}/socket", state.base.replace("http://", "ws://"))}) + } + "chat.postMessage" => json!({"ok":true,"ts":"9.000001"}), + "files.getUploadURLExternal" => { + json!({"ok":true,"file_id":"F1","upload_url":format!("{}/upload/F1",state.base)}) + } + "files.completeUploadExternal" => { + json!({"ok":true,"files":[{"id":"F1","permalink":"https://slack.com/file/F1"}]}) + } + "files.info" => { + json!({"ok":true,"file":{"id":"F1","size":4,"name":"test.txt","mimetype":"text/plain", + "url_private_download":format!("{}/files-pri/F1",state.base)}}) + } + "conversations.replies" => json!({"ok":true,"messages":[],"has_more":false}), + _ => json!({"ok":true}), + }; + axum::Json(value).into_response() +} + +async fn render(reply: Reply) -> Response { + match reply { + Reply::Json(status, value) => (status, axum::Json(value)).into_response(), + Reply::Gate(gate, value) => { + gate.notified().await; + axum::Json(value).into_response() + } + Reply::Bytes(bytes) => bytes.into_response(), + Reply::Redirect(url) => (StatusCode::FOUND, [("location", url)]).into_response(), + } +} + +async fn upload(State(state): State, headers: HeaderMap, body: Bytes) -> Response { + state.requests.lock().await.push(Request { + method: "upload".into(), + payload: json!(body.to_vec()), + authorization: headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned), + query: None, + }); + "OK".into_response() +} + +async fn download(State(state): State, headers: HeaderMap) -> Response { + state.requests.lock().await.push(Request { + method: "download".into(), + payload: Value::Null, + authorization: headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned), + query: None, + }); + let response = state + .overrides + .lock() + .await + .get_mut("download") + .and_then(VecDeque::pop_front); + render(response.unwrap_or(Reply::Bytes(b"test".to_vec()))).await +} + +async fn socket_upgrade(State(state): State, upgrade: WebSocketUpgrade) -> Response { + upgrade.on_upgrade(move |socket| socket_session(state, socket)) +} + +async fn socket_session(state: MockState, mut socket: WebSocket) { + state.connections.fetch_add(1, Ordering::AcqRel); + let _ = socket + .send(Message::Text(json!({"type":"hello"}).to_string().into())) + .await; + loop { + tokio::select! { + message = async { state.inbound.lock().await.recv().await } => { + let Some(message) = message else { return }; + if socket.send(message).await.is_err() { return; } + } + message = socket.next() => match message { + Some(Ok(Message::Text(text))) => { let _ = state.acks.send(serde_json::from_str(&text).unwrap()); } + Some(Ok(Message::Ping(bytes))) => { + let response = socket.send(Message::Pong(bytes)).await; + if response.is_err() { return; } + } + Some(Ok(Message::Close(_))) | Some(Err(_)) | None => return, + _ => {} + } + } + } +} diff --git a/crates/agentchat-slack/tests/transport.rs b/crates/agentchat-slack/tests/transport.rs new file mode 100644 index 0000000..c0abe21 --- /dev/null +++ b/crates/agentchat-slack/tests/transport.rs @@ -0,0 +1,704 @@ +mod support; + +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; +use std::time::Duration; + +use agentchat_core::{Error, Role, UploadFile}; +use agentchat_slack::{events, rendering, Slack}; +use axum::http::StatusCode; +use serde_json::json; +use tokio::sync::{mpsc, Notify}; + +use support::{envelope, event, MockSlack, Reply}; + +#[tokio::test] +async fn authenticates_accounts_and_separates_bot_and_app_credentials() { + let mock = MockSlack::start().await; + let slack = mock.slack(); + let run = tokio::spawn({ + let slack = slack.clone(); + async move { slack.run(|_| async { Ok(()) }).await } + }); + mock.connected(1).await; + assert!(slack.is_connected()); + assert_eq!( + mock.calls("auth.test").await[0].authorization.as_deref(), + Some("Bearer xoxb-test-secret") + ); + assert_eq!( + mock.calls("apps.connections.open").await[0] + .authorization + .as_deref(), + Some("Bearer xapp-test-secret") + ); + slack.close().await.unwrap(); + run.await.unwrap().unwrap(); + assert!(!slack.is_connected()); + assert!(slack.run(|_| async { Ok(()) }).await.is_err()); +} + +#[tokio::test] +async fn refuses_a_different_workspace_before_opening_socket() { + let mock = MockSlack::start().await; + let mut config = mock.config(); + config.workspace_id = Some("T2".into()); + let slack = Slack::new(config).unwrap(); + assert!(slack + .run(|_| async { Ok(()) }) + .await + .unwrap_err() + .to_string() + .contains("different workspace")); + assert!(mock.calls("apps.connections.open").await.is_empty()); + slack.close().await.unwrap(); +} + +#[tokio::test] +async fn acknowledges_before_handler_and_leaves_overflow_for_retry() { + let mock = MockSlack::start().await; + let mut config = mock.config(); + config.max_pending_events = 1; + let slack = Slack::new(config).unwrap(); + let release = Arc::new(Notify::new()); + let (started, mut received) = mpsc::unbounded_channel(); + let run = tokio::spawn({ + let slack = slack.clone(); + let release = release.clone(); + async move { + slack + .run(move |message| { + let release = release.clone(); + let started = started.clone(); + async move { + started.send(message.id.to_string()).unwrap(); + release.notified().await; + Ok(()) + } + }) + .await + } + }); + mock.connected(1).await; + mock.send(envelope("one", "1.000001")); + assert_eq!(mock.ack().await["envelope_id"], "one"); + assert_eq!(received.recv().await.unwrap(), "slack:T1:C1:1.000001"); + mock.send(envelope("two", "2.000001")); + assert!(tokio::time::timeout(Duration::from_millis(50), mock.ack()) + .await + .is_err()); + assert!(received.try_recv().is_err()); + release.notify_one(); + tokio::time::sleep(Duration::from_millis(20)).await; + mock.send(envelope("two-retry", "2.000001")); + assert_eq!(mock.ack().await["envelope_id"], "two-retry"); + assert_eq!(received.recv().await.unwrap(), "slack:T1:C1:2.000001"); + release.notify_one(); + slack.close().await.unwrap(); + run.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn shutdown_drains_accepted_handler_and_allows_its_final_reply() { + let mock = MockSlack::start().await; + let mut config = mock.config(); + config.shutdown_timeout = Duration::from_secs(2); + let slack = Slack::new(config).unwrap(); + let release = Arc::new(Notify::new()); + let started = Arc::new(Notify::new()); + let run = tokio::spawn({ + let slack = slack.clone(); + let callback_slack = slack.clone(); + let release = release.clone(); + let started = started.clone(); + async move { + slack + .run(move |message| { + let slack = callback_slack.clone(); + let release = release.clone(); + let started = started.clone(); + async move { + started.notify_one(); + release.notified().await; + slack.reply(&message, "done").await?; + Ok(()) + } + }) + .await + } + }); + mock.connected(1).await; + mock.send(envelope("one", "1.000001")); + mock.ack().await; + started.notified().await; + let close = tokio::spawn({ + let slack = slack.clone(); + async move { slack.close().await } + }); + tokio::task::yield_now().await; + assert!(!close.is_finished()); + release.notify_one(); + tokio::time::timeout(Duration::from_secs(3), close) + .await + .unwrap() + .unwrap() + .unwrap(); + run.await.unwrap().unwrap(); + assert_eq!(mock.calls("chat.postMessage").await.len(), 1); + slack.close().await.unwrap(); +} + +struct Dropped(Arc); +impl Drop for Dropped { + fn drop(&mut self) { + self.0.store(true, Ordering::Release); + } +} + +#[tokio::test] +async fn shutdown_cancels_stuck_handlers_at_deadline() { + let mock = MockSlack::start().await; + let mut config = mock.config(); + config.shutdown_timeout = Duration::from_millis(20); + let slack = Slack::new(config).unwrap(); + let dropped = Arc::new(AtomicBool::new(false)); + let started = Arc::new(Notify::new()); + let run = tokio::spawn({ + let slack = slack.clone(); + let dropped = dropped.clone(); + let started = started.clone(); + async move { + slack + .run(move |_| { + let dropped = dropped.clone(); + let started = started.clone(); + async move { + let _guard = Dropped(dropped); + started.notify_one(); + std::future::pending::<()>().await; + Ok(()) + } + }) + .await + } + }); + mock.connected(1).await; + mock.send(envelope("one", "1.000001")); + started.notified().await; + tokio::time::timeout(Duration::from_secs(1), slack.close()) + .await + .unwrap() + .unwrap(); + run.await.unwrap().unwrap(); + assert!(dropped.load(Ordering::Acquire)); +} + +#[tokio::test] +async fn cancelled_run_stops_ingress_and_cleans_up() { + let mock = MockSlack::start().await; + let slack = mock.slack(); + let run = tokio::spawn({ + let slack = slack.clone(); + async move { slack.run(|_| async { Ok(()) }).await } + }); + mock.connected(1).await; + run.abort(); + assert!(run.await.unwrap_err().is_cancelled()); + tokio::time::timeout(Duration::from_secs(1), slack.close()) + .await + .unwrap() + .unwrap(); + assert!(!slack.is_connected()); +} + +#[tokio::test] +async fn close_cancels_inflight_authentication_without_waiting_for_http_timeout() { + let mock = MockSlack::start().await; + let gate = Arc::new(Notify::new()); + mock.override_reply( + "auth.test", + Reply::Gate( + gate.clone(), + json!({"ok":true,"team_id":"T1","user_id":"BOT"}), + ), + ) + .await; + let slack = mock.slack(); + let run = tokio::spawn({ + let slack = slack.clone(); + async move { slack.run(|_| async { Ok(()) }).await } + }); + tokio::time::timeout(Duration::from_secs(2), async { + while mock.calls("auth.test").await.is_empty() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(1), slack.close()) + .await + .unwrap() + .unwrap(); + run.await.unwrap().unwrap(); + assert!(mock.calls("apps.connections.open").await.is_empty()); + gate.notify_one(); +} + +#[tokio::test] +async fn reconnects_with_a_fresh_socket_url() { + let mock = MockSlack::start().await; + let slack = mock.slack(); + let (sent, mut received) = mpsc::unbounded_channel(); + let run = tokio::spawn({ + let slack = slack.clone(); + async move { + slack + .run(move |message| { + let sent = sent.clone(); + async move { + sent.send(message).unwrap(); + Ok(()) + } + }) + .await + } + }); + mock.connected(1).await; + mock.send(json!({"type":"disconnect","reason":"refresh_requested"})); + mock.connected(2).await; + mock.send(envelope("after-reconnect", "1.000001")); + assert_eq!(mock.ack().await["envelope_id"], "after-reconnect"); + assert_eq!(received.recv().await.unwrap().text, "hello"); + assert_eq!(mock.calls("apps.connections.open").await.len(), 2); + slack.close().await.unwrap(); + run.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn receiver_errors_propagate_instead_of_disappearing_in_background_tasks() { + let mock = MockSlack::start().await; + let mut config = mock.config(); + config.stop_on_handler_error = true; + let slack = Slack::new(config).unwrap(); + let run = tokio::spawn({ + let slack = slack.clone(); + async move { + slack + .run(|_| async { Err(Error::Handler("agent failed".into())) }) + .await + } + }); + mock.connected(1).await; + mock.send(envelope("one", "1.000001")); + let error = tokio::time::timeout(Duration::from_secs(2), run) + .await + .unwrap() + .unwrap() + .unwrap_err(); + assert_eq!(error.to_string(), "agent failed"); + slack.close().await.unwrap(); +} + +#[tokio::test] +async fn default_handler_failure_policy_keeps_other_turns_running() { + let mock = MockSlack::start().await; + let slack = mock.slack(); + let (sent, mut received) = mpsc::unbounded_channel(); + let run = tokio::spawn({ + let slack = slack.clone(); + async move { + slack + .run(move |message| { + let sent = sent.clone(); + async move { + sent.send(message.id).unwrap(); + Err(Error::Handler("agent failed".into())) + } + }) + .await + } + }); + mock.connected(1).await; + for (id, ts) in [("one", "1.000001"), ("two", "2.000001")] { + mock.send(envelope(id, ts)); + assert_eq!(mock.ack().await["envelope_id"], id); + received.recv().await.unwrap(); + } + assert!(!run.is_finished()); + slack.close().await.unwrap(); + run.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn a_handler_can_close_without_waiting_for_its_own_drain() { + let mock = MockSlack::start().await; + let slack = mock.slack(); + let finished = Arc::new(AtomicBool::new(false)); + let run = tokio::spawn({ + let slack = slack.clone(); + let callback_slack = slack.clone(); + let finished = finished.clone(); + async move { + slack + .run(move |message| { + let slack = callback_slack.clone(); + let finished = finished.clone(); + async move { + slack.close().await?; + slack.reply(&message, "goodbye").await?; + finished.store(true, Ordering::Release); + Ok(()) + } + }) + .await + } + }); + mock.connected(1).await; + mock.send(envelope("one", "1.000001")); + tokio::time::timeout(Duration::from_secs(2), run) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(finished.load(Ordering::Acquire)); + assert_eq!(mock.calls("chat.postMessage").await.len(), 1); +} + +#[tokio::test] +async fn closing_allows_original_admitted_ffi_context_but_rejects_new_or_altered_sources() { + let mock = MockSlack::start().await; + let mut config = mock.config(); + config.shutdown_timeout = Duration::from_secs(2); + let slack = Slack::new(config).unwrap(); + let release = Arc::new(Notify::new()); + let (sent, mut received) = mpsc::unbounded_channel(); + let run = tokio::spawn({ + let slack = slack.clone(); + let release = release.clone(); + async move { + slack + .run(move |message| { + let release = release.clone(); + let sent = sent.clone(); + async move { + sent.send(message).unwrap(); + release.notified().await; + Ok(()) + } + }) + .await + } + }); + mock.connected(1).await; + mock.send(envelope("one", "1.000001")); + let source = received.recv().await.unwrap(); + slack.request_close(); + assert!(matches!( + slack.reply(&source, "unscoped").await, + Err(Error::Closed) + )); + let mut altered = source.clone(); + altered.metadata.insert("channel_id".into(), json!("C2")); + assert!(matches!( + slack + .with_admitted_source(&altered, slack.reply(&altered, "altered")) + .await, + Err(Error::Closed) + )); + slack + .with_admitted_source(&source, slack.reply(&source, "allowed")) + .await + .unwrap(); + release.notify_one(); + slack.close().await.unwrap(); + run.await.unwrap().unwrap(); + assert_eq!(mock.calls("chat.postMessage").await.len(), 1); + assert!(slack + .with_admitted_source(&source, slack.reply(&source, "late")) + .await + .is_err()); +} + +#[tokio::test] +async fn forced_shutdown_cancels_native_operations_started_on_another_ffi_task() { + let mock = MockSlack::start().await; + let mut config = mock.config(); + config.shutdown_timeout = Duration::from_millis(20); + let slack = Slack::new(config).unwrap(); + let (sent, mut received) = mpsc::unbounded_channel(); + let run = tokio::spawn({ + let slack = slack.clone(); + async move { + slack + .run(move |message| { + let sent = sent.clone(); + async move { + sent.send(message).unwrap(); + std::future::pending::<()>().await; + Ok(()) + } + }) + .await + } + }); + mock.connected(1).await; + mock.send(envelope("one", "1.000001")); + let source = received.recv().await.unwrap(); + let started = Arc::new(Notify::new()); + let dropped = Arc::new(AtomicBool::new(false)); + let operation = tokio::spawn({ + let slack = slack.clone(); + let started = started.clone(); + let dropped = dropped.clone(); + async move { + slack + .with_admitted_source(&source, async { + let _drop = Dropped(dropped); + started.notify_one(); + std::future::pending::>().await + }) + .await + } + }); + started.notified().await; + slack.close().await.unwrap(); + run.await.unwrap().unwrap(); + let error = tokio::time::timeout(Duration::from_secs(1), operation) + .await + .unwrap() + .unwrap() + .unwrap_err(); + assert!(error.to_string().contains("delivery may be uncertain")); + assert!(dropped.load(Ordering::Acquire)); +} + +#[tokio::test] +async fn subscription_and_route_checks_run_before_network_sends() { + let mock = MockSlack::start().await; + let slack = mock.slack(); + slack.authenticate().await.unwrap(); + let root = events::normalize(&event("1.000001"), "BOT", "T1").unwrap(); + let mut payload = event("2.000001"); + payload["event"]["type"] = json!("message"); + payload["event"]["channel_type"] = json!("channel"); + payload["event"]["thread_ts"] = json!("1.000001"); + payload["event"]["text"] = json!("follow up"); + assert!(slack.handle_event(&payload).await.unwrap().is_none()); + slack.subscribe(&root).await.unwrap(); + assert!(slack.handle_event(&payload).await.unwrap().is_some()); + let mut foreign = root.clone(); + foreign.metadata.insert("team_id".into(), json!("T2")); + assert!(slack.reply(&foreign, "no").await.is_err()); + assert!(slack.set_status(&foreign, "working").await.is_err()); + assert!(mock.calls("chat.postMessage").await.is_empty()); + let sent = slack.reply(&root, "answer 🦀").await.unwrap(); + assert_eq!(sent.id.as_ref(), "slack:C1:9.000001"); + assert_eq!(sent.conversation_id, root.conversation_id); + assert_eq!(sent.role, Role::Assistant); + assert_eq!( + mock.calls("chat.postMessage").await[0].payload["thread_ts"], + "1.000001" + ); +} + +#[tokio::test] +async fn sends_require_receipts_and_never_retry_errors_or_redirects() { + let mock = MockSlack::start().await; + let slack = mock.slack(); + slack.authenticate().await.unwrap(); + let source = events::normalize(&event("1.000001"), "BOT", "T1").unwrap(); + for reply in [ + Reply::Json(StatusCode::INTERNAL_SERVER_ERROR, json!({"ok":false})), + Reply::Json(StatusCode::OK, json!({"ok":true})), + Reply::Json(StatusCode::OK, json!({"ok":true,"ts":"unknown"})), + Reply::Redirect(format!("{}/api/should-not-follow", mock.state.base)), + ] { + let before = mock.calls("chat.postMessage").await.len(); + mock.override_reply("chat.postMessage", reply).await; + assert!(slack.reply(&source, "answer").await.is_err()); + assert_eq!(mock.calls("chat.postMessage").await.len(), before + 1); + } + assert!(mock.calls("should-not-follow").await.is_empty()); +} + +#[tokio::test] +async fn status_does_not_create_threads_in_plain_dms() { + let mock = MockSlack::start().await; + let slack = mock.slack(); + slack.authenticate().await.unwrap(); + let mut payload = event("1.000001"); + payload["event"]["type"] = json!("message"); + payload["event"]["channel_type"] = json!("im"); + let dm = events::normalize(&payload, "BOT", "T1").unwrap(); + assert!(!slack.set_status(&dm, "working").await.unwrap()); + assert!(mock.calls("assistant.threads.setStatus").await.is_empty()); + let thread = events::normalize(&event("1.000001"), "BOT", "T1").unwrap(); + assert!(slack.set_status(&thread, "working").await.unwrap()); +} + +#[tokio::test] +async fn stop_admission_leaves_existing_status_cleanup_available_until_close() { + let mock = MockSlack::start().await; + let slack = mock.slack(); + let run = tokio::spawn({ + let slack = slack.clone(); + async move { + slack + .run(|_| async { panic!("must not admit after stop_admission") }) + .await + } + }); + mock.connected(1).await; + slack.stop_admission(); + mock.send(envelope("unadmitted", "1.000001")); + assert!(tokio::time::timeout(Duration::from_millis(50), mock.ack()) + .await + .is_err()); + let source = events::normalize(&event("1.000001"), "BOT", "T1").unwrap(); + assert!(slack.handle_event(&event("1.000001")).await.is_err()); + assert!(slack.set_status(&source, "").await.unwrap()); + assert_eq!(mock.calls("assistant.threads.setStatus").await.len(), 1); + slack.close().await.unwrap(); + run.await.unwrap().unwrap(); + assert!(slack.set_status(&source, "").await.is_err()); +} + +#[tokio::test] +async fn history_preserves_root_recent_messages_and_authenticates_mirror_metadata() { + let mock = MockSlack::start().await; + let slack = mock.slack(); + slack.authenticate().await.unwrap(); + let mut payload = event("3.000001"); + payload["event"]["thread_ts"] = json!("1.000001"); + let source = events::normalize(&payload, "BOT", "T1").unwrap(); + let mut mirror = rendering::mirror_payload(&source, "web").unwrap(); + mirror["user"] = json!("BOT"); + mirror["ts"] = json!("2.000001"); + mock.override_reply( + "conversations.replies", + Reply::Json( + StatusCode::OK, + json!({"ok":true, + "messages":[{"ts":"1.000001","user":"U0","text":"root"}, mirror, + {"ts":"3.000001","user":"U1","text":"current"}],"has_more":false}), + ), + ) + .await; + let history = slack.thread_history(&source, 2).await.unwrap(); + assert_eq!(history.len(), 2); + assert_eq!(history[0].text, "root"); + assert_eq!(history[1].sender.id, "U1"); + assert_eq!(history[1].role, Role::User); + assert_eq!(history[1].text, "hello"); + assert!(mock.calls("conversations.replies").await[0] + .query + .as_ref() + .unwrap() + .contains("inclusive=false")); +} + +#[tokio::test] +async fn file_upload_is_staged_and_download_is_explicit_authenticated_and_bounded() { + let mock = MockSlack::start().await; + let slack = mock.slack(); + slack.authenticate().await.unwrap(); + let mut payload = event("1.000001"); + payload["event"]["files"] = json!([{"id":"F1"}]); + let source = events::normalize(&payload, "BOT", "T1").unwrap(); + assert!(mock.calls("files.info").await.is_empty()); + let uploaded = slack + .upload_files( + &source, + &[UploadFile { + filename: "test.txt".into(), + content: b"test".to_vec(), + title: None, + }], + ) + .await + .unwrap(); + assert_eq!(uploaded[0].id, "F1"); + assert_eq!(mock.calls("upload").await[0].authorization, None); + assert_eq!( + mock.calls("files.completeUploadExternal").await[0].payload["thread_ts"], + "1.000001" + ); + let downloaded = slack + .download_attachment(&source, &source.attachments[0], 4) + .await + .unwrap(); + assert_eq!(downloaded.content, b"test"); + assert_eq!( + mock.calls("download").await[0].authorization.as_deref(), + Some("Bearer xoxb-test-secret") + ); + mock.override_reply("download", Reply::Bytes(b"too long".to_vec())) + .await; + assert!(slack + .download_attachment(&source, &source.attachments[0], 4) + .await + .is_err()); + let mut unauthorized = source.attachments[0].clone(); + unauthorized.id = "F2".into(); + assert!(slack + .download_attachment(&source, &unauthorized, 100) + .await + .is_err()); +} + +#[tokio::test] +async fn files_reject_untrusted_destinations_before_sending_credentials() { + let mock = MockSlack::start().await; + let slack = mock.slack(); + slack.authenticate().await.unwrap(); + let mut payload = event("1.000001"); + payload["event"]["files"] = json!([{"id":"F1"}]); + let source = events::normalize(&payload, "BOT", "T1").unwrap(); + mock.override_reply( + "files.info", + Reply::Json( + StatusCode::OK, + json!({"ok":true,"file":{ + "id":"F1","size":1,"url_private_download":"https://example.com/files-pri/F1"}}), + ), + ) + .await; + assert!(slack + .download_attachment(&source, &source.attachments[0], 10) + .await + .is_err()); + assert!(mock.calls("download").await.is_empty()); + mock.override_reply( + "files.getUploadURLExternal", + Reply::Json( + StatusCode::OK, + json!({"ok":true, + "file_id":"F1","upload_url":"https://files.slack.com.evil.example/upload/F1"}), + ), + ) + .await; + assert!(slack + .upload_files( + &source, + &[UploadFile { + filename: "x".into(), + content: vec![1], + title: None + }] + ) + .await + .is_err()); + assert!(mock.calls("files.completeUploadExternal").await.is_empty()); +} + +#[test] +fn configuration_debug_redacts_credentials_and_rejects_remote_plaintext() { + let config = agentchat_slack::SlackConfig::new("secret-bot".into(), "secret-app".into()); + let debug = format!("{config:?}"); + assert!(!debug.contains("secret")); + let mut unsafe_config = config; + unsafe_config.api_base_url = "http://example.com/api".into(); + assert!(Slack::new(unsafe_config).is_err()); +} diff --git a/crates/agentchat/Cargo.toml b/crates/agentchat/Cargo.toml new file mode 100644 index 0000000..43583ee --- /dev/null +++ b/crates/agentchat/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "agentchat" +description = "Connect messaging channels to application-owned agents" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[features] +default = [] +slack = ["dep:agentchat-slack"] + +[dependencies] +agentchat-core.workspace = true +agentchat-slack = { workspace = true, optional = true } + +[dev-dependencies] +async-trait.workspace = true +tokio.workspace = true +serde_json.workspace = true + +[[example]] +name = "slack_echo" +required-features = ["slack"] diff --git a/crates/agentchat/examples/echo.rs b/crates/agentchat/examples/echo.rs new file mode 100644 index 0000000..2aef6e6 --- /dev/null +++ b/crates/agentchat/examples/echo.rs @@ -0,0 +1,41 @@ +//! Run with `cargo run -p agentchat --example echo`. +use std::sync::Arc; + +use agentchat::{AgentChat, Channel, Message, Result, Role, Sender}; +use async_trait::async_trait; + +struct Console; + +#[async_trait] +impl Channel for Console { + fn name(&self) -> &str { + "console" + } + async fn reply(&self, source: &Message, content: &str) -> Result { + println!("{content}"); + Ok(Message { + id: format!("{}:reply", source.id).into(), + sender: Sender { + id: "echo".into(), + display_name: None, + }, + text: content.into(), + role: Role::Assistant, + ..source.clone() + }) + } +} + +#[tokio::main] +async fn main() -> Result<()> { + let app = Arc::new(AgentChat::new()); + let message: Message = serde_json::from_value(serde_json::json!({ + "id": "console:1", "conversation_id": "console:demo", "channel": "console", + "sender": {"id": "person"}, "text": "Hello from Rust", "role": "user" + })) + .expect("valid example message"); + app.receive(Arc::new(Console), message, |context| async move { + Ok(Some(format!("Echo: {}", context.message.text))) + }) + .await +} diff --git a/crates/agentchat/examples/slack_echo.rs b/crates/agentchat/examples/slack_echo.rs new file mode 100644 index 0000000..7686310 --- /dev/null +++ b/crates/agentchat/examples/slack_echo.rs @@ -0,0 +1,31 @@ +//! Set SLACK_BOT_TOKEN and SLACK_APP_TOKEN, then run: +//! `cargo run -p agentchat --features slack --example slack_echo`. +use std::sync::Arc; + +use agentchat::{slack::Slack, AgentChat, Error, Result}; + +#[tokio::main] +async fn main() -> Result<()> { + let app = Arc::new(AgentChat::new()); + let slack = Arc::new(Slack::from_env()?); + let receiver_slack = slack.clone(); + let receive = move |message| { + let app = app.clone(); + let slack = receiver_slack.clone(); + async move { + // This demo echoes every admitted message. Production applications + // authorize the sender here before subscribing or invoking an agent. + slack.subscribe(&message).await?; + app.receive(slack, message, |context| async move { + Ok(Some(context.message.text.clone())) + }) + .await + } + }; + let outcome = tokio::select! { + result = slack.run(receive) => result, + signal = tokio::signal::ctrl_c() => signal.map_err(|error| Error::Transport(error.to_string())), + }; + slack.close().await?; + outcome +} diff --git a/crates/agentchat/src/lib.rs b/crates/agentchat/src/lib.rs new file mode 100644 index 0000000..1110675 --- /dev/null +++ b/crates/agentchat/src/lib.rs @@ -0,0 +1,5 @@ +//! AgentChat's native Rust interface. Enable a provider feature to use its transport. +pub use agentchat_core::*; + +#[cfg(feature = "slack")] +pub use agentchat_slack as slack; diff --git a/docs/architecture.md b/docs/architecture.md index aa26e4c..bfb8812 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,6 +5,11 @@ platform implements the `Channel` protocol and emits the same `Message` values. The application passes channel instances directly to `AgentChat`; there is no provider registry or framework superclass. +The shared Rust core now owns turn transitions and default in-memory storage. +Python keeps its public models, host callbacks and existing provider adapters; +Node and Rust use a native Slack adapter. See the [migration and support +guide](rust-core.md) for the current language/provider matrix. + ```text provider event │ @@ -28,19 +33,29 @@ AgentChat append the returned reply to local state | Module | Owns | | --- | --- | -| `app.py` | Channel lifecycle, deduplication, ordering, dispatch and local-history writes | +| `crates/agentchat-core` | Typed native models, turn transitions, state contracts, in-memory claims/history and native context operations | +| `app.py`, `_runtime.py` | Python channel lifecycle and execution of core effects on the caller's asyncio task; selectable legacy dispatcher | +| `bindings/python` | Python object ownership and conversion to core operations | +| `bindings/node`, `packages/node` | Node callback/lifecycle bridge and typed JavaScript interface | +| `crates/agentchat-slack` | Native Slack routing, normalization, transport, capabilities and confirmed receipts | | `context.py` | Handler-facing `MessageContext` and `Conversation` operations | | `models.py` | Shared message, sender, attachment and reply value objects | | `activity.py` | Refreshing and clearing in-process working indicators | | `channels/base.py` | Required channel operations and optional capability protocols | | `channels//` | Provider SDKs, credentials, payloads, connection resources and delivery | | `integrations/` | Optional helpers for agent frameworks | -| `state/` | Claiming message IDs, conversation locks, local history and storage | +| `state/` | Python state protocol and asyncio conversation locks; default storage delegates to Rust | Core modules depend on shared models and channel contracts, never a concrete provider or agent framework. Adapters do not import each other. Framework helpers consume normalized messages and do not own the channel lifecycle. +The Rust `Turn` engine emits typed effects. Python executes those effects inline +against the existing `Channel` and `State` protocols, preserving custom async +context managers and opaque message metadata. The native runtime executes the +same effects for Rust and Node. Bindings do not implement a separate turn +algorithm or parse provider routes. + The core records an adapter's returned reply and does not independently validate provider receipts. Adapters own delivery confirmation and error propagation. @@ -53,7 +68,7 @@ Applications own sender authorization, agent execution, durable work and deliver recovery. Direct channel binding is also supported when an application already owns deduplication, ordering and history; see the [Slack guide](slack.md#using-an-existing-agent-runner). -## Provider containment +## Python provider containment The shared channel namespace contains only adapter contracts. A provider owns its implementations, exports and optional dependency loading: @@ -84,7 +99,7 @@ private helper `agentchat/_delivery.py` shares bounded background dispatch and cleanup. Provider authentication, acknowledgement, and overflow policy stay in each adapter. See the [provider guides](providers.md) for supported operations. -## Composition +## Python composition The generic adapter boundary remains `Channel`. Within Slack, replace event delivery independently of message behavior: diff --git a/docs/rust-core.md b/docs/rust-core.md new file mode 100644 index 0000000..bb83202 --- /dev/null +++ b/docs/rust-core.md @@ -0,0 +1,139 @@ +# Rust core and language interfaces + +AgentChat uses a shared Rust turn engine with Python, Node.js/TypeScript and Rust +interfaces. The application still owns its agent framework, sender authorization, +model configuration and agent session storage. + +## Python compatibility + +Existing imports, dataclasses, channel objects, decorators and async context +methods keep working: + +```python +from agentchat import AgentChat +from agentchat.channels.slack import Slack + +app = AgentChat(channels=[Slack.from_env()]) + +@app.on_message +async def respond(context): + return context.message.text +``` + +Rust owns turn transitions and the default in-memory claims/history store. The +Python adapter executes effects on the original asyncio task, including the +entire scope of a custom `State.lock()` context manager. It does not serialize +Python messages or arbitrary metadata through JSON. Dataclass identity, custom +state, custom channels, exceptions, cancellation and application overrides of +`reply()` are preserved. The native store participates in Python garbage +collection, including cycles through message metadata. + +Select `AgentChat(..., backend="python")` to use the previous dispatcher and +memory implementation. `AGENTCHAT_BACKEND=python` changes the default for testing +or rollback. An explicit constructor option takes precedence. Backend selection +is immutable for an application instance and failures never replay through the +other backend. Existing channel objects keep their current Python SDK transport; +selecting the Rust core does not replace a constructed channel. + +Installing from a wheel needs no Rust toolchain. Installing this checkout or a +source distribution builds `agentchat._native` with maturin and requires Rust. +The Python minimum remains 3.11. Ordinary CPython wheels use `abi3-py311`; +free-threaded Python and alternative interpreters are not qualified by this +release. The Python fallback is a runtime rollback option, not a replacement for +a missing native binary during installation. + +## Node and Claude Agent SDK + +Build the package from this checkout: + +```bash +cd packages/node +npm ci +npm run build +npm test +npm run smoke +``` + +See the [Node package](../packages/node/README.md) and +[Claude example](../examples/node/README.md) for the JavaScript API and a Slack bot +using `@anthropic-ai/claude-agent-sdk`. The Node package is a native napi-rs addon; +it needs no Python installation. Node 22 and 24 are the initial CI targets. +Browser/edge runtimes, Bun and Deno are outside this initial support matrix. + +Claude executes in the Node handler. Map each AgentChat conversation ID to a +Claude session ID, resume it for subsequent turns, and return the successful +final result for automatic delivery. AgentChat history and Claude sessions are +separate stores. An uncertain Slack reply does not warrant rerunning Claude or +replaying a send. Forward cancellation into the Claude SDK and close AgentChat +explicitly during application shutdown. + +## Rust interface + +`crates/agentchat` is the public Rust facade. Enable its `slack` feature for the +native provider. `crates/agentchat-core` owns typed models, state contracts, +conversation ordering, context operations and the turn engine. Custom channels +implement the open `Channel` trait; they do not need registration in a closed +provider enum. Rust applications use their own Tokio runtime. + +Provider crates own normalization, route validation, authentication, transports +and confirmed receipts. Language bindings do not parse Slack routing fields or +implement independent turn algorithms. Shared domain results use named structs +and enums. IDs stay strings across the public interfaces to avoid JavaScript +integer precision loss. + +## Current channel support + +| Channel | Python interface | Node and Rust interfaces | +| --- | --- | --- | +| Slack | Existing compatible Python adapter, shared Rust core | Native Rust adapter | +| Telegram | Existing compatible Python adapter, shared Rust core | Native port pending | +| WhatsApp | Existing compatible Python adapter, shared Rust core | Native port pending | +| Discord | Existing compatible Python adapter, shared Rust core | Native port pending | +| iMessage | Existing compatible Python adapter, shared Rust core | Native port pending | +| Teams | Existing compatible Python adapter, shared Rust core | Native port pending | + +This is the first migration stage: the shared core and Slack path are available +without rewriting existing Python integrations. Remaining native providers can +be added behind the same channel contract. Their Python transports remain +available throughout migration. + +Claims/history are in-process and unbounded, matching the previous default. +Claims survive handler failures within that state and disappear on restart. +There is no durable queue or exactly-once guarantee. Native Slack makes a single +write attempt and only records confirmed receipts; rate-limit scheduling and +uncertain-delivery recovery remain application concerns. Existing Python +provider-specific behavior is retained. + +## Checks and binary qualification + +Run these checks before proposing a change: + +```bash +python -m pip install -e '.[dev]' +ruff check . +python -m pytest +AGENTCHAT_BACKEND=python python -m pytest +cargo fmt --all --check +cargo test --workspace --locked +cargo clippy --workspace --all-targets --locked -- -D warnings +``` + +Node and Claude example commands are in their respective READMEs. Tests use +injected/local Slack HTTP and WebSocket servers and a fake Claude stream, so they +do not send messages to a workspace or require model credentials. A live echo +check with the application's real Slack installation and Claude credentials is +still needed before deploying that application. + +CI exercises both Python backends, the Rust contracts, Node/Claude flows and +installed packages. Binary jobs build and smoke-test Linux glibc x64/arm64, +macOS x64/arm64 and Windows x64. These are qualification targets: support is +confirmed by passing jobs, not merely by successful cross-compilation. Source +distribution installation is checked independently. Musl and other targets +require separate qualification. + +Coordinate future PyPI, npm and Cargo releases from the same source revision and +version. Build and test the complete binary set before publishing; no workflow +in this change publishes to a registry automatically. Include the core/package +version and platform when reporting a binding issue. Changes to shared behavior +must pass all language contract tests; new provider support must update this +matrix and include setup documentation and an example. diff --git a/examples/node/README.md b/examples/node/README.md new file mode 100644 index 0000000..487d703 --- /dev/null +++ b/examples/node/README.md @@ -0,0 +1,32 @@ +# Slack + Claude Agent SDK + +Build `packages/node` first, then run `npm ci` here. Set `SLACK_BOT_TOKEN`, +`SLACK_APP_TOKEN`, and `ANTHROPIC_API_KEY`, then `npm start`. Node.js 22+ is +required by this example. Claude remains a normal JavaScript SDK dependency; +AgentChat does not embed it into Rust or require Python. + +This is an ESM example and the AgentChat package currently exposes ESM imports +only. It accepts users who can reach the Slack bot; add your application's sender +and conversation authorization before the explicit `context.subscribe()` call. +Subscription allows untagged follow-up messages in that thread to resume the +same Claude session. + +`createClaudeHandler` consumes `query()`'s async stream and returns only a +successful final result. It resumes a separate Claude session for each +`context.conversation.id`, closes each query, and forwards the application's +abort signal to the SDK. The map is in memory; persist it if sessions must +survive application restarts. The example disables tools explicitly. To enable +tools, select them and supply a permission policy appropriate to your application. + +The Claude SDK manages a subprocess and ships platform components through npm +optional dependencies. Install optional dependencies, or configure the SDK's +`pathToClaudeCodeExecutable` when providing your own installation. Native +AgentChat likewise needs its matching platform binary. Browser or edge runtimes +without native addons and subprocess support are not targets for this example. + +`npm test` uses a fake Claude stream and does not call Anthropic. The Node SDK's +tests exercise the actual Rust addon and a local Slack HTTP/WebSocket fixture. +`npm run typecheck` checks this example against the installed Claude SDK types. + +References: [Claude SDK quickstart](https://code.claude.com/docs/en/agent-sdk/quickstart), +[session management](https://code.claude.com/docs/en/agent-sdk/sessions). diff --git a/examples/node/claude-agent.ts b/examples/node/claude-agent.ts new file mode 100644 index 0000000..dd158ec --- /dev/null +++ b/examples/node/claude-agent.ts @@ -0,0 +1,28 @@ +import { query } from "@anthropic-ai/claude-agent-sdk"; +import { AgentChat, Slack } from "@berriai/agentchat"; +import { createClaudeHandler } from "./claude-handler.ts"; + +const app = new AgentChat({ channels: [Slack.fromEnv()] }); +const handle = createClaudeHandler(query, { + // A chat-only example. Enable tools deliberately with your own approval policy. + tools: [], + permissionMode: "default", + ...(process.env.CLAUDE_MODEL ? { model: process.env.CLAUDE_MODEL } : {}), +}); +app.onMessage(async (context) => { + // This demo accepts users able to reach the bot. Apply your authorization + // policy here before subscribing, reading history, or invoking Claude. + await context.subscribe(); + return handle(context); +}); + +const stop = () => { void app.close().catch(console.error); }; +process.once("SIGINT", stop); +process.once("SIGTERM", stop); +try { + await app.run(); +} finally { + await app.close(); + process.removeListener("SIGINT", stop); + process.removeListener("SIGTERM", stop); +} diff --git a/examples/node/claude-handler.test.ts b/examples/node/claude-handler.test.ts new file mode 100644 index 0000000..2cf571a --- /dev/null +++ b/examples/node/claude-handler.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import type { MessageContext } from "@berriai/agentchat"; +import { createClaudeHandler, type ClaudeQuery } from "./claude-handler.ts"; + +const success = (session: string, result: string): SDKMessage => ({ + type: "result", subtype: "success", is_error: false, session_id: session, result, +} as SDKMessage); + +function context(id: string, text = "hello", signal = new AbortController().signal): MessageContext { + return { conversation: { id }, message: { text }, signal } as MessageContext; +} + +test("resumes only the matching conversation and closes every query", async () => { + const calls: Parameters[0][] = []; + let closed = 0; + const query: ClaudeQuery = (input) => { + calls.push(input); + return { + async *[Symbol.asyncIterator]() { yield success(input.options.resume ?? `session-${calls.length}`, "answer"); }, + close() { closed++; }, + }; + }; + const handler = createClaudeHandler(query, { tools: [], permissionMode: "default" }); + assert.equal(await handler(context("thread-a")), "answer"); + assert.equal(await handler(context("thread-b")), "answer"); + assert.equal(await handler(context("thread-a", "follow up")), "answer"); + assert.deepEqual(calls.map((call) => call.options.resume), [undefined, undefined, "session-1"]); + assert.equal(calls[2].prompt, "follow up"); + assert.equal(closed, 3); +}); + +test("failed or incomplete Claude results do not produce replies or replace sessions", async () => { + let attempt = 0; + const resumed: (string | undefined)[] = []; + const handler = createClaudeHandler((input) => { + resumed.push(input.options.resume); + const current = ++attempt; + return { + async *[Symbol.asyncIterator]() { + if (current === 1) yield success("good", "answer"); + else if (current === 2) yield { type: "result", subtype: "error_max_turns", is_error: true, session_id: "bad" } as SDKMessage; + }, + }; + }); + await handler(context("a")); + await assert.rejects(handler(context("a")), /did not return a successful answer/); + await assert.rejects(handler(context("a")), /ended without a successful result/); + assert.deepEqual(resumed, [undefined, "good", "good"]); +}); + +test("application cancellation aborts and closes the Claude query", async () => { + const controller = new AbortController(); + let sdkAborted = false; + let closed = false; + let started!: () => void; + const start = new Promise((resolve) => { started = resolve; }); + const handler = createClaudeHandler(({ options }) => ({ + async *[Symbol.asyncIterator]() { + await new Promise((resolve) => { + options.abortController!.signal.addEventListener("abort", () => { sdkAborted = true; resolve(); }, { once: true }); + started(); + }); + yield success("cancelled", "must not reply"); + }, + close() { closed = true; }, + })); + const response = handler(context("a", "wait", controller.signal)); + await start; + controller.abort(); + await assert.rejects(response, { name: "AbortError" }); + assert.equal(sdkAborted, true); + assert.equal(closed, true); +}); diff --git a/examples/node/claude-handler.ts b/examples/node/claude-handler.ts new file mode 100644 index 0000000..994377b --- /dev/null +++ b/examples/node/claude-handler.ts @@ -0,0 +1,50 @@ +import type { Options, SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import type { MessageHandler } from "@berriai/agentchat"; + +export type ClaudeQuery = (input: { prompt: string; options: Options }) => AsyncIterable & { close?: () => void }; + +/** One SDK session per chat conversation. AgentChat serializes turns in that conversation. */ +export function createClaudeHandler( + query: ClaudeQuery, + options: Omit = {}, +): MessageHandler { + const sessions = new Map(); + return async (context) => { + const abortController = new AbortController(); + const abort = () => abortController.abort(); + if (context.signal.aborted) abort(); + context.signal.addEventListener("abort", abort, { once: true }); + let stream: ReturnType | undefined; + try { + context.signal.throwIfAborted(); + stream = query({ + prompt: context.message.text, + options: { + ...options, + resume: sessions.get(context.conversation.id), + abortController, + }, + }); + let result: string | undefined; + let successfulSession: string | undefined; + for await (const event of stream) { + context.signal.throwIfAborted(); + if (event.type !== "result") continue; + if (event.subtype !== "success" || event.is_error || !event.result) { + throw new Error(`Claude did not return a successful answer (${event.subtype})`); + } + result = event.result; + successfulSession = event.session_id; + } + context.signal.throwIfAborted(); + if (result === undefined || successfulSession === undefined) { + throw new Error("Claude ended without a successful result"); + } + sessions.set(context.conversation.id, successfulSession); + return result; + } finally { + context.signal.removeEventListener("abort", abort); + stream?.close?.(); + } + }; +} diff --git a/examples/node/claude-slack.test.mjs b/examples/node/claude-slack.test.mjs new file mode 100644 index 0000000..776fecd --- /dev/null +++ b/examples/node/claude-slack.test.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { AgentChat, Slack } from "@berriai/agentchat"; +import { createClaudeHandler } from "./claude-handler.ts"; +import { slackFixture, eventually } from "../../packages/node/test/slack-fixture.mjs"; + +test("Claude-style stream runs through the Rust Slack transport and resumes its conversation", { timeout: 10000 }, async () => { + const fixture = await slackFixture(); + const calls = []; + let closed = 0; + const query = (input) => { + calls.push(input); + return { + async *[Symbol.asyncIterator]() { + yield { type: "assistant", message: { content: [{ type: "text", text: "intermediate, not a reply" }] } }; + yield { type: "result", subtype: "success", is_error: false, result: `Claude: ${input.prompt}`, session_id: "claude-session" }; + }, + close() { closed++; }, + }; + }; + const app = new AgentChat({ channels: [new Slack({ botToken: "xoxb-fixture", appToken: "xapp-fixture", apiBaseUrl: fixture.apiBaseUrl, ackEmoji: null })] }); + const handle = createClaudeHandler(query, { tools: [], permissionMode: "default" }); + app.onMessage(async (context) => { + await context.subscribe(); + return handle(context); + }); + const running = app.run(); + try { + await fixture.send("<@B1> hello"); + await eventually(() => fixture.calls.filter((call) => call.method === "chat.postMessage").length === 1); + await fixture.send("remember me", { ts: "100.2", type: "message" }); + await eventually(() => fixture.calls.filter((call) => call.method === "chat.postMessage").length === 2); + assert.deepEqual(calls.map((call) => call.options.resume), [undefined, "claude-session"]); + assert.equal(closed, 2); + assert.deepEqual(fixture.calls.filter((call) => call.method === "chat.postMessage").map((call) => call.body.text), ["Claude: hello", "Claude: remember me"]); + } finally { + await app.close(); + await running; + await fixture.close(); + } +}); diff --git a/examples/node/package-lock.json b/examples/node/package-lock.json new file mode 100644 index 0000000..2c46c45 --- /dev/null +++ b/examples/node/package-lock.json @@ -0,0 +1,1964 @@ +{ + "name": "agentchat-claude-example", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "agentchat-claude-example", + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.0", + "@berriai/agentchat": "file:../../packages/node" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.20.0", + "typescript": "^5.9.0" + } + }, + "../../packages/node": { + "name": "@berriai/agentchat", + "version": "0.1.0-alpha.1", + "license": "MIT", + "devDependencies": { + "@napi-rs/cli": "^3.0.0", + "@types/node": "^22.0.0", + "typescript": "^5.9.0", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.2.141", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.141.tgz", + "integrity": "sha512-AIBacMWGcZIUcXlUoObqjwJ6pmJI3BayAqPAFXuvSq3DHJXdiuZVs7l/zTB5l3nRhRv5cqSrI2XbiDeHgZWizw==", + "license": "SEE LICENSE IN README.md", + "dependencies": { + "@anthropic-ai/sdk": "^0.93.0", + "@modelcontextprotocol/sdk": "^1.29.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.141", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.141", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.141", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.141", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.141", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.141", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.141", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.141" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { + "version": "0.2.141", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.2.141.tgz", + "integrity": "sha512-9HZ0ot6+FwOfQ1aeMqQLH4IJGMm/DcP08SysDxscVjBm6l2JjqleHohxi3zid0DurfGweqT+4x9GScJffwg55g==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { + "version": "0.2.141", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.2.141.tgz", + "integrity": "sha512-4iAdarJaQ+2R58s6QJswZCzUdz2WQmL5lYG7Y+FLzWbRSROFfcH0QYpmOqSaPXd2KRQhIJwEacqecDZd/Q1XKQ==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { + "version": "0.2.141", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.2.141.tgz", + "integrity": "sha512-Jdf0ZEwJzOP8sE6rPqdJN+SxMb0/L8sxJg4twCv/7S+Qzk0hJtls+wxSi+0Tjh6EEMaNxJqEGc7S3fx99Wi99Q==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { + "version": "0.2.141", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.2.141.tgz", + "integrity": "sha512-6H1AJ/AVaWNnV22kubUPkOTRzZFH0+qP9k7WlhriHMN9gtgZcVAsITMddDeGjQsQJMCAdhXFd6sgi7TM1LdeOQ==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { + "version": "0.2.141", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.2.141.tgz", + "integrity": "sha512-DVjp72f3HmrRYpbneWZZWIqkUht5kTZXS7wXGFiwzLz6eNYEgjjh+GcsnhIi8UOwZUtNiKUrjZnoP38ovFqV8A==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { + "version": "0.2.141", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.2.141.tgz", + "integrity": "sha512-fTI1YuM4cxOa4nSgsyMAdB5ELizkWp+w5Ispo4JnnYtcczMAL4D9GBNjWPW0sUzKvjsJOUVim68SmWLWhUOpXQ==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { + "version": "0.2.141", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.2.141.tgz", + "integrity": "sha512-Wm10J6kfbufbPGFELokiJ/7Y5Oqug4Uag3HXFsV8g7TWCpaItx/oqVaJoiGptuAtXQB7xGLQVTuk082wER+Y5w==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { + "version": "0.2.141", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.2.141.tgz", + "integrity": "sha512-IXuP29YJuWbR5Q6xOHrjFVGG54V2s1FC61UVNwEN5fpxL09MwPnbwtQL6fqgzt/U1MP7vWAwpXZriYAklkH/mg==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.93.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.93.0.tgz", + "integrity": "sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@berriai/agentchat": { + "resolved": "../../packages/node", + "link": true + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.5.tgz", + "integrity": "sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4 || ^5.0.0-0" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz", + "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", + "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.3.0", + "content-disposition": "^2.0.1", + "content-type": "^2.0.0", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.8", + "qs": "^6.16.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.1.0", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz", + "integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", + "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/examples/node/package.json b/examples/node/package.json new file mode 100644 index 0000000..f05bdce --- /dev/null +++ b/examples/node/package.json @@ -0,0 +1,15 @@ +{ + "name": "agentchat-claude-example", + "private": true, + "type": "module", + "scripts": { + "start": "tsx claude-agent.ts", + "test": "tsx --test *.test.ts *.test.mjs", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.0", + "@berriai/agentchat": "file:../../packages/node" + }, + "devDependencies": { "tsx": "^4.20.0", "typescript": "^5.9.0", "@types/node": "^22.0.0" } +} diff --git a/examples/node/tsconfig.json b/examples/node/tsconfig.json new file mode 100644 index 0000000..2bc2e22 --- /dev/null +++ b/examples/node/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", + "strict": true, "noEmit": true, "skipLibCheck": true, + "allowImportingTsExtensions": true + }, + "include": ["*.ts"] +} diff --git a/packages/node/README.md b/packages/node/README.md new file mode 100644 index 0000000..2f9eb3e --- /dev/null +++ b/packages/node/README.md @@ -0,0 +1,54 @@ +# @berriai/agentchat + +An ESM Node.js SDK backed by AgentChat's shared Rust dispatch and native Slack +Socket Mode implementation. Requires Node.js 22 or newer. This alpha supports +Slack; other channels and custom JavaScript state implementations are not yet +part of the Node API. + +```ts +import { AgentChat, Slack } from "@berriai/agentchat"; + +const app = new AgentChat({ channels: [Slack.fromEnv()] }); +app.onMessage(async (context) => { + console.log(context.conversation.id); + return `You said: ${context.message.text}`; +}); + +process.once("SIGINT", () => { void app.close(); }); +try { await app.run(); } finally { await app.close(); } +``` + +Set `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN`. `SLACK_TEAM_ID` and +`SLACK_BOT_USER_ID` are optional; otherwise Slack authentication discovers them. +The handler can await `context.reply(text)`, `context.history({ limit: 30 })`, +`context.conversation.history()`, and `context.subscribe()`. Returning a string +sends one additional reply; return `undefined` when already replying manually. +`close()` stops intake, lets admitted turns finish within `shutdownTimeoutMs` +(20 seconds by default), then aborts remaining `context.signal` values. Handlers +must cooperate with cancellation, especially when they own child processes. +Calling `await app.close()` from a handler requests shutdown and returns without +waiting for that same handler; outside a handler it awaits cleanup. Handler errors +report through the optional `onError(error, context)` constructor callback and +intake continues. The default callback logs a fixed failure notice. Startup and +permanent transport failures reject `run()`. + +Native capabilities include `replyRich`, `setStatus`, `uploadFiles`, explicit +`downloadAttachment`, and `mirror`. None downloads event attachments automatically. +Use `const status = await context.working()` and `await status.close()` in a +`finally` block to refresh and clear a transient indicator; `status.update(text)` +changes its label. Upload/download content uses `Uint8Array`/`Buffer` and is not +added to conversation history. The host remains responsible for authorizing +history, file access, mirroring, and thread subscriptions. + +Native metadata is portable JSON. Node handlers execute on Node's event loop; +provider I/O and per-conversation ordering run in Rust. Different conversations +can run concurrently. The default history and deduplication store is in memory. + +See `examples/node` for a Claude Agent SDK integration with conversation sessions, +cancellation, successful-result validation, and explicit tool permissions. + +For repository development: `npm ci`, `npm run build`, `npm test`, +`npm run typecheck`, and `npm run smoke`. Building from source requires Rust +1.88+ and a platform linker. Prebuilt release packages use the native artifact +for the target OS/architecture; building successfully for one target does not +validate another. Browser, Bun, Deno, and Electron support is not claimed. diff --git a/packages/node/native/index.cjs b/packages/node/native/index.cjs new file mode 100644 index 0000000..e63611e --- /dev/null +++ b/packages/node/native/index.cjs @@ -0,0 +1,782 @@ +/* eslint-disable */ +// @ts-nocheck +/* auto-generated by NAPI-RS */ + +const { readFileSync } = require('fs') +let nativeBinding = null +// Which artifact actually loaded. The WASI fallback chain overwrites it with +// the flavor it resolved; the late native retry below leaves it alone because +// it only runs while no WASI candidate has been loaded. +let __napiLoadedBindingTarget = 'native' +const loadErrors = [] + +const isMusl = () => { + let musl = false + if (process.platform === 'linux') { + musl = isMuslFromFilesystem() + if (musl === null) { + musl = isMuslFromReport() + } + if (musl === null) { + musl = isMuslFromChildProcess() + } + } + return musl +} + +const isFileMusl = (f) => f.includes('libc.musl-') || f.includes('ld-musl-') + +const isMuslFromFilesystem = () => { + try { + return readFileSync('/usr/bin/ldd', 'utf-8').includes('musl') + } catch { + return null + } +} + +const isMuslFromReport = () => { + let report = null + if (process.report && typeof process.report.getReport === 'function') { + process.report.excludeNetwork = true + report = process.report.getReport() + } + if (!report) { + return null + } + if (report.header && report.header.glibcVersionRuntime) { + return false + } + if (Array.isArray(report.sharedObjects)) { + if (report.sharedObjects.some(isFileMusl)) { + return true + } + } + return false +} + +const isMuslFromChildProcess = () => { + try { + return require('child_process').execSync('ldd --version', { encoding: 'utf8' }).includes('musl') + } catch (e) { + // If we reach this case, we don't know if the system is musl or not, so is better to just fallback to false + return false + } +} + +function requireNative() { + if (process.env.NAPI_RS_NATIVE_LIBRARY_PATH) { + try { + const overrideBinding = require(process.env.NAPI_RS_NATIVE_LIBRARY_PATH) + // The override may be a generated WASI loader, which already reports its + // own flavor. Adopt it: `module.exports` aliases this object, so claiming + // 'native' would both misreport the artifact and overwrite the loader's + // marker through the alias. + __napiLoadedBindingTarget = + overrideBinding && typeof overrideBinding.__napiBindingTarget === 'string' + ? overrideBinding.__napiBindingTarget + : 'native' + return overrideBinding + } catch (err) { + loadErrors.push(err) + } + } else if (process.platform === 'android') { + if (process.arch === 'arm64') { + try { + return require('./agentchat.android-arm64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@berriai/agentchat-android-arm64') + const bindingPackageVersion = require('@berriai/agentchat-android-arm64/package.json').version + if (bindingPackageVersion !== '0.1.0-alpha.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.0-alpha.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm') { + try { + return require('./agentchat.android-arm-eabi.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@berriai/agentchat-android-arm-eabi') + const bindingPackageVersion = require('@berriai/agentchat-android-arm-eabi/package.json').version + if (bindingPackageVersion !== '0.1.0-alpha.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.0-alpha.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on Android ${process.arch}`)) + } + } else if (process.platform === 'win32') { + if (process.arch === 'x64') { + if ((process.config && process.config.variables && process.config.variables.shlib_suffix === 'dll.a') || (process.config && process.config.variables && process.config.variables.node_target_type === 'shared_library')) { + try { + return require('./agentchat.win32-x64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@berriai/agentchat-win32-x64-gnu') + const bindingPackageVersion = require('@berriai/agentchat-win32-x64-gnu/package.json').version + if (bindingPackageVersion !== '0.1.0-alpha.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.0-alpha.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./agentchat.win32-x64-msvc.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@berriai/agentchat-win32-x64-msvc') + const bindingPackageVersion = require('@berriai/agentchat-win32-x64-msvc/package.json').version + if (bindingPackageVersion !== '0.1.0-alpha.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.0-alpha.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'ia32') { + try { + return require('./agentchat.win32-ia32-msvc.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@berriai/agentchat-win32-ia32-msvc') + const bindingPackageVersion = require('@berriai/agentchat-win32-ia32-msvc/package.json').version + if (bindingPackageVersion !== '0.1.0-alpha.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.0-alpha.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm64') { + try { + return require('./agentchat.win32-arm64-msvc.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@berriai/agentchat-win32-arm64-msvc') + const bindingPackageVersion = require('@berriai/agentchat-win32-arm64-msvc/package.json').version + if (bindingPackageVersion !== '0.1.0-alpha.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.0-alpha.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on Windows: ${process.arch}`)) + } + } else if (process.platform === 'darwin') { + try { + return require('./agentchat.darwin-universal.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@berriai/agentchat-darwin-universal') + const bindingPackageVersion = require('@berriai/agentchat-darwin-universal/package.json').version + if (bindingPackageVersion !== '0.1.0-alpha.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.0-alpha.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + if (process.arch === 'x64') { + try { + return require('./agentchat.darwin-x64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@berriai/agentchat-darwin-x64') + const bindingPackageVersion = require('@berriai/agentchat-darwin-x64/package.json').version + if (bindingPackageVersion !== '0.1.0-alpha.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.0-alpha.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm64') { + try { + return require('./agentchat.darwin-arm64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@berriai/agentchat-darwin-arm64') + const bindingPackageVersion = require('@berriai/agentchat-darwin-arm64/package.json').version + if (bindingPackageVersion !== '0.1.0-alpha.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.0-alpha.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on macOS: ${process.arch}`)) + } + } else if (process.platform === 'freebsd') { + if (process.arch === 'x64') { + try { + return require('./agentchat.freebsd-x64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@berriai/agentchat-freebsd-x64') + const bindingPackageVersion = require('@berriai/agentchat-freebsd-x64/package.json').version + if (bindingPackageVersion !== '0.1.0-alpha.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.0-alpha.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm64') { + try { + return require('./agentchat.freebsd-arm64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@berriai/agentchat-freebsd-arm64') + const bindingPackageVersion = require('@berriai/agentchat-freebsd-arm64/package.json').version + if (bindingPackageVersion !== '0.1.0-alpha.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.0-alpha.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on FreeBSD: ${process.arch}`)) + } + } else if (process.platform === 'linux') { + if (process.arch === 'x64') { + if (isMusl()) { + try { + return require('./agentchat.linux-x64-musl.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@berriai/agentchat-linux-x64-musl') + const bindingPackageVersion = require('@berriai/agentchat-linux-x64-musl/package.json').version + if (bindingPackageVersion !== '0.1.0-alpha.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.0-alpha.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./agentchat.linux-x64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@berriai/agentchat-linux-x64-gnu') + const bindingPackageVersion = require('@berriai/agentchat-linux-x64-gnu/package.json').version + if (bindingPackageVersion !== '0.1.0-alpha.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.0-alpha.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'arm64') { + if (isMusl()) { + try { + return require('./agentchat.linux-arm64-musl.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@berriai/agentchat-linux-arm64-musl') + const bindingPackageVersion = require('@berriai/agentchat-linux-arm64-musl/package.json').version + if (bindingPackageVersion !== '0.1.0-alpha.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.0-alpha.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./agentchat.linux-arm64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@berriai/agentchat-linux-arm64-gnu') + const bindingPackageVersion = require('@berriai/agentchat-linux-arm64-gnu/package.json').version + if (bindingPackageVersion !== '0.1.0-alpha.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.0-alpha.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'arm') { + if (isMusl()) { + try { + return require('./agentchat.linux-arm-musleabihf.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@berriai/agentchat-linux-arm-musleabihf') + const bindingPackageVersion = require('@berriai/agentchat-linux-arm-musleabihf/package.json').version + if (bindingPackageVersion !== '0.1.0-alpha.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.0-alpha.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./agentchat.linux-arm-gnueabihf.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@berriai/agentchat-linux-arm-gnueabihf') + const bindingPackageVersion = require('@berriai/agentchat-linux-arm-gnueabihf/package.json').version + if (bindingPackageVersion !== '0.1.0-alpha.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.0-alpha.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'loong64') { + if (isMusl()) { + try { + return require('./agentchat.linux-loong64-musl.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@berriai/agentchat-linux-loong64-musl') + const bindingPackageVersion = require('@berriai/agentchat-linux-loong64-musl/package.json').version + if (bindingPackageVersion !== '0.1.0-alpha.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.0-alpha.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./agentchat.linux-loong64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@berriai/agentchat-linux-loong64-gnu') + const bindingPackageVersion = require('@berriai/agentchat-linux-loong64-gnu/package.json').version + if (bindingPackageVersion !== '0.1.0-alpha.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.0-alpha.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'riscv64') { + if (isMusl()) { + try { + return require('./agentchat.linux-riscv64-musl.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@berriai/agentchat-linux-riscv64-musl') + const bindingPackageVersion = require('@berriai/agentchat-linux-riscv64-musl/package.json').version + if (bindingPackageVersion !== '0.1.0-alpha.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.0-alpha.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./agentchat.linux-riscv64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@berriai/agentchat-linux-riscv64-gnu') + const bindingPackageVersion = require('@berriai/agentchat-linux-riscv64-gnu/package.json').version + if (bindingPackageVersion !== '0.1.0-alpha.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.0-alpha.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'ppc64') { + try { + return require('./agentchat.linux-ppc64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@berriai/agentchat-linux-ppc64-gnu') + const bindingPackageVersion = require('@berriai/agentchat-linux-ppc64-gnu/package.json').version + if (bindingPackageVersion !== '0.1.0-alpha.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.0-alpha.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 's390x') { + try { + return require('./agentchat.linux-s390x-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@berriai/agentchat-linux-s390x-gnu') + const bindingPackageVersion = require('@berriai/agentchat-linux-s390x-gnu/package.json').version + if (bindingPackageVersion !== '0.1.0-alpha.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.0-alpha.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on Linux: ${process.arch}`)) + } + } else if (process.platform === 'openharmony') { + if (process.arch === 'arm64') { + try { + return require('./agentchat.openharmony-arm64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@berriai/agentchat-openharmony-arm64') + const bindingPackageVersion = require('@berriai/agentchat-openharmony-arm64/package.json').version + if (bindingPackageVersion !== '0.1.0-alpha.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.0-alpha.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'x64') { + try { + return require('./agentchat.openharmony-x64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@berriai/agentchat-openharmony-x64') + const bindingPackageVersion = require('@berriai/agentchat-openharmony-x64/package.json').version + if (bindingPackageVersion !== '0.1.0-alpha.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.0-alpha.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm') { + try { + return require('./agentchat.openharmony-arm.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@berriai/agentchat-openharmony-arm') + const bindingPackageVersion = require('@berriai/agentchat-openharmony-arm/package.json').version + if (bindingPackageVersion !== '0.1.0-alpha.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.0-alpha.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on OpenHarmony: ${process.arch}`)) + } + } else { + loadErrors.push(new Error(`Unsupported OS: ${process.platform}, architecture: ${process.arch}`)) + } +} + +function createLoadErrorChain(errors) { + return errors.reduce((previous, current) => { + let message + try { + message = + current && typeof current.message === 'string' + ? current.message + : String(current) + } catch { + message = 'Unknown error' + } + const error = new Error(message) + error.cause = previous + return error + }, null) +} + +// NAPI_RS_FORCE_WASI is a tri-state flag: +// unset / any other value → native binding preferred, WASI is only a fallback +// 'true' → prefer WASI, but retain native as a lazy fallback +// 'error' → require WASI without initializing a native fallback +// Treating any non-empty string as truthy (the historical behavior) meant +// NAPI_RS_FORCE_WASI=false, NAPI_RS_FORCE_WASI=0, etc. inadvertently triggered +// the WASI path, causing ENOENT for packages shipped without a .wasi.cjs file. +// +// NAPI_RS_WASI_FLAVOR selects one exact generated flavor and implies strict +// WASI loading. It never crosses into another flavor or falls back to native. +const __napiWasiFlavors = ['wasm32-wasi'] +const __napiWasiFlavor = process.env.NAPI_RS_WASI_FLAVOR +const __napiWasiFlavorRequested = + typeof __napiWasiFlavor === 'string' && __napiWasiFlavor.length > 0 +if ( + __napiWasiFlavorRequested && + __napiWasiFlavors.indexOf(__napiWasiFlavor) === -1 +) { + throw new Error( + 'Unsupported WASI flavor "' + + __napiWasiFlavor + + '". Available flavors: ' + + __napiWasiFlavors.join(', '), + ) +} +const forceWasiError = process.env.NAPI_RS_FORCE_WASI === 'error' +const forceWasi = + process.env.NAPI_RS_FORCE_WASI === 'true' || + forceWasiError || + __napiWasiFlavorRequested + +if (!forceWasi) { + nativeBinding = requireNative() +} + +if (!nativeBinding || forceWasi) { + let wasiBinding = null + let wasiBindingLoaded = false + const wasiBindingErrors = [] + const __napiWasiResolveCandidate = (specifier, isPackage, localArtifacts) => { + try { + require.resolve(specifier) + } catch (resolveError) { + if (!resolveError || resolveError.code !== 'MODULE_NOT_FOUND') { + throw resolveError + } + if (isPackage) { + try { + require.resolve(specifier + '/package.json') + } catch (packageError) { + if (packageError && packageError.code === 'MODULE_NOT_FOUND') { + return resolveError + } + // An exports restriction proves the package exists even when its + // package.json is not public. Preserve the root resolution failure. + throw resolveError + } + // The package exists but its main/export target is broken. + throw resolveError + } + return resolveError + } + if (localArtifacts) { + let artifactError = null + for (let i = 0; i < localArtifacts.length; i++) { + try { + require.resolve(localArtifacts[i]) + return null + } catch (resolveError) { + if (!resolveError || resolveError.code !== 'MODULE_NOT_FOUND') { + throw resolveError + } + artifactError = resolveError + } + } + return artifactError + } + return null + } + if (!wasiBindingLoaded && (!__napiWasiFlavorRequested || __napiWasiFlavor === 'wasm32-wasi')) { + let candidateError = null + let candidateFailed = false + try { + candidateError = __napiWasiResolveCandidate('./agentchat.wasi.cjs', false, ['./agentchat.wasm32-wasi.debug.wasm', './agentchat.wasm32-wasi.wasm']) + candidateFailed = candidateError !== null + if (!candidateFailed) { + wasiBinding = require('./agentchat.wasi.cjs') + nativeBinding = wasiBinding + __napiLoadedBindingTarget = 'wasm32-wasi' + wasiBindingLoaded = true + } + } catch (err) { + candidateError = err + candidateFailed = true + } + if (candidateFailed) { + wasiBindingErrors.push(candidateError) + loadErrors.push(candidateError) + } + } + if (!wasiBindingLoaded && (!__napiWasiFlavorRequested || __napiWasiFlavor === 'wasm32-wasi')) { + let candidateError = null + let candidateFailed = false + try { + candidateError = __napiWasiResolveCandidate('@berriai/agentchat-wasm32-wasi', true, undefined) + candidateFailed = candidateError !== null + if (!candidateFailed) { + if (process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + const bindingPackageVersion = require('@berriai/agentchat-wasm32-wasi/package.json').version + if (bindingPackageVersion !== '0.1.0-alpha.1') { + throw new Error(`WASI binding package version mismatch, expected 0.1.0-alpha.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + } + wasiBinding = require('@berriai/agentchat-wasm32-wasi') + nativeBinding = wasiBinding + __napiLoadedBindingTarget = 'wasm32-wasi' + wasiBindingLoaded = true + } + } catch (err) { + candidateError = err + candidateFailed = true + } + if (candidateFailed) { + wasiBindingErrors.push(candidateError) + loadErrors.push(candidateError) + } + } + if ( + !wasiBindingLoaded && + forceWasi && + !forceWasiError && + !__napiWasiFlavorRequested + ) { + nativeBinding = requireNative() + } + if ((forceWasiError || __napiWasiFlavorRequested) && !wasiBindingLoaded) { + const error = new Error( + __napiWasiFlavorRequested + ? 'WASI binding for flavor "' + __napiWasiFlavor + '" not found' + : 'WASI binding not found and NAPI_RS_FORCE_WASI is set to error', + ) + error.cause = createLoadErrorChain(wasiBindingErrors) + throw error + } +} + +if (!nativeBinding) { + if (loadErrors.length > 0) { + const error = new Error( + `Cannot find native binding. ` + + `npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). ` + + 'Please try `npm i` again after removing both package-lock.json and node_modules directory.', + ) + // assign instead of the `new Error(message, { cause })` options form, + // which Node < 16.9 silently ignores + error.cause = createLoadErrorChain(loadErrors) + throw error + } + throw new Error(`Failed to load native binding`) +} + +function __napiStampBindingTarget(exportsObject, target) { + if ( + Object.prototype.hasOwnProperty.call(exportsObject, '__napiBindingTarget') + ) { + if (exportsObject.__napiBindingTarget === target) { + // Already ours: the root entry aliases the object it loaded, so a WASI + // fallback candidate — or a `NAPI_RS_NATIVE_LIBRARY_PATH` override that + // is a generated loader — arrives already stamped with this same value. + return target + } + const error = new Error( + '`__napiBindingTarget` is reserved by the generated binding loader, but the loaded binding already exports it. Rename the export, e.g. #[napi(js_name = "...")].', + ) + error.code = 'ERR_NAPI_BINDING_TARGET_CONFLICT' + throw error + } + if (!Object.isExtensible(exportsObject)) { + // A `#[napi(module_exports)]` hook may seal or freeze this object + // (`Object::seal` / `Object::freeze`). Reporting the artifact is metadata, + // never a reason to fail an otherwise successful load, so the stamp is + // skipped. What a consumer still sees then follows the entry point: the + // browser and deferred loaders declare `__napiBindingTarget` at module + // level and go on reporting it, while the CommonJS entries hand back this + // very object as `module.exports`, so there the value is absent. + return target + } + try { + // [[Define]], not [[Set]]: an ordinary assignment walks the prototype + // chain, so an inherited accessor could swallow the value or throw and + // fail an otherwise successful load. The descriptor is what a successful + // assignment would have produced. + Object.defineProperty(exportsObject, '__napiBindingTarget', { + configurable: true, + enumerable: true, + value: target, + writable: true, + }) + } catch { + // Same rule as the non-extensible skip above: reporting the artifact is + // metadata, never a reason to fail an otherwise successful load. An exotic + // object (a Proxy whose defineProperty trap refuses) is skipped, not + // thrown over. + } + // The CommonJS loaders assign this return value so `cjs-module-lexer` — and + // therefore Node's CJS -> ESM named export detection — can see + // `__napiBindingTarget` statically. + return target +} +// Stamp before the alias, not after. The guard only reads `nativeBinding` +// (`hasOwnProperty` plus a comparison), which is safe against any addon +// accessor; an assignment is not, because a `#[napi(module_exports)]` hook can +// expose a getter reporting this very value and a setter that throws. So the +// assignment lands on the loader's own `module.exports`, still the original +// object here, and the alias below replaces it. +// +// The assignment is what keeps the marker a statically visible CommonJS export: +// `cjs-module-lexer` is Node's CJS -> ESM named export detection, it cannot see +// a bare call, and the later `module.exports = nativeBinding` does not undo the +// detection. The assignment itself always succeeds — its target is this +// loader's own, still extensible `module.exports` — and the alias below then +// discards the value it wrote. What a consumer reads is whatever the guard put +// on `nativeBinding`, so on a frozen binding, where the guard skips, the +// linked import resolves to `undefined`. +module.exports.__napiBindingTarget = __napiStampBindingTarget(nativeBinding, __napiLoadedBindingTarget) +module.exports = nativeBinding +module.exports.NativeAgentChat = nativeBinding.NativeAgentChat +module.exports.NativeWorkingStatus = nativeBinding.NativeWorkingStatus diff --git a/packages/node/native/index.d.ts b/packages/node/native/index.d.ts new file mode 100644 index 0000000..73f8a25 --- /dev/null +++ b/packages/node/native/index.d.ts @@ -0,0 +1,44 @@ +/* auto-generated by NAPI-RS */ +/* eslint-disable */ + +/** + * Which binding artifact the generated loader actually loaded: `'native'` for + * a native addon, otherwise the `platformArchABI` of the WASI flavor. Every + * flavor napi-rs can build is listed, because `NAPI_RS_NATIVE_LIBRARY_PATH` + * can point the loader at a WASI artifact this package does not build itself. + */ +export declare const __napiBindingTarget: 'native' | 'wasm32-wasi' | 'wasm32-wasip1' + +export declare class NativeAgentChat { + constructor(channelsJson: string) + run(handler: (channelIndex: number, messageJson: string) => Promise): Promise + close(): Promise + reply(channelIndex: number, sourceJson: string, text: string): Promise + history(conversationId: string, limit?: number | undefined | null): Promise + threadHistory(channelIndex: number, sourceJson: string, limit: number): Promise + subscribe(channelIndex: number, sourceJson: string): Promise + replyRich(channelIndex: number, sourceJson: string, contentJson: string): Promise + uploadFiles(channelIndex: number, sourceJson: string, files: Array): Promise + downloadAttachment(channelIndex: number, sourceJson: string, attachmentJson: string, maxBytes: number): Promise + setStatus(channelIndex: number, sourceJson: string, status: string): Promise + working(channelIndex: number, sourceJson: string, status: string, refreshIntervalMs: number): Promise + mirror(channelIndex: number, sourceJson: string, messageJson: string, origin: string): Promise +} + +export declare class NativeWorkingStatus { + update(status: string): Promise + close(): Promise +} + +export interface NativeDownloadedFile { + id: string + name: string + content: Buffer + mediaType?: string +} + +export interface NativeUploadFile { + filename: string + content: Buffer + title?: string +} diff --git a/packages/node/package-lock.json b/packages/node/package-lock.json new file mode 100644 index 0000000..0acc5fd --- /dev/null +++ b/packages/node/package-lock.json @@ -0,0 +1,1980 @@ +{ + "name": "@berriai/agentchat", + "version": "0.1.0-alpha.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@berriai/agentchat", + "version": "0.1.0-alpha.1", + "license": "MIT", + "devDependencies": { + "@napi-rs/cli": "^3.0.0", + "@types/node": "^22.0.0", + "typescript": "^5.9.0", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@inquirer/ansi": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.9.tgz", + "integrity": "sha512-0Ywt3S8tqM6TWUcsRlBFQ1x+X3/o8kktX0ZiKAnByYb9GFDknnOhse1PVtfiD2BXPI8ar+BWx9PV9R0lQIcygw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.6.tgz", + "integrity": "sha512-XkOardciF3tow2NUlY6/Qjk1QqYa+6WpmVLTpxXl97Uv4erczDEZ+xg1j6LqBf0ekcRSUiICQA4nQXHHkVKQ9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.9", + "@inquirer/core": "^12.0.4", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.3.tgz", + "integrity": "sha512-ImOBgpl/sJTFp8naeSkNu4S0Sf8F8pxv3/CUx+LoBAdQvCVqFz6rSB2A/oZYsJge3+yphbEDxEkfaRNYq1OU0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.4", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.4.tgz", + "integrity": "sha512-M5fB67y7jPObI45vhD82RskWwCSm0scOvenV/I6K8tIi2gfr9DXqQqJ+iw+Wi6SgapikdOiJibYzz1gW47fsDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.9", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.2.0", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.3.4.tgz", + "integrity": "sha512-imzxQDfw26NpcJAubMj/dE5RjU9Mp80IcT+FzsD6YoxXmL8L1QYnAFdCNv7ccGWh//73+0YP+1kfhwnExdl2VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.4", + "@inquirer/external-editor": "^3.0.6", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.6.tgz", + "integrity": "sha512-JfiLKi7qGuHmcQO/DqR10UFEp054Y1FXZaszP5ZEfHhgHeuFoT5nNFQIXFhed/5/p07SbsV2tIJFwG/dg5+NmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.4", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.6.tgz", + "integrity": "sha512-rWmw6WG8JZ7LMn/srXEylYGhL6EXXJeROE7/4x4GREh6NgzqJBDbIgpNGsYoLPfobeb4VZtEYzPGnT5EFNYhiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.9.tgz", + "integrity": "sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.7.tgz", + "integrity": "sha512-Icrs9tuMBCxGI+lFYNO71VFOnqGO8IIrLIGqnTGh07X8jZ2la9QLAYxXcikHZQjjVhIZUtU2xyH89DVN/pPhVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.4", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.2.4.tgz", + "integrity": "sha512-C6g80U4U2+w2Vxa1CB0MN3a2WnQIfzLSUG0lduO3Au9GsirJsKATZkOwZPK+vZ1BlAlz+/Z5ParfQCS9E0svmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.4", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.2.3.tgz", + "integrity": "sha512-1Uo2qfI2K3wXbfunMpvwAzoZPIUf5iEETwDGqRVbswoorXGqYQJ/bfIJFeLj+pxjq+e2UFPNOmx+fsIl4Urigw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.9", + "@inquirer/core": "^12.0.4", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.7.3", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.7.3.tgz", + "integrity": "sha512-DHK20kO+H5k/XcJQpHQUpVQbMYHHhZchlGWhZVMUV4WIuRuBW5744hipOtriE1vMqPLgnIyXFMHWKkIbTepa4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.2.6", + "@inquirer/confirm": "^6.3.3", + "@inquirer/editor": "^5.3.4", + "@inquirer/expand": "^5.1.6", + "@inquirer/input": "^5.1.7", + "@inquirer/number": "^4.2.4", + "@inquirer/password": "^5.2.3", + "@inquirer/rawlist": "^5.3.6", + "@inquirer/search": "^4.3.4", + "@inquirer/select": "^5.2.6" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.6.tgz", + "integrity": "sha512-2exExhQ5xTBqfN/rRlZkvM1Gm3b0eiLcWe2NqxWxe7qYmnU4M3j/n9TwzfPUb+Q7dAAz9TmBtDWWdF3jbp4ZLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.4", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.3.4.tgz", + "integrity": "sha512-xsk0UeFP04jsbEeFMzghjUaH2aHXyt+WykSajQzq/8qyBNHsgGYHea/sFwv5T08kW7Sp+KDX8aF124zPUIs1Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.4", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.6.tgz", + "integrity": "sha512-jFu7CFghlr0vpi/326a4wz6BGkJH9X3H/iIu+ahyV2Ja52ktqQE3fC9BYP47JKeSPQYGUv5xquE2Vp9PbepZ3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.9", + "@inquirer/core": "^12.0.4", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.2.0.tgz", + "integrity": "sha512-2rnHX8RPo4glJYqnFc/v3nQeIhDcjyRRyaT7MGcfL/8xv/8K03SXYoA2tX06WVSKywWOE/ncbG3dtuzLjR5TCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@napi-rs/cli": { + "version": "3.10.8", + "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-3.10.8.tgz", + "integrity": "sha512-hg7m8VwECTIIHxNzajDG8QgtTOEVAKiIVLCLCW90Th6Z5R/ijWjh16iaQrRvXj4ve/S2fcXYo5cctCWJ8aVxIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/prompts": "^8.5.2", + "@napi-rs/cross-toolchain": "^1.0.3", + "@napi-rs/wasm-tools": "^1.1.0", + "@octokit/rest": "^22.0.1", + "clipanion": "^4.0.0-rc.4", + "colorette": "^2.0.20", + "es-toolkit": "^1.47.0", + "js-yaml": "^5.0.0", + "obug": "^3.0.0", + "semver": "^7.8.2", + "typanion": "^3.14.0", + "typescript": "^6.0.3" + }, + "bin": { + "napi": "dist/cli.js", + "napi-raw": "cli.mjs" + }, + "engines": { + "node": "^20.17.0 || ^22.13.0 || >= 23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4", + "emnapi": "^1.7.1 || ^2.0.0-alpha.4" + }, + "peerDependenciesMeta": { + "@emnapi/core": { + "optional": true + }, + "@emnapi/runtime": { + "optional": true + }, + "emnapi": { + "optional": true + } + } + }, + "node_modules/@napi-rs/cli/node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@napi-rs/cross-toolchain": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/cross-toolchain/-/cross-toolchain-1.0.3.tgz", + "integrity": "sha512-ENPfLe4937bsKVTDA6zdABx4pq9w0tHqRrJHyaGxgaPq03a2Bd1unD5XSKjXJjebsABJ+MjAv1A2OvCgK9yehg==", + "dev": true, + "license": "MIT", + "workspaces": [ + ".", + "arm64/*", + "x64/*" + ], + "dependencies": { + "@napi-rs/lzma": "^1.4.5", + "@napi-rs/tar": "^1.1.0", + "debug": "^4.4.1" + }, + "peerDependencies": { + "@napi-rs/cross-toolchain-arm64-target-aarch64": "^1.0.3", + "@napi-rs/cross-toolchain-arm64-target-armv7": "^1.0.3", + "@napi-rs/cross-toolchain-arm64-target-ppc64le": "^1.0.3", + "@napi-rs/cross-toolchain-arm64-target-s390x": "^1.0.3", + "@napi-rs/cross-toolchain-arm64-target-x86_64": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-aarch64": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-armv7": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-ppc64le": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-s390x": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-x86_64": "^1.0.3" + }, + "peerDependenciesMeta": { + "@napi-rs/cross-toolchain-arm64-target-aarch64": { + "optional": true + }, + "@napi-rs/cross-toolchain-arm64-target-armv7": { + "optional": true + }, + "@napi-rs/cross-toolchain-arm64-target-ppc64le": { + "optional": true + }, + "@napi-rs/cross-toolchain-arm64-target-s390x": { + "optional": true + }, + "@napi-rs/cross-toolchain-arm64-target-x86_64": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-aarch64": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-armv7": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-ppc64le": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-s390x": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-x86_64": { + "optional": true + } + } + }, + "node_modules/@napi-rs/lzma": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma/-/lzma-1.5.1.tgz", + "integrity": "sha512-sgOZ89+y8cDbY+3WbzR8CtIhCuFRWotZ9/2PjPVDJHz6np5KFTAev0DrwiyTJTgFsCRDhfGlbmhMgyhHbWdZ6g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.20 || ^24.12 || >=25" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/lzma-android-arm-eabi": "1.5.1", + "@napi-rs/lzma-android-arm64": "1.5.1", + "@napi-rs/lzma-darwin-arm64": "1.5.1", + "@napi-rs/lzma-darwin-x64": "1.5.1", + "@napi-rs/lzma-freebsd-x64": "1.5.1", + "@napi-rs/lzma-linux-arm-gnueabihf": "1.5.1", + "@napi-rs/lzma-linux-arm64-gnu": "1.5.1", + "@napi-rs/lzma-linux-arm64-musl": "1.5.1", + "@napi-rs/lzma-linux-ppc64-gnu": "1.5.1", + "@napi-rs/lzma-linux-riscv64-gnu": "1.5.1", + "@napi-rs/lzma-linux-s390x-gnu": "1.5.1", + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@napi-rs/lzma-linux-x64-musl": "1.5.1", + "@napi-rs/lzma-wasm32-wasi": "1.5.1", + "@napi-rs/lzma-win32-arm64-msvc": "1.5.1", + "@napi-rs/lzma-win32-ia32-msvc": "1.5.1", + "@napi-rs/lzma-win32-x64-msvc": "1.5.1" + } + }, + "node_modules/@napi-rs/lzma-android-arm-eabi": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-android-arm-eabi/-/lzma-android-arm-eabi-1.5.1.tgz", + "integrity": "sha512-sahBe4ko2Z69NPTddaX6ZgbQZu9SDoITxw1S3dWl1gAGynZG34qHHCT8UaUMFxf3h3zMhCJjEzz4basaBxiTuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-android-arm64": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-android-arm64/-/lzma-android-arm64-1.5.1.tgz", + "integrity": "sha512-7tkQAJJuBHxAxiEBNFgSTpvrtGpbwZYYJUSOmGEK3OfbdbNeoT2rdBxpM/gY1s+itEVbtOSlpaRPPG19MnwOzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-darwin-arm64": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-darwin-arm64/-/lzma-darwin-arm64-1.5.1.tgz", + "integrity": "sha512-XWX8gtF+GHGk3nH3Wm3QUZNcxw9QHsFVZz3MzVLhWWHhceede1J4/vD+3dj3E1iKB9G6mualaZxOoD08R3E+7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-darwin-x64": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-darwin-x64/-/lzma-darwin-x64-1.5.1.tgz", + "integrity": "sha512-CfsqUpMTI1z8enrA/b+GcHM6YDI8D0kqCiqPYEnst4rbOABQ9KZ92ybTTNnlnZ7A017WoMZKUEWc36KXDwi0xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-freebsd-x64": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-freebsd-x64/-/lzma-freebsd-x64-1.5.1.tgz", + "integrity": "sha512-bTyNfg90FXIgE61U7l14aMmVOqRQ6AyP5JMT3jmCStaZI18apLNPdzZ8i7yqxZfKvRMVfPjE2brXIw27c+RRgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-arm-gnueabihf": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm-gnueabihf/-/lzma-linux-arm-gnueabihf-1.5.1.tgz", + "integrity": "sha512-vNE+D8nrw+eOkBsdKCsmDhowDV3pIMKXEhedvXfbgrWbrO7GlZJH+RXL+X+RYLxGwi8Ym61ZMt15sIOnNmh9Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-arm64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm64-gnu/-/lzma-linux-arm64-gnu-1.5.1.tgz", + "integrity": "sha512-csUem4WgoKGTprv/pOPm9UIWbb+hrfUwYXefpTHPAEGVFLl5behEFabisJ7FtihCa3yG2Efcl+yw25rlhhrIYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-arm64-musl": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm64-musl/-/lzma-linux-arm64-musl-1.5.1.tgz", + "integrity": "sha512-kB/xhlVN1eLvVmDJSKZEjp5Gg2xDYexNrB5jwpSMbOkeGS6N9AasByPBg5VqCpMYC+zZi7DM458DRhtWYhqXTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-ppc64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-ppc64-gnu/-/lzma-linux-ppc64-gnu-1.5.1.tgz", + "integrity": "sha512-s28RW0W1yBWQc1nbPdF7tp14koqslY3ZWLVI8uaanX292Dc6ezd4NPVwxEoCNBVON/oD7BmUbWGtyFvmm7dQ5A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-riscv64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-riscv64-gnu/-/lzma-linux-riscv64-gnu-1.5.1.tgz", + "integrity": "sha512-+lGNwYlIN14YPMTNvYtIJJqHFevDTd6Juw/1NmXbWx/iRd/LLrjhlM/yluMX6pxs6NkOGsuuEXJJrbbEUS59OQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-s390x-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-s390x-gnu/-/lzma-linux-s390x-gnu-1.5.1.tgz", + "integrity": "sha512-PB44FFWWFrLeQowhcep1hPD1YcLqKlnnY60RMU74qrxTlr4YGEyzeMItJqh2uivBfv9kQScOF/B0J9+Vab/oyw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-musl": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-musl/-/lzma-linux-x64-musl-1.5.1.tgz", + "integrity": "sha512-I3nsYrWtrW9JpeCr+mkJIVDt0HY3m6qVUBs5vTtoIvJQxwqf1PBXSy5IS7T53ksQFH2kd2UX8rLxJ7B4WISpZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-wasm32-wasi": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-wasm32-wasi/-/lzma-wasm32-wasi-1.5.1.tgz", + "integrity": "sha512-gy3wwPBa6+XEyA4fUzq6CClrXA1ajXjuVf5zbnHytJRgoHznj+mvpU3+co2fxXwqTCmIpn6KrzqH5bRDztBPhA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.2", + "@emnapi/runtime": "1.11.2", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-win32-arm64-msvc": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-arm64-msvc/-/lzma-win32-arm64-msvc-1.5.1.tgz", + "integrity": "sha512-dK+huOsHiyH6oJjij+cnjqFCakk2HgWmpI12Xm4pLUyPphe4ebYoJBgehaNAxprmjFqBQ7nL95YPVz9BHyqmPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-win32-ia32-msvc": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-ia32-msvc/-/lzma-win32-ia32-msvc-1.5.1.tgz", + "integrity": "sha512-dGE8L+0EQ+GyU9ap9InqB/t/PmPG/bLj918q7OsJ29FuTdn8fK4OX3U4IQZhylHIA+/dQ/SXJk5n4yfah2XVvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-win32-x64-msvc": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-x64-msvc/-/lzma-win32-x64-msvc-1.5.1.tgz", + "integrity": "sha512-EKW4t/iqdCT/xnd5t9oXLvVER/PMNAWXKqUAl3fgvUcOILeZIIht77/dVnfFcc9htA/DCBXC/6YQWdW+LusjFA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/tar": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar/-/tar-1.1.1.tgz", + "integrity": "sha512-p6q2HhUc5vwH1CNwfOcrhLoxfgn8ust8Sqlfx+sA4VzAcp1cMbvbkl99tZZlDqOjCHgQNSiTfk/yWPjl/D42qA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/tar-android-arm-eabi": "1.1.1", + "@napi-rs/tar-android-arm64": "1.1.1", + "@napi-rs/tar-darwin-arm64": "1.1.1", + "@napi-rs/tar-darwin-x64": "1.1.1", + "@napi-rs/tar-freebsd-x64": "1.1.1", + "@napi-rs/tar-linux-arm-gnueabihf": "1.1.1", + "@napi-rs/tar-linux-arm64-gnu": "1.1.1", + "@napi-rs/tar-linux-arm64-musl": "1.1.1", + "@napi-rs/tar-linux-ppc64-gnu": "1.1.1", + "@napi-rs/tar-linux-s390x-gnu": "1.1.1", + "@napi-rs/tar-linux-x64-gnu": "1.1.1", + "@napi-rs/tar-linux-x64-musl": "1.1.1", + "@napi-rs/tar-wasm32-wasi": "1.1.1", + "@napi-rs/tar-win32-arm64-msvc": "1.1.1", + "@napi-rs/tar-win32-ia32-msvc": "1.1.1", + "@napi-rs/tar-win32-x64-msvc": "1.1.1" + } + }, + "node_modules/@napi-rs/tar-android-arm-eabi": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-android-arm-eabi/-/tar-android-arm-eabi-1.1.1.tgz", + "integrity": "sha512-cAhnA10cSusAUbcE9HtjQY/tZ9BH/0w2sKtRcQc94TzIlnm7QSr1htJSd/PPrbWNPtrv1orXb2CkrHlVlbnlHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-android-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-android-arm64/-/tar-android-arm64-1.1.1.tgz", + "integrity": "sha512-EslUWHCDBY/g5abTPBiHLsMaML4GagV0TXLm5WL9hAjx/DDtlxz9fegMb77RJ+f7nFLOIsUxF/3QWFvgOT0sMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-darwin-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-darwin-arm64/-/tar-darwin-arm64-1.1.1.tgz", + "integrity": "sha512-+A42/6ES5G9CQ35BOwzwA+WBjLID28r2jNPgc0dteD2hhClIhng0mva7D2ujUlXBNmgNOsr1LHn3stA4uTf4NQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-darwin-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-darwin-x64/-/tar-darwin-x64-1.1.1.tgz", + "integrity": "sha512-RYtE8w1dkEvj8hSJCDV5Jw0Rz2i13fsM7u893zv5O9n/4Ad5GNsw/f4RQ7/0YGSFaenkVxqPFrjmEvUHlKzsrg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-freebsd-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-freebsd-x64/-/tar-freebsd-x64-1.1.1.tgz", + "integrity": "sha512-rEepBvCJUwcuvUYkY83e8aot8RsR5Jcnal4PsG3tbWGKW1yAvcXhyMXf0fN6ZGpVRZFnB+FJqDyBxvsCPEXKhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-arm-gnueabihf": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-arm-gnueabihf/-/tar-linux-arm-gnueabihf-1.1.1.tgz", + "integrity": "sha512-an1bJdfyhI5FpZYyTQ20mrqwR+a676i8GkaYc4Uy12dH/a7TJIfrK6Qa2Gm46arZvxUvx56qxoRKXbpOjUPvwA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-arm64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-arm64-gnu/-/tar-linux-arm64-gnu-1.1.1.tgz", + "integrity": "sha512-w++Vtx36T2yHTKws7GVnmHHcUT1ybB59xLWSh9A8bwEpJVG4dG7Qub9mFe5cpcbfrJ+XP2mKKxC3oUJSunK3iQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-arm64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-arm64-musl/-/tar-linux-arm64-musl-1.1.1.tgz", + "integrity": "sha512-Rh6UFhNtj3i4deJHOBINFIeRL0072mgbeyuK5rl1HokKnNoMKx8qKIZNEzBTTqpogMfDHWGvzyTQdnVxes5dpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-ppc64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-ppc64-gnu/-/tar-linux-ppc64-gnu-1.1.1.tgz", + "integrity": "sha512-Cp+AxFbv9zcyAXtnzQi0OzmgDnQgy2w9D4Ubr+iwzMtVgJcztzcEoCcCrN1k2ATdEB01LX2Vb49IaocGOZhC9Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-s390x-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-s390x-gnu/-/tar-linux-s390x-gnu-1.1.1.tgz", + "integrity": "sha512-ZyscC3SYKTBWyDRYjLOKAd5TyJ7q0KACRdQ8bWrb3rgrra1CCIJD66CsGTH6Dh0AVSdfLwZ8MfIIXU6+14BMjQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-x64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-x64-gnu/-/tar-linux-x64-gnu-1.1.1.tgz", + "integrity": "sha512-LlIv+zg4fiOQge9LQX/ieBdRWE2fhVDjCTHxnunZkbugNmdhdelxWf1RpZb/6ZujWpNF4LPu4N/MW7ygg2oYAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-x64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-x64-musl/-/tar-linux-x64-musl-1.1.1.tgz", + "integrity": "sha512-gZBeoKLjanOVj55qk4EMu13P2i9M0SuINmlGQkOxm1niIJofexzddHUYtqO5o/5QqtyL8lADmAcZplLILMLhHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-wasm32-wasi": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-wasm32-wasi/-/tar-wasm32-wasi-1.1.1.tgz", + "integrity": "sha512-rwtQ1Mdt/ft6g6I54fJzbUeLspl4yTwj6I3UJ6mitKnrN42soJkcDrdh3Y/FGvlpqZTad2YMQ96fGJl3EtAm2Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.2", + "@emnapi/runtime": "1.11.2", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@napi-rs/tar-win32-arm64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-win32-arm64-msvc/-/tar-win32-arm64-msvc-1.1.1.tgz", + "integrity": "sha512-30PVp1AehRpfwxmv5wI4cg0yj3WmWBsZ+1QnLGnvEELu7Eu/+dhNU0nrmhI7VfPgLwSRK2eg9DQTB3tP7Wv9bA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-win32-ia32-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-win32-ia32-msvc/-/tar-win32-ia32-msvc-1.1.1.tgz", + "integrity": "sha512-aI3/rmz+izUChiSeaPxcasAOxhf3FpJNuIHMXlxS/vpW+HIxUsSDR5+XV61PEG5DL4L/75iENVUxmSGM5l2yaw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-win32-x64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-win32-x64-msvc/-/tar-win32-x64-msvc-1.1.1.tgz", + "integrity": "sha512-yJsB2IsrODQVLKbm2Fg1nHiVRbEj49mSPbj4x7JPZWJI0jGVPjohE2Sif0FBbx8OxsVoUODvS0BwksZZ8jl/OA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.5.tgz", + "integrity": "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@napi-rs/wasm-tools": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools/-/wasm-tools-1.1.0.tgz", + "integrity": "sha512-VjHyKEqXAwYZK+HY7iJctYvRm3TFEbaQxeZwvAG1QRkoo1a39phMY8J6x9tUEqJI03W6MysB8F2jacI6wvcx+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.22.0" + }, + "optionalDependencies": { + "@napi-rs/wasm-tools-android-arm-eabi": "1.1.0", + "@napi-rs/wasm-tools-android-arm64": "1.1.0", + "@napi-rs/wasm-tools-darwin-arm64": "1.1.0", + "@napi-rs/wasm-tools-darwin-x64": "1.1.0", + "@napi-rs/wasm-tools-freebsd-x64": "1.1.0", + "@napi-rs/wasm-tools-linux-arm64-gnu": "1.1.0", + "@napi-rs/wasm-tools-linux-arm64-musl": "1.1.0", + "@napi-rs/wasm-tools-linux-x64-gnu": "1.1.0", + "@napi-rs/wasm-tools-linux-x64-musl": "1.1.0", + "@napi-rs/wasm-tools-wasm32-wasi": "1.1.0", + "@napi-rs/wasm-tools-win32-arm64-msvc": "1.1.0", + "@napi-rs/wasm-tools-win32-ia32-msvc": "1.1.0", + "@napi-rs/wasm-tools-win32-x64-msvc": "1.1.0" + } + }, + "node_modules/@napi-rs/wasm-tools-android-arm-eabi": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-android-arm-eabi/-/wasm-tools-android-arm-eabi-1.1.0.tgz", + "integrity": "sha512-p6J8PB59I8d/XItXB/go5JH6nKW+xIbpzaL43EBTV0hi7mrS/Z4gs+MsB04ZrlqZN29BdZV8fChRyasuXLhRaA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-android-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-android-arm64/-/wasm-tools-android-arm64-1.1.0.tgz", + "integrity": "sha512-lWoKN3suypeBSCIRPIw+++sH9V2K6nQkhtdt1opu7XY3v9JwLs6Gw063HWRqkNjphlYpkd/Qy8XcfSPGbJj7nQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-darwin-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-darwin-arm64/-/wasm-tools-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-jfw5vyNDUf6oe0kP8lMveFN9U7cLk1cUosS7uMIfw/xmqmopYfKQ198DAx2g/6aEF7Tm+CqER2gpMpYKui30LA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-darwin-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-darwin-x64/-/wasm-tools-darwin-x64-1.1.0.tgz", + "integrity": "sha512-R+pjeudAB7BYdH1vKkOJM61Tfv5jB6uXkxmFscYd+KKpdUpWBlNG+s4hr0w4i1rMBM91VhIAETZn2pz+MDHK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-freebsd-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-freebsd-x64/-/wasm-tools-freebsd-x64-1.1.0.tgz", + "integrity": "sha512-hQJTe+aazrT++Vgm6I4lUd9099ItUCFYdd+aKg6Ys6nax6d/cZ1barDLTwA2lwOoVDsXMekJI/FOL6ZvVlIYBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-linux-arm64-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-arm64-gnu/-/wasm-tools-linux-arm64-gnu-1.1.0.tgz", + "integrity": "sha512-1TAXJxUHsWGar90k3W/MknavvBMwOWzjh7Q6Spxo8twRcWJbBD5Kow/Q2KhhDq5hxh2sKGDXn3uLc1tdtz4WUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-linux-arm64-musl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-arm64-musl/-/wasm-tools-linux-arm64-musl-1.1.0.tgz", + "integrity": "sha512-7rw3nlubTjNAVRH2LwphCxHy1b/N2/TerXocQ6XRn4Q+buaY1Z7P/hbdALy1i1ex2yfOU2Xcij7ib7ZLi/lKfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-linux-x64-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-x64-gnu/-/wasm-tools-linux-x64-gnu-1.1.0.tgz", + "integrity": "sha512-1sel0t9MRjI/tdT89M8Dd6gPfANeeFP24Xa46R11WeHNwhjsXXZh+xUk50uWCRTSGcaCy3ugm3AMK/lmHYQJkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-linux-x64-musl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-x64-musl/-/wasm-tools-linux-x64-musl-1.1.0.tgz", + "integrity": "sha512-o2jH5AMfor4EKF2HII1LBnMQxoWu7+usPifTEY8Zk6e9OiSi4EJkAXf9v3ANlX7TI2V/cUEV34OEW7r10GiVIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-wasm32-wasi": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-wasm32-wasi/-/wasm-tools-wasm32-wasi-1.1.0.tgz", + "integrity": "sha512-s6YDtDR1UWrsqJPtaxf+JLYLceWVyn3l8OpQYElHkDhf3Qfz9R6Ba3S0OgznTBv38L5/TIHysQ9Q4yO73Z0csg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@napi-rs/wasm-tools-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-tools-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-tools-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-tools-win32-arm64-msvc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-win32-arm64-msvc/-/wasm-tools-win32-arm64-msvc-1.1.0.tgz", + "integrity": "sha512-x+NuxbG84VxU68tU8w7Rf5lSyq0l584M6dVlke5DTweHYFZoMyeqkpbwEq+qsyAX6ivfipK8xRsmFwamb5uDnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-win32-ia32-msvc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-win32-ia32-msvc/-/wasm-tools-win32-ia32-msvc-1.1.0.tgz", + "integrity": "sha512-mdD96QDEp70SX67rXFTY6c725nVYeqEEjyDqzzbNh6u1APj7CI7IMNpMmvE75XbCRl4C2MHZVU4U6AWdAzvyQQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-win32-x64-msvc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-win32-x64-msvc/-/wasm-tools-win32-x64-msvc-1.1.0.tgz", + "integrity": "sha512-bVVjuvhlyVX++3eJXfDR63cXdw1ay5QYac6iq0MKQw8wZARInTM+bXCtByDT4fzVFI3+7ZthYb/ERWRdBNIqgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.8.tgz", + "integrity": "sha512-L7y8eYc+AwxGr2PWI4WFt1VG4TiJ66c26BD16mXpYIlXxG0SMigM1+m4aTSlYyBr5BlQsGAlz8uDCoZN4SEMcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.5", + "@octokit/request": "^10.0.16", + "@octokit/request-error": "^7.1.2", + "@octokit/types": "^18.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.5.tgz", + "integrity": "sha512-iXa654H3yFafF/ieHkukfbgWo2rmXD2ceD0ZOtrPhw1bc3FDch1d9N/TNs0FQ1/cIbwb7kspUX8jzIs8nzb9DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^18.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.5.tgz", + "integrity": "sha512-bt/hm03LeU6Vy7FwTrkkC9p3XGT/lBwClglMqxBSe5/q0E5CdJTXeAqEI0vlw89/LF/G6tryTIH8HirZ3prMVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.16", + "@octokit/types": "^18.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-29.0.1.tgz", + "integrity": "sha512-9qWOMFNxxLokERcms42rU0PTLqQmVs7g5E41TI4mCOxmpFayD1rfC7XxOL55cG9MBZLFlC31BrR37myMKardwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", + "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.16", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.16.tgz", + "integrity": "sha512-A0zWGjHzISIb+9ccG8s0dq7LKO5zVpJLRICjgUb+sJxEWqn8RUHB1rD3AE51+PECvXHIxqZ1VVvs4fHTSD9nUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.5", + "@octokit/request-error": "^7.1.2", + "@octokit/types": "^18.0.0", + "content-type": "^3.0.0", + "json-with-bigint": "^3.5.12", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.2.tgz", + "integrity": "sha512-XZRuT3xZ84D3gYErI1DZvhJ33dCWVV6uzBtWkaBB4TvA/L6eOeTZodxLFVB44bBEEo3vEx7y00UfX1tBLrtLRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^18.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/rest": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz", + "integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.6", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-request-log": "^6.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-18.0.0.tgz", + "integrity": "sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^29.0.1" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/clipanion": { + "version": "4.0.0-rc.4", + "resolved": "https://registry.npmjs.org/clipanion/-/clipanion-4.0.0-rc.4.tgz", + "integrity": "sha512-CXkMQxU6s9GklO/1f714dkKBMu1lopS1WFF0B8o4AxPykR1hpozxSiUZ5ZUeBjfPgCWqbcNOtZVFhB8Lkfp1+Q==", + "dev": true, + "license": "MIT", + "workspaces": [ + "website" + ], + "dependencies": { + "typanion": "^3.8.0" + }, + "peerDependencies": { + "typanion": "*" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-type": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-3.1.1.tgz", + "integrity": "sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/js-yaml": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.3.tgz", + "integrity": "sha512-yzzNtgczwgoVHNigCyKTXcQJU4Y8t/ikU4UxXW0BvrLfHTGWZhsNcOT2s/a7vn81/4KqVnNmHjaUEPlvWnNwVw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, + "node_modules/json-with-bigint": { + "version": "3.5.12", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.12.tgz", + "integrity": "sha512-uwbF/wSSuOgC7qqlq27Xp5B6a2MHVug3t0idZdTqu0JnlFvgJuH7ju+KAk/J06C7GfhoYy2gnb9wz2INqcne7w==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typanion": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/typanion/-/typanion-3.14.0.tgz", + "integrity": "sha512-ZW/lVMRabETuYCd9O9ZvMhAh8GslSqaUjxmK/JLPCh6l73CvLBiuXswj/+7LdnWOgYsQ130FqLzFz5aGT4I3Ug==", + "dev": true, + "license": "MIT", + "workspaces": [ + "website" + ] + }, + "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" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/packages/node/package.json b/packages/node/package.json new file mode 100644 index 0000000..0945c9a --- /dev/null +++ b/packages/node/package.json @@ -0,0 +1,30 @@ +{ + "name": "@berriai/agentchat", + "version": "0.1.0-alpha.1", + "description": "Connect Node.js agents to chat channels through the shared AgentChat Rust core", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, + "files": ["dist", "native", "README.md"], + "engines": { "node": ">=22" }, + "license": "MIT", + "repository": { "type": "git", "url": "git+https://github.com/BerriAI/agentchat.git", "directory": "packages/node" }, + "scripts": { + "build:native": "napi build --platform --release --manifest-path ../../bindings/node/Cargo.toml --output-dir native --js index.cjs", + "build": "npm run build:native && tsc", + "typecheck": "tsc --noEmit", + "test": "node --test test/*.test.mjs", + "smoke": "node scripts/package-smoke.mjs" + }, + "napi": { + "binaryName": "agentchat", + "targets": ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] + }, + "devDependencies": { + "@napi-rs/cli": "^3.0.0", + "@types/node": "^22.0.0", + "typescript": "^5.9.0", + "ws": "^8.18.0" + } +} diff --git a/packages/node/scripts/package-smoke.mjs b/packages/node/scripts/package-smoke.mjs new file mode 100644 index 0000000..441fb1d --- /dev/null +++ b/packages/node/scripts/package-smoke.mjs @@ -0,0 +1,28 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; + +const directory = await mkdtemp(join(tmpdir(), "agentchat-npm-smoke-")); +const npm = process.env.npm_execpath; +if (!npm) throw new Error("Run this check with npm run smoke"); +function run(args, cwd) { + const result = spawnSync(process.execPath, [npm, ...args], { cwd, encoding: "utf8" }); + if (result.status !== 0) throw new Error(result.stderr || result.stdout); + return result.stdout; +} +try { + const packed = JSON.parse(run(["pack", "--json", "--ignore-scripts", "--pack-destination", directory], process.cwd())); + const tarball = resolve(directory, packed[0].filename); + await writeFile(join(directory, "package.json"), JSON.stringify({ private: true, type: "module" })); + run(["install", "--ignore-scripts", "--no-audit", "--no-fund", tarball], directory); + const script = `import { AgentChat, Slack } from '@berriai/agentchat'; + const app = new AgentChat({channels: [new Slack({botToken:'fixture',appToken:'fixture'})]}); + await app.close(); console.log('Installed native package loaded successfully');`; + await writeFile(join(directory, "smoke.mjs"), script); + const result = spawnSync(process.execPath, [join(directory, "smoke.mjs")], { cwd: directory, encoding: "utf8", timeout: 10000 }); + if (result.status !== 0) throw new Error(result.stderr || result.stdout); + console.log(result.stdout.trim()); +} finally { + await rm(directory, { recursive: true, force: true }); +} diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts new file mode 100644 index 0000000..36b5437 --- /dev/null +++ b/packages/node/src/index.ts @@ -0,0 +1,285 @@ +import { createRequire } from "node:module"; +import { AsyncLocalStorage } from "node:async_hooks"; + +export type Json = null | boolean | number | string | readonly Json[] | { readonly [key: string]: Json }; +export interface Sender { readonly id: string; readonly displayName: string | null } +export interface Attachment { readonly id: string; readonly name: string | null; readonly mediaType: string | null; readonly size: number | null } +export interface Message { + readonly id: string; + readonly conversationId: string; + readonly channel: string; + readonly sender: Sender; + readonly text: string; + readonly role: "user" | "assistant"; + readonly metadata: Readonly>; + readonly threadId: string | null; + readonly addressed: boolean; + readonly attachments: readonly Attachment[]; +} + +export interface RichReply { + text: string; + blocks?: readonly Readonly>[]; + attachments?: readonly Readonly>[]; +} +export interface UploadFile { filename: string; content: Uint8Array; title?: string } +export interface UploadedFile { readonly id: string; readonly permalink: string | null } +export interface DownloadedFile { readonly id: string; readonly name: string; readonly content: Buffer; readonly mediaType: string | null } +export interface WorkingStatus { + update(status: string): Promise; + close(): Promise; +} + +export interface SlackOptions { + botToken: string; + appToken: string; + workspaceId?: string; + botUserId?: string; + ackEmoji?: string | null; + maxPendingEvents?: number; + shutdownTimeoutMs?: number; + /** Slack Web API root. HTTP is accepted only for loopback development fixtures. */ + apiBaseUrl?: string; +} + +export class Slack { + readonly #options: SlackOptions; + + constructor(options: SlackOptions) { + if (!options.botToken || !options.appToken) throw new Error("Slack requires botToken and appToken"); + this.#options = { ...options }; + } + + static fromEnv(options: Partial = {}): Slack { + return new Slack({ + ...options, + botToken: options.botToken ?? process.env.SLACK_BOT_TOKEN ?? "", + appToken: options.appToken ?? process.env.SLACK_APP_TOKEN ?? "", + ...(options.workspaceId ?? process.env.SLACK_TEAM_ID ? { workspaceId: options.workspaceId ?? process.env.SLACK_TEAM_ID! } : {}), + ...(options.botUserId ?? process.env.SLACK_BOT_USER_ID ? { botUserId: options.botUserId ?? process.env.SLACK_BOT_USER_ID! } : {}), + }); + } + + /** @internal */ + nativeOptions(): SlackOptions { return { ...this.#options }; } +} + +interface WireMessage { + id: string; conversation_id: string; channel: string; + sender: { id: string; display_name: string | null }; + text: string; role: "user" | "assistant"; metadata?: Record; + thread_id?: string | null; addressed?: boolean; + attachments?: { id: string; name?: string | null; media_type?: string | null; size?: number | null }[]; +} + +function messageFromWire(wire: WireMessage): Message { + return Object.freeze({ + id: wire.id, conversationId: wire.conversation_id, channel: wire.channel, + sender: Object.freeze({ id: wire.sender.id, displayName: wire.sender.display_name ?? null }), + text: wire.text, role: wire.role, metadata: Object.freeze(wire.metadata ?? {}), + threadId: wire.thread_id ?? null, addressed: wire.addressed ?? true, + attachments: Object.freeze((wire.attachments ?? []).map((file) => Object.freeze({ + id: file.id, name: file.name ?? null, mediaType: file.media_type ?? null, size: file.size ?? null, + }))), + }); +} + +function messagesFromJson(json: string): readonly Message[] { + return Object.freeze((JSON.parse(json) as WireMessage[]).map(messageFromWire)); +} + +function messageToWire(message: Message): WireMessage { + return { + id: message.id, conversation_id: message.conversationId, channel: message.channel, + sender: { id: message.sender.id, display_name: message.sender.displayName }, + text: message.text, role: message.role, metadata: { ...message.metadata }, + thread_id: message.threadId, addressed: message.addressed, + attachments: message.attachments.map((file) => ({ + id: file.id, name: file.name, media_type: file.mediaType, size: file.size, + })), + }; +} + +interface NativeApp { + run(handler: (channelIndex: number, messageJson: string) => Promise): Promise; + close(): Promise; + reply(channelIndex: number, sourceJson: string, text: string): Promise; + history(conversationId: string, limit?: number): Promise; + threadHistory(channelIndex: number, sourceJson: string, limit: number): Promise; + subscribe(channelIndex: number, sourceJson: string): Promise; + replyRich(channelIndex: number, sourceJson: string, contentJson: string): Promise; + uploadFiles(channelIndex: number, sourceJson: string, files: { filename: string; content: Buffer; title?: string }[]): Promise; + downloadAttachment(channelIndex: number, sourceJson: string, attachmentJson: string, maxBytes: number): Promise; + setStatus(channelIndex: number, sourceJson: string, status: string): Promise; + mirror(channelIndex: number, sourceJson: string, messageJson: string, origin: string): Promise; + working(channelIndex: number, sourceJson: string, status: string, refreshIntervalMs: number): Promise; +} + +const require = createRequire(import.meta.url); +function loadNative(): { NativeAgentChat: new (channelsJson: string) => NativeApp } { + return require("../native/index.cjs") as { NativeAgentChat: new (channelsJson: string) => NativeApp }; +} + +export class Conversation { + readonly #native: NativeApp; + constructor(readonly id: string, native: NativeApp) { + this.#native = native; + Object.freeze(this); + } + + async history(options: { limit?: number } = {}): Promise { + if (options.limit !== undefined && (!Number.isInteger(options.limit) || options.limit < 0 || options.limit > 0xffff_ffff)) { + throw new RangeError("History limit must be a non-negative 32-bit integer"); + } + return messagesFromJson(await this.#native.history(this.id, options.limit)); + } +} + +export class MessageContext { + readonly message: Message; + readonly sender: Sender; + readonly conversation: Conversation; + readonly #native: NativeApp; + readonly #channelIndex: number; + readonly #sourceJson: string; + + /** @internal */ + constructor( + native: NativeApp, + channelIndex: number, + sourceJson: string, + readonly signal: AbortSignal, + ) { + this.#native = native; + this.#channelIndex = channelIndex; + this.#sourceJson = sourceJson; + this.message = messageFromWire(JSON.parse(sourceJson) as WireMessage); + this.sender = this.message.sender; + this.conversation = new Conversation(this.message.conversationId, native); + Object.freeze(this); + } + + async reply(content: string): Promise { + return messageFromWire(JSON.parse(await this.#native.reply(this.#channelIndex, this.#sourceJson, content)) as WireMessage); + } + + async history(options: { limit?: number } = {}): Promise { + const limit = options.limit ?? 30; + if (!Number.isInteger(limit) || limit < 2 || limit > 100) throw new RangeError("History limit must be between 2 and 100"); + return messagesFromJson(await this.#native.threadHistory(this.#channelIndex, this.#sourceJson, limit)); + } + + /** Subscribe this Slack thread to future messages, including messages without a mention. */ + async subscribe(): Promise { + await this.#native.subscribe(this.#channelIndex, this.#sourceJson); + } + + async replyRich(content: RichReply): Promise { + return messageFromWire(JSON.parse(await this.#native.replyRich(this.#channelIndex, this.#sourceJson, JSON.stringify(content))) as WireMessage); + } + + async uploadFiles(files: readonly UploadFile[]): Promise { + const uploaded = await this.#native.uploadFiles(this.#channelIndex, this.#sourceJson, files.map((file) => ({ + filename: file.filename, content: Buffer.from(file.content), ...(file.title === undefined ? {} : { title: file.title }), + }))); + return Object.freeze(JSON.parse(uploaded) as UploadedFile[]); + } + + async downloadAttachment(attachment: Attachment, options: { maxBytes?: number } = {}): Promise { + const maxBytes = options.maxBytes ?? 10 * 1024 * 1024; + if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0 || maxBytes > 0xffff_ffff) throw new RangeError("maxBytes must be a positive 32-bit integer"); + const file = await this.#native.downloadAttachment(this.#channelIndex, this.#sourceJson, JSON.stringify({ + id: attachment.id, name: attachment.name, media_type: attachment.mediaType, size: attachment.size, + }), maxBytes); + return Object.freeze({ ...file, mediaType: file.mediaType ?? null }); + } + + async setStatus(status: string): Promise { + return this.#native.setStatus(this.#channelIndex, this.#sourceJson, status); + } + + /** Refresh a transient status. Always await close() in a finally block. */ + async working(options: { status?: string; refreshIntervalMs?: number } = {}): Promise { + const interval = options.refreshIntervalMs ?? 60_000; + if (!Number.isInteger(interval) || interval <= 0 || interval > 90_000) throw new RangeError("Refresh interval must be between 1 and 90000 milliseconds"); + return this.#native.working(this.#channelIndex, this.#sourceJson, options.status ?? "is working…", interval); + } + + async mirror(message: Message, options: { origin?: string } = {}): Promise { + const mirrored = await this.#native.mirror(this.#channelIndex, this.#sourceJson, JSON.stringify(messageToWire(message)), options.origin ?? "web"); + return messageFromWire(JSON.parse(mirrored) as WireMessage); + } +} + +export type MessageHandler = (context: MessageContext) => Promise; +export type HandlerErrorHandler = (error: unknown, context: MessageContext) => void | Promise; +const handlerScope = new AsyncLocalStorage(); + +export class AgentChat { + readonly channels: readonly Slack[]; + readonly #native: NativeApp; + readonly #active = new Set(); + readonly #shutdownTimeoutMs: number; + readonly #onError: HandlerErrorHandler; + #handler: MessageHandler | undefined; + #run: Promise | undefined; + #close: Promise | undefined; + + constructor(options: { channels: readonly Slack[]; onError?: HandlerErrorHandler }) { + if (options.channels.length === 0) throw new Error("AgentChat requires at least one channel"); + this.channels = Object.freeze([...options.channels]); + this.#shutdownTimeoutMs = Math.max(...this.channels.map((channel) => channel.nativeOptions().shutdownTimeoutMs ?? 20_000)); + this.#onError = options.onError ?? (() => { console.error("AgentChat message handler failed"); }); + const { NativeAgentChat } = loadNative(); + this.#native = new NativeAgentChat(JSON.stringify(this.channels.map((channel) => channel.nativeOptions()))); + } + + onMessage(handler: MessageHandler): MessageHandler { + if (this.#handler) throw new Error("AgentChat supports one message handler"); + this.#handler = handler; + return handler; + } + + run(): Promise { + if (!this.#handler) return Promise.reject(new Error("Register a handler with app.onMessage")); + if (this.#close) return Promise.reject(new Error("AgentChat is closed")); + if (this.#run) return Promise.reject(new Error("AgentChat is already running")); + const handler = this.#handler; + this.#run = this.#native.run(async (channelIndex, sourceJson) => { + const controller = new AbortController(); + this.#active.add(controller); + const context = new MessageContext(this.#native, channelIndex, sourceJson, controller.signal); + try { + const result = await handlerScope.run(this, () => handler(context)); + if (result !== undefined && result !== null && typeof result !== "string") { + throw new TypeError("Message handler must return a string or no response"); + } + return controller.signal.aborted ? null : result ?? null; + } catch (error) { + if (controller.signal.aborted) return null; + try { await handlerScope.run(this, () => this.#onError(error, context)); } catch { console.error("AgentChat onError callback failed"); } + throw error; + } finally { + this.#active.delete(controller); + } + }).finally(() => { + for (const controller of this.#active) controller.abort(); + }); + return this.#run; + } + + close(): Promise { + if (!this.#close) { + const abort = () => { for (const controller of this.#active) controller.abort(); }; + const deadline = setTimeout(abort, this.#shutdownTimeoutMs); + deadline.unref(); + this.#close = this.#native.close().finally(() => { + clearTimeout(deadline); + abort(); + }); + // A handler can request shutdown, but cannot await its own completion. + if (handlerScope.getStore() === this) void this.#close.catch(() => {}); + } + return handlerScope.getStore() === this ? Promise.resolve() : this.#close; + } +} diff --git a/packages/node/test/integration.test.mjs b/packages/node/test/integration.test.mjs new file mode 100644 index 0000000..125611d --- /dev/null +++ b/packages/node/test/integration.test.mjs @@ -0,0 +1,290 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { AgentChat, Slack } from "../dist/index.js"; +import { slackFixture, eventually } from "./slack-fixture.mjs"; + +test("native Slack dispatch supports reentrant reply/history, deduplication and thread subscription", { timeout: 10000 }, async () => { + const fixture = await slackFixture(); + const app = new AgentChat({ channels: [new Slack({ botToken: "xoxb-fixture", appToken: "xapp-fixture", apiBaseUrl: fixture.apiBaseUrl })] }); + const contexts = []; + let running; + try { + app.onMessage(async (context) => { + contexts.push(context); + const history = await context.conversation.history(); + assert.equal(history.at(-1).id, context.message.id); + assert.equal((await context.history()).at(-1).id, context.message.id); + await context.subscribe(); + if (contexts.length === 1) { + assert.throws(() => { context.sourceJson = '{"channel":"forged"}'; }, TypeError); + assert.throws(() => { context.message.metadata.channel_id = "forged"; }, TypeError); + await context.reply("manual"); + } + return `answer:${context.message.text}`; + }); + running = app.run(); + void running.catch(() => {}); + await fixture.send("<@B1> hello"); + await eventually(() => fixture.calls.filter((call) => call.method === "chat.postMessage").length === 2); + await fixture.send("<@B1> hello", { envelope: "redelivery" }); + await fixture.send("follow up", { ts: "100.2", type: "message" }); + await eventually(() => fixture.calls.filter((call) => call.method === "chat.postMessage").length === 3); + assert.equal(contexts.length, 2); + assert.equal(contexts[0].conversation.id, contexts[1].conversation.id); + assert.deepEqual(fixture.calls.filter((call) => call.method === "chat.postMessage").map((call) => call.body.text), ["manual", "answer:hello", "answer:follow up"]); + await eventually(async () => (await contexts[0].conversation.history()).length === 5); + assert(fixture.calls.filter((call) => call.method === "chat.postMessage").every((call) => call.body.channel === "C1")); + await eventually(() => fixture.acknowledgements.length === 3); + } finally { + try { await app.close(); await running; } finally { await fixture.close(); } + } +}); + +test("close aborts in-flight host handlers and is idempotent", { timeout: 10000 }, async () => { + const fixture = await slackFixture(); + const app = new AgentChat({ channels: [new Slack({ botToken: "xoxb-fixture", appToken: "xapp-fixture", apiBaseUrl: fixture.apiBaseUrl, shutdownTimeoutMs: 100 })] }); + let observedAbort = false; + let started; + const start = new Promise((resolve) => { started = resolve; }); + app.onMessage(async (context) => { + started(); + await new Promise((resolve) => context.signal.addEventListener("abort", () => { observedAbort = true; resolve(); }, { once: true })); + return "must not send after cancellation"; + }); + const running = app.run(); + void running.catch(() => {}); + try { + await fixture.send("<@B1> wait"); + await start; + await Promise.all([app.close(), app.close()]); + await running; + assert.equal(observedAbort, true); + assert.equal(fixture.calls.filter((call) => call.method === "chat.postMessage").length, 0); + } finally { + await app.close(); + await fixture.close(); + } +}); + +test("external close drains an admitted handler and permits its final reply", { timeout: 10000 }, async () => { + const fixture = await slackFixture(); + const app = new AgentChat({ channels: [new Slack({ botToken: "xoxb-fixture", appToken: "xapp-fixture", apiBaseUrl: fixture.apiBaseUrl, shutdownTimeoutMs: 1000 })] }); + let release; + const gate = new Promise((resolve) => { release = resolve; }); + let started; + const start = new Promise((resolve) => { started = resolve; }); + app.onMessage(async (context) => { + started(); + await gate; + assert.equal(context.signal.aborted, false); + await context.reply("final admitted reply"); + }); + const running = app.run(); + void running.catch(() => {}); + try { + await fixture.send("<@B1> finish"); + await start; + const closing = app.close(); + release(); + await closing; + await running; + assert.deepEqual(fixture.calls.filter((call) => call.method === "chat.postMessage").map((call) => call.body.text), ["final admitted reply"]); + } finally { + release(); + try { await app.close(); await running; } finally { await fixture.close(); } + } +}); + +test("a handler can await close without waiting for itself", { timeout: 10000 }, async () => { + const fixture = await slackFixture(); + const app = new AgentChat({ channels: [new Slack({ botToken: "xoxb-fixture", appToken: "xapp-fixture", apiBaseUrl: fixture.apiBaseUrl, shutdownTimeoutMs: 4000 })] }); + let closeElapsed = Infinity; + app.onMessage(async () => { + const start = Date.now(); + await app.close(); + closeElapsed = Date.now() - start; + return "closing gracefully"; + }); + const running = app.run(); + void running.catch(() => {}); + try { + await fixture.send("<@B1> stop"); + await running; + assert(closeElapsed < 500, `close from handler took ${closeElapsed}ms`); + assert.deepEqual(fixture.calls.filter((call) => call.method === "chat.postMessage").map((call) => call.body.text), ["closing gracefully"]); + } finally { + try { await app.close(); } finally { await fixture.close(); } + } +}); + +test("handler failure is observable, sends no reply, and does not stop the next turn", { timeout: 10000 }, async () => { + const fixture = await slackFixture(); + const errors = []; + const app = new AgentChat({ channels: [new Slack({ botToken: "xoxb-fixture", appToken: "xapp-fixture", apiBaseUrl: fixture.apiBaseUrl })], onError: (error) => errors.push(error) }); + app.onMessage(async (context) => { + if (context.message.text === "fail") throw new Error("intentional handler failure"); + return "recovered"; + }); + const running = app.run(); + void running.catch(() => {}); + try { + await fixture.send("<@B1> fail"); + await eventually(() => errors.length === 1); + assert.match(errors[0].message, /intentional handler failure/); + assert.equal(fixture.calls.filter((call) => call.method === "chat.postMessage").length, 0); + await fixture.send("<@B1> recover", { ts: "100.2" }); + await eventually(() => fixture.calls.filter((call) => call.method === "chat.postMessage").length === 1); + } finally { + try { await app.close(); await running; } finally { await fixture.close(); } + } +}); + +test("rich replies, status guards, explicit file transfer and mirroring use native capabilities", { timeout: 10000 }, async () => { + const fixture = await slackFixture(); + let finished; + let failed; + const complete = new Promise((resolve, reject) => { finished = resolve; failed = reject; }); + const app = new AgentChat({ + channels: [new Slack({ botToken: "xoxb-fixture", appToken: "xapp-fixture", apiBaseUrl: fixture.apiBaseUrl })], + onError: failed, + }); + app.onMessage(async (context) => { + const working = await context.working({ refreshIntervalMs: 100 }); + try { + await working.update("reading file"); + const file = await context.downloadAttachment(context.message.attachments[0]); + assert.equal(file.content.toString(), "hello"); + assert.equal(file.mediaType, "text/plain"); + await assert.rejects(context.downloadAttachment({ id: "FOTHER", name: null, size: null, mediaType: null }), /does not belong/); + const uploads = await context.uploadFiles([{ filename: "answer.txt", content: new Uint8Array([104, 105]) }]); + assert.equal(uploads[0].id, "F2"); + await context.replyRich({ text: "fallback", blocks: [{ type: "section", text: { type: "mrkdwn", text: "rich" } }] }); + await context.mirror({ ...context.message, id: "external-message", text: "outside input" }); + assert.equal((await context.conversation.history()).length, 3); + } finally { await working.close(); } + finished(); + }); + const running = app.run(); + void running.catch(failed); + try { + await fixture.send("<@B1> file", { files: [{ id: "F1", name: "hello.txt", size: 5, mimetype: "text/plain" }] }); + await complete; + assert.equal(fixture.calls.find((call) => call.method === "F2").bytes.toString(), "hi"); + assert.equal(fixture.calls.filter((call) => call.method === "files.info").length, 1); + assert.equal(fixture.calls.filter((call) => call.method === "assistant.threads.setStatus").at(-1).body.status, ""); + } finally { + try { await app.close(); await running; } finally { await fixture.close(); } + } +}); + +test("forced close cancels a detached native reply and never appends an uncertain send", { timeout: 10000 }, async () => { + const fixture = await slackFixture({ replyDelayMs: 300 }); + const app = new AgentChat({ + channels: [new Slack({ botToken: "xoxb-fixture", appToken: "xapp-fixture", apiBaseUrl: fixture.apiBaseUrl, shutdownTimeoutMs: 50 })], + onError: () => {}, + }); + let context; + let replyOutcome; + app.onMessage(async (current) => { + context = current; + const reply = current.reply("slow response"); + replyOutcome = reply.then(() => "sent", () => "cancelled"); + await reply; + }); + const running = app.run(); + void running.catch(() => {}); + try { + await fixture.send("<@B1> slow"); + await eventually(() => fixture.calls.some((call) => call.method === "chat.postMessage")); + await app.close(); + await running; + assert.equal(await replyOutcome, "cancelled"); + await new Promise((resolve) => setTimeout(resolve, 350)); + assert.equal((await context.conversation.history()).length, 1); + } finally { + try { await app.close(); } finally { await fixture.close(); } + } +}); + +test("close stops intake and clears retained status guards concurrently before transport shutdown", { timeout: 10000 }, async () => { + const fixture = await slackFixture({ statusClearDelayMs: 300 }); + const app = new AgentChat({ channels: [new Slack({ botToken: "xoxb-fixture", appToken: "xapp-fixture", apiBaseUrl: fixture.apiBaseUrl })] }); + const guards = []; + let deliveries = 0; + app.onMessage(async (context) => { + deliveries += 1; + guards.push(await context.working()); + }); + const running = app.run(); + void running.catch(() => {}); + try { + for (let index = 1; index <= 4; index += 1) { + await fixture.send("<@B1> work", { ts: `100.${index}`, thread: `100.${index}` }); + } + await eventually(() => guards.length === 4); + const start = Date.now(); + const closing = app.close(); + await eventually(() => fixture.calls.some((call) => call.method === "assistant.threads.setStatus" && call.body.status === "")); + await fixture.send("<@B1> too late", { ts: "100.5", thread: "100.5" }); + await closing; + await running; + assert(Date.now() - start < 1000, "status cleanup should take one concurrent delay, not four serial delays"); + assert.equal(deliveries, 4); + assert.equal(fixture.calls.filter((call) => call.method === "assistant.threads.setStatus" && call.body.status === "").length, 4); + assert.equal(fixture.acknowledgements.some((ack) => ack.envelope_id === "env-100.5"), false); + } finally { + try { await app.close(); } finally { await fixture.close(); } + } +}); + +test("close waits for an in-progress status creation and clears it before closing its transport", { timeout: 10000 }, async () => { + let release; + const statusStartGate = new Promise((resolve) => { release = resolve; }); + const fixture = await slackFixture({ statusStartGate }); + const app = new AgentChat({ channels: [new Slack({ botToken: "xoxb-fixture", appToken: "xapp-fixture", apiBaseUrl: fixture.apiBaseUrl })] }); + let creation; + app.onMessage(async (context) => { + creation = context.working().then(() => "created", () => "closed"); + await creation; + }); + const running = app.run(); + void running.catch(() => {}); + try { + await fixture.send("<@B1> status"); + await eventually(() => fixture.calls.some((call) => call.method === "assistant.threads.setStatus")); + const closing = app.close(); + release(); + await closing; + await running; + assert.equal(await creation, "closed"); + assert.equal(fixture.calls.filter((call) => call.method === "assistant.threads.setStatus").at(-1).body.status, ""); + } finally { + release(); + try { await app.close(); } finally { await fixture.close(); } + } +}); + +test("a fatal transport error rejects run and aborts host work after native drain", { timeout: 10000 }, async () => { + const fixture = await slackFixture(); + const app = new AgentChat({ channels: [new Slack({ botToken: "xoxb-fixture", appToken: "xapp-fixture", apiBaseUrl: fixture.apiBaseUrl, shutdownTimeoutMs: 30 })] }); + let context; + let observedAbort = false; + app.onMessage(async (current) => { + context = current; + await new Promise((resolve) => current.signal.addEventListener("abort", () => { observedAbort = true; resolve(); }, { once: true })); + return "must not send"; + }); + const running = app.run(); + void running.catch(() => {}); + try { + await fixture.send("<@B1> wait"); + await eventually(() => context !== undefined); + const socket = await fixture.connection; + socket.send(JSON.stringify({ type: "disconnect", reason: "link_disabled" })); + await assert.rejects(running, /Socket Mode was disabled/); + assert.equal(observedAbort, true); + assert.equal(fixture.calls.some((call) => call.method === "chat.postMessage"), false); + } finally { + try { await app.close(); } finally { await fixture.close(); } + } +}); diff --git a/packages/node/test/slack-fixture.mjs b/packages/node/test/slack-fixture.mjs new file mode 100644 index 0000000..29ef993 --- /dev/null +++ b/packages/node/test/slack-fixture.mjs @@ -0,0 +1,90 @@ +import http from "node:http"; +import { once } from "node:events"; +import { WebSocketServer } from "ws"; + +export async function slackFixture({ replyDelayMs = 0, statusClearDelayMs = 0, statusStartGate } = {}) { + const calls = []; + const sockets = new Set(); + const acknowledgements = []; + const server = http.createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const text = Buffer.concat(chunks).toString(); + const body = request.headers["content-type"]?.includes("application/json") + ? JSON.parse(text || "{}") : Object.fromEntries(new URLSearchParams(text)); + const method = new URL(request.url, "http://localhost").pathname.split("/").at(-1); + calls.push({ method, body, bytes: Buffer.concat(chunks), authorization: request.headers.authorization }); + if (request.url.startsWith("/files-pri/")) { + response.writeHead(200, { "content-type": "application/octet-stream" }); + response.end("hello"); + return; + } + if (request.url.startsWith("/upload/")) { + response.writeHead(200); + response.end("ok"); + return; + } + let result; + if (method === "auth.test") result = { ok: true, team_id: "T1", user_id: "B1", bot_id: "BOT1" }; + else if (method === "apps.connections.open") result = { ok: true, url: `ws://127.0.0.1:${server.address().port}/socket` }; + else if (method === "chat.postMessage") result = { ok: true, channel: body.channel, ts: `200.${calls.length}`, message: { text: body.text, user: "B1" } }; + else if (method === "conversations.replies") result = { ok: true, has_more: false, messages: [{ type: "message", ts: "100.1", user: "U1", text: "root" }] }; + else if (method === "assistant.threads.setStatus") result = { ok: true }; + else if (method === "reactions.add") result = { ok: true }; + else if (method === "chat.getPermalink") result = { ok: true, permalink: "https://slack.example/message" }; + else if (method === "files.info") result = { ok: true, file: { id: "F1", name: "hello.txt", size: 5, mimetype: "text/plain", url_private_download: `http://127.0.0.1:${server.address().port}/files-pri/F1` } }; + else if (method === "files.getUploadURLExternal") result = { ok: true, file_id: "F2", upload_url: `http://127.0.0.1:${server.address().port}/upload/F2` }; + else if (method === "files.completeUploadExternal") result = { ok: true, files: [{ id: "F2", permalink: "https://slack.example/file" }] }; + else result = { ok: false, error: `unsupported_fixture_method:${method}` }; + if (method === "chat.postMessage" && replyDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, replyDelayMs)); + if (response.destroyed) return; + } + if (method === "assistant.threads.setStatus") { + if (body.status && statusStartGate) await statusStartGate; + if (!body.status && statusClearDelayMs > 0) await new Promise((resolve) => setTimeout(resolve, statusClearDelayMs)); + if (response.destroyed) return; + } + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify(result)); + }); + const websocket = new WebSocketServer({ server }); + let connected; + const connection = new Promise((resolve) => { connected = resolve; }); + websocket.on("connection", (socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + socket.on("message", (data) => acknowledgements.push(JSON.parse(data.toString()))); + socket.send(JSON.stringify({ type: "hello" })); + connected(socket); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + return { + apiBaseUrl: `http://127.0.0.1:${server.address().port}/api/`, + calls, acknowledgements, connection, + async send(text, { ts = "100.1", thread = "100.1", channel = "C1", envelope = `env-${ts}`, type = "app_mention", files = [] } = {}) { + const socket = await connection; + socket.send(JSON.stringify({ + type: "events_api", envelope_id: envelope, + payload: { team_id: "T1", event_id: `Ev-${ts}`, event: { + type, user: "U1", channel, channel_type: "channel", text, ts, thread_ts: thread, files, + } }, + })); + }, + async close() { + for (const socket of sockets) socket.terminate(); + await new Promise((resolve) => websocket.close(resolve)); + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + }, + }; +} + +export async function eventually(predicate, timeout = 4000) { + const deadline = Date.now() + timeout; + while (!(await predicate())) { + if (Date.now() > deadline) throw new Error("Timed out waiting for fixture condition"); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} diff --git a/packages/node/tsconfig.json b/packages/node/tsconfig.json new file mode 100644 index 0000000..6a9d26d --- /dev/null +++ b/packages/node/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", + "strict": true, "declaration": true, "declarationMap": true, + "outDir": "dist", "rootDir": "src", "skipLibCheck": true, + "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true + }, + "include": ["src/**/*.ts"] +} diff --git a/pyproject.toml b/pyproject.toml index 9ecc557..9c20992 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] -requires = ["setuptools>=75"] -build-backend = "setuptools.build_meta" +requires = ["maturin>=1.9,<2"] +build-backend = "maturin" [project] name = "agentchat" @@ -37,8 +37,12 @@ dev = [ "ruff>=0.14,<1", ] -[tool.setuptools.packages.find] -where = ["src"] +[tool.maturin] +manifest-path = "bindings/python/Cargo.toml" +python-source = "src" +module-name = "agentchat._native" +features = ["pyo3/extension-module"] +include = ["docs/**/*.md", "examples/**/*.py", "tests/**/*.py", "conformance/*.json"] [tool.pytest.ini_options] addopts = "-q" diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..05e6ca1 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "stable" +profile = "minimal" +components = ["rustfmt", "clippy"] diff --git a/scripts/python-package-smoke.py b/scripts/python-package-smoke.py new file mode 100644 index 0000000..8702998 --- /dev/null +++ b/scripts/python-package-smoke.py @@ -0,0 +1,47 @@ +"""Run outside src/ to verify an installed wheel/sdist and public Python API.""" +import asyncio +from dataclasses import replace + +from agentchat._native import core_version + +from agentchat import AgentChat, Message, Sender + + +class EchoChannel: + name = "smoke" + + def bind(self, receiver): + self.receiver = receiver + + async def run(self): + message = Message("smoke:1", "smoke:thread", self.name, Sender("user"), "hello", "user") + await self.receiver(self, message) + await self.receiver(self, message) + + async def reply(self, source, content): + return replace(source, id="smoke:reply", text=content, role="assistant") + + async def close(self): + pass + + +async def main(): + for backend in ("rust", "python"): + app = AgentChat(channels=[EchoChannel()], backend=backend) + + @app.on_message + async def respond(context): + assert context.message.text == "hello" + assert len(await context.history()) == 1 + return "world" + + try: + await app.run() + history = await app.state.history("smoke:thread") + assert [message.text for message in history] == ["hello", "world"] + finally: + await app.close() + print(f"Python package smoke passed (Rust core {core_version})") + + +asyncio.run(main()) diff --git a/src/agentchat/_backend.py b/src/agentchat/_backend.py new file mode 100644 index 0000000..7750988 --- /dev/null +++ b/src/agentchat/_backend.py @@ -0,0 +1,12 @@ +"""Backend selection is fixed before resources are bound or work is admitted.""" +import os +from typing import Literal + +Backend = Literal["rust", "python"] + + +def resolve_backend(backend: Backend | None) -> Backend: + selected = backend if backend is not None else os.getenv("AGENTCHAT_BACKEND", "rust") + if selected not in ("rust", "python"): + raise ValueError("backend must be 'rust' or 'python'") + return selected diff --git a/src/agentchat/_runtime.py b/src/agentchat/_runtime.py new file mode 100644 index 0000000..bf8509c --- /dev/null +++ b/src/agentchat/_runtime.py @@ -0,0 +1,45 @@ +"""Execute Rust turn effects in the caller's Python task. + +The native engine owns ordering. This adapter only invokes the existing Python +protocols: it neither serializes user objects nor reschedules host callbacks. +""" +from __future__ import annotations + +from contextlib import AsyncExitStack +from typing import TYPE_CHECKING + +from agentchat._native import Effect, NativeTurn +from agentchat.context import MessageContext + +if TYPE_CHECKING: + from agentchat.app import AgentChat, MessageHandler + from agentchat.channels.base import Channel + from agentchat.models import Message + + +async def receive( + app: AgentChat, channel: Channel, message: Message, handler: MessageHandler, +) -> None: + turn = NativeTurn() + async with AsyncExitStack() as scope: + while turn.effect != Effect.Complete: + match turn.effect: + case Effect.Claim: + turn.claimed(await app.state.claim(message.id)) + case Effect.AcquireConversation: + await scope.enter_async_context(app.state.lock(message.conversation_id)) + turn.acquired() + case Effect.AppendIncoming: + await app.state.append(message) + turn.incoming_appended() + case Effect.InvokeHandler: + turn.handler_completed(await handler(MessageContext(app, channel, message))) + case Effect.SendReply: + # The public Python operation already sends and appends. + # Call it intact so application overrides keep working. + await app.reply(channel, message, turn.reply_text) + turn.reply_sent() + case Effect.AppendReply: + # Acknowledge the append performed by the host operation; + # never write the confirmed reply to history a second time. + turn.reply_appended() diff --git a/src/agentchat/app.py b/src/agentchat/app.py index 8c426e2..580a43d 100644 --- a/src/agentchat/app.py +++ b/src/agentchat/app.py @@ -3,6 +3,7 @@ import asyncio from collections.abc import Awaitable, Callable, Sequence +from agentchat._backend import Backend, resolve_backend from agentchat.activity import WorkingStatus from agentchat.channels.base import ( Channel, @@ -33,15 +34,28 @@ def __init__( *, channels: Sequence[Channel], state: State | None = None, + backend: Backend | None = None, ) -> None: if not channels: raise ValueError("AgentChat requires at least one channel") self.channels = tuple(channels) - self.state = state or MemoryState() + self._backend = resolve_backend(backend) + self.state = state or MemoryState(backend=self._backend) + if self.backend == "rust": + # Fail at construction if installation is incomplete, before binding + # channels or admitting work. Never replay a native failure in Python. + from agentchat._runtime import receive + + self._native_receive = receive self._handler: MessageHandler | None = None for channel in self.channels: channel.bind(self._receive) + @property + def backend(self) -> str: + """The backend selected for this instance; it cannot change during a turn.""" + return self._backend + def on_message(self, handler: MessageHandler) -> MessageHandler: if self._handler is not None: raise RuntimeError("AgentChat supports one message handler") @@ -123,6 +137,9 @@ async def mirror( async def _receive(self, channel: Channel, message: Message) -> None: if self._handler is None: raise RuntimeError("Register a message handler with @app.on_message") + if self.backend == "rust": + await self._native_receive(self, channel, message, self._handler) + return if not await self.state.claim(message.id): return async with self.state.lock(message.conversation_id): diff --git a/src/agentchat/context.py b/src/agentchat/context.py index 21b76dd..29e00c1 100644 --- a/src/agentchat/context.py +++ b/src/agentchat/context.py @@ -43,6 +43,11 @@ async def history(self, *, limit: int = 30) -> tuple[Message, ...]: if self.message.thread_id is not None and isinstance(self._channel, ThreadHistoryChannel): previous = await self._channel.thread_history(self.message, limit=limit) messages = (*previous, self.message) + if self._app.backend == "rust": + from agentchat._native import thread_history_indices + + indices = thread_history_indices(len(messages), limit) + return tuple(messages[index] for index in indices) return messages if len(messages) <= limit else messages[:1] + messages[-(limit - 1):] return await self.conversation.history(limit=limit) diff --git a/src/agentchat/state/memory.py b/src/agentchat/state/memory.py index e67a770..0d3fccf 100644 --- a/src/agentchat/state/memory.py +++ b/src/agentchat/state/memory.py @@ -6,11 +6,17 @@ from contextlib import asynccontextmanager from types import MappingProxyType +from agentchat._backend import Backend, resolve_backend from agentchat.models import Message class MemoryState: - def __init__(self) -> None: + def __init__(self, *, backend: Backend | None = None) -> None: + self._native_store = None + if resolve_backend(backend) == "rust": + from agentchat._native import NativeMemoryStore + + self._native_store = NativeMemoryStore() self._claimed_message_ids: set[str] = set() self._messages: dict[str, tuple[Message, ...]] = {} self._conversation_locks: defaultdict[str, asyncio.Lock] = defaultdict(asyncio.Lock) @@ -18,6 +24,8 @@ def __init__(self) -> None: async def claim(self, message_id: str) -> bool: async with self._state_lock: + if self._native_store is not None: + return self._native_store.claim(message_id) if message_id in self._claimed_message_ids: return False self._claimed_message_ids.add(message_id) @@ -29,15 +37,19 @@ async def lock(self, conversation_id: str) -> AsyncIterator[None]: yield async def append(self, message: Message) -> None: + if self._native_store is not None: + self._native_store.append(message.conversation_id, message) + return current_messages = self._messages.get(message.conversation_id, ()) self._messages[message.conversation_id] = (*current_messages, message) async def history( self, conversation_id: str, *, limit: int | None = None ) -> tuple[Message, ...]: - messages = self._messages.get(conversation_id, ()) + messages = self.messages.get(conversation_id, ()) return messages if limit is None else messages[-limit:] @property def messages(self) -> MappingProxyType[str, tuple[Message, ...]]: - return MappingProxyType(self._messages) + values = self._native_store.messages if self._native_store is not None else self._messages + return MappingProxyType(values) diff --git a/tests/channels/slack/test_conformance.py b/tests/channels/slack/test_conformance.py new file mode 100644 index 0000000..3e8d82d --- /dev/null +++ b/tests/channels/slack/test_conformance.py @@ -0,0 +1,34 @@ +"""The native Slack crate consumes these exact Python-reference fixtures too.""" +import json +from pathlib import Path + +import pytest + +from agentchat.channels.slack.events import to_message + +FIXTURES = json.loads( + (Path(__file__).parents[3] / "conformance" / "slack-events.json").read_text() +) + + +@pytest.mark.parametrize("fixture", FIXTURES, ids=lambda fixture: fixture["name"]) +def test_normalization_matches_shared_contract(fixture): + message = to_message( + fixture["payload"], fixture["payload"]["event"], + bot_user_id=fixture["bot_user_id"], workspace_id=fixture["workspace_id"], + ) + if fixture["expected"] is None: + assert message is None + return + assert message is not None + actual = { + "id": message.id, "conversation_id": message.conversation_id, "channel": message.channel, + "sender": {"id": message.sender.id, "display_name": message.sender.display_name}, + "text": message.text, "role": message.role, "metadata": dict(message.metadata), + "thread_id": message.thread_id, "addressed": message.addressed, + "attachments": [ + {"id": a.id, "name": a.name, "media_type": a.media_type, "size": a.size} + for a in message.attachments + ], + } + assert actual == fixture["expected"] diff --git a/tests/test_native_runtime.py b/tests/test_native_runtime.py new file mode 100644 index 0000000..21f83ba --- /dev/null +++ b/tests/test_native_runtime.py @@ -0,0 +1,217 @@ +"""Compatibility at the native boundary, including host task and object identity.""" +import asyncio +import contextvars +import gc +import weakref +from contextlib import asynccontextmanager +from dataclasses import replace +from types import MappingProxyType + +import pytest +from agentchat._native import NativeTurn, core_version + +from agentchat import AgentChat +from agentchat.state import MemoryState +from tests.support.channels import FakeChannel, incoming + + +@pytest.mark.asyncio +@pytest.mark.parametrize("backend", ["rust", "python"]) +async def test_custom_state_scope_and_nested_calls_stay_in_the_original_task(backend): + scope = contextvars.ContextVar("conversation_scope", default=None) + caller = asyncio.current_task() + events = [] + opaque = object() + + class HostState(MemoryState): + @asynccontextmanager + async def lock(self, conversation_id): + assert asyncio.current_task() is caller + token = scope.set(conversation_id) + events.append("enter") + try: + async with super().lock(conversation_id): + yield + finally: + assert asyncio.current_task() is caller + scope.reset(token) + events.append("exit") + + channel = FakeChannel() + state = HostState() + app = AgentChat(channels=[channel], state=state, backend=backend) + source = replace(incoming("one", "hello"), metadata=MappingProxyType({"opaque": opaque})) + + @app.on_message + async def handler(context): + assert asyncio.current_task() is caller + assert scope.get() == source.conversation_id + assert context.message is source + assert context.message.metadata["opaque"] is opaque + assert (await context.history())[0] is source + await context.reply("explicit") + return "automatic" + + assert handler is app._handler + # Unlike wait_for, timeout preserves the current host task. + async with asyncio.timeout(2): + await channel.send(source) + assert events == ["enter", "exit"] + assert scope.get() is None + assert channel.replies == ["explicit", "automatic"] + assert [m.text for m in await state.history(source.conversation_id)] == [ + "hello", "explicit", "automatic", + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("backend", ["rust", "python"]) +async def test_handler_failure_preserves_exception_and_claim_without_replay(backend): + channel = FakeChannel() + app = AgentChat(channels=[channel], backend=backend) + original = LookupError("user failure") + calls = 0 + + @app.on_message + async def handler(context): + nonlocal calls + calls += 1 + raise original + + source = incoming("failed", "hello") + with pytest.raises(LookupError) as failure: + await channel.send(source) + assert failure.value is original + await channel.send(source) + assert calls == 1 + assert channel.replies == [] + assert await app.state.history(source.conversation_id) == (source,) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("backend", ["rust", "python"]) +async def test_cancelled_turn_releases_custom_lock_and_retains_claim(backend): + channel = FakeChannel() + app = AgentChat(channels=[channel], backend=backend) + started = asyncio.Event() + + @app.on_message + async def handler(context): + if context.message.id == "cancelled": + started.set() + await asyncio.Event().wait() + return "next" + + source = incoming("cancelled", "hello") + task = asyncio.create_task(channel.send(source)) + await asyncio.wait_for(started.wait(), timeout=2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + await asyncio.wait_for(channel.send(source), timeout=2) + await asyncio.wait_for(channel.send(incoming("next", "again")), timeout=2) + assert channel.replies == ["next"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("backend", ["rust", "python"]) +async def test_ambiguous_send_is_not_retried_or_recorded(backend): + sends = 0 + + class TimeoutChannel(FakeChannel): + async def reply(self, source, content): + nonlocal sends + sends += 1 + raise TimeoutError("receipt unknown") + + channel = TimeoutChannel() + app = AgentChat(channels=[channel], backend=backend) + + @app.on_message + async def handler(context): + return "answer" + + source = incoming("unknown", "hello") + with pytest.raises(TimeoutError): + await channel.send(source) + await channel.send(source) + assert sends == 1 + assert await app.state.history(source.conversation_id) == (source,) + + +def test_native_boundary_rejects_invalid_transition_without_panicking(): + assert core_version == "0.1.0-alpha.1" + turn = NativeTurn() + with pytest.raises(RuntimeError): + turn.reply_sent() + turn.claimed(False) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("backend", ["rust", "python"]) +async def test_automatic_reply_preserves_application_override(backend): + calls = [] + + class CustomApp(AgentChat): + async def reply(self, channel, source, content): + calls.append(source) + return await super().reply(channel, source, content.upper()) + + channel = FakeChannel() + app = CustomApp(channels=[channel], backend=backend) + + @app.on_message + async def handler(context): + return "answer" + + source = incoming("override", "hello") + await channel.send(source) + assert calls == [source] + assert channel.replies == ["ANSWER"] + assert len(await app.state.history(source.conversation_id)) == 2 + + +def test_backend_selection_is_explicit_and_immutable(monkeypatch): + monkeypatch.setenv("AGENTCHAT_BACKEND", "python") + assert AgentChat(channels=[FakeChannel()]).backend == "python" + app = AgentChat(channels=[FakeChannel()], backend="rust") + assert app.backend == "rust" + with pytest.raises(AttributeError): + app.backend = "python" + with pytest.raises(ValueError, match="backend"): + AgentChat(channels=[FakeChannel()], backend="invalid") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("backend", ["rust", "python"]) +async def test_memory_state_keeps_live_views_and_python_slice_behavior(backend): + state = MemoryState(backend=backend) + view = state.messages + first = incoming("first", "hello") + second = incoming("second", "again") + await state.append(first) + snapshot = view[first.conversation_id] + await state.append(second) + assert snapshot == (first,) + assert view[first.conversation_id] == (first, second) + assert (await state.history(first.conversation_id))[0] is first + assert await state.history(first.conversation_id, limit=0) == (first, second) + assert await state.history(first.conversation_id, limit=-1) == (second,) + assert await state.claim(first.id) is True + assert await state.claim(first.id) is False + + +@pytest.mark.asyncio +async def test_native_memory_releases_cycles_in_opaque_metadata(): + class Payload: + pass + + state = MemoryState(backend="rust") + payload = Payload() + payload.state = state + source = replace(incoming("cycle", "hello"), metadata={"payload": payload}) + await state.append(source) + reference = weakref.ref(payload) + del state, payload, source + gc.collect() + assert reference() is None