diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..5e5931af --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + groups: + github-actions: + patterns: ["*"] + schedule: + interval: "weekly" + cooldown: + default-days: 7 \ No newline at end of file diff --git a/.github/workflows/proto-check.yml b/.github/workflows/proto-check.yml index c7e24af1..5f9bcfea 100644 --- a/.github/workflows/proto-check.yml +++ b/.github/workflows/proto-check.yml @@ -7,19 +7,27 @@ on: - 'proto/**' - 'python/amplifier_core/_grpc_gen/**' - 'crates/amplifier-core/src/generated/**' + - 'uv.lock' + - 'scripts/generate_grpc_stubs.py' + - '.github/workflows/proto-check.yml' pull_request: branches: [main] paths: - 'proto/**' - 'python/amplifier_core/_grpc_gen/**' - 'crates/amplifier-core/src/generated/**' + - 'uv.lock' + - 'scripts/generate_grpc_stubs.py' + - '.github/workflows/proto-check.yml' jobs: proto-sync: name: Verify Python stubs match proto runs-on: ubuntu-latest + env: + PROTO_CHECK_DIR: /tmp/proto_check steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Install protoc run: | @@ -27,46 +35,71 @@ jobs: sudo apt-get install -y protobuf-compiler - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: '3.12' - - name: Install grpcio-tools - run: pip install grpcio-tools + - name: Install locked protobuf generator and runtimes + run: | + python - <<'PY' + import subprocess + import sys + import tomllib + from pathlib import Path + + packages = tomllib.loads(Path("uv.lock").read_text())["package"] + versions = {package["name"]: package["version"] for package in packages} + names = ("grpcio", "grpcio-tools", "protobuf", "setuptools") + subprocess.check_call([ + sys.executable, "-m", "pip", "install", + *(f"{name}=={versions[name]}" for name in names), + ]) + PY - name: Regenerate Python stubs run: | - mkdir -p /tmp/proto_check + mkdir -p "$PROTO_CHECK_DIR" python -m grpc_tools.protoc \ -Iproto \ - --python_out=/tmp/proto_check \ - --grpc_python_out=/tmp/proto_check \ + --python_out="$PROTO_CHECK_DIR" \ + --grpc_python_out="$PROTO_CHECK_DIR" \ proto/amplifier_module.proto - name: Compare with committed stubs run: | - # Check that key message classes exist in regenerated output - python -c " - import importlib.util, sys - spec = importlib.util.spec_from_file_location('pb2', '/tmp/proto_check/amplifier_module_pb2.py') - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - required = ['ChatRequest', 'ChatResponse', 'ToolResult', 'HookResult', 'ModuleInfo'] - missing = [t for t in required if not hasattr(mod, t)] - if missing: - print(f'FAIL: Missing types in regenerated stubs: {missing}') - sys.exit(1) - print('PASS: Regenerated stubs contain all required types') - " + python - <<'PY' + import os + from pathlib import Path + + generated = Path(os.environ["PROTO_CHECK_DIR"]) + mismatches = [] + for directory in (Path("proto"), Path("python/amplifier_core/_grpc_gen")): + for filename in ("amplifier_module_pb2.py", "amplifier_module_pb2_grpc.py"): + expected = (generated / filename).read_bytes() + if directory.name == "_grpc_gen" and filename.endswith("_grpc.py"): + # The packaged service stub uses its sibling schema module. + expected = expected.replace( + b"import amplifier_module_pb2 as amplifier__module__pb2", + b"from . import amplifier_module_pb2 as amplifier__module__pb2", + ) + committed = directory / filename + if committed.read_bytes() != expected: + mismatches.append(str(committed)) + if mismatches: + raise SystemExit("FAIL: regenerate committed stubs: " + ", ".join(mismatches)) + print("PASS: all four committed Python stubs match the locked generator") + PY rust-build: name: Rust build and test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + with: + toolchain: stable - name: Install protoc run: | diff --git a/.github/workflows/rust-core-ci.yml b/.github/workflows/rust-core-ci.yml index 65f04cc6..7a4e5723 100644 --- a/.github/workflows/rust-core-ci.yml +++ b/.github/workflows/rust-core-ci.yml @@ -11,11 +11,12 @@ jobs: name: Rust Kernel Tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: + toolchain: stable components: clippy, rustfmt - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - name: Run Rust tests run: cargo test -p amplifier-core --verbose - name: Check workspace @@ -29,12 +30,14 @@ jobs: name: Node.js Binding Tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: - node-version: '20' + toolchain: stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '24' - name: Build native module working-directory: bindings/node run: | @@ -53,10 +56,12 @@ jobs: matrix: python-version: ['3.11', '3.12', '3.13'] steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - uses: actions/setup-python@v5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + with: + toolchain: stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} - name: Create venv and build diff --git a/.github/workflows/rust-core-wheels.yml b/.github/workflows/rust-core-wheels.yml index 031bf899..9e028ea4 100644 --- a/.github/workflows/rust-core-wheels.yml +++ b/.github/workflows/rust-core-wheels.yml @@ -14,19 +14,19 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@v6 - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: workspaces: ". -> target" cache-on-failure: true - - uses: actions/setup-python@v6 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.11' - - uses: PyO3/maturin-action@v1 + - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 with: args: --profile dist --out dist --interpreter python3.11 manylinux: auto - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: wheels-${{ matrix.os }} path: dist/*.whl @@ -36,20 +36,20 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 steps: - - uses: actions/checkout@v6 - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: workspaces: ". -> target" cache-on-failure: true - - uses: actions/setup-python@v6 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.11' - - uses: PyO3/maturin-action@v1 + - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 with: target: aarch64-unknown-linux-gnu args: --profile dist --out dist --interpreter python3.11 manylinux: auto - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: wheels-linux-aarch64 path: dist/*.whl @@ -59,19 +59,19 @@ jobs: runs-on: macos-latest timeout-minutes: 60 steps: - - uses: actions/checkout@v6 - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: workspaces: ". -> target" cache-on-failure: true - - uses: actions/setup-python@v6 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.11' - - uses: PyO3/maturin-action@v1 + - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 with: target: x86_64-apple-darwin args: --profile dist --out dist - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: wheels-macos-x86_64 path: dist/*.whl @@ -81,19 +81,19 @@ jobs: runs-on: windows-latest timeout-minutes: 60 steps: - - uses: actions/checkout@v6 - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: workspaces: ". -> target" cache-on-failure: true - - uses: actions/setup-python@v6 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.11' - - uses: PyO3/maturin-action@v1 + - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 with: target: aarch64-pc-windows-msvc args: --profile dist --out dist - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: wheels-windows-arm64 path: dist/*.whl @@ -107,9 +107,9 @@ jobs: permissions: id-token: write steps: - - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: 'wheels-*' merge-multiple: true path: dist/ - - uses: pypa/gh-action-pypi-publish@release/v1 + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 diff --git a/CONTRACTS.md b/CONTRACTS.md index 68268270..aa65b548 100644 --- a/CONTRACTS.md +++ b/CONTRACTS.md @@ -46,7 +46,7 @@ field name differs from the JSON key. |-----------------|-------------|---------------|-------| | `HookResult` | `HookResult` (BaseModel) | JSON round-trip at PyO3 boundary | Field-for-field match. Rust `HookAction` enum ↔ Python `Literal` strings. | | `HookAction` | `Literal["continue","deny","modify","inject_context","ask_user"]` | `serde(rename_all = "snake_case")` | Enum variants map 1:1 to string literals. | -| `ToolResult` | `ToolResult` (BaseModel) | JSON round-trip | 1:1. Python adds `__str__()` and `get_serialized_output()` convenience methods. | +| `ToolResult` | `ToolResult` (BaseModel) | JSON round-trip | 1:1. `content`, when present, is a canonical non-empty ordered list of text/base64-image blocks; `None` and `[]` omit it during serialization. Rust enforces this with a private `ToolResultContent` wrapper; Python validates through PyO3. Python also adds `__str__()` and `get_serialized_output()` convenience methods. | | `ModelInfo` | `ModelInfo` (BaseModel) | JSON round-trip | 1:1. | | `ConfigField` | `ConfigField` (BaseModel) | JSON round-trip | Rust `default_value` ↔ Python `default`. | | `ConfigFieldType` | `Literal["text","secret","choice","boolean"]` | snake_case | | @@ -70,7 +70,7 @@ field name differs from the JSON key. | `ToolSpec` | `ToolSpec` (BaseModel) | JSON round-trip | 1:1. | | `ChatRequest` | `ChatRequest` (BaseModel) | JSON round-trip | 1:1. | | `ToolCall` | `ToolCall` (BaseModel) | JSON round-trip | 1:1. | -| `Usage` | `Usage` (BaseModel) | JSON round-trip | 1:1. | +| `Usage` | `Usage` (BaseModel) | JSON round-trip | 1:1. `cost_usd` is optional quoted decimal text at the Rust/protobuf boundary (`Option`); Python exposes it as `Decimal \| None`. None is omitted and `"0"` remains present. | | `Degradation` | `Degradation` (BaseModel) | JSON round-trip | 1:1. | | `ChatResponse` | `ChatResponse` (BaseModel) | JSON round-trip | 1:1. | | `ResponseFormat` (tagged enum) | `ResponseFormat` (Union) | `serde(tag = "type")` | Text/Json/JsonSchema variants match. | diff --git a/Cargo.lock b/Cargo.lock index b8ae9b1b..505ed7e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,8 +40,9 @@ checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" [[package]] name = "amplifier-core" -version = "1.6.1" +version = "2.0.0" dependencies = [ + "base64", "chrono", "log", "prost", @@ -76,7 +77,7 @@ dependencies = [ [[package]] name = "amplifier-core-py" -version = "1.6.1" +version = "2.0.0" dependencies = [ "amplifier-core", "log", @@ -103,6 +104,7 @@ dependencies = [ name = "amplifier-guest" version = "0.1.0" dependencies = [ + "base64", "prost", "serde", "serde_json", @@ -433,27 +435,27 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.131.2" +version = "0.131.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008f1a8d1da5074ad858f398775a6d1989031892e46927df5ed18d3be1ed8717" +checksum = "3867f7a56768640a79fc660d2f60298251dc6d65b5d1c907706cd1afff024957" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.131.2" +version = "0.131.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fd76237df1f4e26edb5ad7971d20280ed1e193331fd257f1b4e4dfefd88dda2" +checksum = "a0661d63dcf8fc4a6538c1ee4d523917c5b27e9fce7a4114cdf9e2b30b4043cf" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.131.2" +version = "0.131.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "380f0bc43e535df6855bbee649efb00bde39c3f33434c47c8e10ac836d21bf47" +checksum = "a8d535b489159ea63e3c40dfbe8d0e12bfb71f2a14845ef2407353e06c5a697c" dependencies = [ "cranelift-entity", "wasmtime-internal-core", @@ -461,9 +463,9 @@ dependencies = [ [[package]] name = "cranelift-bitset" -version = "0.131.2" +version = "0.131.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4811e3e4502de04257e90c0a93225b56d9b85e0f9ad10b81446b415511009610" +checksum = "c3af4f7d421b2354deb01d714266022f38fcdbebc9f5f1ec6d310d3c27286d9e" dependencies = [ "serde", "serde_derive", @@ -472,9 +474,9 @@ dependencies = [ [[package]] name = "cranelift-codegen" -version = "0.131.2" +version = "0.131.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82ffadb34d497f3e76fb3b4baf764c24ba8a51512976a1b77f78bdbf8f4aa687" +checksum = "09fe4c289e67e0221d1705734a57f95e25c289ed0ead7728743ea21285fc4cf1" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -500,9 +502,9 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.131.2" +version = "0.131.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4f6992eb6faf086ddc7deaaa5f279abfe7f5fd5ae5709bd38253450fc7b945" +checksum = "b3063e5363dc5ee6ee8edd930314582c08eb91c209b9564da1cd667f6424b9b3" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -513,24 +515,24 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.131.2" +version = "0.131.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e1b2aad7d055925a4ea9cdbfa9d1d987f9dfc8ad6b708be28f901ac620a298" +checksum = "c34b9c8dbc9edf37744918e56898d4979ef1e764e8e4bbe8b4d50250838ddfe8" [[package]] name = "cranelift-control" -version = "0.131.2" +version = "0.131.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a355348325e0a63b65c00def3871597b9fcc79d25456397010d16d872b3772" +checksum = "4eed9dc54204dc99aad19669bca50142659ed583396d9a99a2aef34d7c136ef4" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.131.2" +version = "0.131.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43f4847d93ce2c80d2bff929aa1004dfb3ce2cf5d881f6ced54b8d654d967ba3" +checksum = "9aa2846b239a046217ecf95cfed0e31be4e86843785d07438ad33f456871e888" dependencies = [ "cranelift-bitset", "serde", @@ -540,9 +542,9 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.131.2" +version = "0.131.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba24e5fe5242cc445e7892ef0a51a4351cf716e3a04ac7a3a05820d056c39818" +checksum = "144f70fa9cd07efb83497c12dc8fb73f360a690cd990c44e8ceebc293d8c13b5" dependencies = [ "cranelift-codegen", "log", @@ -552,15 +554,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.131.2" +version = "0.131.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89bc2035de85c4f04ba7bd57eb5bd3a8b775235bf28852dbf87105115cb8919a" +checksum = "0733ca5b2aaa5f6d5d6a1439e3c44280d34730d4d5c262ca08c6775c8d83f191" [[package]] name = "cranelift-native" -version = "0.131.2" +version = "0.131.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ea6630c16921ab087792750f239d0c0173411e80179ca7c0ce0710ce9e7646a" +checksum = "75b1290d6193b171172d5fe9a6e42326edf487a79f211fbf1e76f912a4aed035" dependencies = [ "cranelift-codegen", "libc", @@ -569,9 +571,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.131.2" +version = "0.131.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faa4bbad54fc28cc0da1f9a5d7f7f826ec8cafda3d503b401b2daaaa93c63ef0" +checksum = "ce0d5c2b4d719566816a0f1c9a9712d35d61e27df0ffd6c72a9afec9048db6c0" [[package]] name = "crc32fast" @@ -718,7 +720,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1120,7 +1122,7 @@ dependencies = [ "hyper", "libc", "pin-project-lite", - "socket2 0.6.2", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -1762,9 +1764,9 @@ dependencies = [ [[package]] name = "pulley-interpreter" -version = "44.0.2" +version = "44.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dff0ead8b4616f81b3d3efd41ce41bcf9ea364a5d8df8be8a8a1f98b50104349" +checksum = "34dff5fd3d9ac4845939fcb4597cd413cb244bc530448ed4766d11b1725e53d0" dependencies = [ "cranelift-bitset", "log", @@ -1774,9 +1776,9 @@ dependencies = [ [[package]] name = "pulley-macros" -version = "44.0.2" +version = "44.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4389e5820b1b39810ac12a27aa665320cab3caa51913a79637c06f284cfe223" +checksum = "6c60fb1c885bdb1efd7c50e8e973714de558b75a65f20c3e9a41398c652aa44b" dependencies = [ "proc-macro2", "quote", @@ -2031,7 +2033,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2254,7 +2256,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2871,9 +2873,9 @@ dependencies = [ [[package]] name = "wasmtime" -version = "44.0.2" +version = "44.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af4eccc0728f061979efa8ff4c962cff7041fead4baadb74973f01b9c47158a4" +checksum = "d807f646bfecc1dbb4990d8c864beebccbdb3f2cd1b39b2b82957b4e294c6058" dependencies = [ "addr2line", "async-trait", @@ -2924,9 +2926,9 @@ dependencies = [ [[package]] name = "wasmtime-environ" -version = "44.0.2" +version = "44.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e84dbe3208c1336a41546beb75927b3b37e2e4fce06653d214b407136fbe295" +checksum = "48b945309908f22473ebcd585ac2993948044228491cd96941d367aa81a49c3f" dependencies = [ "anyhow", "cpp_demangle", @@ -2955,9 +2957,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-cache" -version = "44.0.2" +version = "44.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "910b8dcadc0888344b2dea5a087c836b58156d4f455c52b6dac0bdc776a9d029" +checksum = "f14b8b93c2137c88ed84114d9a09cb11cb8bf9394aba4856e48f5304a4f99eec" dependencies = [ "base64", "directories-next", @@ -2975,9 +2977,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-macro" -version = "44.0.2" +version = "44.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c223bd503db76df8d74d1fcca39e734d25f7a0c1dcaf1509b67f3855d1b0f803" +checksum = "7307dec6251a18ffa9df03120d2945ac2476ce150b5b099f39b199e8f81464dc" dependencies = [ "anyhow", "proc-macro2", @@ -2990,15 +2992,15 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-util" -version = "44.0.2" +version = "44.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab123ad511483a1b918399789d0cc7dea7c5c6476743df73949007b5b225fc74" +checksum = "7a3d899b0270bcf04852f141bd9538cb162a436e7f56b2c6e0f4e57ecc70743a" [[package]] name = "wasmtime-internal-core" -version = "44.0.2" +version = "44.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4364d345719bba7fc4c435992ea1cb0c118f1e90a88c6e6f22a7a4fc507700c6" +checksum = "aedd3947487d0afdd37accb981466fcd60571e898004c8955111f88686581dfc" dependencies = [ "anyhow", "hashbrown 0.16.1", @@ -3008,9 +3010,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-cranelift" -version = "44.0.2" +version = "44.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a3bc28a172037c7864128bb208017a02bba659a59c27acacc048c09e25c1fc" +checksum = "512fd846630c064bfc42909eeba90a7d26703b6ded09ad4778ff6afcbdc868dd" dependencies = [ "cfg-if", "cranelift-codegen", @@ -3035,9 +3037,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-fiber" -version = "44.0.2" +version = "44.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c90a899a47d3da6e384e7b4cad61fdcb27535a395742b32440bdf9980ea83fa" +checksum = "15629ea71394be5812a52cb8fbc6cd039484ab1dd48fce5e1ef58ae89606289a" dependencies = [ "cc", "cfg-if", @@ -3050,9 +3052,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-debug" -version = "44.0.2" +version = "44.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84f364747aa74c686b18925918e5cfd615a73c9613c7a31fc1cd86f42df12fbe" +checksum = "f5fce5fedc1c952a64cdf3c87e4632af072d2aca0bc2c460d53296fb2654757d" dependencies = [ "cc", "object", @@ -3062,9 +3064,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "44.0.2" +version = "44.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3ba98c1492f530833e0d3cc17dbb0c3c57c9f1bb3b078ae44bb55a233e43eba" +checksum = "10005b038e662775ac002f233e429447a58892e89918580fa67ce8cdd9192d0a" dependencies = [ "cfg-if", "libc", @@ -3074,9 +3076,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-unwinder" -version = "44.0.2" +version = "44.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94b8f8a89e8f3660646f820c7d8310a67094156bb866e9d56f1b00892e011206" +checksum = "03f3e0f474281b405a3e9d97239f83f643b572324d292e1ef9dc5e3e0ab04c68" dependencies = [ "cfg-if", "cranelift-codegen", @@ -3087,9 +3089,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-versioned-export-macros" -version = "44.0.2" +version = "44.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a12754f1ffc4a3300d56d324c418b8b32cf029606618da22c7d076213882a3f" +checksum = "910e5393af4aca456113581a5913b8d499cd2189013e983f8764d06ebc42b2ed" dependencies = [ "proc-macro2", "quote", @@ -3098,9 +3100,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-winch" -version = "44.0.2" +version = "44.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b06e4ed07adc579645e5c55c67b3138c49da2e468fad52d3db7b7a098ecc733" +checksum = "55481651bea5b8336fb200fa49914ccf802f5e7617ba9ab0b4691f16d4f97ff8" dependencies = [ "cranelift-codegen", "gimli", @@ -3115,9 +3117,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-wit-bindgen" -version = "44.0.2" +version = "44.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f08787948e3c983799d616ef7dd57463253e9ca8bab6607eef8134f12353f70" +checksum = "d890c3804d0e46000fa901c86ac1a9fdedf684e72dfd64e582b56bb4a78a6746" dependencies = [ "anyhow", "bitflags", @@ -3128,9 +3130,9 @@ dependencies = [ [[package]] name = "wasmtime-wasi" -version = "44.0.2" +version = "44.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b2f19834bc6edbc31ac95fdcfd5ddcd7643759265a1d545dec36ac6cc788ca8" +checksum = "5aa7f927779d92863a1447c1d88192b2242080608cb91ece69c177321488948b" dependencies = [ "async-trait", "bitflags", @@ -3158,9 +3160,9 @@ dependencies = [ [[package]] name = "wasmtime-wasi-io" -version = "44.0.2" +version = "44.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e0c6efdbaf90906016be9ed9ff17b7b58f393876287beebe5bd7fa1de54dbb" +checksum = "d6fd8b702c2aa82bb4907da5ad44120b4385048f6a20731908a6e2485ea7567e" dependencies = [ "async-trait", "bytes", @@ -3202,9 +3204,9 @@ dependencies = [ [[package]] name = "wiggle" -version = "44.0.2" +version = "44.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b644ab90da80bbca28973192978ac452cbd876955bb209e6ff2cd1955e43a7" +checksum = "165512f7870210d0fd45911990b832782b5fb82d40f4ce28cf86b40aaecd453c" dependencies = [ "bitflags", "thiserror 2.0.18", @@ -3216,9 +3218,9 @@ dependencies = [ [[package]] name = "wiggle-generate" -version = "44.0.2" +version = "44.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521f9d558365357274d960340eb9eb4f4d768fafdc79f381fd2e13a85b925ebc" +checksum = "f7f63d06fdf2e9a133407b318574574f66ea6590ea7a42c4a7554c029824454a" dependencies = [ "heck", "proc-macro2", @@ -3230,9 +3232,9 @@ dependencies = [ [[package]] name = "wiggle-macro" -version = "44.0.2" +version = "44.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a386e86021363c9f0abd1e189e8f8a729d9b5aab2bb7172a3e40f2ab647a936" +checksum = "1b976b7285ca32a637e21721021442691a2d7938ab4650cb99472a672e9def6c" dependencies = [ "proc-macro2", "quote", @@ -3262,7 +3264,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3273,9 +3275,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "winch-codegen" -version = "44.0.2" +version = "44.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f16496e92d2b232f9d195ae74f71a674aabae7b7fa722d39068836723d3b653c" +checksum = "436a7fa4109b13b0e555d01ec615ab8b928b7834639aca7b2fdc1f55d70a1f0c" dependencies = [ "cranelift-assembler-x64", "cranelift-codegen", diff --git a/README.md b/README.md index a9c65f81..3f76052e 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,14 @@ maturin develop uv run maturin develop ``` +To regenerate the committed Python gRPC stubs, install the pinned generator +and run the canonical command: + +```bash +python -m pip install grpcio-tools==1.78.0 +python scripts/generate_grpc_stubs.py +``` + See [docs/RUST_CORE_TESTING.md](docs/RUST_CORE_TESTING.md) for the full development setup guide. **Build dependencies**: Rust 1.70+, maturin diff --git a/bindings/node/__tests__/hooks.test.ts b/bindings/node/__tests__/hooks.test.ts index 8ebd9ebf..be3d03fe 100644 --- a/bindings/node/__tests__/hooks.test.ts +++ b/bindings/node/__tests__/hooks.test.ts @@ -1,6 +1,81 @@ import { describe, it, expect } from 'vitest' +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' import { JsHookRegistry, HookAction } from '../index.js' +const compiledIndexPath = fileURLToPath(new URL('../index.js', import.meta.url)) + +function runAsyncContinueChild(): Promise<{ + code: number | null + signal: string | null + stdout: string + stderr: string +}> { + const childSource = ` + const { pathToFileURL } = await import('node:url') + const { default: binding } = await import(pathToFileURL(process.argv[1]).href) + const { JsHookRegistry, HookAction } = binding + const registry = new JsHookRegistry() + let handlerCalled = false + let receivedEvent = '' + + registry.register('tool:pre', async (event, _data) => { + handlerCalled = true + receivedEvent = event + await new Promise(resolve => setImmediate(resolve)) + return JSON.stringify({ action: 'continue' }) + }, 10, 'async-hook') + + const result = await registry.emit('tool:pre', '{"tool":"grep"}') + if (!handlerCalled || receivedEvent !== 'tool:pre') { + throw new Error('async handler did not receive the expected event') + } + console.log('afterEmit') + if (result.action !== HookAction.Continue) { + throw new Error('async handler did not continue') + } + console.log('resultContinue') + process.exit(0) + ` + + return new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + ['--input-type=module', '--eval', childSource, compiledIndexPath], + { stdio: ['ignore', 'pipe', 'pipe'] } + ) + let stdout = '' + let stderr = '' + let settled = false + const timeout = setTimeout(() => { + settled = true + child.kill('SIGKILL') + reject(new Error(`async hook child timed out: ${stderr}`)) + }, 10_000) + + child.stdout.on('data', chunk => { + stdout += chunk + }) + child.stderr.on('data', chunk => { + stderr += chunk + }) + child.once('error', error => { + if (!settled) { + settled = true + clearTimeout(timeout) + reject(error) + } + }) + child.once('close', (code, signal) => { + if (!settled) { + settled = true + clearTimeout(timeout) + resolve({ code, signal, stdout, stderr }) + } + }) + }) +} + describe('JsHookRegistry', () => { it('creates empty registry (listHandlers returns empty object)', () => { const registry = new JsHookRegistry() @@ -85,4 +160,33 @@ describe('JsHookRegistry', () => { expect(parsed).toHaveProperty('custom', 'value') expect(parsed).toHaveProperty('tool', 'grep') }) + + it('supports async handlers returning Promise', async () => { + const result = await runAsyncContinueChild() + + expect(result.signal).toBeNull() + expect(result.code).toBe(0) + expect(result.stdout).toContain('afterEmit') + expect(result.stdout).toContain('resultContinue') + }) + + it('async handler returning deny short-circuits pipeline', async () => { + const registry = new JsHookRegistry() + let secondRan = false + + registry.register('tool:pre', async (_event: string, _data: string) => { + await new Promise(resolve => setImmediate(() => resolve())) + return JSON.stringify({ action: 'deny', reason: 'async blocked' }) + }, 10, 'async-deny') + registry.register('tool:pre', (_event: string, _data: string) => { + secondRan = true + return JSON.stringify({ action: 'continue' }) + }, 20, 'after') + + const result = await registry.emit('tool:pre', '{"tool":"rm"}') + + expect(result.action).toBe(HookAction.Deny) + expect(result.reason).toBe('async blocked') + expect(secondRan).toBe(false) + }) }) diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts index b06fa5d7..d91d8e5a 100644 --- a/bindings/node/index.d.ts +++ b/bindings/node/index.d.ts @@ -3,7 +3,6 @@ /* auto-generated by NAPI-RS */ -export declare function hello(): string export const enum HookAction { Continue = 'Continue', Deny = 'Deny', @@ -31,6 +30,25 @@ export const enum UserMessageLevel { Warning = 'Warning', Error = 'Error' } +/** Structured error object returned to JS with a typed `code` property. */ +export interface JsAmplifierError { + code: string + message: string +} +/** + * Converts an error variant name and message into a typed `JsAmplifierError`. + * + * See [`error_code_for_variant`] for the variant → code mapping. + */ +export declare function amplifierErrorToJs(variant: string, message: string): JsAmplifierError +export interface JsContextInjection { + content: string + role: ContextInjectionRole + ephemeral: boolean + appendToLastToolResult: boolean + hookName: string + event: string +} export interface JsHookResult { action: HookAction reason?: string @@ -44,25 +62,15 @@ export interface JsHookResult { approvalPrompt?: string approvalTimeout?: number approvalDefault?: ApprovalDefault + contextInjections?: Array } -/** Structured error object returned to JS with a typed `code` property. */ -export interface JsAmplifierError { - code: string - message: string -} -/** - * Converts an error variant name and message into a typed `JsAmplifierError`. - * - * See [`error_code_for_variant`] for the variant → code mapping. - */ -export declare function amplifierErrorToJs(variant: string, message: string): JsAmplifierError /** Result from resolving a module path. */ export interface JsModuleManifest { /** * How the module is loaded and invoked. * * Valid values (string literal union): - * `"python"` | `"wasm"` | `"grpc"` | `"native"` + * `"python"` | `"wasm"` | `"grpc"` | `"rust"` */ transport: string /** @@ -76,7 +84,7 @@ export interface JsModuleManifest { * Artifact format used to locate or load the module. * * Valid values (string literal union): - * `"wasm"` | `"grpc"` | `"python"` + * `"wasm"` | `"grpc"` | `"python"` | `"rust"` * * - `"wasm"` — `artifactPath` contains the `.wasm` component file path * - `"grpc"` — `endpoint` contains the gRPC service URL @@ -89,6 +97,8 @@ export interface JsModuleManifest { endpoint?: string /** Python package name for import (present when `artifactType` is `"python"`). */ packageName?: string + /** Rust crate name for native linking (present when `artifactType` is `"rust"`). */ + crateName?: string } /** * Resolve a module from a filesystem path. @@ -103,6 +113,7 @@ export declare function resolveModule(path: string): JsModuleManifest * For Python modules: returns an error (TS host can't load Python). */ export declare function loadWasmFromPath(path: string): string +export declare function hello(): string /** * Wraps `amplifier_core::CancellationToken` for Node.js. * @@ -117,35 +128,6 @@ export declare class JsCancellationToken { requestImmediate(): void reset(): void } -/** - * Wraps `amplifier_core::HookRegistry` for Node.js. - * - * Provides register/emit/listHandlers/setDefaultFields — the event backbone - * of the kernel. - */ -export declare class JsHookRegistry { - constructor() - /** - * Register a hook handler for the given event name. - * - * ## Handler signature - * - * The `handler` callback receives two string arguments and must return a - * JSON-serialized `HookResult` (or a `Promise` that resolves to one): - * - * ```ts - * (event: string, dataJson: string) => string | Promise - * ``` - * - * Where the return value is a JSON string matching the `JsHookResult` - * shape, e.g. `'{"action":"Continue"}'`. If the handler returns an - * invalid JSON string, the kernel fails closed and treats it as `Deny`. - */ - register(event: string, handler: (...args: any[]) => any, priority: number, name: string): void - emit(event: string, dataJson: string): Promise - listHandlers(): Record> - setDefaultFields(defaultsJson: string): void -} /** * Wraps `amplifier_core::Coordinator` for Node.js — the central hub holding * module mount points, capabilities, hook registry, cancellation token, and config. @@ -175,6 +157,35 @@ export declare class JsCoordinator { toDict(): Record cleanup(): Promise } +/** + * Wraps `amplifier_core::HookRegistry` for Node.js. + * + * Provides register/emit/listHandlers/setDefaultFields — the event backbone + * of the kernel. + */ +export declare class JsHookRegistry { + constructor() + /** + * Register a hook handler for the given event name. + * + * ## Handler signature + * + * The `handler` callback receives two string arguments and must return a + * JSON-serialized `HookResult` (or a `Promise` that resolves to one): + * + * ```ts + * (event: string, dataJson: string) => string | Promise + * ``` + * + * Where the return value is a JSON string matching the `JsHookResult` + * shape, e.g. `'{"action":"Continue"}'`. If the handler returns an + * invalid JSON string, the kernel fails closed and treats it as `Deny`. + */ + register(event: string, handler: (...args: any[]) => any, priority: number, name: string): void + emit(event: string, dataJson: string): Promise + listHandlers(): Record> + setDefaultFields(defaultsJson: string): void +} /** * Wraps `amplifier_core::Session` for Node.js — the top-level entry point. * diff --git a/bindings/node/index.js b/bindings/node/index.js index a87ad596..31243e81 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -310,19 +310,19 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { hello, HookAction, SessionState, ContextInjectionRole, ApprovalDefault, UserMessageLevel, JsCancellationToken, JsHookRegistry, JsCoordinator, JsAmplifierSession, JsToolBridge, amplifierErrorToJs, resolveModule, loadWasmFromPath } = nativeBinding +const { JsCancellationToken, JsCoordinator, HookAction, SessionState, ContextInjectionRole, ApprovalDefault, UserMessageLevel, amplifierErrorToJs, JsHookRegistry, resolveModule, loadWasmFromPath, JsAmplifierSession, JsToolBridge, hello } = nativeBinding -module.exports.hello = hello +module.exports.JsCancellationToken = JsCancellationToken +module.exports.JsCoordinator = JsCoordinator module.exports.HookAction = HookAction module.exports.SessionState = SessionState module.exports.ContextInjectionRole = ContextInjectionRole module.exports.ApprovalDefault = ApprovalDefault module.exports.UserMessageLevel = UserMessageLevel -module.exports.JsCancellationToken = JsCancellationToken -module.exports.JsHookRegistry = JsHookRegistry -module.exports.JsCoordinator = JsCoordinator -module.exports.JsAmplifierSession = JsAmplifierSession -module.exports.JsToolBridge = JsToolBridge module.exports.amplifierErrorToJs = amplifierErrorToJs +module.exports.JsHookRegistry = JsHookRegistry module.exports.resolveModule = resolveModule module.exports.loadWasmFromPath = loadWasmFromPath +module.exports.JsAmplifierSession = JsAmplifierSession +module.exports.JsToolBridge = JsToolBridge +module.exports.hello = hello diff --git a/bindings/node/package-lock.json b/bindings/node/package-lock.json index e85aabfd..a254742f 100644 --- a/bindings/node/package-lock.json +++ b/bindings/node/package-lock.json @@ -10,66 +10,71 @@ "devDependencies": { "@napi-rs/cli": "^2", "typescript": "^5", - "vitest": "^3" + "vitest": "^5.0.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "aix" - ], "engines": { - "node": ">=18" + "node": ">=6.0.0" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], + "node_modules/@napi-rs/cli": { + "version": "2.18.4", + "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz", + "integrity": "sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "bin": { + "napi": "scripts/index.js" + }, "engines": { - "node": ">=18" + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", "cpu": [ - "x64" + "arm" ], "dev": true, "license": "MIT", @@ -77,14 +82,15 @@ "os": [ "android" ], + "peer": true, "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", "cpu": [ "arm64" ], @@ -92,18 +98,19 @@ "license": "MIT", "optional": true, "os": [ - "darwin" + "android" ], + "peer": true, "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", @@ -111,31 +118,33 @@ "os": [ "darwin" ], + "peer": true, "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "freebsd" + "darwin" ], + "peer": true, "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", "cpu": [ "x64" ], @@ -145,14 +154,15 @@ "os": [ "freebsd" ], + "peer": true, "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", "cpu": [ "arm" ], @@ -162,218 +172,141 @@ "os": [ "linux" ], + "peer": true, "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" + "libc": [ + "glibc" ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", "cpu": [ - "loong64" + "arm64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" + "libc": [ + "musl" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", "cpu": [ "ppc64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" + "libc": [ + "glibc" ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", "cpu": [ "s390x" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" + "libc": [ + "glibc" ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" + "libc": [ + "glibc" ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "openbsd" + "linux" ], + "peer": true, "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ - "openbsd" + "linux" ], + "peer": true, "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", "cpu": [ "arm64" ], @@ -383,31 +316,15 @@ "os": [ "openharmony" ], + "peer": true, "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", "cpu": [ "arm64" ], @@ -417,31 +334,15 @@ "os": [ "win32" ], + "peer": true, "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", "cpu": [ "x64" ], @@ -451,383 +352,18 @@ "os": [ "win32" ], + "peer": true, "engines": { - "node": ">=18" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@napi-rs/cli": { - "version": "2.18.4", - "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz", - "integrity": "sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==", - "dev": true, - "license": "MIT", - "bin": { - "napi": "scripts/index.js" - }, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" - ], + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "peer": true }, "node_modules/@types/chai": { "version": "5.2.3", @@ -848,46 +384,30 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, - "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", + "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.4", + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.0", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "magic-string": "^1.2.3" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -898,201 +418,54 @@ } } }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", + "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" + "url": "https://opencollective.com/vitest" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 16" + "node": ">=12" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=18" } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "peer": true, "engines": { - "node": ">=6" + "node": ">=8" } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, - "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1104,9 +477,9 @@ } }, "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1142,45 +515,310 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, - "license": "MIT" + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.3.1.tgz", + "integrity": "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, - "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/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "dev": true, "funding": [ { @@ -1189,6 +827,7 @@ } ], "license": "MIT", + "peer": true, "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -1196,21 +835,18 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], "license": "MIT", "engines": { - "node": ">= 14.16" + "node": ">=12.20.0" } }, "node_modules/picocolors": { @@ -1218,12 +854,13 @@ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -1234,9 +871,9 @@ } }, "node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -1253,8 +890,9 @@ } ], "license": "MIT", + "peer": true, "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1262,49 +900,39 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "node_modules/rolldown": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.149.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" } }, "node_modules/siginfo": { @@ -1320,6 +948,7 @@ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -1332,48 +961,41 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", "dev": true, "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" + "engines": { + "node": ">=20.0.0" } }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -1382,36 +1004,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1427,18 +1019,18 @@ } }, "node_modules/vite": { - "version": "7.3.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", - "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "esbuild": "^0.27.0 || ^0.28.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -1454,9 +1046,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -1469,13 +1062,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -1501,89 +1097,73 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", + "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", + "@vitest/mocker": "5.0.0", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.0", + "@vitest/browser-preview": "5.0.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.0", + "@vitest/coverage-v8": "5.0.0", + "@vitest/ui": "5.0.0", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -1594,6 +1174,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, diff --git a/bindings/node/package.json b/bindings/node/package.json index 323fed12..61d48329 100644 --- a/bindings/node/package.json +++ b/bindings/node/package.json @@ -14,7 +14,7 @@ }, "devDependencies": { "@napi-rs/cli": "^2", - "vitest": "^3", - "typescript": "^5" + "typescript": "^5", + "vitest": "^5.0.0" } } diff --git a/bindings/node/src/hook_result.rs b/bindings/node/src/hook_result.rs index f0a528d8..4c94bde4 100644 --- a/bindings/node/src/hook_result.rs +++ b/bindings/node/src/hook_result.rs @@ -2,10 +2,20 @@ // Structs — exported as TypeScript interfaces via #[napi(object)] // --------------------------------------------------------------------------- -use amplifier_core::models::HookResult; +use amplifier_core::models::{ContextInjection, HookResult}; use crate::enums::{ApprovalDefault, ContextInjectionRole, HookAction, UserMessageLevel}; +#[napi(object)] +pub struct JsContextInjection { + pub content: String, + pub role: ContextInjectionRole, + pub ephemeral: bool, + pub append_to_last_tool_result: bool, + pub hook_name: String, + pub event: String, +} + #[napi(object)] pub struct JsHookResult { pub action: HookAction, @@ -20,6 +30,7 @@ pub struct JsHookResult { pub approval_prompt: Option, pub approval_timeout: Option, pub approval_default: Option, + pub context_injections: Option>, } // --------------------------------------------------------------------------- @@ -40,5 +51,23 @@ pub(crate) fn hook_result_to_js(result: HookResult) -> JsHookResult { approval_prompt: result.approval_prompt, approval_timeout: Some(result.approval_timeout), approval_default: Some(result.approval_default.into()), + context_injections: Some( + result + .context_injections + .into_iter() + .map(context_injection_to_js) + .collect(), + ), + } +} + +fn context_injection_to_js(injection: ContextInjection) -> JsContextInjection { + JsContextInjection { + content: injection.content, + role: injection.role.into(), + ephemeral: injection.ephemeral, + append_to_last_tool_result: injection.append_to_last_tool_result, + hook_name: injection.hook_name, + event: injection.event, } } diff --git a/bindings/node/src/hooks.rs b/bindings/node/src/hooks.rs index 91f6adb6..f802dc2c 100644 --- a/bindings/node/src/hooks.rs +++ b/bindings/node/src/hooks.rs @@ -43,14 +43,24 @@ impl HookHandler for JsHookHandlerBridge { "{}".to_string() }); Box::pin(async move { - let result_str: String = - self.callback - .call_async((event, data_str)) - .await - .map_err(|e| HookError::HandlerFailed { - message: e.to_string(), - handler_name: None, - })?; + // The JS handler may return either a bare string (synchronous) or a + // Promise (async). `call_async` returns whichever value the + // JS function returned, so await a promise result explicitly. + let ret: Either> = self + .callback + .call_async((event, data_str)) + .await + .map_err(|e| HookError::HandlerFailed { + message: e.to_string(), + handler_name: None, + })?; + let result_str = match ret { + Either::A(result) => result, + Either::B(promise) => promise.await.map_err(|e| HookError::HandlerFailed { + message: e.to_string(), + handler_name: None, + })?, + }; let hook_result: HookResult = serde_json::from_str(&result_str).unwrap_or_else(|e| { log::error!( "SECURITY: Hook handler returned unparseable result — failing closed (Deny): {e} — json: {result_str}" diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index 3e4f5f26..9f5b196e 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -33,7 +33,7 @@ pub use enums::{ ApprovalDefault, ContextInjectionRole, HookAction, SessionState, UserMessageLevel, }; pub use errors::{amplifier_error_to_js, JsAmplifierError}; -pub use hook_result::JsHookResult; +pub use hook_result::{JsContextInjection, JsHookResult}; pub use hooks::JsHookRegistry; pub use module_resolver::{load_wasm_from_path, resolve_module, JsModuleManifest}; pub use session::JsAmplifierSession; diff --git a/bindings/python/Cargo.toml b/bindings/python/Cargo.toml index d43c48bd..9de970bd 100644 --- a/bindings/python/Cargo.toml +++ b/bindings/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "amplifier-core-py" -version = "1.6.1" +version = "2.0.0" edition = "2021" description = "PyO3 bridge for amplifier-core Rust kernel" license = "MIT" diff --git a/bindings/python/src/bridges.rs b/bindings/python/src/bridges.rs index 5195d784..9092c6d5 100644 --- a/bindings/python/src/bridges.rs +++ b/bindings/python/src/bridges.rs @@ -32,6 +32,12 @@ use crate::helpers::{is_approval_granted, json_dumps_safe, try_model_dump}; /// and return a dict (or None for a default continue result). pub(crate) struct PyHookHandlerBridge { pub(crate) callable: Py, + /// Event-loop fallback captured while registering the handler. + /// + /// Native callbacks from blocking WASM work do not inherit Tokio task + /// locals. This intentionally retains only the loop, not a contextvars + /// snapshot, so the emitting task's current context remains authoritative. + pub(crate) fallback_locals: Option, } // Safety: Py is Send+Sync (PyO3 handles GIL acquisition). @@ -89,14 +95,24 @@ impl HookHandler for PyHookHandlerBridge { handler_name: None, })?; - // Step 2: If it's a coroutine, convert to a Rust Future via - // pyo3_async_runtimes::tokio::into_future() and await OUTSIDE the GIL. - // This is the key fix: the old code used run_coroutine_threadsafe / - // asyncio.run() which either deadlocked or created a throwaway event loop. - // into_future() properly drives the coroutine on the caller's event loop. + // Step 2: If it's a coroutine, convert to a Rust Future and await + // OUTSIDE the GIL. Prefer the current emitting task's locals, then + // fall back to the registration loop when a native callback (such as + // a blocking WASM host import) has lost Tokio task-local state. let py_result = if is_coro { let future = Python::try_attach(|py| { - pyo3_async_runtimes::tokio::into_future(py_result_or_coro.into_bound(py)) + let locals = pyo3_async_runtimes::tokio::get_current_locals(py) + .ok() + .or_else(|| self.fallback_locals.clone()) + .ok_or_else(|| { + pyo3::exceptions::PyRuntimeError::new_err( + "No running Python event loop available for coroutine conversion", + ) + })?; + pyo3_async_runtimes::into_future_with_locals( + &locals, + py_result_or_coro.into_bound(py), + ) }) .ok_or_else(|| HookError::HandlerFailed { message: "Failed to attach to Python runtime for coroutine conversion" @@ -109,7 +125,8 @@ impl HookHandler for PyHookHandlerBridge { })?; // Await OUTSIDE the GIL — drives the Python coroutine on the - // caller's asyncio event loop via pyo3-async-runtimes task locals. + // current task's loop, or its registration loop after native + // re-entry has lost task-local state. future.await.map_err(|e| HookError::HandlerFailed { message: format!("Python async handler error: {e}"), handler_name: None, diff --git a/bindings/python/src/coordinator/hook_dispatch.rs b/bindings/python/src/coordinator/hook_dispatch.rs index ffa8b407..e5baba60 100644 --- a/bindings/python/src/coordinator/hook_dispatch.rs +++ b/bindings/python/src/coordinator/hook_dispatch.rs @@ -34,6 +34,16 @@ fn truncate_for_log(s: &str, max_chars: usize) -> String { } } +struct PreparedInjection { + content: String, + role: String, + ephemeral: bool, + append_to_last_tool_result: bool, + hook_name: String, + event: String, + tokens: usize, +} + #[pymethods] impl PyCoordinator { /// Process a HookResult and route actions to appropriate subsystems. @@ -118,6 +128,10 @@ impl PyCoordinator { let approval_timeout: f64 = result.getattr("approval_timeout")?.extract()?; let approval_default: String = result.getattr("approval_default")?.extract()?; + if action == "inject_context" { + return self.process_context_injections(py, result, event, hook_name); + } + // Read coordinator config let size_limit: Option = { let val = self.get_injection_size_limit(py)?; @@ -505,6 +519,296 @@ impl PyCoordinator { } impl PyCoordinator { + fn process_context_injections<'py>( + &mut self, + py: Python<'py>, + result: Bound<'py, PyAny>, + event: String, + hook_name: &str, + ) -> PyResult> { + let scalar_content: Option = { + let value = result.getattr("context_injection")?; + (!value.is_none()).then(|| value.extract()).transpose()? + }; + let scalar_role: String = result.getattr("context_injection_role")?.extract()?; + let scalar_ephemeral: bool = result.getattr("ephemeral")?.extract()?; + let scalar_append: bool = result.getattr("append_to_last_tool_result")?.extract()?; + let suppress_output: bool = result.getattr("suppress_output")?.extract()?; + let user_message: Option = { + let value = result.getattr("user_message")?; + (!value.is_none()).then(|| value.extract()).transpose()? + }; + let user_message_level: String = result.getattr("user_message_level")?.extract()?; + let user_message_source: Option = { + let value = result.getattr("user_message_source")?; + (!value.is_none()).then(|| value.extract()).transpose()? + }; + + let size_limit: Option = { + let value = self.get_injection_size_limit(py)?; + (!value.bind(py).is_none()) + .then(|| value.extract(py)) + .transpose()? + }; + let budget: Option = { + let value = self.get_injection_budget_per_turn(py)?; + (!value.bind(py).is_none()) + .then(|| value.extract(py)) + .transpose()? + }; + + let items = result.getattr("context_injections")?; + let mut raw_items = Vec::new(); + for item in items.try_iter()? { + let item = item?; + raw_items.push(( + item.getattr("content")?.extract::()?, + item.getattr("role")?.extract::()?, + item.getattr("ephemeral")?.extract::()?, + item.getattr("append_to_last_tool_result")? + .extract::()?, + item.getattr("hook_name")?.extract::()?, + item.getattr("event")?.extract::()?, + )); + } + + // Scalar-only results predate the structured carrier. Normalize them + // here for direct callers that have not passed through HookRegistry. + if raw_items.is_empty() { + if let Some(content) = scalar_content { + raw_items.push(( + content, + scalar_role, + scalar_ephemeral, + scalar_append, + hook_name.to_string(), + event.clone(), + )); + } + } + + const CHARS_PER_TOKEN_ESTIMATE: usize = 4; + let sanitize = py + .import("amplifier_core.models")? + .getattr("_sanitize_for_llm")?; + let mut prepared = Vec::new(); + for (content, role, ephemeral, append, item_hook_name, item_event) in raw_items { + let sanitized_content: String = sanitize + .call1((content,))? + .extract() + .map_err(|error| { + log::error!( + "SECURITY: Sanitization unavailable for hook '{}' — rejecting injection: {error}", + item_hook_name + ); + PyValueError::new_err( + "Context injection rejected: content sanitization function unavailable", + ) + })?; + if sanitized_content.is_empty() { + continue; + } + let char_count = sanitized_content.chars().count(); + if let Some(limit) = size_limit { + if char_count > limit { + log::error!( + "Hook injection too large: {} (chars={}, limit={})", + item_hook_name, + char_count, + limit + ); + return Err(PyValueError::new_err(format!( + "Context injection exceeds {} characters", + limit + ))); + } + } + prepared.push(PreparedInjection { + content: sanitized_content, + role, + ephemeral, + append_to_last_tool_result: append, + hook_name: item_hook_name, + event: item_event, + tokens: char_count / CHARS_PER_TOKEN_ESTIMATE, + }); + } + + // Every item is validated before the budget state is charged or any + // context write begins. Budget is still advisory, as it was for the + // legacy scalar path, but applies to the total item sequence. + let mut running_total = self.current_turn_injections; + for item in &prepared { + if let Some(limit) = budget { + if running_total + item.tokens > limit { + log::warn!( + "Warning: Hook injection budget exceeded \ + (hook={}, current={}, attempted={}, budget={})", + item.hook_name, + running_total, + item.tokens, + limit + ); + } + } + running_total += item.tokens; + } + self.current_turn_injections = running_total; + + let context_obj: Py = { + let mount_points = self.mount_points.bind(py); + match mount_points.get_item("context")? { + Some(context) if !context.is_none() => context.unbind(), + _ => py.None(), + } + }; + let has_context = { + let context = context_obj.bind(py); + !context.is_none() && context.hasattr("add_message")? + }; + + let now = if has_context && prepared.iter().any(|item| !item.ephemeral) { + let datetime = py.import("datetime")?; + let utc = datetime.getattr("timezone")?.getattr("utc")?; + Some( + datetime + .getattr("datetime")? + .call_method1("now", (utc,))? + .call_method0("isoformat")? + .unbind(), + ) + } else { + None + }; + + let mut messages = Vec::new(); + let mut residual = Vec::new(); + for item in prepared { + log::info!( + "Hook context injection \ + (hook={}, event={}, chars={}, role={}, tokens={}, ephemeral={})", + item.hook_name, + item.event, + item.content.chars().count(), + item.role, + item.tokens, + item.ephemeral + ); + if item.ephemeral { + residual.push(item); + continue; + } + if has_context { + let metadata = PyDict::new(py); + metadata.set_item("source", "hook")?; + metadata.set_item("hook_name", &item.hook_name)?; + metadata.set_item("event", &item.event)?; + metadata.set_item("timestamp", now.as_ref().unwrap().bind(py))?; + metadata.set_item("requested_role", &item.role)?; + metadata.set_item("ephemeral", false)?; + metadata.set_item("persisted", true)?; + + let message = PyDict::new(py); + message.set_item("role", &item.role)?; + message.set_item("content", &item.content)?; + message.set_item("metadata", metadata)?; + messages.push(message.into_any().unbind()); + } + } + + if let Some(message) = user_message.filter(|message| !message.is_empty()) { + let source = user_message_source.as_deref().unwrap_or(hook_name); + let display = self.display_system_obj.bind(py); + if display.is_none() { + log::debug!( + "Hook message ({}): {} (hook={})", + user_message_level, + truncate_for_log(&message, 200), + source + ); + } else if let Err(error) = display.call_method( + "show_message", + (&message, &user_message_level, format!("hook:{source}")), + None, + ) { + log::error!("Error calling display_system: {error}"); + } + } + if suppress_output { + log::debug!("Hook '{}' requested output suppression", hook_name); + } + + let result_py = result.unbind(); + let hook_result_cls = py + .import("amplifier_core.models")? + .getattr("HookResult")? + .unbind(); + wrap_future_as_coroutine( + py, + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let bridge = PyContextManagerBridge { + py_obj: context_obj, + }; + for message in messages { + bridge.add_message(message).await?; + } + Self::make_residual_hook_result(&hook_result_cls, &result_py, &residual) + }), + ) + } + + fn make_residual_hook_result( + hook_result_cls: &Py, + original: &Py, + residual: &[PreparedInjection], + ) -> PyResult> { + Python::try_attach(|py| -> PyResult> { + let values = original.call_method0(py, "model_dump")?; + let updates = PyDict::new(py); + let injections = pyo3::types::PyList::empty(py); + for item in residual { + let injection = PyDict::new(py); + injection.set_item("content", &item.content)?; + injection.set_item("role", &item.role)?; + injection.set_item("ephemeral", item.ephemeral)?; + injection.set_item( + "append_to_last_tool_result", + item.append_to_last_tool_result, + )?; + injection.set_item("hook_name", &item.hook_name)?; + injection.set_item("event", &item.event)?; + injections.append(injection)?; + } + updates.set_item("context_injections", injections)?; + if residual.is_empty() { + updates.set_item("action", "continue")?; + updates.set_item("context_injection", py.None())?; + updates.set_item("context_injection_role", "system")?; + updates.set_item("ephemeral", false)?; + updates.set_item("append_to_last_tool_result", false)?; + } else { + updates.set_item("action", "inject_context")?; + updates.set_item( + "context_injection", + residual + .iter() + .map(|item| item.content.as_str()) + .collect::>() + .join("\n\n"), + )?; + updates.set_item("context_injection_role", &residual[0].role)?; + updates.set_item("ephemeral", true)?; + updates.set_item( + "append_to_last_tool_result", + residual.iter().any(|item| item.append_to_last_tool_result), + )?; + } + values.call_method1(py, "update", (updates,))?; + hook_result_cls.call_method1(py, "model_validate", (values,)) + }) + .ok_or_else(|| PyRuntimeError::new_err("Failed to create residual HookResult"))? + } + /// Construct a HookResult Python object with the given action and optional reason. /// /// Centralises the `Python::try_attach` + `PyDict` + `hook_result_cls.call` pattern diff --git a/bindings/python/src/coordinator/mod.rs b/bindings/python/src/coordinator/mod.rs index ead0986e..ebf61885 100644 --- a/bindings/python/src/coordinator/mod.rs +++ b/bindings/python/src/coordinator/mod.rs @@ -138,8 +138,8 @@ impl PyCoordinator { let inner = Arc::new(amplifier_core::Coordinator::new(rust_config)); - // Create the hooks registry - let hooks_instance = Py::new(py, PyHookRegistry::new())?; + // Python and native/WASM dispatch must use the same registry. + let hooks_instance = Py::new(py, PyHookRegistry::from_shared(inner.hooks_shared()))?; let hooks_any: Py = hooks_instance.clone_ref(py).into_any(); // Create the cancellation token diff --git a/bindings/python/src/hooks.rs b/bindings/python/src/hooks.rs index b9dec4cc..054c9111 100644 --- a/bindings/python/src/hooks.rs +++ b/bindings/python/src/hooks.rs @@ -61,15 +61,22 @@ pub(crate) struct PyHookRegistry { unregister_fns: Arc>>>, } +impl PyHookRegistry { + /// Wrap the coordinator's registry so every transport shares dispatch. + pub(crate) fn from_shared(inner: Arc) -> Self { + Self { + inner, + unregister_fns: Arc::new(std::sync::Mutex::new(HashMap::new())), + } + } +} + #[pymethods] impl PyHookRegistry { /// Create a new empty hook registry. #[new] pub(crate) fn new() -> Self { - Self { - inner: Arc::new(amplifier_core::HookRegistry::new()), - unregister_fns: Arc::new(std::sync::Mutex::new(HashMap::new())), - } + Self::from_shared(Arc::new(amplifier_core::HookRegistry::new())) } /// Register a Python callable as a hook handler. @@ -96,7 +103,17 @@ impl PyHookRegistry { ) -> PyResult> { let handler_name = name.unwrap_or_else(|| format!("_auto_{event}_{}", uuid::Uuid::new_v4())); - let bridge = Arc::new(PyHookHandlerBridge { callable: handler }); + // A native callback can re-enter the shared registry from a Tokio + // blocking thread, where task locals are unavailable. Keep the + // registration loop as a fallback only; do not snapshot contextvars, + // because the current emitting task's context must take precedence. + let fallback_locals = pyo3_async_runtimes::tokio::get_current_locals(py) + .ok() + .map(|locals| pyo3_async_runtimes::TaskLocals::new(locals.event_loop(py))); + let bridge = Arc::new(PyHookHandlerBridge { + callable: handler, + fallback_locals, + }); let unregister_fn = self.inner .register(event, bridge, priority, Some(handler_name.clone())); diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index d2419039..0529e375 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -102,6 +102,30 @@ fn json_to_proto_chat_response(json_str: &str) -> PyResult> { Ok(proto.encode_to_vec()) } +/// Normalize untrusted ToolResult rich content through the Rust core. +#[pyfunction(name = "_normalize_tool_result_content")] +fn normalize_tool_result_content(content_json: &str) -> PyResult> { + let content = serde_json::from_str(content_json) + .map_err(|_| pyo3::exceptions::PyValueError::new_err("invalid tool result content"))?; + let normalized = amplifier_core::ToolResult::normalize_content(content) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string()))?; + normalized + .map(|value| { + serde_json::to_string(&value) + .map_err(|_| pyo3::exceptions::PyValueError::new_err("invalid tool result content")) + }) + .transpose() +} + +/// Return an image-safe ToolResult payload for hook events. +#[pyfunction(name = "_tool_result_safe_hook_presentation")] +fn tool_result_safe_hook_presentation(tool_result_json: &str) -> PyResult { + let result: amplifier_core::ToolResult = serde_json::from_str(tool_result_json) + .map_err(|_| pyo3::exceptions::PyValueError::new_err("invalid tool result content"))?; + serde_json::to_string(&result.safe_hook_presentation()) + .map_err(|_| pyo3::exceptions::PyValueError::new_err("invalid tool result content")) +} + // --------------------------------------------------------------------------- // Module registration // --------------------------------------------------------------------------- @@ -134,6 +158,8 @@ fn _engine(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(resolve_module, m)?)?; m.add_function(wrap_pyfunction!(proto_chat_request_to_json, m)?)?; m.add_function(wrap_pyfunction!(json_to_proto_chat_response, m)?)?; + m.add_function(wrap_pyfunction!(normalize_tool_result_content, m)?)?; + m.add_function(wrap_pyfunction!(tool_result_safe_hook_presentation, m)?)?; #[cfg(feature = "wasm")] { m.add_function(wrap_pyfunction!(load_wasm_from_path, m)?)?; diff --git a/bindings/python/src/module_resolver.rs b/bindings/python/src/module_resolver.rs index e86325c6..ddbd5370 100644 --- a/bindings/python/src/module_resolver.rs +++ b/bindings/python/src/module_resolver.rs @@ -15,7 +15,10 @@ use pyo3::types::PyDict; /// and artifact-specific keys ("artifact_path", "endpoint", "package_name"). #[pyfunction] pub(crate) fn resolve_module(py: Python<'_>, path: String) -> PyResult> { - let manifest = amplifier_core::module_resolver::resolve_module(std::path::Path::new(&path)) + let manifest = py + .detach(move || { + amplifier_core::module_resolver::resolve_module(std::path::Path::new(&path)) + }) .map_err(|e| PyErr::new::(format!("{e}")))?; let dict = PyDict::new(py); @@ -78,7 +81,10 @@ pub(crate) const RUST_TRANSPORT_ERROR_MSG: &str = #[cfg(feature = "wasm")] #[pyfunction] pub(crate) fn load_wasm_from_path(py: Python<'_>, path: String) -> PyResult> { - let manifest = amplifier_core::module_resolver::resolve_module(std::path::Path::new(&path)) + let manifest = py + .detach(move || { + amplifier_core::module_resolver::resolve_module(std::path::Path::new(&path)) + }) .map_err(|e| PyErr::new::(format!("{e}")))?; if manifest.transport == amplifier_core::transport::Transport::Rust { @@ -92,14 +98,19 @@ pub(crate) fn load_wasm_from_path(py: Python<'_>, path: String) -> PyResult(format!("WASM engine creation failed: {e}")) - })?; - - let coordinator = std::sync::Arc::new(amplifier_core::Coordinator::new_for_test()); - let loaded = - amplifier_core::module_resolver::load_module(&manifest, engine.inner(), Some(coordinator)) - .map_err(|e| PyErr::new::(format!("Module loading failed: {e}")))?; + let loaded = py + .detach(move || { + let engine = amplifier_core::wasm_engine::WasmEngine::new() + .map_err(|e| format!("WASM engine creation failed: {e}"))?; + let coordinator = std::sync::Arc::new(amplifier_core::Coordinator::new_for_test()); + amplifier_core::module_resolver::load_module( + &manifest, + engine.inner(), + Some(coordinator), + ) + .map_err(|e| format!("Module loading failed: {e}")) + }) + .map_err(PyErr::new::)?; let dict = PyDict::new(py); dict.set_item("status", "loaded")?; diff --git a/bindings/python/src/session.rs b/bindings/python/src/session.rs index 699e5757..b6233b42 100644 --- a/bindings/python/src/session.rs +++ b/bindings/python/src/session.rs @@ -44,6 +44,8 @@ pub(crate) struct PySession { cached_session_id: String, /// Cached parent_id. cached_parent_id: Option, + /// Cached `session.metadata` (CP-SM passthrough), `None` when unconfigured. + cached_session_metadata: Option, } #[pymethods] @@ -112,6 +114,12 @@ impl PySession { let json_str: String = json_dumps_safe(py, config.as_any())?; let value: Value = serde_json::from_str(&json_str) .map_err(|e| PyErr::new::(format!("Invalid config JSON: {e}")))?; + // CP-SM (docs/specs/CONTRIBUTION_CHANNELS.md): cache `session.metadata` here, + // alongside session_id/parent_id, so the lifecycle emit below can include it + // without re-entering Python. Read before `value` is moved into SessionConfig. + let cached_session_metadata = + amplifier_core::session::session_metadata_passthrough(value.get("session")); + let session_config = amplifier_core::SessionConfig::from_value(value) .map_err(|e| PyErr::new::(format!("Invalid session config: {e}")))?; @@ -180,6 +188,7 @@ impl PySession { is_resumed, cached_session_id: actual_session_id, cached_parent_id: actual_parent_id, + cached_session_metadata, }) } @@ -386,10 +395,16 @@ impl PySession { let hook_registry = hooks.extract::>()?; hook_registry.inner.clone() }; - let pre_event_data = serde_json::json!({ + let mut pre_event_data = serde_json::json!({ "session_id": self.cached_session_id, "parent_id": self.cached_parent_id, }); + // CP-SM passthrough (docs/specs/CONTRIBUTION_CHANNELS.md), matching the + // pure-Python kernel. Unconfigured or empty metadata leaves the payload + // exactly as it was before this change. + if let Some(metadata) = &self.cached_session_metadata { + pre_event_data["metadata"] = metadata.clone(); + } // Clone references for the async block let coordinator = self.coordinator.clone_ref(py); diff --git a/bindings/python/src/wasm.rs b/bindings/python/src/wasm.rs index 79cf647b..2127c11e 100644 --- a/bindings/python/src/wasm.rs +++ b/bindings/python/src/wasm.rs @@ -762,7 +762,10 @@ pub(crate) fn load_and_mount_wasm( coordinator: &PyCoordinator, path: String, ) -> PyResult> { - let manifest = amplifier_core::module_resolver::resolve_module(std::path::Path::new(&path)) + let manifest = py + .detach(move || { + amplifier_core::module_resolver::resolve_module(std::path::Path::new(&path)) + }) .map_err(|e| PyErr::new::(format!("{e}")))?; if manifest.transport != amplifier_core::transport::Transport::Wasm { @@ -772,21 +775,23 @@ pub(crate) fn load_and_mount_wasm( ))); } - let engine = amplifier_core::wasm_engine::WasmEngine::new().map_err(|e| { - PyErr::new::(format!("WASM engine creation failed: {e}")) - })?; - // Use the real coordinator's inner Arc for orchestrator modules let rust_coordinator = coordinator.inner.clone(); - let loaded = amplifier_core::module_resolver::load_module( - &manifest, - engine.inner(), - Some(rust_coordinator), - ) - .map_err(|e| PyErr::new::(format!("Module loading failed: {e}")))?; + let loaded = py + .detach(move || { + let engine = amplifier_core::wasm_engine::WasmEngine::new() + .map_err(|e| format!("WASM engine creation failed: {e}"))?; + amplifier_core::module_resolver::load_module( + &manifest, + engine.inner(), + Some(rust_coordinator), + ) + .map_err(|e| format!("Module loading failed: {e}")) + }) + .map_err(PyErr::new::)?; let dict = PyDict::new(py); - dict.set_item("module_type", loaded.variant_name())?; + dict.set_item("module_type", loaded.variant_name().to_ascii_lowercase())?; match loaded { amplifier_core::module_resolver::LoadedModule::Tool(tool) => { diff --git a/bindings/python/tests/test_grpc_integration.py b/bindings/python/tests/test_grpc_integration.py index ee784fb4..79baefe9 100644 --- a/bindings/python/tests/test_grpc_integration.py +++ b/bindings/python/tests/test_grpc_integration.py @@ -35,6 +35,16 @@ async def Execute(self, request, context): success=True, output=json.dumps(output).encode("utf-8"), content_type="application/json", + content_blocks=[ + amplifier_module_pb2.ContentBlock( + text_block=amplifier_module_pb2.TextBlock(text="echo result") + ), + amplifier_module_pb2.ContentBlock( + image_block=amplifier_module_pb2.ImageBlock( + media_type="image/png", data=b"\x00" + ) + ), + ], ) server = grpc_aio.server() @@ -76,6 +86,13 @@ async def test_grpc_tool_bridge_full_roundtrip(mock_tool_server): result = await bridge.execute(message="hello world") assert result["success"] is True assert result["output"]["echoed"] == "hello world" + assert result["content"] == [ + {"type": "text", "text": "echo result"}, + { + "type": "image", + "source": {"type": "base64", "media_type": "image/png", "data": "AA=="}, + }, + ] # Cleanup await bridge.cleanup() diff --git a/bindings/python/tests/test_schema_sync.py b/bindings/python/tests/test_schema_sync.py index 0702e570..49d9c827 100644 --- a/bindings/python/tests/test_schema_sync.py +++ b/bindings/python/tests/test_schema_sync.py @@ -148,3 +148,22 @@ def test_hook_result_json_roundtrip(): assert restored.context_injection == "Lint error on line 42" assert restored.suppress_output is True assert restored.user_message == "Found 1 issue" + + +def test_model_info_pricing_field(): + """ModelInfo.pricing and Pricing are importable and round-trip via JSON.""" + from amplifier_core import ModelInfo, Pricing + + info = ModelInfo( + id="test-model", + display_name="Test", + context_window=1000, + max_output_tokens=100, + pricing=Pricing(input_per_million=3.0, output_per_million=15.0), + ) + json_str = info.model_dump_json() + parsed = json.loads(json_str) + assert parsed["pricing"]["input_per_million"] == 3.0 + + restored = ModelInfo.model_validate(parsed) + assert restored.pricing.output_per_million == 15.0 diff --git a/bindings/python/tests/test_session_metadata_rust.py b/bindings/python/tests/test_session_metadata_rust.py new file mode 100644 index 00000000..52148d1d --- /dev/null +++ b/bindings/python/tests/test_session_metadata_rust.py @@ -0,0 +1,228 @@ +"""CP-SM metadata passthrough, exercised against the class that actually runs. + +`tests/test_session_metadata.py` covers the same contract against the +*pure-Python* `amplifier_core.session.AmplifierSession`. Production code does +``from amplifier_core import AmplifierSession``, which is ``RustSession`` +(`python/amplifier_core/__init__.py`) -- so those tests were green on a code +path no runtime consumer executes, and the Rust emit shipped without the +metadata merge. + +This module is the parallel suite against `RustSession`. Keep the two in sync: +a payload contract that is only asserted on one side of the switchover is a +contract that can regress silently. + +CP-SM: Kernel reads config.session.metadata and includes it as optional +'metadata' key in event payloads. Pure passthrough - no interpretation or +validation. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from amplifier_core._engine import RustSession +from amplifier_core.events import SESSION_FORK, SESSION_RESUME, SESSION_START + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _config(metadata=None): + """Minimal mount plan, optionally carrying session.metadata.""" + session: dict = { + "orchestrator": "loop-basic", + "context": "context-simple", + } + if metadata is not None: + session["metadata"] = metadata + return {"session": session, "providers": [], "hooks": [], "tools": []} + + +async def _make_session(config: dict, **kwargs) -> RustSession: + """Create a RustSession whose module loading is stubbed out.""" + session = RustSession(config=config, **kwargs) + with patch("amplifier_core._session_init.initialize_session", AsyncMock()): + await session.initialize() + return session + + +def _mount_stubs(session: RustSession) -> None: + """Mount the minimum required for execute() to reach the orchestrator.""" + orchestrator = AsyncMock() + orchestrator.execute = AsyncMock(return_value="ok") + session.coordinator.mount_points["orchestrator"] = orchestrator + session.coordinator.mount_points["context"] = AsyncMock() + session.coordinator.mount_points["providers"] = {"mock": AsyncMock()} + + +def _capture(session: RustSession, event: str) -> list[dict]: + """Register a handler that records payloads for `event`.""" + captured: list[dict] = [] + + async def _handler(_event: str, data: dict): + captured.append(dict(data)) + return None + + session.coordinator.hooks.register(event, _handler, name=f"capture-{event}") + return captured + + +# --------------------------------------------------------------------------- +# session:start +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_session_start_includes_metadata_when_configured(): + """session:start carries config.session.metadata verbatim.""" + metadata = {"agent_name": "test-agent", "run_id": "abc123"} + session = await _make_session(_config(metadata)) + starts = _capture(session, SESSION_START) + _mount_stubs(session) + + await session.execute("hello") + + assert len(starts) == 1, f"Expected 1 session:start, got {len(starts)}" + assert "metadata" in starts[0], ( + "Expected 'metadata' key in session:start payload. " + f"Got keys: {sorted(starts[0])}" + ) + assert starts[0]["metadata"] == metadata + + +@pytest.mark.asyncio +async def test_session_start_passes_nested_metadata_through_untouched(): + """Passthrough is verbatim -- nested objects are not flattened or rewritten.""" + metadata = { + "invocation": { + "schema": 1, + "mode": "single", + "stdin_isatty": False, + "launched_by_session_id": None, + } + } + session = await _make_session(_config(metadata)) + starts = _capture(session, SESSION_START) + _mount_stubs(session) + + await session.execute("hello") + + assert starts[0]["metadata"] == metadata + + +@pytest.mark.asyncio +async def test_session_start_excludes_metadata_when_not_configured(): + """No metadata configured => payload is exactly what it was before CP-SM.""" + session = await _make_session(_config()) + starts = _capture(session, SESSION_START) + _mount_stubs(session) + + await session.execute("hello") + + assert len(starts) == 1 + assert "metadata" not in starts[0], ( + "Expected no 'metadata' key when unconfigured. " + f"Got keys: {sorted(starts[0])}" + ) + + +@pytest.mark.asyncio +async def test_session_start_excludes_empty_metadata(): + """An empty dict is falsy for the Python kernel -- the Rust kernel must agree.""" + session = await _make_session(_config({})) + starts = _capture(session, SESSION_START) + _mount_stubs(session) + + await session.execute("hello") + + assert "metadata" not in starts[0], ( + "Empty metadata must behave exactly like absent metadata " + "(matches the pure-Python kernel's `if session_metadata:` guard)." + ) + + +@pytest.mark.asyncio +async def test_metadata_does_not_displace_existing_payload_fields(): + """Additive only: session_id / parent_id keep their meaning and values.""" + parent_id = "parent-session-id-123" + session = await _make_session( + _config({"tag": "some-tag"}), + session_id="child-session-id-456", + parent_id=parent_id, + ) + starts = _capture(session, SESSION_START) + _mount_stubs(session) + + await session.execute("hello") + + payload = starts[0] + assert payload["session_id"] == "child-session-id-456" + assert payload["parent_id"] == parent_id + assert payload["metadata"] == {"tag": "some-tag"} + + +# --------------------------------------------------------------------------- +# session:resume +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_session_resume_includes_metadata_when_configured(): + """session:resume carries metadata too -- same emit site, same contract.""" + metadata = {"agent_name": "resumed-agent"} + session = await _make_session( + _config(metadata), session_id="resumed-id", is_resumed=True + ) + resumes = _capture(session, SESSION_RESUME) + _mount_stubs(session) + + await session.execute("hello") + + assert len(resumes) == 1 + assert resumes[0]["metadata"] == metadata + + +@pytest.mark.asyncio +async def test_session_resume_excludes_metadata_when_not_configured(): + session = await _make_session( + _config(), session_id="resumed-id-2", is_resumed=True + ) + resumes = _capture(session, SESSION_RESUME) + _mount_stubs(session) + + await session.execute("hello") + + assert len(resumes) == 1 + assert "metadata" not in resumes[0] + + +# --------------------------------------------------------------------------- +# session:fork +# +# fork is emitted by the shared Python helper (`_session_init.py`), which both +# session classes delegate to -- so it already honored CP-SM. Pinned here so +# the three emit paths stay in agreement. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_session_fork_includes_metadata_when_configured(): + metadata = {"agent_name": "child-agent", "depth": 1} + parent_id = "parent-session-id-789" + session = RustSession(config=_config(metadata), parent_id=parent_id) + + loader = Mock() + loader.load = AsyncMock(return_value=AsyncMock(return_value=None)) + loader.get_on_session_ready_queue = Mock(return_value=[]) + loader.clear_on_session_ready_queue = Mock(return_value=None) + session.coordinator.loader = loader + + forks = _capture(session, SESSION_FORK) + + await session.initialize() + + assert len(forks) == 1, f"Expected 1 session:fork, got {len(forks)}" + assert forks[0]["metadata"] == metadata + assert forks[0]["parent"] == parent_id diff --git a/bindings/python/tests/test_switchover_hooks.py b/bindings/python/tests/test_switchover_hooks.py index d075cb64..990c7db7 100644 --- a/bindings/python/tests/test_switchover_hooks.py +++ b/bindings/python/tests/test_switchover_hooks.py @@ -167,3 +167,48 @@ async def loop_detecting_handler(event, data): "Handler ran on a different event loop — " "this means asyncio.run() was used instead of into_future()" ) + + +def test_async_handler_registered_outside_event_loop_uses_emitting_loop(): + """An async handler registered synchronously can deny from asyncio.run().""" + import asyncio + + registry = RustHookRegistry() + + async def async_handler(event, data): + await asyncio.sleep(0) + return {"action": "deny", "reason": "async denial"} + + registry.register("test:async-hook", async_handler, 0, name="outside-loop") + + async def emit(): + return await registry.emit("test:async-hook", {}) + + result = asyncio.run(emit()) + assert result.action == "deny" + assert result.reason == "async denial" + + +@pytest.mark.asyncio +async def test_async_handler_uses_emitting_contextvars_not_registration_context(): + """The current emitting task's contextvars win over registration context.""" + import asyncio + import contextvars + + context_value = contextvars.ContextVar("hook_context_value") + registry = RustHookRegistry() + context_value.set("A") + + async def async_handler(event, data): + await asyncio.sleep(0) + return { + "action": "deny", + "reason": f"context={context_value.get()}", + } + + registry.register("test:async-hook", async_handler, 0, name="context-aware") + context_value.set("B") + + result = await registry.emit("test:async-hook", {}) + assert result.action == "deny" + assert result.reason == "context=B" diff --git a/crates/amplifier-core/Cargo.toml b/crates/amplifier-core/Cargo.toml index fadbe067..f959282e 100644 --- a/crates/amplifier-core/Cargo.toml +++ b/crates/amplifier-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "amplifier-core" -version = "1.6.1" +version = "2.0.0" edition = "2021" description = "Pure Rust kernel for the Amplifier modular AI agent system" license = "MIT" @@ -10,6 +10,7 @@ repository = "https://github.com/microsoft/amplifier-core" tokio = { version = "1", features = ["rt", "sync", "time", "macros", "net"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +base64 = "0.22" thiserror = "2" uuid = { version = "1", features = ["v4"] } chrono = { version = "0.4", features = ["serde"] } diff --git a/crates/amplifier-core/src/bridges/grpc_hook.rs b/crates/amplifier-core/src/bridges/grpc_hook.rs index 5a7fa7d8..0a1f8cb3 100644 --- a/crates/amplifier-core/src/bridges/grpc_hook.rs +++ b/crates/amplifier-core/src/bridges/grpc_hook.rs @@ -194,6 +194,35 @@ impl GrpcHookBridge { } }; + let context_injections = proto + .context_injections + .into_iter() + .map(|injection| { + let role = match amplifier_module::ContextInjectionRole::try_from(injection.role) { + Ok(amplifier_module::ContextInjectionRole::System) => { + models::ContextInjectionRole::System + } + Ok(amplifier_module::ContextInjectionRole::User) => { + models::ContextInjectionRole::User + } + Ok(amplifier_module::ContextInjectionRole::Assistant) => { + models::ContextInjectionRole::Assistant + } + Ok(amplifier_module::ContextInjectionRole::Unspecified) | Err(_) => { + models::ContextInjectionRole::System + } + }; + models::ContextInjection { + content: injection.content, + role, + ephemeral: injection.ephemeral, + append_to_last_tool_result: injection.append_to_last_tool_result, + hook_name: injection.hook_name, + event: injection.event, + } + }) + .collect(); + let approval_prompt = if proto.approval_prompt.is_empty() { None } else { @@ -267,6 +296,7 @@ impl GrpcHookBridge { user_message_level, user_message_source, append_to_last_tool_result: proto.append_to_last_tool_result, + context_injections, extensions: HashMap::new(), } } @@ -333,6 +363,7 @@ mod tests { user_message_level: 0, user_message_source: String::new(), append_to_last_tool_result: false, + context_injections: vec![], } } diff --git a/crates/amplifier-core/src/bridges/grpc_provider.rs b/crates/amplifier-core/src/bridges/grpc_provider.rs index 609dff93..7f51c98f 100644 --- a/crates/amplifier-core/src/bridges/grpc_provider.rs +++ b/crates/amplifier-core/src/bridges/grpc_provider.rs @@ -47,6 +47,23 @@ fn parse_defaults_json(json_str: &str, id: &str) -> HashMap { }) } +/// Parse a JSON string into an optional Pricing, logging a warning on +/// non-empty parse failures. Empty string means "no pricing available". +fn parse_pricing_json(json_str: &str, id: &str) -> Option { + if json_str.is_empty() { + return None; + } + serde_json::from_str(json_str) + .map_err(|e| { + log::warn!( + "Failed to parse model '{}' pricing_json: {e} — pricing unavailable", + id + ); + e + }) + .ok() +} + /// A bridge that wraps a remote gRPC `ProviderService` as a native [`Provider`]. /// /// The client is held behind a [`tokio::sync::Mutex`] because @@ -125,6 +142,7 @@ impl Provider for GrpcProviderBridge { .into_iter() .map(|m| { let defaults = parse_defaults_json(&m.defaults_json, &m.id); + let pricing = parse_pricing_json(&m.pricing_json, &m.id); ModelInfo { id: m.id, display_name: m.display_name, @@ -132,6 +150,7 @@ impl Provider for GrpcProviderBridge { max_output_tokens: m.max_output_tokens as i64, capabilities: m.capabilities, defaults, + pricing, } }) .collect(); diff --git a/crates/amplifier-core/src/bridges/grpc_tool.rs b/crates/amplifier-core/src/bridges/grpc_tool.rs index aa7eb831..76839f53 100644 --- a/crates/amplifier-core/src/bridges/grpc_tool.rs +++ b/crates/amplifier-core/src/bridges/grpc_tool.rs @@ -28,6 +28,7 @@ use tonic::transport::Channel; use crate::errors::ToolError; use crate::generated::amplifier_module; use crate::generated::amplifier_module::tool_service_client::ToolServiceClient; +use crate::generated::conversions::proto_tool_result_content_to_native; use crate::messages; use crate::models::ToolResult; use crate::traits::Tool; @@ -156,11 +157,22 @@ impl Tool for GrpcToolBridge { Value::String(resp.error), )])) }; + let content = + proto_tool_result_content_to_native(resp.content_blocks).map_err(|error| { + log::warn!( + "gRPC tool '{}' returned invalid ToolResult content: {error}", + self.name + ); + ToolError::Other { + message: "invalid tool result content from gRPC tool".to_string(), + } + })?; Ok(ToolResult { success: resp.success, output, error, + content, }) }) } diff --git a/crates/amplifier-core/src/bridges/wasm_orchestrator.rs b/crates/amplifier-core/src/bridges/wasm_orchestrator.rs index 73aefa33..dbf34dc5 100644 --- a/crates/amplifier-core/src/bridges/wasm_orchestrator.rs +++ b/crates/amplifier-core/src/bridges/wasm_orchestrator.rs @@ -544,6 +544,7 @@ mod tests { success: true, output: Some(serde_json::json!({"prompt": "hello from test"})), error: None, + content: None, }], )); coordinator.mount_tool("echo-tool", echo); diff --git a/crates/amplifier-core/src/bridges/wasm_tool.rs b/crates/amplifier-core/src/bridges/wasm_tool.rs index c10bf6ba..4db6eca0 100644 --- a/crates/amplifier-core/src/bridges/wasm_tool.rs +++ b/crates/amplifier-core/src/bridges/wasm_tool.rs @@ -168,6 +168,12 @@ impl WasmToolBridge { } } +fn deserialize_guest_tool_result(result_bytes: &[u8]) -> Result { + serde_json::from_slice(result_bytes).map_err(|_| ToolError::Other { + message: "invalid ToolResult returned by WASM tool".to_string(), + }) +} + impl Tool for WasmToolBridge { fn name(&self) -> &str { &self.name @@ -206,10 +212,7 @@ impl Tool for WasmToolBridge { message: format!("WASM execute failed: {e}"), })?; - let tool_result: ToolResult = - serde_json::from_slice(&result_bytes).map_err(|e| ToolError::Other { - message: format!("failed to deserialize ToolResult: {e}"), - })?; + let tool_result = deserialize_guest_tool_result(&result_bytes)?; Ok(tool_result) }) @@ -232,6 +235,18 @@ mod tests { let _: Arc = Arc::new(bridge); } + #[test] + fn wasm_host_rejects_invalid_guest_rich_content_without_echoing_it() { + let error = deserialize_guest_tool_result( + br#"{"content":[{"type":"thinking","thinking":"WASM-GUEST-SECRET"}]}"#, + ) + .unwrap_err() + .to_string(); + + assert_eq!(error, "invalid ToolResult returned by WASM tool"); + assert!(!error.contains("WASM-GUEST-SECRET")); + } + /// Helper: read the echo-tool.wasm fixture bytes. /// /// The fixture lives at the workspace root under `tests/fixtures/wasm/`. diff --git a/crates/amplifier-core/src/generated/amplifier.module.rs b/crates/amplifier-core/src/generated/amplifier.module.rs index d510a11f..7df472a3 100644 --- a/crates/amplifier-core/src/generated/amplifier.module.rs +++ b/crates/amplifier-core/src/generated/amplifier.module.rs @@ -33,6 +33,9 @@ pub struct ToolExecuteResponse { /// Error message if success is false. #[prost(string, tag = "4")] pub error: ::prost::alloc::string::String, + /// Canonical rich tool-result content. + #[prost(message, repeated, tag = "5")] + pub content_blocks: ::prost::alloc::vec::Vec, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct ModuleInfo { @@ -318,7 +321,7 @@ pub mod response_format { JsonSchema(super::JsonSchemaFormat), } } -#[derive(Clone, Copy, PartialEq, ::prost::Message)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct Usage { #[prost(int32, tag = "1")] pub prompt_tokens: i32, @@ -332,6 +335,8 @@ pub struct Usage { pub cache_read_tokens: ::core::option::Option, #[prost(int32, optional, tag = "6")] pub cache_creation_tokens: ::core::option::Option, + #[prost(string, optional, tag = "7")] + pub cost_usd: ::core::option::Option<::prost::alloc::string::String>, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct Degradation { @@ -403,6 +408,23 @@ pub struct ToolResult { pub output_json: ::prost::alloc::string::String, #[prost(string, tag = "3")] pub error_json: ::prost::alloc::string::String, + #[prost(message, repeated, tag = "4")] + pub content_blocks: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ContextInjection { + #[prost(string, tag = "1")] + pub content: ::prost::alloc::string::String, + #[prost(enumeration = "ContextInjectionRole", tag = "2")] + pub role: i32, + #[prost(bool, tag = "3")] + pub ephemeral: bool, + #[prost(bool, tag = "4")] + pub append_to_last_tool_result: bool, + #[prost(string, tag = "5")] + pub hook_name: ::prost::alloc::string::String, + #[prost(string, tag = "6")] + pub event: ::prost::alloc::string::String, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct HookResult { @@ -437,6 +459,8 @@ pub struct HookResult { pub user_message_source: ::prost::alloc::string::String, #[prost(bool, tag = "15")] pub append_to_last_tool_result: bool, + #[prost(message, repeated, tag = "16")] + pub context_injections: ::prost::alloc::vec::Vec, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct ModelInfo { @@ -452,6 +476,10 @@ pub struct ModelInfo { pub capabilities: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, #[prost(string, tag = "6")] pub defaults_json: ::prost::alloc::string::String, + /// JSON-encoded Pricing, or empty string if pricing is unavailable + /// (e.g., local providers, self-hosted backends). + #[prost(string, tag = "7")] + pub pricing_json: ::prost::alloc::string::String, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct ProviderInfo { diff --git a/crates/amplifier-core/src/generated/conversions.rs b/crates/amplifier-core/src/generated/conversions.rs index 39b06cfa..b3bf2e34 100644 --- a/crates/amplifier-core/src/generated/conversions.rs +++ b/crates/amplifier-core/src/generated/conversions.rs @@ -1,4 +1,4 @@ -//! Bidirectional `From` conversions between hand-written Rust types and +//! Bidirectional conversions between hand-written Rust types and //! proto-generated types, enabling zero-copy-style mapping across the gRPC //! boundary. @@ -26,9 +26,11 @@ fn from_json_or_default(json: &str, la // ToolResult conversions // --------------------------------------------------------------------------- -impl From for super::amplifier_module::ToolResult { - fn from(native: crate::models::ToolResult) -> Self { - Self { +impl TryFrom for super::amplifier_module::ToolResult { + type Error = crate::models::ToolResultContentError; + + fn try_from(native: crate::models::ToolResult) -> Result { + Ok(Self { success: native.success, output_json: native .output @@ -38,13 +40,16 @@ impl From for super::amplifier_module::ToolResult { .error .map(|e| to_json_or_warn(&e, "ToolResult error")) .unwrap_or_default(), - } + content_blocks: native_tool_result_content_to_proto(native.content)?, + }) } } -impl From for crate::models::ToolResult { - fn from(proto: super::amplifier_module::ToolResult) -> Self { - Self { +impl TryFrom for crate::models::ToolResult { + type Error = crate::models::ToolResultContentError; + + fn try_from(proto: super::amplifier_module::ToolResult) -> Result { + Ok(Self { success: proto.success, output: if proto.output_json.is_empty() { None @@ -66,7 +71,8 @@ impl From for crate::models::ToolResult { }) .ok() }, - } + content: proto_tool_result_content_to_native(proto.content_blocks)?, + }) } } @@ -95,6 +101,11 @@ impl From for super::amplifier_module::ModelInfo { }), capabilities: native.capabilities, defaults_json: to_json_or_warn(&native.defaults, "ModelInfo defaults"), + pricing_json: native + .pricing + .as_ref() + .map(|p| to_json_or_warn(p, "ModelInfo pricing")) + .unwrap_or_default(), } } } @@ -112,6 +123,19 @@ impl From for crate::models::ModelInfo { } else { from_json_or_default(&proto.defaults_json, "ModelInfo defaults_json") }, + pricing: if proto.pricing_json.is_empty() { + None + } else { + match serde_json::from_str::(&proto.pricing_json) { + Ok(p) => Some(p), + Err(e) => { + log::warn!( + "Failed to parse ModelInfo pricing_json: {e} — pricing unavailable" + ); + None + } + } + }, } } } @@ -168,6 +192,7 @@ impl From for super::amplifier_module::Usage { i32::MAX }) }), + cost_usd: native.cost_usd, } } } @@ -181,6 +206,7 @@ impl From for crate::messages::Usage { reasoning_tokens: proto.reasoning_tokens.map(i64::from), cache_read_tokens: proto.cache_read_tokens.map(i64::from), cache_write_tokens: proto.cache_creation_tokens.map(i64::from), + cost_usd: proto.cost_usd, extensions: HashMap::new(), } } @@ -257,6 +283,88 @@ fn proto_visibility_to_native(vis: i32) -> Option { // ContentBlock conversion helpers (private) // --------------------------------------------------------------------------- +/// Convert canonical ToolResult blocks without using the generic Message image +/// representation, which redundantly stores base64 data in `source_json`. +pub(crate) fn native_tool_result_content_to_proto( + content: Option, +) -> Result, crate::models::ToolResultContentError> { + use super::amplifier_module::content_block::Block; + use crate::messages::ContentBlock; + use base64::Engine; + + content + .iter() + .flat_map(crate::models::ToolResultContent::iter) + .map(|block| match block { + ContentBlock::Text { text, .. } => Ok(super::amplifier_module::ContentBlock { + block: Some(Block::TextBlock(super::amplifier_module::TextBlock { + text: text.clone(), + })), + visibility: 0, + }), + ContentBlock::Image { source, .. } => { + let media_type = source + .get("media_type") + .and_then(serde_json::Value::as_str) + .ok_or(crate::models::ToolResultContentError::InvalidImageMediaType)?; + let data = source + .get("data") + .and_then(serde_json::Value::as_str) + .ok_or(crate::models::ToolResultContentError::InvalidImageData)?; + let data = base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|_| crate::models::ToolResultContentError::InvalidImageData)?; + Ok(super::amplifier_module::ContentBlock { + block: Some(Block::ImageBlock(super::amplifier_module::ImageBlock { + media_type: media_type.to_string(), + data, + source_json: String::new(), + })), + visibility: 0, + }) + } + _ => Err(crate::models::ToolResultContentError::InvalidContent), + }) + .collect() +} + +/// Decode ToolResult-only protobuf blocks and validate them through the +/// canonical ToolResult ingress path. +pub(crate) fn proto_tool_result_content_to_native( + content: Vec, +) -> Result, crate::models::ToolResultContentError> { + use super::amplifier_module::content_block::Block; + use base64::Engine; + + let raw_content = content + .into_iter() + .map(|block| { + if block.visibility != 0 { + return Err(crate::models::ToolResultContentError::InvalidContent); + } + match block.block { + Some(Block::TextBlock(text)) => Ok(serde_json::json!({ + "type": "text", + "text": text.text, + })), + Some(Block::ImageBlock(image)) if image.source_json.is_empty() => { + Ok(serde_json::json!({ + "type": "image", + "source": { + "type": "base64", + "media_type": image.media_type, + "data": base64::engine::general_purpose::STANDARD.encode(image.data), + } + })) + } + _ => Err(crate::models::ToolResultContentError::InvalidContent), + } + }) + .collect::, _>>()?; + + crate::models::ToolResult::normalize_content(Some(raw_content)) +} + fn native_content_block_to_proto( block: crate::messages::ContentBlock, ) -> super::amplifier_module::ContentBlock { @@ -602,6 +710,27 @@ pub fn native_hook_result_to_proto( .map(|d| to_json_or_warn(d, "HookResult data")) .unwrap_or_default(); + let context_injections = result + .context_injections + .iter() + .map(|injection| amplifier_module::ContextInjection { + content: injection.content.clone(), + role: match injection.role { + ContextInjectionRole::System => { + amplifier_module::ContextInjectionRole::System as i32 + } + ContextInjectionRole::User => amplifier_module::ContextInjectionRole::User as i32, + ContextInjectionRole::Assistant => { + amplifier_module::ContextInjectionRole::Assistant as i32 + } + }, + ephemeral: injection.ephemeral, + append_to_last_tool_result: injection.append_to_last_tool_result, + hook_name: injection.hook_name.clone(), + event: injection.event.clone(), + }) + .collect(); + amplifier_module::HookResult { action, data_json, @@ -618,6 +747,7 @@ pub fn native_hook_result_to_proto( user_message_level, user_message_source: result.user_message_source.clone().unwrap_or_default(), append_to_last_tool_result: result.append_to_last_tool_result, + context_injections, } } @@ -999,9 +1129,28 @@ mod tests { success: true, output: Some(serde_json::json!({"key": "value"})), error: None, + content: crate::models::ToolResult::normalize_content(Some(vec![ + serde_json::json!({"type": "text", "text": "details"}), + serde_json::json!({ + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "AA==" + } + }), + ])) + .unwrap(), }; - let proto: super::super::amplifier_module::ToolResult = original.clone().into(); - let restored: crate::models::ToolResult = proto.into(); + let proto: super::super::amplifier_module::ToolResult = + original.clone().try_into().unwrap(); + let image = match proto.content_blocks[1].block.as_ref().unwrap() { + super::super::amplifier_module::content_block::Block::ImageBlock(image) => image, + _ => panic!("expected image block"), + }; + assert_eq!(image.data, vec![0]); + assert!(image.source_json.is_empty()); + let restored: crate::models::ToolResult = proto.try_into().unwrap(); assert_eq!(original, restored); } @@ -1014,12 +1163,54 @@ mod tests { "message".to_string(), serde_json::json!("something failed"), )])), + content: None, }; - let proto: super::super::amplifier_module::ToolResult = original.clone().into(); - let restored: crate::models::ToolResult = proto.into(); + let proto: super::super::amplifier_module::ToolResult = + original.clone().try_into().unwrap(); + let restored: crate::models::ToolResult = proto.try_into().unwrap(); assert_eq!(original, restored); } + #[test] + fn tool_result_proto_rejects_malformed_rich_content_without_dropping_it() { + let proto = super::super::amplifier_module::ToolResult { + success: true, + output_json: String::new(), + error_json: String::new(), + content_blocks: vec![super::super::amplifier_module::ContentBlock { + block: None, + visibility: 0, + }], + }; + + assert_eq!( + crate::models::ToolResult::try_from(proto), + Err(crate::models::ToolResultContentError::InvalidContent) + ); + + let noncanonical = super::super::amplifier_module::ToolResult { + success: true, + output_json: String::new(), + error_json: String::new(), + content_blocks: vec![super::super::amplifier_module::ContentBlock { + block: Some( + super::super::amplifier_module::content_block::Block::ImageBlock( + super::super::amplifier_module::ImageBlock { + media_type: "image/png".to_string(), + data: vec![0], + source_json: "unexpected".to_string(), + }, + ), + ), + visibility: 1, + }], + }; + assert_eq!( + crate::models::ToolResult::try_from(noncanonical), + Err(crate::models::ToolResultContentError::InvalidContent) + ); + } + #[test] fn model_info_roundtrip() { let original = crate::models::ModelInfo { @@ -1029,12 +1220,56 @@ mod tests { max_output_tokens: 8192, capabilities: vec!["tools".into(), "vision".into()], defaults: HashMap::from([("temperature".to_string(), serde_json::json!(0.7))]), + pricing: Some(crate::models::Pricing { + input_per_million: 30.0, + output_per_million: 60.0, + cache_read_per_million: None, + cache_write_per_million: None, + currency: "USD".into(), + }), }; let proto: super::super::amplifier_module::ModelInfo = original.clone().into(); let restored: crate::models::ModelInfo = proto.into(); assert_eq!(original, restored); } + #[test] + fn model_info_pricing_none_roundtrips_to_empty_json() { + let original = crate::models::ModelInfo { + id: "local-model".into(), + display_name: "Local Model".into(), + context_window: 8192, + max_output_tokens: 4096, + capabilities: vec![], + defaults: HashMap::new(), + pricing: None, + }; + let proto: super::super::amplifier_module::ModelInfo = original.clone().into(); + assert!(proto.pricing_json.is_empty()); + let restored: crate::models::ModelInfo = proto.into(); + assert_eq!(original, restored); + assert!(restored.pricing.is_none()); + } + + #[test] + fn model_info_pricing_invalid_json_becomes_none() { + let mut proto = super::super::amplifier_module::ModelInfo { + id: "broken-model".into(), + display_name: "Broken".into(), + context_window: 1000, + max_output_tokens: 100, + capabilities: vec![], + defaults_json: String::new(), + pricing_json: "not-valid-json".into(), + }; + let restored: crate::models::ModelInfo = proto.clone().into(); + assert!(restored.pricing.is_none()); + + proto.pricing_json = String::new(); + let restored_empty: crate::models::ModelInfo = proto.into(); + assert!(restored_empty.pricing.is_none()); + } + #[test] fn usage_roundtrip() { let original = crate::messages::Usage { @@ -1044,9 +1279,11 @@ mod tests { reasoning_tokens: Some(20), cache_read_tokens: Some(10), cache_write_tokens: None, // 0 in proto, None when restored + cost_usd: Some("0.000000000123456789".into()), extensions: HashMap::new(), }; let proto: super::super::amplifier_module::Usage = original.clone().into(); + assert_eq!(proto.cost_usd.as_deref(), Some("0.000000000123456789")); let restored: crate::messages::Usage = proto.into(); assert_eq!(original.input_tokens, restored.input_tokens); assert_eq!(original.output_tokens, restored.output_tokens); @@ -1055,6 +1292,7 @@ mod tests { assert_eq!(original.cache_read_tokens, restored.cache_read_tokens); // cache_write_tokens: None → None (optional proto preserves None) assert_eq!(restored.cache_write_tokens, None); + assert_eq!(restored.cost_usd.as_deref(), Some("0.000000000123456789")); // extensions are lost in proto roundtrip (proto has no extensions field) assert!(restored.extensions.is_empty()); } @@ -1068,9 +1306,11 @@ mod tests { reasoning_tokens: Some(50), cache_read_tokens: Some(30), cache_write_tokens: Some(20), + cost_usd: None, extensions: HashMap::new(), }; let proto: super::super::amplifier_module::Usage = original.clone().into(); + assert!(proto.cost_usd.is_none()); let restored: crate::messages::Usage = proto.into(); assert_eq!(original.input_tokens, restored.input_tokens); assert_eq!(original.output_tokens, restored.output_tokens); @@ -1078,6 +1318,7 @@ mod tests { assert_eq!(original.reasoning_tokens, restored.reasoning_tokens); assert_eq!(original.cache_read_tokens, restored.cache_read_tokens); assert_eq!(original.cache_write_tokens, restored.cache_write_tokens); + assert!(restored.cost_usd.is_none()); } /// Verify that `Some(0)` survives roundtrip now that proto uses `optional` fields. @@ -1090,9 +1331,11 @@ mod tests { reasoning_tokens: Some(0), cache_read_tokens: Some(0), cache_write_tokens: Some(0), + cost_usd: Some("0".into()), extensions: HashMap::new(), }; let proto: super::super::amplifier_module::Usage = original.clone().into(); + assert_eq!(proto.cost_usd.as_deref(), Some("0")); let restored: crate::messages::Usage = proto.into(); assert_eq!( restored.reasoning_tokens, @@ -1109,6 +1352,7 @@ mod tests { Some(0), "Some(0) cache_write_tokens must survive roundtrip" ); + assert_eq!(restored.cost_usd.as_deref(), Some("0")); } // -- E-3: ModelInfo i64→i32 overflow clamps to i32::MAX -- @@ -1122,6 +1366,7 @@ mod tests { max_output_tokens: 100, capabilities: vec![], defaults: HashMap::new(), + pricing: None, }; let proto: super::super::amplifier_module::ModelInfo = original.into(); assert_eq!(proto.context_window, i32::MAX); @@ -1136,6 +1381,7 @@ mod tests { max_output_tokens: i64::from(i32::MAX) + 500, capabilities: vec![], defaults: HashMap::new(), + pricing: None, }; let proto: super::super::amplifier_module::ModelInfo = original.into(); assert_eq!(proto.max_output_tokens, i32::MAX); @@ -1152,6 +1398,7 @@ mod tests { reasoning_tokens: None, cache_read_tokens: None, cache_write_tokens: None, + cost_usd: None, extensions: HashMap::new(), }; let proto: super::super::amplifier_module::Usage = original.into(); @@ -1167,6 +1414,7 @@ mod tests { reasoning_tokens: None, cache_read_tokens: None, cache_write_tokens: None, + cost_usd: None, extensions: HashMap::new(), }; let proto: super::super::amplifier_module::Usage = original.into(); @@ -1182,6 +1430,7 @@ mod tests { reasoning_tokens: None, cache_read_tokens: None, cache_write_tokens: None, + cost_usd: None, extensions: HashMap::new(), }; let proto: super::super::amplifier_module::Usage = original.into(); @@ -1868,6 +2117,7 @@ mod tests { reasoning_tokens: Some(50), cache_read_tokens: Some(20), cache_write_tokens: None, + cost_usd: None, extensions: HashMap::new(), }), degradation: Some(Degradation { @@ -2289,7 +2539,8 @@ mod tests { fn hook_result_roundtrip_via_bridge_reverse() { use crate::bridges::grpc_hook::GrpcHookBridge; use crate::models::{ - ApprovalDefault, ContextInjectionRole, HookAction, HookResult, UserMessageLevel, + ApprovalDefault, ContextInjection, ContextInjectionRole, HookAction, HookResult, + UserMessageLevel, }; let original = HookResult { @@ -2308,6 +2559,14 @@ mod tests { user_message_level: UserMessageLevel::Warning, user_message_source: Some("approval-hook".to_string()), append_to_last_tool_result: false, + context_injections: vec![ContextInjection { + content: "ordered injection".to_string(), + role: ContextInjectionRole::Assistant, + ephemeral: true, + append_to_last_tool_result: true, + hook_name: "registered-hook".to_string(), + event: "tool:post".to_string(), + }], extensions: HashMap::new(), }; @@ -2334,6 +2593,7 @@ mod tests { restored.append_to_last_tool_result, original.append_to_last_tool_result ); + assert_eq!(restored.context_injections, original.context_injections); } #[test] diff --git a/crates/amplifier-core/src/generated/equivalence_tests.rs b/crates/amplifier-core/src/generated/equivalence_tests.rs index 87b4775f..f2c3f80d 100644 --- a/crates/amplifier-core/src/generated/equivalence_tests.rs +++ b/crates/amplifier-core/src/generated/equivalence_tests.rs @@ -42,6 +42,7 @@ mod tests { success: true, output_json: r#"{"value": 42}"#.into(), error_json: String::new(), + content_blocks: vec![], }; assert!(result.success); assert_eq!(result.output_json, r#"{"value": 42}"#); @@ -52,6 +53,7 @@ mod tests { success: false, output_json: String::new(), error_json: r#"{"code":"NOT_FOUND"}"#.into(), + content_blocks: vec![], }; assert!(!err_result.success); assert!(err_result.output_json.is_empty()); @@ -59,7 +61,7 @@ mod tests { } #[test] - fn proto_hook_result_has_all_15_fields() { + fn proto_hook_result_has_all_16_fields() { let result = HookResult { action: HookAction::Modify as i32, data_json: r#"{"modified": true}"#.into(), @@ -76,6 +78,7 @@ mod tests { user_message_level: UserMessageLevel::Warning as i32, user_message_source: "content-filter".into(), append_to_last_tool_result: true, + context_injections: vec![], }; assert_eq!(result.action, HookAction::Modify as i32); assert_eq!(result.data_json, r#"{"modified": true}"#); @@ -176,6 +179,7 @@ mod tests { reasoning_tokens: Some(20), cache_read_tokens: Some(30), cache_creation_tokens: Some(10), + cost_usd: Some("0.000000000123456789".into()), }; assert_eq!(usage.prompt_tokens, 100); assert_eq!(usage.completion_tokens, 50); @@ -183,6 +187,7 @@ mod tests { assert_eq!(usage.reasoning_tokens, Some(20)); assert_eq!(usage.cache_read_tokens, Some(30)); assert_eq!(usage.cache_creation_tokens, Some(10)); + assert_eq!(usage.cost_usd.as_deref(), Some("0.000000000123456789")); } #[test] @@ -194,6 +199,8 @@ mod tests { max_output_tokens: 4096, capabilities: vec!["vision".into(), "tools".into(), "streaming".into()], defaults_json: r#"{"temperature":0.7}"#.into(), + pricing_json: + r#"{"input_per_million":15.0,"output_per_million":75.0,"currency":"USD"}"#.into(), }; assert_eq!(info.id, "claude-3-opus"); assert_eq!(info.display_name, "Claude 3 Opus"); @@ -201,6 +208,10 @@ mod tests { assert_eq!(info.max_output_tokens, 4096); assert_eq!(info.capabilities.len(), 3); assert_eq!(info.defaults_json, r#"{"temperature":0.7}"#); + assert_eq!( + info.pricing_json, + r#"{"input_per_million":15.0,"output_per_million":75.0,"currency":"USD"}"# + ); } #[test] diff --git a/crates/amplifier-core/src/grpc_server.rs b/crates/amplifier-core/src/grpc_server.rs index 54d75738..f8351557 100644 --- a/crates/amplifier-core/src/grpc_server.rs +++ b/crates/amplifier-core/src/grpc_server.rs @@ -227,29 +227,11 @@ impl KernelService for KernelServiceImpl { // Execute the tool match tool.execute(input).await { Ok(result) => { - let output_json = result - .output - .map(|v| { - serde_json::to_string(&v).unwrap_or_else(|e| { - log::warn!("Failed to serialize tool result output to JSON: {e}"); - String::new() - }) - }) - .unwrap_or_default(); - let error_json = result - .error - .map(|e| { - serde_json::to_string(&e).unwrap_or_else(|ser_err| { - log::warn!("Failed to serialize tool result error to JSON: {ser_err}"); - String::new() - }) - }) - .unwrap_or_default(); - Ok(Response::new(amplifier_module::ToolResult { - success: result.success, - output_json, - error_json, - })) + let response = result.try_into().map_err(|error| { + log::warn!("Invalid canonical ToolResult content for {tool_name}: {error}"); + Status::internal("Invalid tool result content") + })?; + Ok(Response::new(response)) } Err(e) => { log::error!("Tool execution failed for {tool_name}: {e}"); @@ -544,6 +526,102 @@ impl KernelService for KernelServiceImpl { #[cfg(test)] mod tests { use super::*; + use std::collections::HashMap; + use std::future::Future; + use std::pin::Pin; + + struct CostProvider { + cost_usd: Option, + } + + impl CostProvider { + fn new(cost_usd: Option<&str>) -> Self { + Self { + cost_usd: cost_usd.map(String::from), + } + } + } + + impl crate::traits::Provider for CostProvider { + fn name(&self) -> &str { + "cost-provider" + } + + fn get_info(&self) -> crate::models::ProviderInfo { + crate::models::ProviderInfo { + id: self.name().into(), + display_name: "Cost Provider".into(), + credential_env_vars: Vec::new(), + capabilities: Vec::new(), + defaults: HashMap::new(), + config_fields: Vec::new(), + } + } + + fn list_models( + &self, + ) -> Pin< + Box< + dyn Future< + Output = Result< + Vec, + crate::errors::ProviderError, + >, + > + Send + + '_, + >, + > { + Box::pin(async { Ok(Vec::new()) }) + } + + fn complete( + &self, + _request: crate::messages::ChatRequest, + ) -> Pin< + Box< + dyn Future< + Output = Result< + crate::messages::ChatResponse, + crate::errors::ProviderError, + >, + > + Send + + '_, + >, + > { + let cost_usd = self.cost_usd.clone(); + Box::pin(async move { + Ok(crate::messages::ChatResponse { + content: vec![crate::messages::ContentBlock::Text { + text: "response with provider-reported cost".into(), + visibility: None, + extensions: HashMap::new(), + }], + tool_calls: None, + usage: Some(crate::messages::Usage { + input_tokens: 10, + output_tokens: 5, + total_tokens: 15, + reasoning_tokens: None, + cache_read_tokens: None, + cache_write_tokens: None, + cost_usd, + extensions: HashMap::new(), + }), + degradation: None, + finish_reason: Some("stop".into()), + metadata: None, + extensions: HashMap::new(), + }) + }) + } + + fn parse_tool_calls( + &self, + response: &crate::messages::ChatResponse, + ) -> Vec { + response.tool_calls.clone().unwrap_or_default() + } + } #[test] fn kernel_service_impl_compiles() { @@ -1375,6 +1453,104 @@ mod tests { ); } + #[tokio::test] + async fn generated_grpc_client_preserves_provider_reported_cost_for_unary_and_streaming() { + use crate::generated::amplifier_module::kernel_service_client::KernelServiceClient; + use crate::generated::amplifier_module::kernel_service_server::KernelServiceServer; + use tokio::sync::oneshot; + use tokio_stream::wrappers::TcpListenerStream; + use tonic::transport::Server; + + let coord = Arc::new(Coordinator::new(Default::default())); + coord.mount_provider( + "with-cost", + Arc::new(CostProvider::new(Some("0.000000000123456789"))), + ); + coord.mount_provider("without-cost", Arc::new(CostProvider::new(None))); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("test server must bind"); + let address = listener + .local_addr() + .expect("test server must have an address"); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let server = tokio::spawn(async move { + Server::builder() + .add_service(KernelServiceServer::new(KernelServiceImpl::new(coord))) + .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async { + let _ = shutdown_rx.await; + }) + .await + }); + + let mut client = KernelServiceClient::connect(format!("http://{address}")) + .await + .expect("generated client must connect to test server"); + let request = amplifier_module::CompleteWithProviderRequest { + provider_name: "with-cost".into(), + request: Some(make_chat_request("ping")), + }; + + let unary = client + .complete_with_provider(request.clone()) + .await + .expect("unary RPC must succeed") + .into_inner(); + assert_eq!( + unary.usage.and_then(|usage| usage.cost_usd), + Some("0.000000000123456789".into()) + ); + + let mut stream = client + .complete_with_provider_streaming(request) + .await + .expect("one-shot streaming RPC must succeed") + .into_inner(); + let streamed = stream + .message() + .await + .expect("stream must not fail") + .expect("stream must contain one response"); + assert_eq!( + streamed.usage.and_then(|usage| usage.cost_usd), + Some("0.000000000123456789".into()) + ); + assert!( + stream + .message() + .await + .expect("stream must not fail") + .is_none(), + "one-shot streaming RPC must end after one response" + ); + + let absent_cost = client + .complete_with_provider(amplifier_module::CompleteWithProviderRequest { + provider_name: "without-cost".into(), + request: Some(make_chat_request("ping")), + }) + .await + .expect("unary RPC without cost must succeed") + .into_inner(); + assert!( + absent_cost + .usage + .expect("provider returned usage") + .cost_usd + .is_none(), + "None cost must remain absent in protobuf" + ); + + shutdown_tx + .send(()) + .expect("test server must still accept shutdown"); + server + .await + .expect("test server task must not panic") + .expect("test server must shut down cleanly"); + } + #[tokio::test] async fn complete_with_provider_not_found_returns_not_found_status() { let coord = Arc::new(Coordinator::new(Default::default())); @@ -1526,6 +1702,82 @@ mod tests { // H-07: JSON payload size limits // ----------------------------------------------------------------------- + #[tokio::test] + async fn execute_tool_transports_canonical_rich_content() { + struct RichTool; + + impl crate::traits::Tool for RichTool { + fn name(&self) -> &str { + "rich-tool" + } + + fn description(&self) -> &str { + "Returns canonical rich content" + } + + fn get_spec(&self) -> crate::messages::ToolSpec { + crate::messages::ToolSpec { + name: self.name().to_string(), + parameters: std::collections::HashMap::new(), + description: Some(self.description().to_string()), + extensions: std::collections::HashMap::new(), + } + } + + fn execute( + &self, + _input: serde_json::Value, + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = Result, + > + Send + + '_, + >, + > { + Box::pin(async { + Ok(crate::models::ToolResult { + success: true, + output: None, + error: None, + content: crate::models::ToolResult::normalize_content(Some(vec![ + serde_json::json!({"type": "text", "text": "details"}), + serde_json::json!({ + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "AA==" + } + }), + ])) + .unwrap(), + }) + }) + } + } + + let coord = Arc::new(Coordinator::new(Default::default())); + coord.mount_tool("rich-tool", Arc::new(RichTool)); + let service = KernelServiceImpl::new(coord); + let response = service + .execute_tool(Request::new(amplifier_module::ExecuteToolRequest { + tool_name: "rich-tool".to_string(), + input_json: "{}".to_string(), + })) + .await + .unwrap() + .into_inner(); + + assert_eq!(response.content_blocks.len(), 2); + let image = match response.content_blocks[1].block.as_ref().unwrap() { + amplifier_module::content_block::Block::ImageBlock(image) => image, + _ => panic!("expected image block"), + }; + assert_eq!(image.data, vec![0]); + assert!(image.source_json.is_empty()); + } + #[tokio::test] async fn execute_tool_rejects_oversized_input_json() { use crate::testing::FakeTool; diff --git a/crates/amplifier-core/src/hooks.rs b/crates/amplifier-core/src/hooks.rs index d10b113d..f212de40 100644 --- a/crates/amplifier-core/src/hooks.rs +++ b/crates/amplifier-core/src/hooks.rs @@ -31,7 +31,7 @@ use std::time::Duration; use serde_json::Value; -use crate::models::{HookAction, HookResult}; +use crate::models::{ContextInjection, HookAction, HookResult}; use crate::traits::HookHandler; // --------------------------------------------------------------------------- @@ -209,7 +209,7 @@ impl HookRegistry { let mut inject_context_results: Vec = Vec::new(); for (handler, name) in &entries { - let result = match handler.handle(event, current_data.clone()).await { + let mut result = match handler.handle(event, current_data.clone()).await { Ok(r) => r, Err(e) => { // Error in handler -- log and continue (matches Python behaviour). @@ -222,6 +222,8 @@ impl HookRegistry { } }; + normalize_inject_context_result(&mut result, name, event); + // Deny short-circuits immediately if result.action == HookAction::Deny { return result; @@ -241,7 +243,7 @@ impl HookRegistry { } // Collect inject_context for merging at end - if result.action == HookAction::InjectContext && result.context_injection.is_some() { + if result.action == HookAction::InjectContext && !result.context_injections.is_empty() { inject_context_results.push(result.clone()); } @@ -402,58 +404,111 @@ fn value_to_map(value: &Value) -> HashMap { /// Merge multiple inject_context HookResults into a single result. /// -/// Combines injections with `"\n\n"` separator. `context_injection_role` and -/// `suppress_output` are taken from the first result (these are display/role -/// settings where "first wins" is a reasonable, harmless default). `ephemeral` -/// and `append_to_last_tool_result` instead use OR semantics across ALL -/// results, not just the first: -/// -/// `ephemeral` in particular MUST be the logical OR of every contributing -/// result, not `first.ephemeral`. The combined `context_injection` string is -/// the concatenation of every hook's content -- if even one of those hooks -/// marked its own contribution `ephemeral: true` (regenerated per turn), the -/// resulting combined string is regenerated per turn too, byte-for-byte -/// identical only when ALL contributors are stable. Taking only the first -/// result's `ephemeral` flag meant a single non-ephemeral hook running before -/// an ephemeral one (pure registration-order luck) would silently downgrade -/// the merged result to `ephemeral: false` -- causing every downstream -/// consumer that trusts `Message.metadata["ephemeral"]` (e.g. the Anthropic -/// provider's conversation-region prompt-cache breakpoint placement) to lose -/// the ephemeral signal for the ENTIRE combined injection, not just the -/// stable part. See amplifier_module_provider_anthropic's -/// `_count_trailing_ephemeral_messages` / `_apply_conversation_cache_control`. +/// Keeps the ordered list lossless and projects the legacy scalar fields from +/// every item: bodies are joined with `"\n\n"`, the first role wins, and both +/// boolean flags use OR semantics. fn merge_inject_context_results(results: &[HookResult]) -> HookResult { if results.is_empty() { return HookResult::default(); } - if results.len() == 1 { - return results[0].clone(); + let context_injections: Vec = results + .iter() + .flat_map(|result| { + if result.context_injections.is_empty() { + result + .context_injection + .as_ref() + .map(|content| { + vec![ContextInjection { + content: content.clone(), + role: result.context_injection_role.clone(), + ephemeral: result.ephemeral, + append_to_last_tool_result: result.append_to_last_tool_result, + hook_name: "unknown".to_string(), + event: String::new(), + }] + }) + .unwrap_or_default() + } else { + result.context_injections.clone() + } + }) + .collect(); + + if context_injections.is_empty() { + return HookResult::default(); } - // Combine all injections - let combined_content: String = results + let combined_content = context_injections .iter() - .filter_map(|r| r.context_injection.as_deref()) + .map(|injection| injection.content.as_str()) .collect::>() .join("\n\n"); - - // role/suppress_output: "first wins" -- harmless display-only settings. let first = &results[0]; + let first_injection = &context_injections[0]; + + // A single handler may return multiple structured injections. This is + // normalization, not aggregation: retain the handler's other result-level + // fields (including UI and approval metadata) while updating only the + // legacy injection projection. + if results.len() == 1 { + let mut result = first.clone(); + result.action = HookAction::InjectContext; + result.context_injection = Some(combined_content); + result.context_injection_role = first_injection.role.clone(); + result.ephemeral = context_injections + .iter() + .any(|injection| injection.ephemeral); + result.append_to_last_tool_result = context_injections + .iter() + .any(|injection| injection.append_to_last_tool_result); + result.context_injections = context_injections; + return result; + } HookResult { action: HookAction::InjectContext, context_injection: Some(combined_content), - context_injection_role: first.context_injection_role.clone(), - // OR semantics: ANY contributing result marking itself ephemeral - // makes the whole merged injection ephemeral (see doc comment above). - ephemeral: results.iter().any(|r| r.ephemeral), + context_injection_role: first_injection.role.clone(), + ephemeral: context_injections + .iter() + .any(|injection| injection.ephemeral), suppress_output: first.suppress_output, - append_to_last_tool_result: results.iter().any(|r| r.append_to_last_tool_result), + append_to_last_tool_result: context_injections + .iter() + .any(|injection| injection.append_to_last_tool_result), + context_injections, ..Default::default() } } +/// Bind injection provenance at the trusted registration boundary and promote +/// legacy scalar injections into the structured carrier. +fn normalize_inject_context_result(result: &mut HookResult, handler_name: &str, event: &str) { + if result.action != HookAction::InjectContext { + return; + } + + if result.context_injections.is_empty() { + if let Some(content) = result.context_injection.clone() { + result.context_injections.push(ContextInjection { + content, + role: result.context_injection_role.clone(), + ephemeral: result.ephemeral, + append_to_last_tool_result: result.append_to_last_tool_result, + hook_name: handler_name.to_string(), + event: event.to_string(), + }); + } + } + + for injection in &mut result.context_injections { + injection.hook_name = handler_name.to_string(); + injection.event = event.to_string(); + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -830,6 +885,91 @@ mod tests { assert!(injection.contains("second injection")); } + #[tokio::test] + async fn single_structured_injection_result_preserves_non_injection_fields() { + use crate::models::{ApprovalDefault, ContextInjectionRole, UserMessageLevel}; + + let registry = HookRegistry::new(); + let original_data = HashMap::from([("keep".to_string(), serde_json::json!(true))]); + let original_extensions = + HashMap::from([("future_field".to_string(), serde_json::json!("preserved"))]); + let handler = Arc::new(SimpleHandler(HookResult { + action: HookAction::InjectContext, + data: Some(original_data.clone()), + reason: Some("keep reason".into()), + // The scalar fields are intentionally stale: the list is canonical. + context_injection: Some("stale scalar".into()), + context_injection_role: ContextInjectionRole::System, + ephemeral: false, + approval_prompt: Some("keep approval".into()), + approval_options: Some(vec!["allow".into(), "deny".into()]), + approval_timeout: 42.0, + approval_default: ApprovalDefault::Allow, + suppress_output: true, + user_message: Some("Context injected".into()), + user_message_level: UserMessageLevel::Warning, + user_message_source: Some("status-context".into()), + append_to_last_tool_result: false, + context_injections: vec![ + ContextInjection { + content: "first".into(), + role: ContextInjectionRole::User, + ephemeral: false, + append_to_last_tool_result: false, + hook_name: "untrusted".into(), + event: "untrusted".into(), + }, + ContextInjection { + content: "second".into(), + role: ContextInjectionRole::Assistant, + ephemeral: true, + append_to_last_tool_result: true, + hook_name: "untrusted".into(), + event: "untrusted".into(), + }, + ], + extensions: original_extensions.clone(), + })); + let _ = registry.register( + "test:structured", + handler, + 0, + Some("structured-hook".into()), + ); + + let result = registry + .emit("test:structured", serde_json::json!({})) + .await; + + assert_eq!(result.action, HookAction::InjectContext); + assert_eq!(result.context_injection.as_deref(), Some("first\n\nsecond")); + assert_eq!(result.context_injection_role, ContextInjectionRole::User); + assert!(result.ephemeral); + assert!(result.append_to_last_tool_result); + assert_eq!(result.context_injections.len(), 2); + assert_eq!(result.context_injections[0].hook_name, "structured-hook"); + assert_eq!(result.context_injections[0].event, "test:structured"); + assert_eq!(result.context_injections[1].hook_name, "structured-hook"); + assert_eq!(result.context_injections[1].event, "test:structured"); + assert_eq!(result.data, Some(original_data)); + assert_eq!(result.reason.as_deref(), Some("keep reason")); + assert_eq!(result.approval_prompt.as_deref(), Some("keep approval")); + assert_eq!( + result.approval_options, + Some(vec!["allow".into(), "deny".into()]) + ); + assert_eq!(result.approval_timeout, 42.0); + assert_eq!(result.approval_default, ApprovalDefault::Allow); + assert!(result.suppress_output); + assert_eq!(result.user_message.as_deref(), Some("Context injected")); + assert_eq!(result.user_message_level, UserMessageLevel::Warning); + assert_eq!( + result.user_message_source.as_deref(), + Some("status-context") + ); + assert_eq!(result.extensions, original_extensions); + } + // --------------------------------------------------------------- // Unregister // --------------------------------------------------------------- @@ -1257,6 +1397,39 @@ mod tests { ); } + #[tokio::test] + async fn registered_mixed_legacy_injections_preserve_item_lifetime() { + let registry = HookRegistry::new(); + let durable = Arc::new(SimpleHandler(HookResult { + action: HookAction::InjectContext, + context_injection: Some("durable".into()), + context_injection_role: crate::models::ContextInjectionRole::Assistant, + ephemeral: false, + ..Default::default() + })); + let temporary = Arc::new(SimpleHandler(HookResult { + action: HookAction::InjectContext, + context_injection: Some("temporary".into()), + context_injection_role: crate::models::ContextInjectionRole::User, + ephemeral: true, + ..Default::default() + })); + + let _ = registry.register("test:mixed", durable, 0, Some("durable-hook".into())); + let _ = registry.register("test:mixed", temporary, 10, Some("temporary-hook".into())); + + let result = registry.emit("test:mixed", serde_json::json!({})).await; + assert_eq!(result.context_injections.len(), 2); + assert_eq!(result.context_injections[0].content, "durable"); + assert!(!result.context_injections[0].ephemeral); + assert_eq!(result.context_injections[0].hook_name, "durable-hook"); + assert_eq!(result.context_injections[0].event, "test:mixed"); + assert_eq!(result.context_injections[1].content, "temporary"); + assert!(result.context_injections[1].ephemeral); + assert_eq!(result.context_injections[1].hook_name, "temporary-hook"); + assert_eq!(result.context_injections[1].event, "test:mixed"); + } + /// Verify that the `log` crate is available and usable from amplifier-core. /// This ensures Rust code can emit structured log messages that will be /// routed through Python's logging module via pyo3-log in the bindings. @@ -1282,15 +1455,19 @@ mod tests { static LOG_MESSAGES: OnceLock>> = OnceLock::new(); impl log::Log for TestLogger { - fn enabled(&self, _metadata: &log::Metadata) -> bool { - true + fn enabled(&self, metadata: &log::Metadata) -> bool { + metadata.target().starts_with("amplifier_core::hooks") + && metadata.level() <= log::Level::Warn } fn log(&self, record: &log::Record) { + if !self.enabled(record.metadata()) { + return; + } + // Display implementations may themselves log. Never format while + // holding the non-reentrant capture mutex. + let message = format!("{}", record.args()); let messages = LOG_MESSAGES.get_or_init(|| Mutex::new(Vec::new())); - messages - .lock() - .unwrap() - .push((record.level(), format!("{}", record.args()))); + messages.lock().unwrap().push((record.level(), message)); } fn flush(&self) {} } @@ -1299,7 +1476,68 @@ mod tests { fn install_test_logger() { // set_logger is one-shot per process; ignore AlreadySet errors - let _ = log::set_logger(&TEST_LOGGER).map(|()| log::set_max_level(log::LevelFilter::Trace)); + let _ = log::set_logger(&TEST_LOGGER).map(|()| log::set_max_level(log::LevelFilter::Warn)); + } + + #[test] + fn test_logger_formats_reentrant_messages_before_locking() { + use log::Log; + use std::cell::Cell; + use std::fmt; + + struct ReentrantDisplay<'a>(&'a Cell); + + impl fmt::Display for ReentrantDisplay<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + TEST_LOGGER.log( + &log::Record::builder() + .level(log::Level::Warn) + .target("amplifier_core::hooks") + .args(format_args!("nested formatting regression")) + .build(), + ); + self.0.set(true); + formatter.write_str("outer formatting regression") + } + } + + let nested_returned = Cell::new(false); + TEST_LOGGER.log( + &log::Record::builder() + .level(log::Level::Warn) + .target("amplifier_core::hooks") + .args(format_args!("{}", ReentrantDisplay(&nested_returned))) + .build(), + ); + assert!(nested_returned.get(), "nested logging must return"); + } + + #[test] + fn test_logger_ignores_dependency_records_without_formatting() { + use log::Log; + use std::fmt; + + struct MustNotFormat; + + impl fmt::Display for MustNotFormat { + fn fmt(&self, _: &mut fmt::Formatter<'_>) -> fmt::Result { + panic!("dependency log arguments must not be formatted"); + } + } + + assert!(!TEST_LOGGER.enabled( + &log::Metadata::builder() + .level(log::Level::Warn) + .target("cranelift_codegen") + .build() + )); + TEST_LOGGER.log( + &log::Record::builder() + .level(log::Level::Warn) + .target("cranelift_codegen") + .args(format_args!("{}", MustNotFormat)) + .build(), + ); } fn clear_captured_logs() { diff --git a/crates/amplifier-core/src/lib.rs b/crates/amplifier-core/src/lib.rs index 33909879..02e52f76 100644 --- a/crates/amplifier-core/src/lib.rs +++ b/crates/amplifier-core/src/lib.rs @@ -51,8 +51,9 @@ pub use errors::{AmplifierError, ContextError, HookError, ProviderError, Session // Core data models pub use models::{ ApprovalDefault, ApprovalRequest, ApprovalResponse, ConfigField, ConfigFieldType, - ContextInjectionRole, HookAction, HookResult, ModelInfo, ModuleInfo, ModuleType, ProviderInfo, - SessionState, SessionStatus, ToolResult, UserMessageLevel, + ContextInjection, ContextInjectionRole, HookAction, HookResult, ModelInfo, ModuleInfo, + ModuleType, ProviderInfo, SessionState, SessionStatus, ToolResult, ToolResultContent, + ToolResultContentError, UserMessageLevel, }; // Chat protocol models diff --git a/crates/amplifier-core/src/messages.rs b/crates/amplifier-core/src/messages.rs index bb0e746d..4a5b2a33 100644 --- a/crates/amplifier-core/src/messages.rs +++ b/crates/amplifier-core/src/messages.rs @@ -275,6 +275,8 @@ pub struct Usage { pub cache_read_tokens: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cache_write_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost_usd: Option, #[serde(flatten)] pub extensions: HashMap, } @@ -936,6 +938,7 @@ mod tests { reasoning_tokens: Some(20), cache_read_tokens: None, cache_write_tokens: None, + cost_usd: Some("0.000000000123456789".into()), extensions: HashMap::new(), }; let json = serde_json::to_value(&usage).unwrap(); @@ -943,6 +946,7 @@ mod tests { assert_eq!(json["output_tokens"], 50); assert_eq!(json["total_tokens"], 150); assert_eq!(json["reasoning_tokens"], 20); + assert_eq!(json["cost_usd"], "0.000000000123456789"); assert!(json.get("cache_read_tokens").is_none()); let deserialized: Usage = serde_json::from_value(json).unwrap(); assert_eq!(deserialized, usage); @@ -1003,6 +1007,7 @@ mod tests { reasoning_tokens: None, cache_read_tokens: None, cache_write_tokens: None, + cost_usd: Some("0".into()), extensions: HashMap::new(), }), degradation: None, @@ -1015,6 +1020,7 @@ mod tests { assert_eq!(json["content"][0]["type"], "text"); assert_eq!(json["content"][0]["text"], "Hello!"); assert_eq!(json["usage"]["input_tokens"], 10); + assert_eq!(json["usage"]["cost_usd"], "0"); assert_eq!(json["finish_reason"], "stop"); assert!(json.get("tool_calls").is_none()); assert!(json.get("degradation").is_none()); diff --git a/crates/amplifier-core/src/models.rs b/crates/amplifier-core/src/models.rs index 266c62e7..79bec4a4 100644 --- a/crates/amplifier-core/src/models.rs +++ b/crates/amplifier-core/src/models.rs @@ -6,9 +6,12 @@ use std::collections::HashMap; +use base64::Engine; use serde::{Deserialize, Serialize}; use serde_json::Value; +use crate::messages::ContentBlock; + // --------------------------------------------------------------------------- // Enums // --------------------------------------------------------------------------- @@ -106,6 +109,40 @@ pub enum SessionState { // Structs // --------------------------------------------------------------------------- +/// One independently processed context injection from a hook. +/// +/// The registry binds `hook_name` and `event` from the registered handler that +/// emitted this item. Consumers must not rely on handler-provided provenance. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContextInjection { + /// Text to inject into the agent's conversation context. + pub content: String, + + /// Role for the injected message in conversation. + #[serde(default)] + pub role: ContextInjectionRole, + + /// Whether this injection is request-only rather than durable history. + #[serde(default)] + pub ephemeral: bool, + + /// Carrier for post-tool-result placement; interpreted by the loop layer. + #[serde(default)] + pub append_to_last_tool_result: bool, + + /// Registered handler name, bound by the hook registry. + #[serde(default = "default_unknown_hook_name")] + pub hook_name: String, + + /// Event that emitted the injection, bound by the hook registry. + #[serde(default)] + pub event: String, +} + +fn default_unknown_hook_name() -> String { + "unknown".to_string() +} + /// Result from hook execution with enhanced capabilities. /// /// Hooks can observe, block, modify operations, inject context to the agent, @@ -187,6 +224,13 @@ pub struct HookResult { #[serde(default)] pub append_to_last_tool_result: bool, + /// Ordered, lossless context injection items. + /// + /// The legacy scalar context fields remain for backwards compatibility and + /// are projected from this list by the hook registry. + #[serde(default)] + pub context_injections: Vec, + /// Extension fields for forward-compatibility. /// Captures any unknown JSON keys during deserialization. #[serde(flatten)] @@ -215,6 +259,7 @@ impl Default for HookResult { user_message_level: UserMessageLevel::default(), user_message_source: None, append_to_last_tool_result: false, + context_injections: Vec::new(), extensions: HashMap::new(), } } @@ -234,6 +279,14 @@ pub struct ToolResult { /// Error details if failed. #[serde(default)] pub error: Option>, + + /// Canonical rich tool-result content. + #[serde( + default, + deserialize_with = "deserialize_tool_result_content", + skip_serializing_if = "Option::is_none" + )] + pub content: Option, } fn default_true() -> bool { @@ -246,10 +299,59 @@ impl Default for ToolResult { success: true, output: None, error: None, + content: None, } } } +/// A fixed, input-safe error returned when rich tool-result content is invalid. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ToolResultContentError { + #[error("invalid tool result content")] + InvalidContent, + #[error("invalid tool result image source")] + InvalidImageSource, + #[error("invalid tool result image media type")] + InvalidImageMediaType, + #[error("invalid tool result image data")] + InvalidImageData, +} + +/// A non-empty, ordered collection of canonical ToolResult content blocks. +/// +/// The wrapped vector is deliberately private: only [`ToolResult::normalize_content`] +/// may create this type, so generic message blocks, URL images, extensions, and +/// empty collections cannot cross the ToolResult boundary. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(transparent)] +pub struct ToolResultContent(Vec); + +impl ToolResultContent { + /// Iterate over the canonical blocks without exposing mutable or owned storage. + pub fn iter(&self) -> impl Iterator { + self.0.iter() + } +} + +/// Deserialize ToolResult content through its strict canonicalization boundary. +/// +/// Decode as a JSON value first so type errors cannot include untrusted image data +/// in the error returned to callers. +fn deserialize_tool_result_content<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let raw_content = Option::::deserialize(deserializer)?; + let raw_content = match raw_content { + None => None, + Some(Value::Array(content)) => Some(content), + Some(_) => return Err(serde::de::Error::custom("invalid tool result content")), + }; + ToolResult::normalize_content(raw_content).map_err(serde::de::Error::custom) +} + impl ToolResult { /// Create a new ToolResult with auto-populate behavior. /// When success=false and output is None, auto-populates output @@ -263,11 +365,132 @@ impl ToolResult { success, output, error, + content: None, }; result.auto_populate_output(); result } + /// Strictly validate and canonicalize untrusted rich tool-result content. + /// + /// Only text and base64 image blocks are accepted. Unknown block/source + /// fields are deliberately dropped at this boundary. + pub fn normalize_content( + content: Option>, + ) -> Result, ToolResultContentError> { + let Some(content) = content else { + return Ok(None); + }; + if content.is_empty() { + return Ok(None); + } + + let mut normalized = Vec::with_capacity(content.len()); + for block in content { + let object = block + .as_object() + .ok_or(ToolResultContentError::InvalidContent)?; + let block_type = object + .get("type") + .and_then(Value::as_str) + .ok_or(ToolResultContentError::InvalidContent)?; + + match block_type { + "text" => { + let text = object + .get("text") + .and_then(Value::as_str) + .ok_or(ToolResultContentError::InvalidContent)?; + normalized.push(ContentBlock::Text { + text: text.to_string(), + visibility: None, + extensions: HashMap::new(), + }); + } + "image" => { + let source = object + .get("source") + .and_then(Value::as_object) + .ok_or(ToolResultContentError::InvalidImageSource)?; + if source.get("type").and_then(Value::as_str) != Some("base64") { + return Err(ToolResultContentError::InvalidImageSource); + } + let media_type = source + .get("media_type") + .and_then(Value::as_str) + .ok_or(ToolResultContentError::InvalidImageMediaType)?; + if !valid_image_media_type(media_type) { + return Err(ToolResultContentError::InvalidImageMediaType); + } + let data = source + .get("data") + .and_then(Value::as_str) + .ok_or(ToolResultContentError::InvalidImageData)?; + if data.is_empty() { + return Err(ToolResultContentError::InvalidImageData); + } + let decoded = base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|_| ToolResultContentError::InvalidImageData)?; + if base64::engine::general_purpose::STANDARD.encode(decoded) != data { + return Err(ToolResultContentError::InvalidImageData); + } + normalized.push(ContentBlock::Image { + source: HashMap::from([ + ("type".to_string(), Value::String("base64".to_string())), + ( + "media_type".to_string(), + Value::String(media_type.to_string()), + ), + ("data".to_string(), Value::String(data.to_string())), + ]), + visibility: None, + extensions: HashMap::new(), + }); + } + _ => return Err(ToolResultContentError::InvalidContent), + } + } + + Ok(Some(ToolResultContent(normalized))) + } + + /// Produce an image-safe representation for hook event payloads. + pub fn safe_hook_presentation(&self) -> Value { + let mut result = serde_json::Map::new(); + result.insert("success".to_string(), Value::Bool(self.success)); + result.insert( + "output".to_string(), + self.output.clone().unwrap_or(Value::Null), + ); + result.insert( + "error".to_string(), + self.error + .as_ref() + .and_then(|error| serde_json::to_value(error).ok()) + .unwrap_or(Value::Null), + ); + + if let Some(content) = &self.content { + let safe_content: Vec = content + .iter() + .filter_map(|block| match block { + ContentBlock::Text { text, .. } => { + Some(serde_json::json!({"type": "text", "text": text})) + } + ContentBlock::Image { .. } => Some(serde_json::json!({ + "type": "text", + "text": "[Image omitted from tool event; original retained for model request if unmodified.]" + })), + _ => None, + }) + .collect(); + result.insert("content".to_string(), Value::Array(safe_content)); + } + + Value::Object(result) + } + /// Auto-populate output from error message when tools forget to set it. fn auto_populate_output(&mut self) { if !self.success && self.output.is_none() { @@ -280,6 +503,24 @@ impl ToolResult { } } +fn valid_image_media_type(media_type: &str) -> bool { + let Some(subtype) = media_type.strip_prefix("image/") else { + return false; + }; + let mut chars = subtype.chars(); + let Some(first) = chars.next() else { + return false; + }; + first.is_ascii_alphanumeric() + && chars.all(|character| { + character.is_ascii_alphanumeric() + || matches!( + character, + '!' | '#' | '$' | '&' | '^' | '_' | '.' | '+' | '-' + ) + }) +} + /// Model metadata for provider models. /// /// Describes capabilities and defaults for a specific model available from a provider. @@ -304,6 +545,11 @@ pub struct ModelInfo { /// Model-specific default config values (e.g., temperature, max_tokens). #[serde(default)] pub defaults: HashMap, + + /// Per-model pricing information. None when pricing is not available + /// (e.g., local providers like ollama, self-hosted backends like vllm). + #[serde(default)] + pub pricing: Option, } /// A configuration field that a provider needs, with prompt metadata. @@ -352,6 +598,36 @@ pub struct ConfigField { pub requires_model: bool, } +/// Per-model pricing information. +/// +/// Rates are per million tokens, in the specified currency. Surfaced via +/// `/v1/models` so HTTP-bridge applications (e.g., amplifier-app-opencode) +/// can display cost estimates without maintaining their own pricing tables. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Pricing { + /// Cost per million input tokens. + pub input_per_million: f64, + + /// Cost per million output tokens. + pub output_per_million: f64, + + /// Cost per million cache-read input tokens (None if not supported). + #[serde(default)] + pub cache_read_per_million: Option, + + /// Cost per million cache-write input tokens (None if not supported). + #[serde(default)] + pub cache_write_per_million: Option, + + /// ISO 4217 currency code. + #[serde(default = "default_currency")] + pub currency: String, +} + +fn default_currency() -> String { + "USD".to_string() +} + /// Provider metadata. /// /// Describes capabilities, authentication requirements, and defaults for a provider. @@ -560,6 +836,23 @@ mod tests { assert!(result.context_injection.is_none()); assert_eq!(result.context_injection_role, ContextInjectionRole::System); assert!(!result.ephemeral); + assert!(result.context_injections.is_empty()); + } + + #[test] + fn context_injection_serialization_roundtrip() { + let injection = ContextInjection { + content: "temporary reminder".into(), + role: ContextInjectionRole::User, + ephemeral: true, + append_to_last_tool_result: true, + hook_name: "registered-hook".into(), + event: "tool:post".into(), + }; + + let json = serde_json::to_string(&injection).unwrap(); + let restored: ContextInjection = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, injection); } #[test] @@ -655,6 +948,7 @@ mod tests { assert!(result.success); assert!(result.output.is_none()); assert!(result.error.is_none()); + assert!(result.content.is_none()); } #[test] @@ -663,6 +957,7 @@ mod tests { success: true, output: Some(json!({"key": "value"})), error: None, + content: None, }; let json_str = serde_json::to_string(&result).unwrap(); let deserialized: ToolResult = serde_json::from_str(&json_str).unwrap(); @@ -678,6 +973,7 @@ mod tests { "message".to_string(), json!("command failed"), )])), + content: None, }; assert!(!result.success); assert_eq!( @@ -686,6 +982,131 @@ mod tests { ); } + #[test] + fn tool_result_content_normalization_is_canonical_and_strict() { + let content = ToolResult::normalize_content(Some(vec![ + json!({"type": "text", "text": "details", "visibility": "user", "extra": true}), + json!({ + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "AA==", + "ignored": true + }, + "ignored": true + }), + ])) + .unwrap() + .unwrap(); + assert_eq!( + serde_json::to_value(&content).unwrap(), + json!([ + {"type": "text", "text": "details"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "AA==" + } + } + ]) + ); + assert_eq!( + ToolResult::normalize_content(Some(vec![json!({ + "type": "image", + "source": {"type": "base64", "media_type": "image/png", "data": "AA"} + })])), + Err(ToolResultContentError::InvalidImageData) + ); + } + + #[test] + fn tool_result_deserialization_normalizes_content_without_input_echo() { + let valid: ToolResult = serde_json::from_value(json!({ + "content": [ + {"type": "text", "text": "details", "visibility": "user"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "AA==", + "ignored": true + }, + "ignored": true + } + ] + })) + .unwrap(); + assert_eq!( + serde_json::to_value(valid.content).unwrap(), + json!([ + {"type": "text", "text": "details"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "AA==" + } + } + ]) + ); + + let empty: ToolResult = serde_json::from_value(json!({"content": []})).unwrap(); + assert!(empty.content.is_none()); + assert_eq!( + serde_json::to_value(empty).unwrap(), + json!({"success": true, "output": null, "error": null}) + ); + + for content in [ + json!([{"type": "thinking", "thinking": "private-bytes"}]), + json!("private-bytes"), + ] { + let error = serde_json::from_value::(json!({"content": content})) + .unwrap_err() + .to_string(); + assert!(error.contains("invalid tool result content")); + assert!(!error.contains("private-bytes")); + } + } + + #[test] + fn tool_result_safe_hook_presentation_omits_image_bytes() { + let result = ToolResult { + success: true, + output: Some(json!({"status": "ok"})), + error: None, + content: ToolResult::normalize_content(Some(vec![ + json!({"type": "text", "text": "details"}), + json!({ + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "AA==" + } + }), + ])) + .unwrap(), + }; + let safe = result.safe_hook_presentation(); + assert_eq!( + safe["content"], + json!([ + {"type": "text", "text": "details"}, + { + "type": "text", + "text": "[Image omitted from tool event; original retained for model request if unmodified.]" + } + ]) + ); + assert!(!safe.to_string().contains("AA==")); + } + // --- ToolResult auto-populate tests --- #[test] @@ -727,6 +1148,7 @@ mod tests { max_output_tokens: 4096, capabilities: vec!["streaming".into()], defaults: Default::default(), + pricing: None, }; assert_eq!(info.id, "gpt-4"); } @@ -740,6 +1162,13 @@ mod tests { max_output_tokens: 8192, capabilities: vec!["tools".into(), "vision".into(), "streaming".into()], defaults: HashMap::from([("temperature".into(), json!(0.7))]), + pricing: Some(Pricing { + input_per_million: 3.0, + output_per_million: 15.0, + cache_read_per_million: None, + cache_write_per_million: None, + currency: "USD".into(), + }), }; let json_str = serde_json::to_string(&info).unwrap(); let deserialized: ModelInfo = serde_json::from_str(&json_str).unwrap(); diff --git a/crates/amplifier-core/src/module_resolver.rs b/crates/amplifier-core/src/module_resolver.rs index 89d6a1d7..4aba0df2 100644 --- a/crates/amplifier-core/src/module_resolver.rs +++ b/crates/amplifier-core/src/module_resolver.rs @@ -1625,7 +1625,7 @@ artifact = "module.wasm" std::fs::write(&wasm_file, &wasm_bytes).expect("write wasm"); // This is the actual sha256 of echo-tool.wasm. - let correct_hash = "114d733baedeec912b8da160adbc863ae14519b88f776d0d3c19f8446e73afb7"; + let correct_hash = "9b82c883832bec6b73fe3b4af31f65ed63f744a2081c5483895f6d3d62b3dd73"; let manifest = ModuleManifest { transport: Transport::Wasm, diff --git a/crates/amplifier-core/src/session.rs b/crates/amplifier-core/src/session.rs index 6598ed5e..68dcc77a 100644 --- a/crates/amplifier-core/src/session.rs +++ b/crates/amplifier-core/src/session.rs @@ -112,6 +112,34 @@ impl SessionConfig { } } +/// CP-SM: extract the `session.metadata` passthrough value from a session +/// config's `session` section. +/// +/// `session.metadata` is a documented contribution channel +/// (`docs/specs/CONTRIBUTION_CHANNELS.md`): the kernel reads it and includes it +/// as an optional `metadata` key in lifecycle event payloads. Pure passthrough — +/// no interpretation, no validation. +/// +/// Returns `None` when the key is absent *or* empty, mirroring the pure-Python +/// kernel's `if session_metadata:` guard (`python/amplifier_core/session.py`). +/// That guard is what keeps the payload byte-identical for every caller that +/// does not configure metadata. +/// +/// Shared by all Rust emit paths so they cannot drift apart again. +pub fn session_metadata_passthrough(session_section: Option<&Value>) -> Option { + let metadata = session_section?.get("metadata")?; + // Mirror Python truthiness: null / false / 0 / "" / [] / {} are all falsy. + let non_empty = match metadata { + Value::Null => false, + Value::Bool(b) => *b, + Value::Number(n) => n.as_f64().is_none_or(|f| f != 0.0), + Value::String(s) => !s.is_empty(), + Value::Array(a) => !a.is_empty(), + Value::Object(o) => !o.is_empty(), + }; + non_empty.then(|| metadata.clone()) +} + // --------------------------------------------------------------------------- // Session // --------------------------------------------------------------------------- @@ -313,16 +341,19 @@ impl Session { events::SESSION_START }; - self.coordinator - .hooks() - .emit( - event, - serde_json::json!({ - "session_id": self.session_id, - "parent_id": self.parent_id, - }), - ) - .await; + let mut payload = serde_json::json!({ + "session_id": self.session_id, + "parent_id": self.parent_id, + }); + // CP-SM passthrough (docs/specs/CONTRIBUTION_CHANNELS.md). Absent or + // empty metadata leaves the payload exactly as it was before. + if let Some(metadata) = + session_metadata_passthrough(self.coordinator.config().get("session")) + { + payload["metadata"] = metadata; + } + + self.coordinator.hooks().emit(event, payload).await; } // Get orchestrator @@ -754,6 +785,130 @@ mod tests { ); } + // --------------------------------------------------------------- + // CP-SM: session.metadata passthrough + // --------------------------------------------------------------- + + #[test] + fn session_metadata_passthrough_reads_configured_value() { + let section = serde_json::json!({ + "orchestrator": "loop-basic", + "context": "context-simple", + "metadata": {"agent_name": "test-agent"}, + }); + assert_eq!( + session_metadata_passthrough(Some(§ion)), + Some(serde_json::json!({"agent_name": "test-agent"})) + ); + } + + #[test] + fn session_metadata_passthrough_is_none_when_absent_or_empty() { + assert_eq!(session_metadata_passthrough(None), None); + + let no_key = serde_json::json!({"orchestrator": "loop-basic"}); + assert_eq!(session_metadata_passthrough(Some(&no_key)), None); + + // Empty / falsy values must behave exactly like "absent" so the payload + // stays byte-identical for callers that configure nothing meaningful. + for empty in [ + serde_json::json!({}), + serde_json::json!([]), + serde_json::json!(""), + serde_json::json!(null), + ] { + let section = serde_json::json!({"metadata": empty}); + assert_eq!(session_metadata_passthrough(Some(§ion)), None); + } + } + + /// CP-SM contract: `config.session.metadata` must reach the `session:start` + /// payload. Specified in `docs/specs/CONTRIBUTION_CHANNELS.md` and already + /// honored by the pure-Python kernel; this pins the Rust kernel to it too. + #[tokio::test] + async fn execute_emits_session_start_with_metadata_when_configured() { + let config = SessionConfig::from_value(serde_json::json!({ + "session": { + "orchestrator": "loop-basic", + "context": "context-simple", + "metadata": {"invocation": {"schema": 1, "mode": "single"}}, + } + })) + .expect("valid config"); + let mut session = Session::new(config, None, None); + session + .coordinator_mut() + .set_orchestrator(Arc::new(FakeOrchestrator::new("ok"))); + session + .coordinator_mut() + .set_context(Arc::new(FakeContextManager::new())); + session + .coordinator_mut() + .mount_provider("test", Arc::new(FakeProvider::new("test", "hi"))); + + let handler = Arc::new(FakeHookHandler::new()); + let _ = session.coordinator().hooks().register( + events::SESSION_START, + handler.clone(), + 0, + Some("test-handler".into()), + ); + + session.set_initialized(); + let _ = session.execute("hello").await; + + let recorded = handler.recorded_events(); + let (_, data) = recorded + .iter() + .find(|(name, _)| name == events::SESSION_START) + .expect("session:start must be emitted"); + assert_eq!( + data.get("metadata"), + Some(&serde_json::json!({"invocation": {"schema": 1, "mode": "single"}})), + "session:start must carry config.session.metadata verbatim. Payload: {data:?}" + ); + // Nothing else moved. + assert!(data.get("session_id").is_some()); + assert!(data.get("parent_id").is_some()); + } + + /// Back-compat: unconfigured metadata leaves the payload exactly as it was. + #[tokio::test] + async fn execute_omits_metadata_when_not_configured() { + let config = SessionConfig::minimal("loop-basic", "context-simple"); + let mut session = Session::new(config, None, None); + session + .coordinator_mut() + .set_orchestrator(Arc::new(FakeOrchestrator::new("ok"))); + session + .coordinator_mut() + .set_context(Arc::new(FakeContextManager::new())); + session + .coordinator_mut() + .mount_provider("test", Arc::new(FakeProvider::new("test", "hi"))); + + let handler = Arc::new(FakeHookHandler::new()); + let _ = session.coordinator().hooks().register( + events::SESSION_START, + handler.clone(), + 0, + Some("test-handler".into()), + ); + + session.set_initialized(); + let _ = session.execute("hello").await; + + let recorded = handler.recorded_events(); + let (_, data) = recorded + .iter() + .find(|(name, _)| name == events::SESSION_START) + .expect("session:start must be emitted"); + assert!( + data.get("metadata").is_none(), + "session:start must not invent a metadata key. Payload: {data:?}" + ); + } + /// Regression test for the Rust-port regression: session:start must fire /// exactly ONCE per session, not once per execute() call. /// diff --git a/crates/amplifier-core/src/testing.rs b/crates/amplifier-core/src/testing.rs index 0a49a9e1..5253122e 100644 --- a/crates/amplifier-core/src/testing.rs +++ b/crates/amplifier-core/src/testing.rs @@ -82,6 +82,7 @@ impl Tool for EchoTool { success: true, output: Some(input), error: None, + content: None, }) }) } @@ -169,6 +170,7 @@ impl Tool for FakeTool { success: true, output: Some(input), error: None, + content: None, } } else { responses.remove(0) @@ -571,11 +573,13 @@ mod tests { success: true, output: Some(serde_json::json!("first")), error: None, + content: None, }, crate::models::ToolResult { success: false, output: None, error: None, + content: None, }, ], ); diff --git a/crates/amplifier-core/src/traits.rs b/crates/amplifier-core/src/traits.rs index d2df7602..443937de 100644 --- a/crates/amplifier-core/src/traits.rs +++ b/crates/amplifier-core/src/traits.rs @@ -91,7 +91,7 @@ use crate::models::{ /// input: Value, /// ) -> Pin> + Send + '_>> { /// Box::pin(async move { -/// Ok(ToolResult { success: true, output: Some(input), error: None }) +/// Ok(ToolResult { success: true, output: Some(input), error: None, content: None }) /// }) /// } /// } diff --git a/crates/amplifier-core/tests/grpc_tool_e2e.rs b/crates/amplifier-core/tests/grpc_tool_e2e.rs index ffba3147..ccc7611e 100644 --- a/crates/amplifier-core/tests/grpc_tool_e2e.rs +++ b/crates/amplifier-core/tests/grpc_tool_e2e.rs @@ -38,6 +38,61 @@ impl ToolService for EchoToolService { output: req.input, content_type: req.content_type, error: String::new(), + content_blocks: vec![ + amplifier_module::ContentBlock { + block: Some(amplifier_module::content_block::Block::TextBlock( + amplifier_module::TextBlock { + text: "echo result".to_string(), + }, + )), + visibility: 0, + }, + amplifier_module::ContentBlock { + block: Some(amplifier_module::content_block::Block::ImageBlock( + amplifier_module::ImageBlock { + media_type: "image/png".to_string(), + data: vec![0], + source_json: String::new(), + }, + )), + visibility: 0, + }, + ], + }, + )) + } +} + +/// A ToolService response with an invalid rich-content oneof. +struct InvalidContentToolService; + +#[tonic::async_trait] +impl ToolService for InvalidContentToolService { + async fn get_spec( + &self, + _request: tonic::Request, + ) -> Result, tonic::Status> { + Ok(tonic::Response::new(amplifier_module::ToolSpec { + name: "invalid-content".to_string(), + description: "Returns malformed rich content".to_string(), + parameters_json: "{}".to_string(), + })) + } + + async fn execute( + &self, + _request: tonic::Request, + ) -> Result, tonic::Status> { + Ok(tonic::Response::new( + amplifier_module::ToolExecuteResponse { + success: true, + output: b"null".to_vec(), + content_type: "application/json".to_string(), + error: String::new(), + content_blocks: vec![amplifier_module::ContentBlock { + block: None, + visibility: 0, + }], }, )) } @@ -88,6 +143,7 @@ impl ToolService for InvalidParamsToolService { output: b"{}".to_vec(), content_type: "application/json".to_string(), error: String::new(), + content_blocks: vec![], }, )) } @@ -123,6 +179,7 @@ impl ToolService for BinaryOutputToolService { output: vec![0xFF, 0xFE, 0x00, 0x01], // not valid JSON content_type: "application/json".to_string(), error: String::new(), + content_blocks: vec![], }, )) } @@ -158,6 +215,7 @@ impl ToolService for NonJsonContentTypeToolService { output: b"{\"result\": 42}".to_vec(), content_type: "text/plain".to_string(), error: String::new(), + content_blocks: vec![], }, )) } @@ -180,7 +238,36 @@ async fn grpc_tool_round_trip() -> Result<(), Box Result<(), Box> { + let endpoint = spawn_tool_server(ToolServiceServer::new(InvalidContentToolService)).await; + let bridge = GrpcToolBridge::connect(&endpoint).await?; + let error = bridge + .execute(serde_json::json!({})) + .await + .unwrap_err() + .to_string(); + assert_eq!(error, "invalid tool result content from gRPC tool"); Ok(()) } diff --git a/crates/amplifier-core/tests/wasm_e2e.rs b/crates/amplifier-core/tests/wasm_e2e.rs index ed74e62c..f22035f6 100644 --- a/crates/amplifier-core/tests/wasm_e2e.rs +++ b/crates/amplifier-core/tests/wasm_e2e.rs @@ -95,6 +95,20 @@ async fn tool_execute_roundtrip() { Some(input), "ToolResult.output should echo the input" ); + assert_eq!( + serde_json::to_value(result.content).unwrap(), + json!([ + {"type": "text", "text": "echo result"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "AA==" + } + } + ]) + ); } // --------------------------------------------------------------------------- @@ -291,6 +305,18 @@ async fn provider_complete() { !response.content.is_empty(), "provider.complete() should return non-empty content" ); + assert_eq!( + response + .usage + .as_ref() + .and_then(|usage| usage.cost_usd.as_deref()), + Some("0.000000000123456789"), + "provider-reported cost must survive the WASM response boundary" + ); + assert!( + provider.parse_tool_calls(&response).is_empty(), + "parse_tool_calls must accept a response carrying usage" + ); } // --------------------------------------------------------------------------- diff --git a/crates/amplifier-guest/Cargo.toml b/crates/amplifier-guest/Cargo.toml index 185ffd9f..c4f2792f 100644 --- a/crates/amplifier-guest/Cargo.toml +++ b/crates/amplifier-guest/Cargo.toml @@ -8,6 +8,7 @@ default = ["kernel-stub"] kernel-stub = [] [dependencies] +base64 = "0.22" prost = "0.13" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/amplifier-guest/src/lib.rs b/crates/amplifier-guest/src/lib.rs index c7385061..5c9522c7 100644 --- a/crates/amplifier-guest/src/lib.rs +++ b/crates/amplifier-guest/src/lib.rs @@ -646,6 +646,7 @@ mod tool_tests { success: true, output: Some(input), error: None, + content: None, }) } } @@ -746,6 +747,7 @@ mod macro_tests { success: true, output: Some(input), error: None, + content: None, }) } } @@ -1289,6 +1291,7 @@ mod provider_tests { max_output_tokens: 1024, capabilities: vec!["chat".to_string()], defaults: HashMap::new(), + pricing: None, }]) } @@ -1296,6 +1299,7 @@ mod provider_tests { Ok(ChatResponse { content: vec![json!({"type": "text", "text": "hello"})], tool_calls: None, + usage: None, finish_reason: Some("stop".to_string()), extra: HashMap::new(), }) @@ -1344,6 +1348,7 @@ mod provider_tests { let resp = ChatResponse { content: vec![], tool_calls: None, + usage: None, finish_reason: None, extra: HashMap::new(), }; @@ -1357,6 +1362,7 @@ mod provider_tests { let resp = ChatResponse { content: vec![], tool_calls: Some(vec![json!({"name": "echo", "args": {}})]), + usage: None, finish_reason: None, extra: HashMap::new(), }; @@ -1430,6 +1436,7 @@ mod provider_macro_tests { Ok(ChatResponse { content: vec![], tool_calls: None, + usage: None, finish_reason: None, extra: HashMap::new(), }) diff --git a/crates/amplifier-guest/src/types.rs b/crates/amplifier-guest/src/types.rs index ada17d44..11ec0e45 100644 --- a/crates/amplifier-guest/src/types.rs +++ b/crates/amplifier-guest/src/types.rs @@ -1,3 +1,4 @@ +use base64::Engine; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; @@ -20,18 +21,131 @@ pub struct ToolResult { pub output: Option, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option>, + #[serde( + default, + deserialize_with = "deserialize_tool_result_content", + skip_serializing_if = "Option::is_none" + )] + pub content: Option, } fn default_true() -> bool { true } +/// A non-empty collection of canonical ToolResult text/base64-image blocks. +/// +/// The storage is private so a WASM guest cannot create an empty collection or +/// bypass the ToolResult-only validation rules with arbitrary JSON values. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(transparent)] +pub struct ToolResultContent(Vec); + +impl ToolResultContent { + /// Strictly validate and canonicalize guest content. + /// + /// `None` and an empty array retain the legacy no-content representation. + pub fn normalize(content: Option>) -> Result, &'static str> { + let Some(content) = content else { + return Ok(None); + }; + if content.is_empty() { + return Ok(None); + } + + let mut normalized = Vec::with_capacity(content.len()); + for block in content { + let object = block.as_object().ok_or("invalid tool result content")?; + match object.get("type").and_then(Value::as_str) { + Some("text") => { + let text = object + .get("text") + .and_then(Value::as_str) + .ok_or("invalid tool result content")?; + normalized.push(serde_json::json!({"type": "text", "text": text})); + } + Some("image") => { + let source = object + .get("source") + .and_then(Value::as_object) + .ok_or("invalid tool result image source")?; + if source.get("type").and_then(Value::as_str) != Some("base64") { + return Err("invalid tool result image source"); + } + let media_type = source + .get("media_type") + .and_then(Value::as_str) + .ok_or("invalid tool result image media type")?; + if !valid_image_media_type(media_type) { + return Err("invalid tool result image media type"); + } + let data = source + .get("data") + .and_then(Value::as_str) + .filter(|data| !data.is_empty()) + .ok_or("invalid tool result image data")?; + let decoded = base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|_| "invalid tool result image data")?; + if base64::engine::general_purpose::STANDARD.encode(decoded) != data { + return Err("invalid tool result image data"); + } + normalized.push(serde_json::json!({ + "type": "image", + "source": { + "type": "base64", + "media_type": media_type, + "data": data, + } + })); + } + _ => return Err("invalid tool result content"), + } + } + Ok(Some(Self(normalized))) + } +} + +fn deserialize_tool_result_content<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let raw_content = Option::::deserialize(deserializer)?; + let raw_content = match raw_content { + None => None, + Some(Value::Array(content)) => Some(content), + Some(_) => return Err(serde::de::Error::custom("invalid tool result content")), + }; + ToolResultContent::normalize(raw_content).map_err(serde::de::Error::custom) +} + +fn valid_image_media_type(media_type: &str) -> bool { + let Some(subtype) = media_type.strip_prefix("image/") else { + return false; + }; + let mut chars = subtype.chars(); + let Some(first) = chars.next() else { + return false; + }; + first.is_ascii_alphanumeric() + && chars.all(|character| { + character.is_ascii_alphanumeric() + || matches!( + character, + '!' | '#' | '$' | '&' | '^' | '_' | '.' | '+' | '-' + ) + }) +} + impl Default for ToolResult { fn default() -> Self { Self { success: true, output: None, error: None, + content: None, } } } @@ -171,6 +285,27 @@ pub struct ProviderInfo { pub defaults: HashMap, } +/// Per-model pricing information. +/// +/// Rates are per million tokens, in the specified currency. Mirrors +/// `amplifier_core::models::Pricing` on the native side (this crate has no +/// dependency on `amplifier-core`, so the struct is duplicated here). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Pricing { + pub input_per_million: f64, + pub output_per_million: f64, + #[serde(default)] + pub cache_read_per_million: Option, + #[serde(default)] + pub cache_write_per_million: Option, + #[serde(default = "default_currency")] + pub currency: String, +} + +fn default_currency() -> String { + "USD".to_string() +} + /// Metadata about a specific model. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ModelInfo { @@ -180,6 +315,10 @@ pub struct ModelInfo { pub max_output_tokens: i64, pub capabilities: Vec, pub defaults: HashMap, + /// Per-model pricing information. None when pricing is not available + /// (e.g., local providers like ollama, self-hosted backends like vllm). + #[serde(default)] + pub pricing: Option, } /// Request for an LLM chat completion. @@ -196,12 +335,30 @@ pub struct ChatRequest { pub extra: HashMap, } +/// Token usage information reported by an LLM provider. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Usage { + pub input_tokens: i64, + pub output_tokens: i64, + pub total_tokens: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_read_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_write_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost_usd: Option, +} + /// Response from an LLM chat completion. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ChatResponse { pub content: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub tool_calls: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage: Option, #[serde(skip_serializing_if = "Option::is_none")] pub finish_reason: Option, #[serde(flatten)] @@ -253,6 +410,7 @@ mod tests { assert!(result.success); assert!(result.output.is_none()); assert!(result.error.is_none()); + assert!(result.content.is_none()); } #[test] @@ -265,6 +423,11 @@ mod tests { m.insert("code".to_string(), json!(404)); m }), + content: ToolResultContent::normalize(Some(vec![json!({ + "type": "text", + "text": "details" + })])) + .unwrap(), }; let json_str = serde_json::to_string(&result).unwrap(); let deserialized: ToolResult = serde_json::from_str(&json_str).unwrap(); @@ -272,6 +435,45 @@ mod tests { assert_eq!(deserialized.output, Some(json!("hello"))); let err = deserialized.error.as_ref().unwrap(); assert_eq!(err.get("code"), Some(&json!(404))); + assert_eq!( + serde_json::to_value(deserialized.content).unwrap(), + json!([{"type": "text", "text": "details"}]) + ); + } + + #[test] + fn test_tool_result_empty_content_omits_key() { + let result: ToolResult = serde_json::from_value(json!({"content": []})).unwrap(); + assert!(result.content.is_none()); + assert_eq!( + serde_json::to_value(result).unwrap(), + json!({"success": true}) + ); + } + + #[test] + fn test_tool_result_media_type_matches_core_contract() { + assert!(ToolResultContent::normalize(Some(vec![json!({ + "type": "image", + "source": { + "type": "base64", + "media_type": "image/a_b", + "data": "AA==" + } + })])) + .is_ok()); + } + + #[test] + fn test_tool_result_rejects_invalid_content_without_echoing_it() { + let error = serde_json::from_value::(json!({ + "content": [{"type": "thinking", "thinking": "WASM-GUEST-SECRET"}] + })) + .unwrap_err() + .to_string(); + + assert!(error.contains("invalid tool result content")); + assert!(!error.contains("WASM-GUEST-SECRET")); } // --- HookAction tests --- @@ -473,9 +675,17 @@ mod tests { max_output_tokens: 4096, capabilities: vec!["chat".to_string(), "tools".to_string()], defaults: HashMap::new(), + pricing: Some(Pricing { + input_per_million: 30.0, + output_per_million: 60.0, + cache_read_per_million: None, + cache_write_per_million: None, + currency: "USD".to_string(), + }), }; assert_eq!(info.context_window, 128000); assert_eq!(info.max_output_tokens, 4096); + assert_eq!(info.pricing.as_ref().unwrap().input_per_million, 30.0); } // --- ChatRequest tests --- @@ -513,6 +723,15 @@ mod tests { let resp = ChatResponse { content: vec![json!({"type": "text", "text": "Hello!"})], tool_calls: None, + usage: Some(Usage { + input_tokens: 10, + output_tokens: 5, + total_tokens: 15, + reasoning_tokens: None, + cache_read_tokens: None, + cache_write_tokens: None, + cost_usd: Some("0.000000000123456789".to_string()), + }), finish_reason: Some("stop".to_string()), extra: HashMap::new(), }; @@ -521,10 +740,43 @@ mod tests { assert_eq!(deserialized.content.len(), 1); assert_eq!(deserialized.content[0]["text"], json!("Hello!")); assert!(deserialized.tool_calls.is_none()); + assert_eq!( + deserialized + .usage + .as_ref() + .and_then(|usage| usage.cost_usd.as_deref()), + Some("0.000000000123456789") + ); assert_eq!(deserialized.finish_reason, Some("stop".to_string())); assert!(deserialized.extra.is_empty()); } + #[test] + fn test_usage_cost_usd_none_is_omitted_and_zero_is_preserved() { + let absent = Usage { + input_tokens: 10, + output_tokens: 5, + total_tokens: 15, + reasoning_tokens: None, + cache_read_tokens: None, + cache_write_tokens: None, + cost_usd: None, + }; + assert!(serde_json::to_value(&absent) + .unwrap() + .get("cost_usd") + .is_none()); + + let present_zero = Usage { + cost_usd: Some("0".to_string()), + ..absent + }; + let serialized = serde_json::to_value(&present_zero).unwrap(); + assert_eq!(serialized["cost_usd"], "0"); + let restored: Usage = serde_json::from_value(serialized).unwrap(); + assert_eq!(restored.cost_usd.as_deref(), Some("0")); + } + // --- PartialEq roundtrip tests --- #[test] @@ -551,6 +803,11 @@ mod tests { m.insert("code".to_string(), json!(404)); m }), + content: ToolResultContent::normalize(Some(vec![json!({ + "type": "text", + "text": "details" + })])) + .unwrap(), }; let json_str = serde_json::to_string(&original).unwrap(); let deserialized: ToolResult = serde_json::from_str(&json_str).unwrap(); @@ -638,6 +895,13 @@ mod tests { max_output_tokens: 4096, capabilities: vec!["chat".to_string(), "tools".to_string()], defaults: HashMap::new(), + pricing: Some(Pricing { + input_per_million: 30.0, + output_per_million: 60.0, + cache_read_per_million: Some(15.0), + cache_write_per_million: Some(37.5), + currency: "USD".to_string(), + }), }; let json_str = serde_json::to_string(&original).unwrap(); let deserialized: ModelInfo = serde_json::from_str(&json_str).unwrap(); diff --git a/docs/MIGRATING_TO_2_0.md b/docs/MIGRATING_TO_2_0.md new file mode 100644 index 00000000..7221b7bd --- /dev/null +++ b/docs/MIGRATING_TO_2_0.md @@ -0,0 +1,70 @@ +# Migrating to amplifier-core 2.0 + +This release combines rich tool-result content, native session metadata, model +pricing and precise usage cost, canonical source-module imports, and ordered +hook context injections. Existing JSON/Python payloads can omit the new optional +fields. The major version reflects changes to public Rust construction and +conversion APIs and stricter module identity checks. + +## Upgrade the CLI before Core + +Use an amplifier-app-cli version containing the configured-source credential +preflight and import-free provider banner. Older CLI preflight can import a +provider from a different checkout before Core sees the configured source. Core +2.0 rejects that conflicting package identity rather than executing mixed code. + +Install and validate the CLI companion first; then upgrade Core using a newly +built native wheel. Do not substitute only Python source while retaining an old +native extension. Restart workers after updating their environment so already +imported modules cannot outlive the installed version. + +## Rust callers + +- `ToolResult` has a new `content` field. Prefer `ToolResult::new(...)`, or add + `content: None` to existing struct literals. Construct rich content through + `ToolResult::normalize_content`; `ToolResultContent` keeps its vector private + so invalid content cannot bypass validation. +- Conversions between the public Rust and generated protobuf `ToolResult` now + implement `TryFrom`. Replace `.into()` with `.try_into()` and propagate or + handle `ToolResultContentError`. +- Add `pricing: None` to existing `ModelInfo` literals, `cost_usd: None` to + `Usage` literals, and `context_injections: Vec::new()` to `HookResult` literals. + `HookResult` and `ToolResult` also support default-based construction. +- `Usage.cost_usd` is an optional decimal string. Preserve its precision instead + of converting it through a floating-point number. + +## Rich tool results + +The optional `ToolResult.content` carries an ordered, non-empty list of text and +base64 image blocks. Only the supported canonical block shapes are accepted; +URL images and unrelated message-block variants are rejected. Legacy +`success`, `output`, and `error` fields remain available. Callers should handle +validation failures rather than dropping invalid content silently. + +## Source-module identity + +Source modules import under their canonical Python package names, including +relative and sibling imports. Do not preload the same package from a different +checkout, or mutate `sys.modules` to hide a conflict. Resolve and activate the +configured source before importing provider metadata. A worker restart is +required when changing the source of a package already imported in that worker. + +## Ordered hook injections + +`HookResult.context_injections` is the authoritative ordered list when present. +Each `ContextInjection` has its own content, role, lifetime, placement flag, and +registry-bound provenance. Consumers must process each item once; do not also +apply the scalar compatibility projection. + +The native coordinator validates the batch, persists durable items in order, +and returns only ephemeral items for the orchestrator to apply to the current +request. An all-durable result is consumed and returns `continue`. Legacy scalar +hook results remain accepted. Custom loops that bypass the coordinator must +honor each item's lifetime and placement rather than flattening the whole batch. + +## Release ordering + +Land the backwards-compatible CLI companion first, validate the combined Core +source with current consumers, and then land the Core carrier. Publishing the +version tag and native wheels is a separate release action. A source merge +alone is not evidence that a running host or worker has upgraded. diff --git a/docs/module-failure-reasons.md b/docs/module-failure-reasons.md new file mode 100644 index 00000000..f987a015 --- /dev/null +++ b/docs/module-failure-reasons.md @@ -0,0 +1,9 @@ +# Module-load failure categories + +The `module:load_failed` event includes an optional `reason_code` in addition to its existing module type, ID, and legacy error text. Hosts can expose the bounded category without forwarding exception text or configuration. + +Categories are `invalid_package_layout`, `missing_source`, `invalid_entry_point`, `invalid_module_metadata`, `validation_failed`, and `unknown`. Codes originate from typed loader failures and named validation checks, never from exception-string parsing. Unknown exceptions fall back to `unknown`. Missing metadata still uses the existing naming fallback; explicitly invalid declared types are rejected instead of swallowing the validation error and falling back. + +The legacy `error` field remains unchanged for compatibility and is not a safe public diagnostic. Consumers should allowlist `reason_code` and supply their own remediation text. This addition changes neither the provider/tool/hook nonfatal session policy nor host decisions about incomplete configured sessions. + +Validation used Python source over the installed native kernel: 44 loader and session-initialization tests passed, including fixtures for the four requested categories, unknown and forged codes, and event compatibility. No native code or provider protocol changed. diff --git a/proto/amplifier_module.proto b/proto/amplifier_module.proto index e12f988a..fb8c319c 100644 --- a/proto/amplifier_module.proto +++ b/proto/amplifier_module.proto @@ -36,6 +36,8 @@ message ToolExecuteResponse { string content_type = 3; // Error message if success is false. string error = 4; + // Canonical rich tool-result content. + repeated ContentBlock content_blocks = 5; } // --------------------------------------------------------------------------- @@ -305,6 +307,7 @@ message Usage { optional int32 reasoning_tokens = 4; optional int32 cache_read_tokens = 5; optional int32 cache_creation_tokens = 6; + optional string cost_usd = 7; } message Degradation { @@ -387,6 +390,16 @@ message ToolResult { bool success = 1; string output_json = 2; string error_json = 3; + repeated ContentBlock content_blocks = 4; +} + +message ContextInjection { + string content = 1; + ContextInjectionRole role = 2; + bool ephemeral = 3; + bool append_to_last_tool_result = 4; + string hook_name = 5; + string event = 6; } message HookResult { @@ -406,6 +419,7 @@ message HookResult { UserMessageLevel user_message_level = 13; string user_message_source = 14; bool append_to_last_tool_result = 15; + repeated ContextInjection context_injections = 16; } message ModelInfo { @@ -415,6 +429,9 @@ message ModelInfo { int32 max_output_tokens = 4; repeated string capabilities = 5; string defaults_json = 6; + // JSON-encoded Pricing, or empty string if pricing is unavailable + // (e.g., local providers, self-hosted backends). + string pricing_json = 7; } message ProviderInfo { diff --git a/proto/amplifier_module_pb2.py b/proto/amplifier_module_pb2.py index 6f4d7515..4d56a913 100644 --- a/proto/amplifier_module_pb2.py +++ b/proto/amplifier_module_pb2.py @@ -24,7 +24,7 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x16\x61mplifier_module.proto\x12\x10\x61mplifier.module\"\x07\n\x05\x45mpty\"F\n\x08ToolSpec\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x17\n\x0fparameters_json\x18\x03 \x01(\t\"9\n\x12ToolExecuteRequest\x12\r\n\x05input\x18\x01 \x01(\x0c\x12\x14\n\x0c\x63ontent_type\x18\x02 \x01(\t\"[\n\x13ToolExecuteResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0e\n\x06output\x18\x02 \x01(\x0c\x12\x14\n\x0c\x63ontent_type\x18\x03 \x01(\t\x12\r\n\x05\x65rror\x18\x04 \x01(\t\"\xd6\x01\n\nModuleInfo\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0f\n\x07version\x18\x03 \x01(\t\x12\x31\n\x0bmodule_type\x18\x04 \x01(\x0e\x32\x1c.amplifier.module.ModuleType\x12\x13\n\x0bmount_point\x18\x05 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x06 \x01(\t\x12\x1a\n\x12\x63onfig_schema_json\x18\x07 \x01(\t\x12\x14\n\x0c\x63\x61pabilities\x18\x08 \x03(\t\x12\x0e\n\x06\x61uthor\x18\t \x01(\t\"\x8c\x01\n\x0cMountRequest\x12:\n\x06\x63onfig\x18\x01 \x03(\x0b\x32*.amplifier.module.MountRequest.ConfigEntry\x12\x11\n\tmodule_id\x18\x02 \x01(\t\x1a-\n\x0b\x43onfigEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"_\n\rMountResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12.\n\x06status\x18\x03 \x01(\x0e\x32\x1e.amplifier.module.HealthStatus\"V\n\x13HealthCheckResponse\x12.\n\x06status\x18\x01 \x01(\x0e\x32\x1e.amplifier.module.HealthStatus\x12\x0f\n\x07message\x18\x02 \x01(\t\"\xca\x02\n\x0b\x43onfigField\x12\n\n\x02id\x18\x01 \x01(\t\x12\x14\n\x0c\x64isplay_name\x18\x02 \x01(\t\x12\x35\n\nfield_type\x18\x03 \x01(\x0e\x32!.amplifier.module.ConfigFieldType\x12\x0e\n\x06prompt\x18\x04 \x01(\t\x12\x0f\n\x07\x65nv_var\x18\x05 \x01(\t\x12\x0f\n\x07\x63hoices\x18\x06 \x03(\t\x12\x10\n\x08required\x18\x07 \x01(\x08\x12\x15\n\rdefault_value\x18\x08 \x01(\t\x12>\n\tshow_when\x18\t \x03(\x0b\x32+.amplifier.module.ConfigField.ShowWhenEntry\x12\x16\n\x0erequires_model\x18\n \x01(\x08\x1a/\n\rShowWhenEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xbc\x01\n\rProviderError\x12\x37\n\nerror_type\x18\x01 \x01(\x0e\x32#.amplifier.module.ProviderErrorType\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x15\n\rprovider_name\x18\x03 \x01(\t\x12\r\n\x05model\x18\x04 \x01(\t\x12\x13\n\x0bstatus_code\x18\x05 \x01(\x05\x12\x11\n\tretryable\x18\x06 \x01(\x08\x12\x13\n\x0bretry_after\x18\x07 \x01(\x01\"\x97\x01\n\tToolError\x12\x33\n\nerror_type\x18\x01 \x01(\x0e\x32\x1f.amplifier.module.ToolErrorType\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x11\n\ttool_name\x18\x03 \x01(\t\x12\x0e\n\x06stdout\x18\x04 \x01(\t\x12\x0e\n\x06stderr\x18\x05 \x01(\t\x12\x11\n\texit_code\x18\x06 \x01(\x05\"d\n\tHookError\x12\x33\n\nerror_type\x18\x01 \x01(\x0e\x32\x1f.amplifier.module.HookErrorType\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x11\n\thook_name\x18\x03 \x01(\t\"\xef\x01\n\x0e\x41mplifierError\x12\x39\n\x0eprovider_error\x18\x01 \x01(\x0b\x32\x1f.amplifier.module.ProviderErrorH\x00\x12\x31\n\ntool_error\x18\x02 \x01(\x0b\x32\x1b.amplifier.module.ToolErrorH\x00\x12\x31\n\nhook_error\x18\x03 \x01(\x0b\x32\x1b.amplifier.module.HookErrorH\x00\x12\x17\n\rgeneric_error\x18\x04 \x01(\tH\x00\x12\x1a\n\x10validation_error\x18\x05 \x01(\tH\x00\x42\x07\n\x05\x65rror\"\x19\n\tTextBlock\x12\x0c\n\x04text\x18\x01 \x01(\t\"E\n\rThinkingBlock\x12\x10\n\x08thinking\x18\x01 \x01(\t\x12\x11\n\tsignature\x18\x02 \x01(\t\x12\x0f\n\x07\x63ontent\x18\x03 \x01(\t\"%\n\x15RedactedThinkingBlock\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\t\"=\n\rToolCallBlock\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x12\n\ninput_json\x18\x03 \x01(\t\"<\n\x0fToolResultBlock\x12\x14\n\x0ctool_call_id\x18\x01 \x01(\t\x12\x13\n\x0boutput_json\x18\x02 \x01(\t\"C\n\nImageBlock\x12\x12\n\nmedia_type\x18\x01 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\x12\x13\n\x0bsource_json\x18\x03 \x01(\t\"2\n\x0eReasoningBlock\x12\x0f\n\x07\x63ontent\x18\x01 \x03(\t\x12\x0f\n\x07summary\x18\x02 \x03(\t\"\xf1\x03\n\x0c\x43ontentBlock\x12\x31\n\ntext_block\x18\x01 \x01(\x0b\x32\x1b.amplifier.module.TextBlockH\x00\x12\x39\n\x0ethinking_block\x18\x02 \x01(\x0b\x32\x1f.amplifier.module.ThinkingBlockH\x00\x12J\n\x17redacted_thinking_block\x18\x03 \x01(\x0b\x32\'.amplifier.module.RedactedThinkingBlockH\x00\x12:\n\x0ftool_call_block\x18\x04 \x01(\x0b\x32\x1f.amplifier.module.ToolCallBlockH\x00\x12>\n\x11tool_result_block\x18\x05 \x01(\x0b\x32!.amplifier.module.ToolResultBlockH\x00\x12\x33\n\x0bimage_block\x18\x06 \x01(\x0b\x32\x1c.amplifier.module.ImageBlockH\x00\x12;\n\x0freasoning_block\x18\x07 \x01(\x0b\x32 .amplifier.module.ReasoningBlockH\x00\x12\x30\n\nvisibility\x18\x08 \x01(\x0e\x32\x1c.amplifier.module.VisibilityB\x07\n\x05\x62lock\"B\n\x10\x43ontentBlockList\x12.\n\x06\x62locks\x18\x01 \x03(\x0b\x32\x1e.amplifier.module.ContentBlock\"\xca\x01\n\x07Message\x12$\n\x04role\x18\x01 \x01(\x0e\x32\x16.amplifier.module.Role\x12\x16\n\x0ctext_content\x18\x02 \x01(\tH\x00\x12;\n\rblock_content\x18\x03 \x01(\x0b\x32\".amplifier.module.ContentBlockListH\x00\x12\x0c\n\x04name\x18\x04 \x01(\t\x12\x14\n\x0ctool_call_id\x18\x05 \x01(\t\x12\x15\n\rmetadata_json\x18\x06 \x01(\tB\t\n\x07\x63ontent\"C\n\x0fToolCallMessage\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x16\n\x0e\x61rguments_json\x18\x03 \x01(\t\"K\n\rToolSpecProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x17\n\x0fparameters_json\x18\x03 \x01(\t\"7\n\x10JsonSchemaFormat\x12\x13\n\x0bschema_json\x18\x01 \x01(\t\x12\x0e\n\x06strict\x18\x02 \x01(\x08\"u\n\x0eResponseFormat\x12\x0e\n\x04text\x18\x01 \x01(\x08H\x00\x12\x0e\n\x04json\x18\x02 \x01(\x08H\x00\x12\x39\n\x0bjson_schema\x18\x03 \x01(\x0b\x32\".amplifier.module.JsonSchemaFormatH\x00\x42\x08\n\x06\x66ormat\"\xf7\x01\n\x05Usage\x12\x15\n\rprompt_tokens\x18\x01 \x01(\x05\x12\x19\n\x11\x63ompletion_tokens\x18\x02 \x01(\x05\x12\x14\n\x0ctotal_tokens\x18\x03 \x01(\x05\x12\x1d\n\x10reasoning_tokens\x18\x04 \x01(\x05H\x00\x88\x01\x01\x12\x1e\n\x11\x63\x61\x63he_read_tokens\x18\x05 \x01(\x05H\x01\x88\x01\x01\x12\"\n\x15\x63\x61\x63he_creation_tokens\x18\x06 \x01(\x05H\x02\x88\x01\x01\x42\x13\n\x11_reasoning_tokensB\x14\n\x12_cache_read_tokensB\x18\n\x16_cache_creation_tokens\"@\n\x0b\x44\x65gradation\x12\x11\n\trequested\x18\x01 \x01(\t\x12\x0e\n\x06\x61\x63tual\x18\x02 \x01(\t\x12\x0e\n\x06reason\x18\x03 \x01(\t\"\x81\x03\n\x0b\x43hatRequest\x12+\n\x08messages\x18\x01 \x03(\x0b\x32\x19.amplifier.module.Message\x12.\n\x05tools\x18\x02 \x03(\x0b\x32\x1f.amplifier.module.ToolSpecProto\x12\x39\n\x0fresponse_format\x18\x03 \x01(\x0b\x32 .amplifier.module.ResponseFormat\x12\x13\n\x0btemperature\x18\x04 \x01(\x01\x12\r\n\x05top_p\x18\x05 \x01(\x01\x12\x19\n\x11max_output_tokens\x18\x06 \x01(\x05\x12\x17\n\x0f\x63onversation_id\x18\x07 \x01(\t\x12\x0e\n\x06stream\x18\x08 \x01(\x08\x12\x15\n\rmetadata_json\x18\t \x01(\t\x12\r\n\x05model\x18\n \x01(\t\x12\x13\n\x0btool_choice\x18\x0b \x01(\t\x12\x0c\n\x04stop\x18\x0c \x03(\t\x12\x18\n\x10reasoning_effort\x18\r \x01(\t\x12\x0f\n\x07timeout\x18\x0e \x01(\x01\"\x98\x02\n\x0c\x43hatResponse\x12\x0f\n\x07\x63ontent\x18\x01 \x01(\t\x12\x35\n\ntool_calls\x18\x02 \x03(\x0b\x32!.amplifier.module.ToolCallMessage\x12&\n\x05usage\x18\x03 \x01(\x0b\x32\x17.amplifier.module.Usage\x12\x32\n\x0b\x64\x65gradation\x18\x04 \x01(\x0b\x32\x1d.amplifier.module.Degradation\x12\x15\n\rfinish_reason\x18\x05 \x01(\t\x12\x15\n\rmetadata_json\x18\x06 \x01(\t\x12\x36\n\x0e\x63ontent_blocks\x18\x07 \x03(\x0b\x32\x1e.amplifier.module.ContentBlock\"F\n\nToolResult\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x13\n\x0boutput_json\x18\x02 \x01(\t\x12\x12\n\nerror_json\x18\x03 \x01(\t\"\xa7\x04\n\nHookResult\x12,\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x1c.amplifier.module.HookAction\x12\x11\n\tdata_json\x18\x02 \x01(\t\x12\x0e\n\x06reason\x18\x03 \x01(\t\x12\x19\n\x11\x63ontext_injection\x18\x04 \x01(\t\x12\x46\n\x16\x63ontext_injection_role\x18\x05 \x01(\x0e\x32&.amplifier.module.ContextInjectionRole\x12\x11\n\tephemeral\x18\x06 \x01(\x08\x12\x17\n\x0f\x61pproval_prompt\x18\x07 \x01(\t\x12\x18\n\x10\x61pproval_options\x18\x08 \x03(\t\x12\x1d\n\x10\x61pproval_timeout\x18\t \x01(\x01H\x00\x88\x01\x01\x12;\n\x10\x61pproval_default\x18\n \x01(\x0e\x32!.amplifier.module.ApprovalDefault\x12\x17\n\x0fsuppress_output\x18\x0b \x01(\x08\x12\x14\n\x0cuser_message\x18\x0c \x01(\t\x12>\n\x12user_message_level\x18\r \x01(\x0e\x32\".amplifier.module.UserMessageLevel\x12\x1b\n\x13user_message_source\x18\x0e \x01(\t\x12\"\n\x1a\x61ppend_to_last_tool_result\x18\x0f \x01(\x08\x42\x13\n\x11_approval_timeout\"\x8d\x01\n\tModelInfo\x12\n\n\x02id\x18\x01 \x01(\t\x12\x14\n\x0c\x64isplay_name\x18\x02 \x01(\t\x12\x16\n\x0e\x63ontext_window\x18\x03 \x01(\x05\x12\x19\n\x11max_output_tokens\x18\x04 \x01(\x05\x12\x14\n\x0c\x63\x61pabilities\x18\x05 \x03(\t\x12\x15\n\rdefaults_json\x18\x06 \x01(\t\"\xb0\x01\n\x0cProviderInfo\x12\n\n\x02id\x18\x01 \x01(\t\x12\x14\n\x0c\x64isplay_name\x18\x02 \x01(\t\x12\x1b\n\x13\x63redential_env_vars\x18\x03 \x03(\t\x12\x14\n\x0c\x63\x61pabilities\x18\x04 \x03(\t\x12\x15\n\rdefaults_json\x18\x05 \x01(\t\x12\x34\n\rconfig_fields\x18\x06 \x03(\x0b\x32\x1d.amplifier.module.ConfigField\"\x80\x01\n\x0f\x41pprovalRequest\x12\x11\n\ttool_name\x18\x01 \x01(\t\x12\x0e\n\x06\x61\x63tion\x18\x02 \x01(\t\x12\x14\n\x0c\x64\x65tails_json\x18\x03 \x01(\t\x12\x12\n\nrisk_level\x18\x04 \x01(\t\x12\x14\n\x07timeout\x18\x05 \x01(\x01H\x00\x88\x01\x01\x42\n\n\x08_timeout\"F\n\x10\x41pprovalResponse\x12\x10\n\x08\x61pproved\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x10\n\x08remember\x18\x03 \x01(\x08\"A\n\x12ListModelsResponse\x12+\n\x06models\x18\x01 \x03(\x0b\x32\x1b.amplifier.module.ModelInfo\"O\n\x16ParseToolCallsResponse\x12\x35\n\ntool_calls\x18\x01 \x03(\x0b\x32!.amplifier.module.ToolCallMessage\"@\n\x1aOrchestratorExecuteRequest\x12\x0e\n\x06prompt\x18\x01 \x01(\t\x12\x12\n\nsession_id\x18\x02 \x01(\t\">\n\x1bOrchestratorExecuteResponse\x12\x10\n\x08response\x18\x01 \x01(\t\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"?\n\x11\x41\x64\x64MessageRequest\x12*\n\x07message\x18\x01 \x01(\x0b\x32\x19.amplifier.module.Message\"B\n\x13GetMessagesResponse\x12+\n\x08messages\x18\x01 \x03(\x0b\x32\x19.amplifier.module.Message\"J\n\x1bGetMessagesForRequestParams\x12\x14\n\x0ctoken_budget\x18\x01 \x01(\x05\x12\x15\n\rprovider_name\x18\x02 \x01(\t\"A\n\x12SetMessagesRequest\x12+\n\x08messages\x18\x01 \x03(\x0b\x32\x19.amplifier.module.Message\"5\n\x11HookHandleRequest\x12\r\n\x05\x65vent\x18\x01 \x01(\t\x12\x11\n\tdata_json\x18\x02 \x01(\t\".\n\x17GetSubscriptionsRequest\x12\x13\n\x0b\x63onfig_json\x18\x01 \x01(\t\"V\n\x18GetSubscriptionsResponse\x12:\n\rsubscriptions\x18\x01 \x03(\x0b\x32#.amplifier.module.EventSubscription\"B\n\x11\x45ventSubscription\x12\r\n\x05\x65vent\x18\x01 \x01(\t\x12\x10\n\x08priority\x18\x02 \x01(\x05\x12\x0c\n\x04name\x18\x03 \x01(\t\"d\n\x1b\x43ompleteWithProviderRequest\x12\x15\n\rprovider_name\x18\x01 \x01(\t\x12.\n\x07request\x18\x02 \x01(\x0b\x32\x1d.amplifier.module.ChatRequest\";\n\x12\x45xecuteToolRequest\x12\x11\n\ttool_name\x18\x01 \x01(\t\x12\x12\n\ninput_json\x18\x02 \x01(\t\"3\n\x0f\x45mitHookRequest\x12\r\n\x05\x65vent\x18\x01 \x01(\t\x12\x11\n\tdata_json\x18\x02 \x01(\t\"V\n\x19\x45mitHookAndCollectRequest\x12\r\n\x05\x65vent\x18\x01 \x01(\t\x12\x11\n\tdata_json\x18\x02 \x01(\t\x12\x17\n\x0ftimeout_seconds\x18\x03 \x01(\x01\"4\n\x1a\x45mitHookAndCollectResponse\x12\x16\n\x0eresponses_json\x18\x01 \x03(\t\"(\n\x12GetMessagesRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\"Y\n\x17KernelAddMessageRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12*\n\x07message\x18\x02 \x01(\x0b\x32\x19.amplifier.module.Message\"a\n\x17GetMountedModuleRequest\x12\x13\n\x0bmodule_name\x18\x01 \x01(\t\x12\x31\n\x0bmodule_type\x18\x02 \x01(\x0e\x32\x1c.amplifier.module.ModuleType\"U\n\x18GetMountedModuleResponse\x12\r\n\x05\x66ound\x18\x01 \x01(\x08\x12*\n\x04info\x18\x02 \x01(\x0b\x32\x1c.amplifier.module.ModuleInfo\"=\n\x19RegisterCapabilityRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\nvalue_json\x18\x02 \x01(\t\"$\n\x14GetCapabilityRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\":\n\x15GetCapabilityResponse\x12\r\n\x05\x66ound\x18\x01 \x01(\x08\x12\x12\n\nvalue_json\x18\x02 \x01(\t*\xbc\x01\n\nModuleType\x12\x1b\n\x17MODULE_TYPE_UNSPECIFIED\x10\x00\x12\x18\n\x14MODULE_TYPE_PROVIDER\x10\x01\x12\x14\n\x10MODULE_TYPE_TOOL\x10\x02\x12\x14\n\x10MODULE_TYPE_HOOK\x10\x03\x12\x16\n\x12MODULE_TYPE_MEMORY\x10\x04\x12\x19\n\x15MODULE_TYPE_GUARDRAIL\x10\x05\x12\x18\n\x14MODULE_TYPE_APPROVAL\x10\x06*\x82\x01\n\x0cHealthStatus\x12\x1d\n\x19HEALTH_STATUS_UNSPECIFIED\x10\x00\x12\x19\n\x15HEALTH_STATUS_SERVING\x10\x01\x12\x1d\n\x19HEALTH_STATUS_NOT_SERVING\x10\x02\x12\x19\n\x15HEALTH_STATUS_UNKNOWN\x10\x03*\xad\x01\n\x0f\x43onfigFieldType\x12!\n\x1d\x43ONFIG_FIELD_TYPE_UNSPECIFIED\x10\x00\x12\x1c\n\x18\x43ONFIG_FIELD_TYPE_STRING\x10\x01\x12\x1c\n\x18\x43ONFIG_FIELD_TYPE_NUMBER\x10\x02\x12\x1d\n\x19\x43ONFIG_FIELD_TYPE_BOOLEAN\x10\x03\x12\x1c\n\x18\x43ONFIG_FIELD_TYPE_SECRET\x10\x04*\xd8\x02\n\x11ProviderErrorType\x12#\n\x1fPROVIDER_ERROR_TYPE_UNSPECIFIED\x10\x00\x12\x1c\n\x18PROVIDER_ERROR_TYPE_AUTH\x10\x01\x12\"\n\x1ePROVIDER_ERROR_TYPE_RATE_LIMIT\x10\x02\x12&\n\"PROVIDER_ERROR_TYPE_CONTEXT_LENGTH\x10\x03\x12\'\n#PROVIDER_ERROR_TYPE_INVALID_REQUEST\x10\x04\x12&\n\"PROVIDER_ERROR_TYPE_CONTENT_FILTER\x10\x05\x12#\n\x1fPROVIDER_ERROR_TYPE_UNAVAILABLE\x10\x06\x12\x1f\n\x1bPROVIDER_ERROR_TYPE_TIMEOUT\x10\x07\x12\x1d\n\x19PROVIDER_ERROR_TYPE_OTHER\x10\x08*\x8c\x01\n\rToolErrorType\x12\x1f\n\x1bTOOL_ERROR_TYPE_UNSPECIFIED\x10\x00\x12\x1d\n\x19TOOL_ERROR_TYPE_EXECUTION\x10\x01\x12\x1e\n\x1aTOOL_ERROR_TYPE_VALIDATION\x10\x02\x12\x1b\n\x17TOOL_ERROR_TYPE_TIMEOUT\x10\x03*\x8c\x01\n\rHookErrorType\x12\x1f\n\x1bHOOK_ERROR_TYPE_UNSPECIFIED\x10\x00\x12\x1d\n\x19HOOK_ERROR_TYPE_EXECUTION\x10\x01\x12\x1e\n\x1aHOOK_ERROR_TYPE_VALIDATION\x10\x02\x12\x1b\n\x17HOOK_ERROR_TYPE_TIMEOUT\x10\x03*\x86\x01\n\x04Role\x12\x14\n\x10ROLE_UNSPECIFIED\x10\x00\x12\x0f\n\x0bROLE_SYSTEM\x10\x01\x12\r\n\tROLE_USER\x10\x02\x12\x12\n\x0eROLE_ASSISTANT\x10\x03\x12\r\n\tROLE_TOOL\x10\x04\x12\x11\n\rROLE_FUNCTION\x10\x05\x12\x12\n\x0eROLE_DEVELOPER\x10\x06*o\n\nVisibility\x12\x1a\n\x16VISIBILITY_UNSPECIFIED\x10\x00\x12\x12\n\x0eVISIBILITY_ALL\x10\x01\x12\x17\n\x13VISIBILITY_LLM_ONLY\x10\x02\x12\x18\n\x14VISIBILITY_USER_ONLY\x10\x03*\xab\x01\n\nHookAction\x12\x1b\n\x17HOOK_ACTION_UNSPECIFIED\x10\x00\x12\x18\n\x14HOOK_ACTION_CONTINUE\x10\x01\x12\x16\n\x12HOOK_ACTION_MODIFY\x10\x02\x12\x14\n\x10HOOK_ACTION_DENY\x10\x03\x12\x1e\n\x1aHOOK_ACTION_INJECT_CONTEXT\x10\x04\x12\x18\n\x14HOOK_ACTION_ASK_USER\x10\x05*\xa8\x01\n\x14\x43ontextInjectionRole\x12&\n\"CONTEXT_INJECTION_ROLE_UNSPECIFIED\x10\x00\x12!\n\x1d\x43ONTEXT_INJECTION_ROLE_SYSTEM\x10\x01\x12\x1f\n\x1b\x43ONTEXT_INJECTION_ROLE_USER\x10\x02\x12$\n CONTEXT_INJECTION_ROLE_ASSISTANT\x10\x03*l\n\x0f\x41pprovalDefault\x12 \n\x1c\x41PPROVAL_DEFAULT_UNSPECIFIED\x10\x00\x12\x1c\n\x18\x41PPROVAL_DEFAULT_APPROVE\x10\x01\x12\x19\n\x15\x41PPROVAL_DEFAULT_DENY\x10\x02*\x91\x01\n\x10UserMessageLevel\x12\"\n\x1eUSER_MESSAGE_LEVEL_UNSPECIFIED\x10\x00\x12\x1b\n\x17USER_MESSAGE_LEVEL_INFO\x10\x01\x12\x1e\n\x1aUSER_MESSAGE_LEVEL_WARNING\x10\x02\x12\x1c\n\x18USER_MESSAGE_LEVEL_ERROR\x10\x03\x32\xa5\x01\n\x0bToolService\x12>\n\x07GetSpec\x12\x17.amplifier.module.Empty\x1a\x1a.amplifier.module.ToolSpec\x12V\n\x07\x45xecute\x12$.amplifier.module.ToolExecuteRequest\x1a%.amplifier.module.ToolExecuteResponse2\x9f\x03\n\x0fProviderService\x12\x42\n\x07GetInfo\x12\x17.amplifier.module.Empty\x1a\x1e.amplifier.module.ProviderInfo\x12K\n\nListModels\x12\x17.amplifier.module.Empty\x1a$.amplifier.module.ListModelsResponse\x12I\n\x08\x43omplete\x12\x1d.amplifier.module.ChatRequest\x1a\x1e.amplifier.module.ChatResponse\x12T\n\x11\x43ompleteStreaming\x12\x1d.amplifier.module.ChatRequest\x1a\x1e.amplifier.module.ChatResponse0\x01\x12Z\n\x0eParseToolCalls\x12\x1e.amplifier.module.ChatResponse\x1a(.amplifier.module.ParseToolCallsResponse2}\n\x13OrchestratorService\x12\x66\n\x07\x45xecute\x12,.amplifier.module.OrchestratorExecuteRequest\x1a-.amplifier.module.OrchestratorExecuteResponse2\xa3\x03\n\x0e\x43ontextService\x12J\n\nAddMessage\x12#.amplifier.module.AddMessageRequest\x1a\x17.amplifier.module.Empty\x12M\n\x0bGetMessages\x12\x17.amplifier.module.Empty\x1a%.amplifier.module.GetMessagesResponse\x12m\n\x15GetMessagesForRequest\x12-.amplifier.module.GetMessagesForRequestParams\x1a%.amplifier.module.GetMessagesResponse\x12L\n\x0bSetMessages\x12$.amplifier.module.SetMessagesRequest\x1a\x17.amplifier.module.Empty\x12\x39\n\x05\x43lear\x12\x17.amplifier.module.Empty\x1a\x17.amplifier.module.Empty2\xc5\x01\n\x0bHookService\x12K\n\x06Handle\x12#.amplifier.module.HookHandleRequest\x1a\x1c.amplifier.module.HookResult\x12i\n\x10GetSubscriptions\x12).amplifier.module.GetSubscriptionsRequest\x1a*.amplifier.module.GetSubscriptionsResponse2k\n\x0f\x41pprovalService\x12X\n\x0fRequestApproval\x12!.amplifier.module.ApprovalRequest\x1a\".amplifier.module.ApprovalResponse2\xd0\x07\n\rKernelService\x12\x65\n\x14\x43ompleteWithProvider\x12-.amplifier.module.CompleteWithProviderRequest\x1a\x1e.amplifier.module.ChatResponse\x12p\n\x1d\x43ompleteWithProviderStreaming\x12-.amplifier.module.CompleteWithProviderRequest\x1a\x1e.amplifier.module.ChatResponse0\x01\x12Q\n\x0b\x45xecuteTool\x12$.amplifier.module.ExecuteToolRequest\x1a\x1c.amplifier.module.ToolResult\x12K\n\x08\x45mitHook\x12!.amplifier.module.EmitHookRequest\x1a\x1c.amplifier.module.HookResult\x12o\n\x12\x45mitHookAndCollect\x12+.amplifier.module.EmitHookAndCollectRequest\x1a,.amplifier.module.EmitHookAndCollectResponse\x12Z\n\x0bGetMessages\x12$.amplifier.module.GetMessagesRequest\x1a%.amplifier.module.GetMessagesResponse\x12P\n\nAddMessage\x12).amplifier.module.KernelAddMessageRequest\x1a\x17.amplifier.module.Empty\x12i\n\x10GetMountedModule\x12).amplifier.module.GetMountedModuleRequest\x1a*.amplifier.module.GetMountedModuleResponse\x12Z\n\x12RegisterCapability\x12+.amplifier.module.RegisterCapabilityRequest\x1a\x17.amplifier.module.Empty\x12`\n\rGetCapability\x12&.amplifier.module.GetCapabilityRequest\x1a\'.amplifier.module.GetCapabilityResponse2\xaf\x02\n\x0fModuleLifecycle\x12H\n\x05Mount\x12\x1e.amplifier.module.MountRequest\x1a\x1f.amplifier.module.MountResponse\x12;\n\x07\x43leanup\x12\x17.amplifier.module.Empty\x1a\x17.amplifier.module.Empty\x12M\n\x0bHealthCheck\x12\x17.amplifier.module.Empty\x1a%.amplifier.module.HealthCheckResponse\x12\x46\n\rGetModuleInfo\x12\x17.amplifier.module.Empty\x1a\x1c.amplifier.module.ModuleInfob\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x16\x61mplifier_module.proto\x12\x10\x61mplifier.module\"\x07\n\x05\x45mpty\"F\n\x08ToolSpec\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x17\n\x0fparameters_json\x18\x03 \x01(\t\"9\n\x12ToolExecuteRequest\x12\r\n\x05input\x18\x01 \x01(\x0c\x12\x14\n\x0c\x63ontent_type\x18\x02 \x01(\t\"\x93\x01\n\x13ToolExecuteResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0e\n\x06output\x18\x02 \x01(\x0c\x12\x14\n\x0c\x63ontent_type\x18\x03 \x01(\t\x12\r\n\x05\x65rror\x18\x04 \x01(\t\x12\x36\n\x0e\x63ontent_blocks\x18\x05 \x03(\x0b\x32\x1e.amplifier.module.ContentBlock\"\xd6\x01\n\nModuleInfo\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0f\n\x07version\x18\x03 \x01(\t\x12\x31\n\x0bmodule_type\x18\x04 \x01(\x0e\x32\x1c.amplifier.module.ModuleType\x12\x13\n\x0bmount_point\x18\x05 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x06 \x01(\t\x12\x1a\n\x12\x63onfig_schema_json\x18\x07 \x01(\t\x12\x14\n\x0c\x63\x61pabilities\x18\x08 \x03(\t\x12\x0e\n\x06\x61uthor\x18\t \x01(\t\"\x8c\x01\n\x0cMountRequest\x12:\n\x06\x63onfig\x18\x01 \x03(\x0b\x32*.amplifier.module.MountRequest.ConfigEntry\x12\x11\n\tmodule_id\x18\x02 \x01(\t\x1a-\n\x0b\x43onfigEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"_\n\rMountResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12.\n\x06status\x18\x03 \x01(\x0e\x32\x1e.amplifier.module.HealthStatus\"V\n\x13HealthCheckResponse\x12.\n\x06status\x18\x01 \x01(\x0e\x32\x1e.amplifier.module.HealthStatus\x12\x0f\n\x07message\x18\x02 \x01(\t\"\xca\x02\n\x0b\x43onfigField\x12\n\n\x02id\x18\x01 \x01(\t\x12\x14\n\x0c\x64isplay_name\x18\x02 \x01(\t\x12\x35\n\nfield_type\x18\x03 \x01(\x0e\x32!.amplifier.module.ConfigFieldType\x12\x0e\n\x06prompt\x18\x04 \x01(\t\x12\x0f\n\x07\x65nv_var\x18\x05 \x01(\t\x12\x0f\n\x07\x63hoices\x18\x06 \x03(\t\x12\x10\n\x08required\x18\x07 \x01(\x08\x12\x15\n\rdefault_value\x18\x08 \x01(\t\x12>\n\tshow_when\x18\t \x03(\x0b\x32+.amplifier.module.ConfigField.ShowWhenEntry\x12\x16\n\x0erequires_model\x18\n \x01(\x08\x1a/\n\rShowWhenEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xbc\x01\n\rProviderError\x12\x37\n\nerror_type\x18\x01 \x01(\x0e\x32#.amplifier.module.ProviderErrorType\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x15\n\rprovider_name\x18\x03 \x01(\t\x12\r\n\x05model\x18\x04 \x01(\t\x12\x13\n\x0bstatus_code\x18\x05 \x01(\x05\x12\x11\n\tretryable\x18\x06 \x01(\x08\x12\x13\n\x0bretry_after\x18\x07 \x01(\x01\"\x97\x01\n\tToolError\x12\x33\n\nerror_type\x18\x01 \x01(\x0e\x32\x1f.amplifier.module.ToolErrorType\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x11\n\ttool_name\x18\x03 \x01(\t\x12\x0e\n\x06stdout\x18\x04 \x01(\t\x12\x0e\n\x06stderr\x18\x05 \x01(\t\x12\x11\n\texit_code\x18\x06 \x01(\x05\"d\n\tHookError\x12\x33\n\nerror_type\x18\x01 \x01(\x0e\x32\x1f.amplifier.module.HookErrorType\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x11\n\thook_name\x18\x03 \x01(\t\"\xef\x01\n\x0e\x41mplifierError\x12\x39\n\x0eprovider_error\x18\x01 \x01(\x0b\x32\x1f.amplifier.module.ProviderErrorH\x00\x12\x31\n\ntool_error\x18\x02 \x01(\x0b\x32\x1b.amplifier.module.ToolErrorH\x00\x12\x31\n\nhook_error\x18\x03 \x01(\x0b\x32\x1b.amplifier.module.HookErrorH\x00\x12\x17\n\rgeneric_error\x18\x04 \x01(\tH\x00\x12\x1a\n\x10validation_error\x18\x05 \x01(\tH\x00\x42\x07\n\x05\x65rror\"\x19\n\tTextBlock\x12\x0c\n\x04text\x18\x01 \x01(\t\"E\n\rThinkingBlock\x12\x10\n\x08thinking\x18\x01 \x01(\t\x12\x11\n\tsignature\x18\x02 \x01(\t\x12\x0f\n\x07\x63ontent\x18\x03 \x01(\t\"%\n\x15RedactedThinkingBlock\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\t\"=\n\rToolCallBlock\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x12\n\ninput_json\x18\x03 \x01(\t\"<\n\x0fToolResultBlock\x12\x14\n\x0ctool_call_id\x18\x01 \x01(\t\x12\x13\n\x0boutput_json\x18\x02 \x01(\t\"C\n\nImageBlock\x12\x12\n\nmedia_type\x18\x01 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\x12\x13\n\x0bsource_json\x18\x03 \x01(\t\"2\n\x0eReasoningBlock\x12\x0f\n\x07\x63ontent\x18\x01 \x03(\t\x12\x0f\n\x07summary\x18\x02 \x03(\t\"\xf1\x03\n\x0c\x43ontentBlock\x12\x31\n\ntext_block\x18\x01 \x01(\x0b\x32\x1b.amplifier.module.TextBlockH\x00\x12\x39\n\x0ethinking_block\x18\x02 \x01(\x0b\x32\x1f.amplifier.module.ThinkingBlockH\x00\x12J\n\x17redacted_thinking_block\x18\x03 \x01(\x0b\x32\'.amplifier.module.RedactedThinkingBlockH\x00\x12:\n\x0ftool_call_block\x18\x04 \x01(\x0b\x32\x1f.amplifier.module.ToolCallBlockH\x00\x12>\n\x11tool_result_block\x18\x05 \x01(\x0b\x32!.amplifier.module.ToolResultBlockH\x00\x12\x33\n\x0bimage_block\x18\x06 \x01(\x0b\x32\x1c.amplifier.module.ImageBlockH\x00\x12;\n\x0freasoning_block\x18\x07 \x01(\x0b\x32 .amplifier.module.ReasoningBlockH\x00\x12\x30\n\nvisibility\x18\x08 \x01(\x0e\x32\x1c.amplifier.module.VisibilityB\x07\n\x05\x62lock\"B\n\x10\x43ontentBlockList\x12.\n\x06\x62locks\x18\x01 \x03(\x0b\x32\x1e.amplifier.module.ContentBlock\"\xca\x01\n\x07Message\x12$\n\x04role\x18\x01 \x01(\x0e\x32\x16.amplifier.module.Role\x12\x16\n\x0ctext_content\x18\x02 \x01(\tH\x00\x12;\n\rblock_content\x18\x03 \x01(\x0b\x32\".amplifier.module.ContentBlockListH\x00\x12\x0c\n\x04name\x18\x04 \x01(\t\x12\x14\n\x0ctool_call_id\x18\x05 \x01(\t\x12\x15\n\rmetadata_json\x18\x06 \x01(\tB\t\n\x07\x63ontent\"C\n\x0fToolCallMessage\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x16\n\x0e\x61rguments_json\x18\x03 \x01(\t\"K\n\rToolSpecProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x17\n\x0fparameters_json\x18\x03 \x01(\t\"7\n\x10JsonSchemaFormat\x12\x13\n\x0bschema_json\x18\x01 \x01(\t\x12\x0e\n\x06strict\x18\x02 \x01(\x08\"u\n\x0eResponseFormat\x12\x0e\n\x04text\x18\x01 \x01(\x08H\x00\x12\x0e\n\x04json\x18\x02 \x01(\x08H\x00\x12\x39\n\x0bjson_schema\x18\x03 \x01(\x0b\x32\".amplifier.module.JsonSchemaFormatH\x00\x42\x08\n\x06\x66ormat\"\x9b\x02\n\x05Usage\x12\x15\n\rprompt_tokens\x18\x01 \x01(\x05\x12\x19\n\x11\x63ompletion_tokens\x18\x02 \x01(\x05\x12\x14\n\x0ctotal_tokens\x18\x03 \x01(\x05\x12\x1d\n\x10reasoning_tokens\x18\x04 \x01(\x05H\x00\x88\x01\x01\x12\x1e\n\x11\x63\x61\x63he_read_tokens\x18\x05 \x01(\x05H\x01\x88\x01\x01\x12\"\n\x15\x63\x61\x63he_creation_tokens\x18\x06 \x01(\x05H\x02\x88\x01\x01\x12\x15\n\x08\x63ost_usd\x18\x07 \x01(\tH\x03\x88\x01\x01\x42\x13\n\x11_reasoning_tokensB\x14\n\x12_cache_read_tokensB\x18\n\x16_cache_creation_tokensB\x0b\n\t_cost_usd\"@\n\x0b\x44\x65gradation\x12\x11\n\trequested\x18\x01 \x01(\t\x12\x0e\n\x06\x61\x63tual\x18\x02 \x01(\t\x12\x0e\n\x06reason\x18\x03 \x01(\t\"\x81\x03\n\x0b\x43hatRequest\x12+\n\x08messages\x18\x01 \x03(\x0b\x32\x19.amplifier.module.Message\x12.\n\x05tools\x18\x02 \x03(\x0b\x32\x1f.amplifier.module.ToolSpecProto\x12\x39\n\x0fresponse_format\x18\x03 \x01(\x0b\x32 .amplifier.module.ResponseFormat\x12\x13\n\x0btemperature\x18\x04 \x01(\x01\x12\r\n\x05top_p\x18\x05 \x01(\x01\x12\x19\n\x11max_output_tokens\x18\x06 \x01(\x05\x12\x17\n\x0f\x63onversation_id\x18\x07 \x01(\t\x12\x0e\n\x06stream\x18\x08 \x01(\x08\x12\x15\n\rmetadata_json\x18\t \x01(\t\x12\r\n\x05model\x18\n \x01(\t\x12\x13\n\x0btool_choice\x18\x0b \x01(\t\x12\x0c\n\x04stop\x18\x0c \x03(\t\x12\x18\n\x10reasoning_effort\x18\r \x01(\t\x12\x0f\n\x07timeout\x18\x0e \x01(\x01\"\x98\x02\n\x0c\x43hatResponse\x12\x0f\n\x07\x63ontent\x18\x01 \x01(\t\x12\x35\n\ntool_calls\x18\x02 \x03(\x0b\x32!.amplifier.module.ToolCallMessage\x12&\n\x05usage\x18\x03 \x01(\x0b\x32\x17.amplifier.module.Usage\x12\x32\n\x0b\x64\x65gradation\x18\x04 \x01(\x0b\x32\x1d.amplifier.module.Degradation\x12\x15\n\rfinish_reason\x18\x05 \x01(\t\x12\x15\n\rmetadata_json\x18\x06 \x01(\t\x12\x36\n\x0e\x63ontent_blocks\x18\x07 \x03(\x0b\x32\x1e.amplifier.module.ContentBlock\"~\n\nToolResult\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x13\n\x0boutput_json\x18\x02 \x01(\t\x12\x12\n\nerror_json\x18\x03 \x01(\t\x12\x36\n\x0e\x63ontent_blocks\x18\x04 \x03(\x0b\x32\x1e.amplifier.module.ContentBlock\"\xb2\x01\n\x10\x43ontextInjection\x12\x0f\n\x07\x63ontent\x18\x01 \x01(\t\x12\x34\n\x04role\x18\x02 \x01(\x0e\x32&.amplifier.module.ContextInjectionRole\x12\x11\n\tephemeral\x18\x03 \x01(\x08\x12\"\n\x1a\x61ppend_to_last_tool_result\x18\x04 \x01(\x08\x12\x11\n\thook_name\x18\x05 \x01(\t\x12\r\n\x05\x65vent\x18\x06 \x01(\t\"\xe7\x04\n\nHookResult\x12,\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x1c.amplifier.module.HookAction\x12\x11\n\tdata_json\x18\x02 \x01(\t\x12\x0e\n\x06reason\x18\x03 \x01(\t\x12\x19\n\x11\x63ontext_injection\x18\x04 \x01(\t\x12\x46\n\x16\x63ontext_injection_role\x18\x05 \x01(\x0e\x32&.amplifier.module.ContextInjectionRole\x12\x11\n\tephemeral\x18\x06 \x01(\x08\x12\x17\n\x0f\x61pproval_prompt\x18\x07 \x01(\t\x12\x18\n\x10\x61pproval_options\x18\x08 \x03(\t\x12\x1d\n\x10\x61pproval_timeout\x18\t \x01(\x01H\x00\x88\x01\x01\x12;\n\x10\x61pproval_default\x18\n \x01(\x0e\x32!.amplifier.module.ApprovalDefault\x12\x17\n\x0fsuppress_output\x18\x0b \x01(\x08\x12\x14\n\x0cuser_message\x18\x0c \x01(\t\x12>\n\x12user_message_level\x18\r \x01(\x0e\x32\".amplifier.module.UserMessageLevel\x12\x1b\n\x13user_message_source\x18\x0e \x01(\t\x12\"\n\x1a\x61ppend_to_last_tool_result\x18\x0f \x01(\x08\x12>\n\x12\x63ontext_injections\x18\x10 \x03(\x0b\x32\".amplifier.module.ContextInjectionB\x13\n\x11_approval_timeout\"\xa3\x01\n\tModelInfo\x12\n\n\x02id\x18\x01 \x01(\t\x12\x14\n\x0c\x64isplay_name\x18\x02 \x01(\t\x12\x16\n\x0e\x63ontext_window\x18\x03 \x01(\x05\x12\x19\n\x11max_output_tokens\x18\x04 \x01(\x05\x12\x14\n\x0c\x63\x61pabilities\x18\x05 \x03(\t\x12\x15\n\rdefaults_json\x18\x06 \x01(\t\x12\x14\n\x0cpricing_json\x18\x07 \x01(\t\"\xb0\x01\n\x0cProviderInfo\x12\n\n\x02id\x18\x01 \x01(\t\x12\x14\n\x0c\x64isplay_name\x18\x02 \x01(\t\x12\x1b\n\x13\x63redential_env_vars\x18\x03 \x03(\t\x12\x14\n\x0c\x63\x61pabilities\x18\x04 \x03(\t\x12\x15\n\rdefaults_json\x18\x05 \x01(\t\x12\x34\n\rconfig_fields\x18\x06 \x03(\x0b\x32\x1d.amplifier.module.ConfigField\"\x80\x01\n\x0f\x41pprovalRequest\x12\x11\n\ttool_name\x18\x01 \x01(\t\x12\x0e\n\x06\x61\x63tion\x18\x02 \x01(\t\x12\x14\n\x0c\x64\x65tails_json\x18\x03 \x01(\t\x12\x12\n\nrisk_level\x18\x04 \x01(\t\x12\x14\n\x07timeout\x18\x05 \x01(\x01H\x00\x88\x01\x01\x42\n\n\x08_timeout\"F\n\x10\x41pprovalResponse\x12\x10\n\x08\x61pproved\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x10\n\x08remember\x18\x03 \x01(\x08\"A\n\x12ListModelsResponse\x12+\n\x06models\x18\x01 \x03(\x0b\x32\x1b.amplifier.module.ModelInfo\"O\n\x16ParseToolCallsResponse\x12\x35\n\ntool_calls\x18\x01 \x03(\x0b\x32!.amplifier.module.ToolCallMessage\"@\n\x1aOrchestratorExecuteRequest\x12\x0e\n\x06prompt\x18\x01 \x01(\t\x12\x12\n\nsession_id\x18\x02 \x01(\t\">\n\x1bOrchestratorExecuteResponse\x12\x10\n\x08response\x18\x01 \x01(\t\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"?\n\x11\x41\x64\x64MessageRequest\x12*\n\x07message\x18\x01 \x01(\x0b\x32\x19.amplifier.module.Message\"B\n\x13GetMessagesResponse\x12+\n\x08messages\x18\x01 \x03(\x0b\x32\x19.amplifier.module.Message\"J\n\x1bGetMessagesForRequestParams\x12\x14\n\x0ctoken_budget\x18\x01 \x01(\x05\x12\x15\n\rprovider_name\x18\x02 \x01(\t\"A\n\x12SetMessagesRequest\x12+\n\x08messages\x18\x01 \x03(\x0b\x32\x19.amplifier.module.Message\"5\n\x11HookHandleRequest\x12\r\n\x05\x65vent\x18\x01 \x01(\t\x12\x11\n\tdata_json\x18\x02 \x01(\t\".\n\x17GetSubscriptionsRequest\x12\x13\n\x0b\x63onfig_json\x18\x01 \x01(\t\"V\n\x18GetSubscriptionsResponse\x12:\n\rsubscriptions\x18\x01 \x03(\x0b\x32#.amplifier.module.EventSubscription\"B\n\x11\x45ventSubscription\x12\r\n\x05\x65vent\x18\x01 \x01(\t\x12\x10\n\x08priority\x18\x02 \x01(\x05\x12\x0c\n\x04name\x18\x03 \x01(\t\"d\n\x1b\x43ompleteWithProviderRequest\x12\x15\n\rprovider_name\x18\x01 \x01(\t\x12.\n\x07request\x18\x02 \x01(\x0b\x32\x1d.amplifier.module.ChatRequest\";\n\x12\x45xecuteToolRequest\x12\x11\n\ttool_name\x18\x01 \x01(\t\x12\x12\n\ninput_json\x18\x02 \x01(\t\"3\n\x0f\x45mitHookRequest\x12\r\n\x05\x65vent\x18\x01 \x01(\t\x12\x11\n\tdata_json\x18\x02 \x01(\t\"V\n\x19\x45mitHookAndCollectRequest\x12\r\n\x05\x65vent\x18\x01 \x01(\t\x12\x11\n\tdata_json\x18\x02 \x01(\t\x12\x17\n\x0ftimeout_seconds\x18\x03 \x01(\x01\"4\n\x1a\x45mitHookAndCollectResponse\x12\x16\n\x0eresponses_json\x18\x01 \x03(\t\"(\n\x12GetMessagesRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\"Y\n\x17KernelAddMessageRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12*\n\x07message\x18\x02 \x01(\x0b\x32\x19.amplifier.module.Message\"a\n\x17GetMountedModuleRequest\x12\x13\n\x0bmodule_name\x18\x01 \x01(\t\x12\x31\n\x0bmodule_type\x18\x02 \x01(\x0e\x32\x1c.amplifier.module.ModuleType\"U\n\x18GetMountedModuleResponse\x12\r\n\x05\x66ound\x18\x01 \x01(\x08\x12*\n\x04info\x18\x02 \x01(\x0b\x32\x1c.amplifier.module.ModuleInfo\"=\n\x19RegisterCapabilityRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\nvalue_json\x18\x02 \x01(\t\"$\n\x14GetCapabilityRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\":\n\x15GetCapabilityResponse\x12\r\n\x05\x66ound\x18\x01 \x01(\x08\x12\x12\n\nvalue_json\x18\x02 \x01(\t*\xbc\x01\n\nModuleType\x12\x1b\n\x17MODULE_TYPE_UNSPECIFIED\x10\x00\x12\x18\n\x14MODULE_TYPE_PROVIDER\x10\x01\x12\x14\n\x10MODULE_TYPE_TOOL\x10\x02\x12\x14\n\x10MODULE_TYPE_HOOK\x10\x03\x12\x16\n\x12MODULE_TYPE_MEMORY\x10\x04\x12\x19\n\x15MODULE_TYPE_GUARDRAIL\x10\x05\x12\x18\n\x14MODULE_TYPE_APPROVAL\x10\x06*\x82\x01\n\x0cHealthStatus\x12\x1d\n\x19HEALTH_STATUS_UNSPECIFIED\x10\x00\x12\x19\n\x15HEALTH_STATUS_SERVING\x10\x01\x12\x1d\n\x19HEALTH_STATUS_NOT_SERVING\x10\x02\x12\x19\n\x15HEALTH_STATUS_UNKNOWN\x10\x03*\xad\x01\n\x0f\x43onfigFieldType\x12!\n\x1d\x43ONFIG_FIELD_TYPE_UNSPECIFIED\x10\x00\x12\x1c\n\x18\x43ONFIG_FIELD_TYPE_STRING\x10\x01\x12\x1c\n\x18\x43ONFIG_FIELD_TYPE_NUMBER\x10\x02\x12\x1d\n\x19\x43ONFIG_FIELD_TYPE_BOOLEAN\x10\x03\x12\x1c\n\x18\x43ONFIG_FIELD_TYPE_SECRET\x10\x04*\xd8\x02\n\x11ProviderErrorType\x12#\n\x1fPROVIDER_ERROR_TYPE_UNSPECIFIED\x10\x00\x12\x1c\n\x18PROVIDER_ERROR_TYPE_AUTH\x10\x01\x12\"\n\x1ePROVIDER_ERROR_TYPE_RATE_LIMIT\x10\x02\x12&\n\"PROVIDER_ERROR_TYPE_CONTEXT_LENGTH\x10\x03\x12\'\n#PROVIDER_ERROR_TYPE_INVALID_REQUEST\x10\x04\x12&\n\"PROVIDER_ERROR_TYPE_CONTENT_FILTER\x10\x05\x12#\n\x1fPROVIDER_ERROR_TYPE_UNAVAILABLE\x10\x06\x12\x1f\n\x1bPROVIDER_ERROR_TYPE_TIMEOUT\x10\x07\x12\x1d\n\x19PROVIDER_ERROR_TYPE_OTHER\x10\x08*\x8c\x01\n\rToolErrorType\x12\x1f\n\x1bTOOL_ERROR_TYPE_UNSPECIFIED\x10\x00\x12\x1d\n\x19TOOL_ERROR_TYPE_EXECUTION\x10\x01\x12\x1e\n\x1aTOOL_ERROR_TYPE_VALIDATION\x10\x02\x12\x1b\n\x17TOOL_ERROR_TYPE_TIMEOUT\x10\x03*\x8c\x01\n\rHookErrorType\x12\x1f\n\x1bHOOK_ERROR_TYPE_UNSPECIFIED\x10\x00\x12\x1d\n\x19HOOK_ERROR_TYPE_EXECUTION\x10\x01\x12\x1e\n\x1aHOOK_ERROR_TYPE_VALIDATION\x10\x02\x12\x1b\n\x17HOOK_ERROR_TYPE_TIMEOUT\x10\x03*\x86\x01\n\x04Role\x12\x14\n\x10ROLE_UNSPECIFIED\x10\x00\x12\x0f\n\x0bROLE_SYSTEM\x10\x01\x12\r\n\tROLE_USER\x10\x02\x12\x12\n\x0eROLE_ASSISTANT\x10\x03\x12\r\n\tROLE_TOOL\x10\x04\x12\x11\n\rROLE_FUNCTION\x10\x05\x12\x12\n\x0eROLE_DEVELOPER\x10\x06*o\n\nVisibility\x12\x1a\n\x16VISIBILITY_UNSPECIFIED\x10\x00\x12\x12\n\x0eVISIBILITY_ALL\x10\x01\x12\x17\n\x13VISIBILITY_LLM_ONLY\x10\x02\x12\x18\n\x14VISIBILITY_USER_ONLY\x10\x03*\xab\x01\n\nHookAction\x12\x1b\n\x17HOOK_ACTION_UNSPECIFIED\x10\x00\x12\x18\n\x14HOOK_ACTION_CONTINUE\x10\x01\x12\x16\n\x12HOOK_ACTION_MODIFY\x10\x02\x12\x14\n\x10HOOK_ACTION_DENY\x10\x03\x12\x1e\n\x1aHOOK_ACTION_INJECT_CONTEXT\x10\x04\x12\x18\n\x14HOOK_ACTION_ASK_USER\x10\x05*\xa8\x01\n\x14\x43ontextInjectionRole\x12&\n\"CONTEXT_INJECTION_ROLE_UNSPECIFIED\x10\x00\x12!\n\x1d\x43ONTEXT_INJECTION_ROLE_SYSTEM\x10\x01\x12\x1f\n\x1b\x43ONTEXT_INJECTION_ROLE_USER\x10\x02\x12$\n CONTEXT_INJECTION_ROLE_ASSISTANT\x10\x03*l\n\x0f\x41pprovalDefault\x12 \n\x1c\x41PPROVAL_DEFAULT_UNSPECIFIED\x10\x00\x12\x1c\n\x18\x41PPROVAL_DEFAULT_APPROVE\x10\x01\x12\x19\n\x15\x41PPROVAL_DEFAULT_DENY\x10\x02*\x91\x01\n\x10UserMessageLevel\x12\"\n\x1eUSER_MESSAGE_LEVEL_UNSPECIFIED\x10\x00\x12\x1b\n\x17USER_MESSAGE_LEVEL_INFO\x10\x01\x12\x1e\n\x1aUSER_MESSAGE_LEVEL_WARNING\x10\x02\x12\x1c\n\x18USER_MESSAGE_LEVEL_ERROR\x10\x03\x32\xa5\x01\n\x0bToolService\x12>\n\x07GetSpec\x12\x17.amplifier.module.Empty\x1a\x1a.amplifier.module.ToolSpec\x12V\n\x07\x45xecute\x12$.amplifier.module.ToolExecuteRequest\x1a%.amplifier.module.ToolExecuteResponse2\x9f\x03\n\x0fProviderService\x12\x42\n\x07GetInfo\x12\x17.amplifier.module.Empty\x1a\x1e.amplifier.module.ProviderInfo\x12K\n\nListModels\x12\x17.amplifier.module.Empty\x1a$.amplifier.module.ListModelsResponse\x12I\n\x08\x43omplete\x12\x1d.amplifier.module.ChatRequest\x1a\x1e.amplifier.module.ChatResponse\x12T\n\x11\x43ompleteStreaming\x12\x1d.amplifier.module.ChatRequest\x1a\x1e.amplifier.module.ChatResponse0\x01\x12Z\n\x0eParseToolCalls\x12\x1e.amplifier.module.ChatResponse\x1a(.amplifier.module.ParseToolCallsResponse2}\n\x13OrchestratorService\x12\x66\n\x07\x45xecute\x12,.amplifier.module.OrchestratorExecuteRequest\x1a-.amplifier.module.OrchestratorExecuteResponse2\xa3\x03\n\x0e\x43ontextService\x12J\n\nAddMessage\x12#.amplifier.module.AddMessageRequest\x1a\x17.amplifier.module.Empty\x12M\n\x0bGetMessages\x12\x17.amplifier.module.Empty\x1a%.amplifier.module.GetMessagesResponse\x12m\n\x15GetMessagesForRequest\x12-.amplifier.module.GetMessagesForRequestParams\x1a%.amplifier.module.GetMessagesResponse\x12L\n\x0bSetMessages\x12$.amplifier.module.SetMessagesRequest\x1a\x17.amplifier.module.Empty\x12\x39\n\x05\x43lear\x12\x17.amplifier.module.Empty\x1a\x17.amplifier.module.Empty2\xc5\x01\n\x0bHookService\x12K\n\x06Handle\x12#.amplifier.module.HookHandleRequest\x1a\x1c.amplifier.module.HookResult\x12i\n\x10GetSubscriptions\x12).amplifier.module.GetSubscriptionsRequest\x1a*.amplifier.module.GetSubscriptionsResponse2k\n\x0f\x41pprovalService\x12X\n\x0fRequestApproval\x12!.amplifier.module.ApprovalRequest\x1a\".amplifier.module.ApprovalResponse2\xd0\x07\n\rKernelService\x12\x65\n\x14\x43ompleteWithProvider\x12-.amplifier.module.CompleteWithProviderRequest\x1a\x1e.amplifier.module.ChatResponse\x12p\n\x1d\x43ompleteWithProviderStreaming\x12-.amplifier.module.CompleteWithProviderRequest\x1a\x1e.amplifier.module.ChatResponse0\x01\x12Q\n\x0b\x45xecuteTool\x12$.amplifier.module.ExecuteToolRequest\x1a\x1c.amplifier.module.ToolResult\x12K\n\x08\x45mitHook\x12!.amplifier.module.EmitHookRequest\x1a\x1c.amplifier.module.HookResult\x12o\n\x12\x45mitHookAndCollect\x12+.amplifier.module.EmitHookAndCollectRequest\x1a,.amplifier.module.EmitHookAndCollectResponse\x12Z\n\x0bGetMessages\x12$.amplifier.module.GetMessagesRequest\x1a%.amplifier.module.GetMessagesResponse\x12P\n\nAddMessage\x12).amplifier.module.KernelAddMessageRequest\x1a\x17.amplifier.module.Empty\x12i\n\x10GetMountedModule\x12).amplifier.module.GetMountedModuleRequest\x1a*.amplifier.module.GetMountedModuleResponse\x12Z\n\x12RegisterCapability\x12+.amplifier.module.RegisterCapabilityRequest\x1a\x17.amplifier.module.Empty\x12`\n\rGetCapability\x12&.amplifier.module.GetCapabilityRequest\x1a\'.amplifier.module.GetCapabilityResponse2\xaf\x02\n\x0fModuleLifecycle\x12H\n\x05Mount\x12\x1e.amplifier.module.MountRequest\x1a\x1f.amplifier.module.MountResponse\x12;\n\x07\x43leanup\x12\x17.amplifier.module.Empty\x1a\x17.amplifier.module.Empty\x12M\n\x0bHealthCheck\x12\x17.amplifier.module.Empty\x1a%.amplifier.module.HealthCheckResponse\x12\x46\n\rGetModuleInfo\x12\x17.amplifier.module.Empty\x1a\x1c.amplifier.module.ModuleInfob\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -35,170 +35,172 @@ _globals['_MOUNTREQUEST_CONFIGENTRY']._serialized_options = b'8\001' _globals['_CONFIGFIELD_SHOWWHENENTRY']._loaded_options = None _globals['_CONFIGFIELD_SHOWWHENENTRY']._serialized_options = b'8\001' - _globals['_MODULETYPE']._serialized_start=7113 - _globals['_MODULETYPE']._serialized_end=7301 - _globals['_HEALTHSTATUS']._serialized_start=7304 - _globals['_HEALTHSTATUS']._serialized_end=7434 - _globals['_CONFIGFIELDTYPE']._serialized_start=7437 - _globals['_CONFIGFIELDTYPE']._serialized_end=7610 - _globals['_PROVIDERERRORTYPE']._serialized_start=7613 - _globals['_PROVIDERERRORTYPE']._serialized_end=7957 - _globals['_TOOLERRORTYPE']._serialized_start=7960 - _globals['_TOOLERRORTYPE']._serialized_end=8100 - _globals['_HOOKERRORTYPE']._serialized_start=8103 - _globals['_HOOKERRORTYPE']._serialized_end=8243 - _globals['_ROLE']._serialized_start=8246 - _globals['_ROLE']._serialized_end=8380 - _globals['_VISIBILITY']._serialized_start=8382 - _globals['_VISIBILITY']._serialized_end=8493 - _globals['_HOOKACTION']._serialized_start=8496 - _globals['_HOOKACTION']._serialized_end=8667 - _globals['_CONTEXTINJECTIONROLE']._serialized_start=8670 - _globals['_CONTEXTINJECTIONROLE']._serialized_end=8838 - _globals['_APPROVALDEFAULT']._serialized_start=8840 - _globals['_APPROVALDEFAULT']._serialized_end=8948 - _globals['_USERMESSAGELEVEL']._serialized_start=8951 - _globals['_USERMESSAGELEVEL']._serialized_end=9096 + _globals['_MODULETYPE']._serialized_start=7529 + _globals['_MODULETYPE']._serialized_end=7717 + _globals['_HEALTHSTATUS']._serialized_start=7720 + _globals['_HEALTHSTATUS']._serialized_end=7850 + _globals['_CONFIGFIELDTYPE']._serialized_start=7853 + _globals['_CONFIGFIELDTYPE']._serialized_end=8026 + _globals['_PROVIDERERRORTYPE']._serialized_start=8029 + _globals['_PROVIDERERRORTYPE']._serialized_end=8373 + _globals['_TOOLERRORTYPE']._serialized_start=8376 + _globals['_TOOLERRORTYPE']._serialized_end=8516 + _globals['_HOOKERRORTYPE']._serialized_start=8519 + _globals['_HOOKERRORTYPE']._serialized_end=8659 + _globals['_ROLE']._serialized_start=8662 + _globals['_ROLE']._serialized_end=8796 + _globals['_VISIBILITY']._serialized_start=8798 + _globals['_VISIBILITY']._serialized_end=8909 + _globals['_HOOKACTION']._serialized_start=8912 + _globals['_HOOKACTION']._serialized_end=9083 + _globals['_CONTEXTINJECTIONROLE']._serialized_start=9086 + _globals['_CONTEXTINJECTIONROLE']._serialized_end=9254 + _globals['_APPROVALDEFAULT']._serialized_start=9256 + _globals['_APPROVALDEFAULT']._serialized_end=9364 + _globals['_USERMESSAGELEVEL']._serialized_start=9367 + _globals['_USERMESSAGELEVEL']._serialized_end=9512 _globals['_EMPTY']._serialized_start=44 _globals['_EMPTY']._serialized_end=51 _globals['_TOOLSPEC']._serialized_start=53 _globals['_TOOLSPEC']._serialized_end=123 _globals['_TOOLEXECUTEREQUEST']._serialized_start=125 _globals['_TOOLEXECUTEREQUEST']._serialized_end=182 - _globals['_TOOLEXECUTERESPONSE']._serialized_start=184 - _globals['_TOOLEXECUTERESPONSE']._serialized_end=275 - _globals['_MODULEINFO']._serialized_start=278 - _globals['_MODULEINFO']._serialized_end=492 - _globals['_MOUNTREQUEST']._serialized_start=495 - _globals['_MOUNTREQUEST']._serialized_end=635 - _globals['_MOUNTREQUEST_CONFIGENTRY']._serialized_start=590 - _globals['_MOUNTREQUEST_CONFIGENTRY']._serialized_end=635 - _globals['_MOUNTRESPONSE']._serialized_start=637 - _globals['_MOUNTRESPONSE']._serialized_end=732 - _globals['_HEALTHCHECKRESPONSE']._serialized_start=734 - _globals['_HEALTHCHECKRESPONSE']._serialized_end=820 - _globals['_CONFIGFIELD']._serialized_start=823 - _globals['_CONFIGFIELD']._serialized_end=1153 - _globals['_CONFIGFIELD_SHOWWHENENTRY']._serialized_start=1106 - _globals['_CONFIGFIELD_SHOWWHENENTRY']._serialized_end=1153 - _globals['_PROVIDERERROR']._serialized_start=1156 - _globals['_PROVIDERERROR']._serialized_end=1344 - _globals['_TOOLERROR']._serialized_start=1347 - _globals['_TOOLERROR']._serialized_end=1498 - _globals['_HOOKERROR']._serialized_start=1500 - _globals['_HOOKERROR']._serialized_end=1600 - _globals['_AMPLIFIERERROR']._serialized_start=1603 - _globals['_AMPLIFIERERROR']._serialized_end=1842 - _globals['_TEXTBLOCK']._serialized_start=1844 - _globals['_TEXTBLOCK']._serialized_end=1869 - _globals['_THINKINGBLOCK']._serialized_start=1871 - _globals['_THINKINGBLOCK']._serialized_end=1940 - _globals['_REDACTEDTHINKINGBLOCK']._serialized_start=1942 - _globals['_REDACTEDTHINKINGBLOCK']._serialized_end=1979 - _globals['_TOOLCALLBLOCK']._serialized_start=1981 - _globals['_TOOLCALLBLOCK']._serialized_end=2042 - _globals['_TOOLRESULTBLOCK']._serialized_start=2044 - _globals['_TOOLRESULTBLOCK']._serialized_end=2104 - _globals['_IMAGEBLOCK']._serialized_start=2106 - _globals['_IMAGEBLOCK']._serialized_end=2173 - _globals['_REASONINGBLOCK']._serialized_start=2175 - _globals['_REASONINGBLOCK']._serialized_end=2225 - _globals['_CONTENTBLOCK']._serialized_start=2228 - _globals['_CONTENTBLOCK']._serialized_end=2725 - _globals['_CONTENTBLOCKLIST']._serialized_start=2727 - _globals['_CONTENTBLOCKLIST']._serialized_end=2793 - _globals['_MESSAGE']._serialized_start=2796 - _globals['_MESSAGE']._serialized_end=2998 - _globals['_TOOLCALLMESSAGE']._serialized_start=3000 - _globals['_TOOLCALLMESSAGE']._serialized_end=3067 - _globals['_TOOLSPECPROTO']._serialized_start=3069 - _globals['_TOOLSPECPROTO']._serialized_end=3144 - _globals['_JSONSCHEMAFORMAT']._serialized_start=3146 - _globals['_JSONSCHEMAFORMAT']._serialized_end=3201 - _globals['_RESPONSEFORMAT']._serialized_start=3203 - _globals['_RESPONSEFORMAT']._serialized_end=3320 - _globals['_USAGE']._serialized_start=3323 - _globals['_USAGE']._serialized_end=3570 - _globals['_DEGRADATION']._serialized_start=3572 - _globals['_DEGRADATION']._serialized_end=3636 - _globals['_CHATREQUEST']._serialized_start=3639 - _globals['_CHATREQUEST']._serialized_end=4024 - _globals['_CHATRESPONSE']._serialized_start=4027 - _globals['_CHATRESPONSE']._serialized_end=4307 - _globals['_TOOLRESULT']._serialized_start=4309 - _globals['_TOOLRESULT']._serialized_end=4379 - _globals['_HOOKRESULT']._serialized_start=4382 - _globals['_HOOKRESULT']._serialized_end=4933 - _globals['_MODELINFO']._serialized_start=4936 - _globals['_MODELINFO']._serialized_end=5077 - _globals['_PROVIDERINFO']._serialized_start=5080 - _globals['_PROVIDERINFO']._serialized_end=5256 - _globals['_APPROVALREQUEST']._serialized_start=5259 - _globals['_APPROVALREQUEST']._serialized_end=5387 - _globals['_APPROVALRESPONSE']._serialized_start=5389 - _globals['_APPROVALRESPONSE']._serialized_end=5459 - _globals['_LISTMODELSRESPONSE']._serialized_start=5461 - _globals['_LISTMODELSRESPONSE']._serialized_end=5526 - _globals['_PARSETOOLCALLSRESPONSE']._serialized_start=5528 - _globals['_PARSETOOLCALLSRESPONSE']._serialized_end=5607 - _globals['_ORCHESTRATOREXECUTEREQUEST']._serialized_start=5609 - _globals['_ORCHESTRATOREXECUTEREQUEST']._serialized_end=5673 - _globals['_ORCHESTRATOREXECUTERESPONSE']._serialized_start=5675 - _globals['_ORCHESTRATOREXECUTERESPONSE']._serialized_end=5737 - _globals['_ADDMESSAGEREQUEST']._serialized_start=5739 - _globals['_ADDMESSAGEREQUEST']._serialized_end=5802 - _globals['_GETMESSAGESRESPONSE']._serialized_start=5804 - _globals['_GETMESSAGESRESPONSE']._serialized_end=5870 - _globals['_GETMESSAGESFORREQUESTPARAMS']._serialized_start=5872 - _globals['_GETMESSAGESFORREQUESTPARAMS']._serialized_end=5946 - _globals['_SETMESSAGESREQUEST']._serialized_start=5948 - _globals['_SETMESSAGESREQUEST']._serialized_end=6013 - _globals['_HOOKHANDLEREQUEST']._serialized_start=6015 - _globals['_HOOKHANDLEREQUEST']._serialized_end=6068 - _globals['_GETSUBSCRIPTIONSREQUEST']._serialized_start=6070 - _globals['_GETSUBSCRIPTIONSREQUEST']._serialized_end=6116 - _globals['_GETSUBSCRIPTIONSRESPONSE']._serialized_start=6118 - _globals['_GETSUBSCRIPTIONSRESPONSE']._serialized_end=6204 - _globals['_EVENTSUBSCRIPTION']._serialized_start=6206 - _globals['_EVENTSUBSCRIPTION']._serialized_end=6272 - _globals['_COMPLETEWITHPROVIDERREQUEST']._serialized_start=6274 - _globals['_COMPLETEWITHPROVIDERREQUEST']._serialized_end=6374 - _globals['_EXECUTETOOLREQUEST']._serialized_start=6376 - _globals['_EXECUTETOOLREQUEST']._serialized_end=6435 - _globals['_EMITHOOKREQUEST']._serialized_start=6437 - _globals['_EMITHOOKREQUEST']._serialized_end=6488 - _globals['_EMITHOOKANDCOLLECTREQUEST']._serialized_start=6490 - _globals['_EMITHOOKANDCOLLECTREQUEST']._serialized_end=6576 - _globals['_EMITHOOKANDCOLLECTRESPONSE']._serialized_start=6578 - _globals['_EMITHOOKANDCOLLECTRESPONSE']._serialized_end=6630 - _globals['_GETMESSAGESREQUEST']._serialized_start=6632 - _globals['_GETMESSAGESREQUEST']._serialized_end=6672 - _globals['_KERNELADDMESSAGEREQUEST']._serialized_start=6674 - _globals['_KERNELADDMESSAGEREQUEST']._serialized_end=6763 - _globals['_GETMOUNTEDMODULEREQUEST']._serialized_start=6765 - _globals['_GETMOUNTEDMODULEREQUEST']._serialized_end=6862 - _globals['_GETMOUNTEDMODULERESPONSE']._serialized_start=6864 - _globals['_GETMOUNTEDMODULERESPONSE']._serialized_end=6949 - _globals['_REGISTERCAPABILITYREQUEST']._serialized_start=6951 - _globals['_REGISTERCAPABILITYREQUEST']._serialized_end=7012 - _globals['_GETCAPABILITYREQUEST']._serialized_start=7014 - _globals['_GETCAPABILITYREQUEST']._serialized_end=7050 - _globals['_GETCAPABILITYRESPONSE']._serialized_start=7052 - _globals['_GETCAPABILITYRESPONSE']._serialized_end=7110 - _globals['_TOOLSERVICE']._serialized_start=9099 - _globals['_TOOLSERVICE']._serialized_end=9264 - _globals['_PROVIDERSERVICE']._serialized_start=9267 - _globals['_PROVIDERSERVICE']._serialized_end=9682 - _globals['_ORCHESTRATORSERVICE']._serialized_start=9684 - _globals['_ORCHESTRATORSERVICE']._serialized_end=9809 - _globals['_CONTEXTSERVICE']._serialized_start=9812 - _globals['_CONTEXTSERVICE']._serialized_end=10231 - _globals['_HOOKSERVICE']._serialized_start=10234 - _globals['_HOOKSERVICE']._serialized_end=10431 - _globals['_APPROVALSERVICE']._serialized_start=10433 - _globals['_APPROVALSERVICE']._serialized_end=10540 - _globals['_KERNELSERVICE']._serialized_start=10543 - _globals['_KERNELSERVICE']._serialized_end=11519 - _globals['_MODULELIFECYCLE']._serialized_start=11522 - _globals['_MODULELIFECYCLE']._serialized_end=11825 + _globals['_TOOLEXECUTERESPONSE']._serialized_start=185 + _globals['_TOOLEXECUTERESPONSE']._serialized_end=332 + _globals['_MODULEINFO']._serialized_start=335 + _globals['_MODULEINFO']._serialized_end=549 + _globals['_MOUNTREQUEST']._serialized_start=552 + _globals['_MOUNTREQUEST']._serialized_end=692 + _globals['_MOUNTREQUEST_CONFIGENTRY']._serialized_start=647 + _globals['_MOUNTREQUEST_CONFIGENTRY']._serialized_end=692 + _globals['_MOUNTRESPONSE']._serialized_start=694 + _globals['_MOUNTRESPONSE']._serialized_end=789 + _globals['_HEALTHCHECKRESPONSE']._serialized_start=791 + _globals['_HEALTHCHECKRESPONSE']._serialized_end=877 + _globals['_CONFIGFIELD']._serialized_start=880 + _globals['_CONFIGFIELD']._serialized_end=1210 + _globals['_CONFIGFIELD_SHOWWHENENTRY']._serialized_start=1163 + _globals['_CONFIGFIELD_SHOWWHENENTRY']._serialized_end=1210 + _globals['_PROVIDERERROR']._serialized_start=1213 + _globals['_PROVIDERERROR']._serialized_end=1401 + _globals['_TOOLERROR']._serialized_start=1404 + _globals['_TOOLERROR']._serialized_end=1555 + _globals['_HOOKERROR']._serialized_start=1557 + _globals['_HOOKERROR']._serialized_end=1657 + _globals['_AMPLIFIERERROR']._serialized_start=1660 + _globals['_AMPLIFIERERROR']._serialized_end=1899 + _globals['_TEXTBLOCK']._serialized_start=1901 + _globals['_TEXTBLOCK']._serialized_end=1926 + _globals['_THINKINGBLOCK']._serialized_start=1928 + _globals['_THINKINGBLOCK']._serialized_end=1997 + _globals['_REDACTEDTHINKINGBLOCK']._serialized_start=1999 + _globals['_REDACTEDTHINKINGBLOCK']._serialized_end=2036 + _globals['_TOOLCALLBLOCK']._serialized_start=2038 + _globals['_TOOLCALLBLOCK']._serialized_end=2099 + _globals['_TOOLRESULTBLOCK']._serialized_start=2101 + _globals['_TOOLRESULTBLOCK']._serialized_end=2161 + _globals['_IMAGEBLOCK']._serialized_start=2163 + _globals['_IMAGEBLOCK']._serialized_end=2230 + _globals['_REASONINGBLOCK']._serialized_start=2232 + _globals['_REASONINGBLOCK']._serialized_end=2282 + _globals['_CONTENTBLOCK']._serialized_start=2285 + _globals['_CONTENTBLOCK']._serialized_end=2782 + _globals['_CONTENTBLOCKLIST']._serialized_start=2784 + _globals['_CONTENTBLOCKLIST']._serialized_end=2850 + _globals['_MESSAGE']._serialized_start=2853 + _globals['_MESSAGE']._serialized_end=3055 + _globals['_TOOLCALLMESSAGE']._serialized_start=3057 + _globals['_TOOLCALLMESSAGE']._serialized_end=3124 + _globals['_TOOLSPECPROTO']._serialized_start=3126 + _globals['_TOOLSPECPROTO']._serialized_end=3201 + _globals['_JSONSCHEMAFORMAT']._serialized_start=3203 + _globals['_JSONSCHEMAFORMAT']._serialized_end=3258 + _globals['_RESPONSEFORMAT']._serialized_start=3260 + _globals['_RESPONSEFORMAT']._serialized_end=3377 + _globals['_USAGE']._serialized_start=3380 + _globals['_USAGE']._serialized_end=3663 + _globals['_DEGRADATION']._serialized_start=3665 + _globals['_DEGRADATION']._serialized_end=3729 + _globals['_CHATREQUEST']._serialized_start=3732 + _globals['_CHATREQUEST']._serialized_end=4117 + _globals['_CHATRESPONSE']._serialized_start=4120 + _globals['_CHATRESPONSE']._serialized_end=4400 + _globals['_TOOLRESULT']._serialized_start=4402 + _globals['_TOOLRESULT']._serialized_end=4528 + _globals['_CONTEXTINJECTION']._serialized_start=4531 + _globals['_CONTEXTINJECTION']._serialized_end=4709 + _globals['_HOOKRESULT']._serialized_start=4712 + _globals['_HOOKRESULT']._serialized_end=5327 + _globals['_MODELINFO']._serialized_start=5330 + _globals['_MODELINFO']._serialized_end=5493 + _globals['_PROVIDERINFO']._serialized_start=5496 + _globals['_PROVIDERINFO']._serialized_end=5672 + _globals['_APPROVALREQUEST']._serialized_start=5675 + _globals['_APPROVALREQUEST']._serialized_end=5803 + _globals['_APPROVALRESPONSE']._serialized_start=5805 + _globals['_APPROVALRESPONSE']._serialized_end=5875 + _globals['_LISTMODELSRESPONSE']._serialized_start=5877 + _globals['_LISTMODELSRESPONSE']._serialized_end=5942 + _globals['_PARSETOOLCALLSRESPONSE']._serialized_start=5944 + _globals['_PARSETOOLCALLSRESPONSE']._serialized_end=6023 + _globals['_ORCHESTRATOREXECUTEREQUEST']._serialized_start=6025 + _globals['_ORCHESTRATOREXECUTEREQUEST']._serialized_end=6089 + _globals['_ORCHESTRATOREXECUTERESPONSE']._serialized_start=6091 + _globals['_ORCHESTRATOREXECUTERESPONSE']._serialized_end=6153 + _globals['_ADDMESSAGEREQUEST']._serialized_start=6155 + _globals['_ADDMESSAGEREQUEST']._serialized_end=6218 + _globals['_GETMESSAGESRESPONSE']._serialized_start=6220 + _globals['_GETMESSAGESRESPONSE']._serialized_end=6286 + _globals['_GETMESSAGESFORREQUESTPARAMS']._serialized_start=6288 + _globals['_GETMESSAGESFORREQUESTPARAMS']._serialized_end=6362 + _globals['_SETMESSAGESREQUEST']._serialized_start=6364 + _globals['_SETMESSAGESREQUEST']._serialized_end=6429 + _globals['_HOOKHANDLEREQUEST']._serialized_start=6431 + _globals['_HOOKHANDLEREQUEST']._serialized_end=6484 + _globals['_GETSUBSCRIPTIONSREQUEST']._serialized_start=6486 + _globals['_GETSUBSCRIPTIONSREQUEST']._serialized_end=6532 + _globals['_GETSUBSCRIPTIONSRESPONSE']._serialized_start=6534 + _globals['_GETSUBSCRIPTIONSRESPONSE']._serialized_end=6620 + _globals['_EVENTSUBSCRIPTION']._serialized_start=6622 + _globals['_EVENTSUBSCRIPTION']._serialized_end=6688 + _globals['_COMPLETEWITHPROVIDERREQUEST']._serialized_start=6690 + _globals['_COMPLETEWITHPROVIDERREQUEST']._serialized_end=6790 + _globals['_EXECUTETOOLREQUEST']._serialized_start=6792 + _globals['_EXECUTETOOLREQUEST']._serialized_end=6851 + _globals['_EMITHOOKREQUEST']._serialized_start=6853 + _globals['_EMITHOOKREQUEST']._serialized_end=6904 + _globals['_EMITHOOKANDCOLLECTREQUEST']._serialized_start=6906 + _globals['_EMITHOOKANDCOLLECTREQUEST']._serialized_end=6992 + _globals['_EMITHOOKANDCOLLECTRESPONSE']._serialized_start=6994 + _globals['_EMITHOOKANDCOLLECTRESPONSE']._serialized_end=7046 + _globals['_GETMESSAGESREQUEST']._serialized_start=7048 + _globals['_GETMESSAGESREQUEST']._serialized_end=7088 + _globals['_KERNELADDMESSAGEREQUEST']._serialized_start=7090 + _globals['_KERNELADDMESSAGEREQUEST']._serialized_end=7179 + _globals['_GETMOUNTEDMODULEREQUEST']._serialized_start=7181 + _globals['_GETMOUNTEDMODULEREQUEST']._serialized_end=7278 + _globals['_GETMOUNTEDMODULERESPONSE']._serialized_start=7280 + _globals['_GETMOUNTEDMODULERESPONSE']._serialized_end=7365 + _globals['_REGISTERCAPABILITYREQUEST']._serialized_start=7367 + _globals['_REGISTERCAPABILITYREQUEST']._serialized_end=7428 + _globals['_GETCAPABILITYREQUEST']._serialized_start=7430 + _globals['_GETCAPABILITYREQUEST']._serialized_end=7466 + _globals['_GETCAPABILITYRESPONSE']._serialized_start=7468 + _globals['_GETCAPABILITYRESPONSE']._serialized_end=7526 + _globals['_TOOLSERVICE']._serialized_start=9515 + _globals['_TOOLSERVICE']._serialized_end=9680 + _globals['_PROVIDERSERVICE']._serialized_start=9683 + _globals['_PROVIDERSERVICE']._serialized_end=10098 + _globals['_ORCHESTRATORSERVICE']._serialized_start=10100 + _globals['_ORCHESTRATORSERVICE']._serialized_end=10225 + _globals['_CONTEXTSERVICE']._serialized_start=10228 + _globals['_CONTEXTSERVICE']._serialized_end=10647 + _globals['_HOOKSERVICE']._serialized_start=10650 + _globals['_HOOKSERVICE']._serialized_end=10847 + _globals['_APPROVALSERVICE']._serialized_start=10849 + _globals['_APPROVALSERVICE']._serialized_end=10956 + _globals['_KERNELSERVICE']._serialized_start=10959 + _globals['_KERNELSERVICE']._serialized_end=11935 + _globals['_MODULELIFECYCLE']._serialized_start=11938 + _globals['_MODULELIFECYCLE']._serialized_end=12241 # @@protoc_insertion_point(module_scope) diff --git a/proto/test_task03_module_specific.py b/proto/test_task03_module_specific.py index 80c7b8b6..85d69339 100644 --- a/proto/test_task03_module_specific.py +++ b/proto/test_task03_module_specific.py @@ -114,7 +114,7 @@ def test_user_message_level_enum(): # --------------------------------------------------------------------------- def test_tool_result_message(): - """ToolResult message with 3 fields: success, output_json, error_json.""" + """ToolResult message with rich content alongside legacy fields.""" content = read_proto() assert "message ToolResult" in content, "Missing message ToolResult" m = re.search(r'message ToolResult\s*\{([^}]+)\}', content) @@ -123,6 +123,9 @@ def test_tool_result_message(): assert field_present(body, "bool success"), "ToolResult missing field: success" assert field_present(body, "string output_json"), "ToolResult missing field: output_json" assert field_present(body, "string error_json"), "ToolResult missing field: error_json" + assert field_present(body, "repeated ContentBlock content_blocks"), ( + "ToolResult missing field: content_blocks" + ) def test_hook_result_message_15_fields(): diff --git a/pyproject.toml b/pyproject.toml index 194e602b..5269da7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "amplifier-core" -version = "1.6.1" +version = "2.0.0" description = "Rust kernel with Python bindings for the Amplifier modular AI agent framework" license = "MIT" readme = "README.md" diff --git a/python/amplifier_core/__init__.py b/python/amplifier_core/__init__.py index b4e014ce..37279429 100644 --- a/python/amplifier_core/__init__.py +++ b/python/amplifier_core/__init__.py @@ -6,7 +6,7 @@ AmplifierSession`) still give the pure-Python implementations. """ -__version__ = "1.6.1" +__version__ = "2.0.0" # --- Rust-backed primary types (THE SWITCHOVER) --- # These four were previously imported from their Python submodules. @@ -74,9 +74,11 @@ from .message_models import ToolSpec from .message_models import Usage from .models import ConfigField +from .models import ContextInjection from .models import HookResult from .models import ModelInfo from .models import ModuleInfo +from .models import Pricing from .models import ProviderInfo from .models import SessionStatus from .models import ToolResult @@ -114,10 +116,12 @@ "HookRegistry", "ToolCall", "ToolResult", + "ContextInjection", "HookResult", "ConfigField", "ModelInfo", "ModuleInfo", + "Pricing", "ProviderInfo", "SessionStatus", "ApprovalRequest", diff --git a/python/amplifier_core/_engine.pyi b/python/amplifier_core/_engine.pyi index d3fb0e43..d3e70492 100644 --- a/python/amplifier_core/_engine.pyi +++ b/python/amplifier_core/_engine.pyi @@ -18,6 +18,10 @@ from typing import Any, Optional __version__: str RUST_AVAILABLE: bool +# Private helpers used by ``ToolResult`` to enforce the Rust content boundary. +def _normalize_tool_result_content(content_json: str) -> str | None: ... +def _tool_result_safe_hook_presentation(tool_result_json: str) -> str: ... + # --------------------------------------------------------------------------- # RustSession — wraps amplifier_core::Session # --------------------------------------------------------------------------- diff --git a/python/amplifier_core/_grpc_gen/__init__.py b/python/amplifier_core/_grpc_gen/__init__.py index 4b204869..8e32a4e4 100644 --- a/python/amplifier_core/_grpc_gen/__init__.py +++ b/python/amplifier_core/_grpc_gen/__init__.py @@ -1,8 +1,5 @@ """Generated gRPC stubs for amplifier_module.proto. Do not edit these files directly. Regenerate with: - python -m grpc_tools.protoc -I proto \ - --python_out=python/amplifier_core/_grpc_gen \ - --grpc_python_out=python/amplifier_core/_grpc_gen \ - proto/amplifier_module.proto + python scripts/generate_grpc_stubs.py """ diff --git a/python/amplifier_core/_grpc_gen/amplifier_module_pb2.py b/python/amplifier_core/_grpc_gen/amplifier_module_pb2.py index 6f4d7515..4d56a913 100644 --- a/python/amplifier_core/_grpc_gen/amplifier_module_pb2.py +++ b/python/amplifier_core/_grpc_gen/amplifier_module_pb2.py @@ -24,7 +24,7 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x16\x61mplifier_module.proto\x12\x10\x61mplifier.module\"\x07\n\x05\x45mpty\"F\n\x08ToolSpec\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x17\n\x0fparameters_json\x18\x03 \x01(\t\"9\n\x12ToolExecuteRequest\x12\r\n\x05input\x18\x01 \x01(\x0c\x12\x14\n\x0c\x63ontent_type\x18\x02 \x01(\t\"[\n\x13ToolExecuteResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0e\n\x06output\x18\x02 \x01(\x0c\x12\x14\n\x0c\x63ontent_type\x18\x03 \x01(\t\x12\r\n\x05\x65rror\x18\x04 \x01(\t\"\xd6\x01\n\nModuleInfo\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0f\n\x07version\x18\x03 \x01(\t\x12\x31\n\x0bmodule_type\x18\x04 \x01(\x0e\x32\x1c.amplifier.module.ModuleType\x12\x13\n\x0bmount_point\x18\x05 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x06 \x01(\t\x12\x1a\n\x12\x63onfig_schema_json\x18\x07 \x01(\t\x12\x14\n\x0c\x63\x61pabilities\x18\x08 \x03(\t\x12\x0e\n\x06\x61uthor\x18\t \x01(\t\"\x8c\x01\n\x0cMountRequest\x12:\n\x06\x63onfig\x18\x01 \x03(\x0b\x32*.amplifier.module.MountRequest.ConfigEntry\x12\x11\n\tmodule_id\x18\x02 \x01(\t\x1a-\n\x0b\x43onfigEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"_\n\rMountResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12.\n\x06status\x18\x03 \x01(\x0e\x32\x1e.amplifier.module.HealthStatus\"V\n\x13HealthCheckResponse\x12.\n\x06status\x18\x01 \x01(\x0e\x32\x1e.amplifier.module.HealthStatus\x12\x0f\n\x07message\x18\x02 \x01(\t\"\xca\x02\n\x0b\x43onfigField\x12\n\n\x02id\x18\x01 \x01(\t\x12\x14\n\x0c\x64isplay_name\x18\x02 \x01(\t\x12\x35\n\nfield_type\x18\x03 \x01(\x0e\x32!.amplifier.module.ConfigFieldType\x12\x0e\n\x06prompt\x18\x04 \x01(\t\x12\x0f\n\x07\x65nv_var\x18\x05 \x01(\t\x12\x0f\n\x07\x63hoices\x18\x06 \x03(\t\x12\x10\n\x08required\x18\x07 \x01(\x08\x12\x15\n\rdefault_value\x18\x08 \x01(\t\x12>\n\tshow_when\x18\t \x03(\x0b\x32+.amplifier.module.ConfigField.ShowWhenEntry\x12\x16\n\x0erequires_model\x18\n \x01(\x08\x1a/\n\rShowWhenEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xbc\x01\n\rProviderError\x12\x37\n\nerror_type\x18\x01 \x01(\x0e\x32#.amplifier.module.ProviderErrorType\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x15\n\rprovider_name\x18\x03 \x01(\t\x12\r\n\x05model\x18\x04 \x01(\t\x12\x13\n\x0bstatus_code\x18\x05 \x01(\x05\x12\x11\n\tretryable\x18\x06 \x01(\x08\x12\x13\n\x0bretry_after\x18\x07 \x01(\x01\"\x97\x01\n\tToolError\x12\x33\n\nerror_type\x18\x01 \x01(\x0e\x32\x1f.amplifier.module.ToolErrorType\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x11\n\ttool_name\x18\x03 \x01(\t\x12\x0e\n\x06stdout\x18\x04 \x01(\t\x12\x0e\n\x06stderr\x18\x05 \x01(\t\x12\x11\n\texit_code\x18\x06 \x01(\x05\"d\n\tHookError\x12\x33\n\nerror_type\x18\x01 \x01(\x0e\x32\x1f.amplifier.module.HookErrorType\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x11\n\thook_name\x18\x03 \x01(\t\"\xef\x01\n\x0e\x41mplifierError\x12\x39\n\x0eprovider_error\x18\x01 \x01(\x0b\x32\x1f.amplifier.module.ProviderErrorH\x00\x12\x31\n\ntool_error\x18\x02 \x01(\x0b\x32\x1b.amplifier.module.ToolErrorH\x00\x12\x31\n\nhook_error\x18\x03 \x01(\x0b\x32\x1b.amplifier.module.HookErrorH\x00\x12\x17\n\rgeneric_error\x18\x04 \x01(\tH\x00\x12\x1a\n\x10validation_error\x18\x05 \x01(\tH\x00\x42\x07\n\x05\x65rror\"\x19\n\tTextBlock\x12\x0c\n\x04text\x18\x01 \x01(\t\"E\n\rThinkingBlock\x12\x10\n\x08thinking\x18\x01 \x01(\t\x12\x11\n\tsignature\x18\x02 \x01(\t\x12\x0f\n\x07\x63ontent\x18\x03 \x01(\t\"%\n\x15RedactedThinkingBlock\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\t\"=\n\rToolCallBlock\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x12\n\ninput_json\x18\x03 \x01(\t\"<\n\x0fToolResultBlock\x12\x14\n\x0ctool_call_id\x18\x01 \x01(\t\x12\x13\n\x0boutput_json\x18\x02 \x01(\t\"C\n\nImageBlock\x12\x12\n\nmedia_type\x18\x01 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\x12\x13\n\x0bsource_json\x18\x03 \x01(\t\"2\n\x0eReasoningBlock\x12\x0f\n\x07\x63ontent\x18\x01 \x03(\t\x12\x0f\n\x07summary\x18\x02 \x03(\t\"\xf1\x03\n\x0c\x43ontentBlock\x12\x31\n\ntext_block\x18\x01 \x01(\x0b\x32\x1b.amplifier.module.TextBlockH\x00\x12\x39\n\x0ethinking_block\x18\x02 \x01(\x0b\x32\x1f.amplifier.module.ThinkingBlockH\x00\x12J\n\x17redacted_thinking_block\x18\x03 \x01(\x0b\x32\'.amplifier.module.RedactedThinkingBlockH\x00\x12:\n\x0ftool_call_block\x18\x04 \x01(\x0b\x32\x1f.amplifier.module.ToolCallBlockH\x00\x12>\n\x11tool_result_block\x18\x05 \x01(\x0b\x32!.amplifier.module.ToolResultBlockH\x00\x12\x33\n\x0bimage_block\x18\x06 \x01(\x0b\x32\x1c.amplifier.module.ImageBlockH\x00\x12;\n\x0freasoning_block\x18\x07 \x01(\x0b\x32 .amplifier.module.ReasoningBlockH\x00\x12\x30\n\nvisibility\x18\x08 \x01(\x0e\x32\x1c.amplifier.module.VisibilityB\x07\n\x05\x62lock\"B\n\x10\x43ontentBlockList\x12.\n\x06\x62locks\x18\x01 \x03(\x0b\x32\x1e.amplifier.module.ContentBlock\"\xca\x01\n\x07Message\x12$\n\x04role\x18\x01 \x01(\x0e\x32\x16.amplifier.module.Role\x12\x16\n\x0ctext_content\x18\x02 \x01(\tH\x00\x12;\n\rblock_content\x18\x03 \x01(\x0b\x32\".amplifier.module.ContentBlockListH\x00\x12\x0c\n\x04name\x18\x04 \x01(\t\x12\x14\n\x0ctool_call_id\x18\x05 \x01(\t\x12\x15\n\rmetadata_json\x18\x06 \x01(\tB\t\n\x07\x63ontent\"C\n\x0fToolCallMessage\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x16\n\x0e\x61rguments_json\x18\x03 \x01(\t\"K\n\rToolSpecProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x17\n\x0fparameters_json\x18\x03 \x01(\t\"7\n\x10JsonSchemaFormat\x12\x13\n\x0bschema_json\x18\x01 \x01(\t\x12\x0e\n\x06strict\x18\x02 \x01(\x08\"u\n\x0eResponseFormat\x12\x0e\n\x04text\x18\x01 \x01(\x08H\x00\x12\x0e\n\x04json\x18\x02 \x01(\x08H\x00\x12\x39\n\x0bjson_schema\x18\x03 \x01(\x0b\x32\".amplifier.module.JsonSchemaFormatH\x00\x42\x08\n\x06\x66ormat\"\xf7\x01\n\x05Usage\x12\x15\n\rprompt_tokens\x18\x01 \x01(\x05\x12\x19\n\x11\x63ompletion_tokens\x18\x02 \x01(\x05\x12\x14\n\x0ctotal_tokens\x18\x03 \x01(\x05\x12\x1d\n\x10reasoning_tokens\x18\x04 \x01(\x05H\x00\x88\x01\x01\x12\x1e\n\x11\x63\x61\x63he_read_tokens\x18\x05 \x01(\x05H\x01\x88\x01\x01\x12\"\n\x15\x63\x61\x63he_creation_tokens\x18\x06 \x01(\x05H\x02\x88\x01\x01\x42\x13\n\x11_reasoning_tokensB\x14\n\x12_cache_read_tokensB\x18\n\x16_cache_creation_tokens\"@\n\x0b\x44\x65gradation\x12\x11\n\trequested\x18\x01 \x01(\t\x12\x0e\n\x06\x61\x63tual\x18\x02 \x01(\t\x12\x0e\n\x06reason\x18\x03 \x01(\t\"\x81\x03\n\x0b\x43hatRequest\x12+\n\x08messages\x18\x01 \x03(\x0b\x32\x19.amplifier.module.Message\x12.\n\x05tools\x18\x02 \x03(\x0b\x32\x1f.amplifier.module.ToolSpecProto\x12\x39\n\x0fresponse_format\x18\x03 \x01(\x0b\x32 .amplifier.module.ResponseFormat\x12\x13\n\x0btemperature\x18\x04 \x01(\x01\x12\r\n\x05top_p\x18\x05 \x01(\x01\x12\x19\n\x11max_output_tokens\x18\x06 \x01(\x05\x12\x17\n\x0f\x63onversation_id\x18\x07 \x01(\t\x12\x0e\n\x06stream\x18\x08 \x01(\x08\x12\x15\n\rmetadata_json\x18\t \x01(\t\x12\r\n\x05model\x18\n \x01(\t\x12\x13\n\x0btool_choice\x18\x0b \x01(\t\x12\x0c\n\x04stop\x18\x0c \x03(\t\x12\x18\n\x10reasoning_effort\x18\r \x01(\t\x12\x0f\n\x07timeout\x18\x0e \x01(\x01\"\x98\x02\n\x0c\x43hatResponse\x12\x0f\n\x07\x63ontent\x18\x01 \x01(\t\x12\x35\n\ntool_calls\x18\x02 \x03(\x0b\x32!.amplifier.module.ToolCallMessage\x12&\n\x05usage\x18\x03 \x01(\x0b\x32\x17.amplifier.module.Usage\x12\x32\n\x0b\x64\x65gradation\x18\x04 \x01(\x0b\x32\x1d.amplifier.module.Degradation\x12\x15\n\rfinish_reason\x18\x05 \x01(\t\x12\x15\n\rmetadata_json\x18\x06 \x01(\t\x12\x36\n\x0e\x63ontent_blocks\x18\x07 \x03(\x0b\x32\x1e.amplifier.module.ContentBlock\"F\n\nToolResult\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x13\n\x0boutput_json\x18\x02 \x01(\t\x12\x12\n\nerror_json\x18\x03 \x01(\t\"\xa7\x04\n\nHookResult\x12,\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x1c.amplifier.module.HookAction\x12\x11\n\tdata_json\x18\x02 \x01(\t\x12\x0e\n\x06reason\x18\x03 \x01(\t\x12\x19\n\x11\x63ontext_injection\x18\x04 \x01(\t\x12\x46\n\x16\x63ontext_injection_role\x18\x05 \x01(\x0e\x32&.amplifier.module.ContextInjectionRole\x12\x11\n\tephemeral\x18\x06 \x01(\x08\x12\x17\n\x0f\x61pproval_prompt\x18\x07 \x01(\t\x12\x18\n\x10\x61pproval_options\x18\x08 \x03(\t\x12\x1d\n\x10\x61pproval_timeout\x18\t \x01(\x01H\x00\x88\x01\x01\x12;\n\x10\x61pproval_default\x18\n \x01(\x0e\x32!.amplifier.module.ApprovalDefault\x12\x17\n\x0fsuppress_output\x18\x0b \x01(\x08\x12\x14\n\x0cuser_message\x18\x0c \x01(\t\x12>\n\x12user_message_level\x18\r \x01(\x0e\x32\".amplifier.module.UserMessageLevel\x12\x1b\n\x13user_message_source\x18\x0e \x01(\t\x12\"\n\x1a\x61ppend_to_last_tool_result\x18\x0f \x01(\x08\x42\x13\n\x11_approval_timeout\"\x8d\x01\n\tModelInfo\x12\n\n\x02id\x18\x01 \x01(\t\x12\x14\n\x0c\x64isplay_name\x18\x02 \x01(\t\x12\x16\n\x0e\x63ontext_window\x18\x03 \x01(\x05\x12\x19\n\x11max_output_tokens\x18\x04 \x01(\x05\x12\x14\n\x0c\x63\x61pabilities\x18\x05 \x03(\t\x12\x15\n\rdefaults_json\x18\x06 \x01(\t\"\xb0\x01\n\x0cProviderInfo\x12\n\n\x02id\x18\x01 \x01(\t\x12\x14\n\x0c\x64isplay_name\x18\x02 \x01(\t\x12\x1b\n\x13\x63redential_env_vars\x18\x03 \x03(\t\x12\x14\n\x0c\x63\x61pabilities\x18\x04 \x03(\t\x12\x15\n\rdefaults_json\x18\x05 \x01(\t\x12\x34\n\rconfig_fields\x18\x06 \x03(\x0b\x32\x1d.amplifier.module.ConfigField\"\x80\x01\n\x0f\x41pprovalRequest\x12\x11\n\ttool_name\x18\x01 \x01(\t\x12\x0e\n\x06\x61\x63tion\x18\x02 \x01(\t\x12\x14\n\x0c\x64\x65tails_json\x18\x03 \x01(\t\x12\x12\n\nrisk_level\x18\x04 \x01(\t\x12\x14\n\x07timeout\x18\x05 \x01(\x01H\x00\x88\x01\x01\x42\n\n\x08_timeout\"F\n\x10\x41pprovalResponse\x12\x10\n\x08\x61pproved\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x10\n\x08remember\x18\x03 \x01(\x08\"A\n\x12ListModelsResponse\x12+\n\x06models\x18\x01 \x03(\x0b\x32\x1b.amplifier.module.ModelInfo\"O\n\x16ParseToolCallsResponse\x12\x35\n\ntool_calls\x18\x01 \x03(\x0b\x32!.amplifier.module.ToolCallMessage\"@\n\x1aOrchestratorExecuteRequest\x12\x0e\n\x06prompt\x18\x01 \x01(\t\x12\x12\n\nsession_id\x18\x02 \x01(\t\">\n\x1bOrchestratorExecuteResponse\x12\x10\n\x08response\x18\x01 \x01(\t\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"?\n\x11\x41\x64\x64MessageRequest\x12*\n\x07message\x18\x01 \x01(\x0b\x32\x19.amplifier.module.Message\"B\n\x13GetMessagesResponse\x12+\n\x08messages\x18\x01 \x03(\x0b\x32\x19.amplifier.module.Message\"J\n\x1bGetMessagesForRequestParams\x12\x14\n\x0ctoken_budget\x18\x01 \x01(\x05\x12\x15\n\rprovider_name\x18\x02 \x01(\t\"A\n\x12SetMessagesRequest\x12+\n\x08messages\x18\x01 \x03(\x0b\x32\x19.amplifier.module.Message\"5\n\x11HookHandleRequest\x12\r\n\x05\x65vent\x18\x01 \x01(\t\x12\x11\n\tdata_json\x18\x02 \x01(\t\".\n\x17GetSubscriptionsRequest\x12\x13\n\x0b\x63onfig_json\x18\x01 \x01(\t\"V\n\x18GetSubscriptionsResponse\x12:\n\rsubscriptions\x18\x01 \x03(\x0b\x32#.amplifier.module.EventSubscription\"B\n\x11\x45ventSubscription\x12\r\n\x05\x65vent\x18\x01 \x01(\t\x12\x10\n\x08priority\x18\x02 \x01(\x05\x12\x0c\n\x04name\x18\x03 \x01(\t\"d\n\x1b\x43ompleteWithProviderRequest\x12\x15\n\rprovider_name\x18\x01 \x01(\t\x12.\n\x07request\x18\x02 \x01(\x0b\x32\x1d.amplifier.module.ChatRequest\";\n\x12\x45xecuteToolRequest\x12\x11\n\ttool_name\x18\x01 \x01(\t\x12\x12\n\ninput_json\x18\x02 \x01(\t\"3\n\x0f\x45mitHookRequest\x12\r\n\x05\x65vent\x18\x01 \x01(\t\x12\x11\n\tdata_json\x18\x02 \x01(\t\"V\n\x19\x45mitHookAndCollectRequest\x12\r\n\x05\x65vent\x18\x01 \x01(\t\x12\x11\n\tdata_json\x18\x02 \x01(\t\x12\x17\n\x0ftimeout_seconds\x18\x03 \x01(\x01\"4\n\x1a\x45mitHookAndCollectResponse\x12\x16\n\x0eresponses_json\x18\x01 \x03(\t\"(\n\x12GetMessagesRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\"Y\n\x17KernelAddMessageRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12*\n\x07message\x18\x02 \x01(\x0b\x32\x19.amplifier.module.Message\"a\n\x17GetMountedModuleRequest\x12\x13\n\x0bmodule_name\x18\x01 \x01(\t\x12\x31\n\x0bmodule_type\x18\x02 \x01(\x0e\x32\x1c.amplifier.module.ModuleType\"U\n\x18GetMountedModuleResponse\x12\r\n\x05\x66ound\x18\x01 \x01(\x08\x12*\n\x04info\x18\x02 \x01(\x0b\x32\x1c.amplifier.module.ModuleInfo\"=\n\x19RegisterCapabilityRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\nvalue_json\x18\x02 \x01(\t\"$\n\x14GetCapabilityRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\":\n\x15GetCapabilityResponse\x12\r\n\x05\x66ound\x18\x01 \x01(\x08\x12\x12\n\nvalue_json\x18\x02 \x01(\t*\xbc\x01\n\nModuleType\x12\x1b\n\x17MODULE_TYPE_UNSPECIFIED\x10\x00\x12\x18\n\x14MODULE_TYPE_PROVIDER\x10\x01\x12\x14\n\x10MODULE_TYPE_TOOL\x10\x02\x12\x14\n\x10MODULE_TYPE_HOOK\x10\x03\x12\x16\n\x12MODULE_TYPE_MEMORY\x10\x04\x12\x19\n\x15MODULE_TYPE_GUARDRAIL\x10\x05\x12\x18\n\x14MODULE_TYPE_APPROVAL\x10\x06*\x82\x01\n\x0cHealthStatus\x12\x1d\n\x19HEALTH_STATUS_UNSPECIFIED\x10\x00\x12\x19\n\x15HEALTH_STATUS_SERVING\x10\x01\x12\x1d\n\x19HEALTH_STATUS_NOT_SERVING\x10\x02\x12\x19\n\x15HEALTH_STATUS_UNKNOWN\x10\x03*\xad\x01\n\x0f\x43onfigFieldType\x12!\n\x1d\x43ONFIG_FIELD_TYPE_UNSPECIFIED\x10\x00\x12\x1c\n\x18\x43ONFIG_FIELD_TYPE_STRING\x10\x01\x12\x1c\n\x18\x43ONFIG_FIELD_TYPE_NUMBER\x10\x02\x12\x1d\n\x19\x43ONFIG_FIELD_TYPE_BOOLEAN\x10\x03\x12\x1c\n\x18\x43ONFIG_FIELD_TYPE_SECRET\x10\x04*\xd8\x02\n\x11ProviderErrorType\x12#\n\x1fPROVIDER_ERROR_TYPE_UNSPECIFIED\x10\x00\x12\x1c\n\x18PROVIDER_ERROR_TYPE_AUTH\x10\x01\x12\"\n\x1ePROVIDER_ERROR_TYPE_RATE_LIMIT\x10\x02\x12&\n\"PROVIDER_ERROR_TYPE_CONTEXT_LENGTH\x10\x03\x12\'\n#PROVIDER_ERROR_TYPE_INVALID_REQUEST\x10\x04\x12&\n\"PROVIDER_ERROR_TYPE_CONTENT_FILTER\x10\x05\x12#\n\x1fPROVIDER_ERROR_TYPE_UNAVAILABLE\x10\x06\x12\x1f\n\x1bPROVIDER_ERROR_TYPE_TIMEOUT\x10\x07\x12\x1d\n\x19PROVIDER_ERROR_TYPE_OTHER\x10\x08*\x8c\x01\n\rToolErrorType\x12\x1f\n\x1bTOOL_ERROR_TYPE_UNSPECIFIED\x10\x00\x12\x1d\n\x19TOOL_ERROR_TYPE_EXECUTION\x10\x01\x12\x1e\n\x1aTOOL_ERROR_TYPE_VALIDATION\x10\x02\x12\x1b\n\x17TOOL_ERROR_TYPE_TIMEOUT\x10\x03*\x8c\x01\n\rHookErrorType\x12\x1f\n\x1bHOOK_ERROR_TYPE_UNSPECIFIED\x10\x00\x12\x1d\n\x19HOOK_ERROR_TYPE_EXECUTION\x10\x01\x12\x1e\n\x1aHOOK_ERROR_TYPE_VALIDATION\x10\x02\x12\x1b\n\x17HOOK_ERROR_TYPE_TIMEOUT\x10\x03*\x86\x01\n\x04Role\x12\x14\n\x10ROLE_UNSPECIFIED\x10\x00\x12\x0f\n\x0bROLE_SYSTEM\x10\x01\x12\r\n\tROLE_USER\x10\x02\x12\x12\n\x0eROLE_ASSISTANT\x10\x03\x12\r\n\tROLE_TOOL\x10\x04\x12\x11\n\rROLE_FUNCTION\x10\x05\x12\x12\n\x0eROLE_DEVELOPER\x10\x06*o\n\nVisibility\x12\x1a\n\x16VISIBILITY_UNSPECIFIED\x10\x00\x12\x12\n\x0eVISIBILITY_ALL\x10\x01\x12\x17\n\x13VISIBILITY_LLM_ONLY\x10\x02\x12\x18\n\x14VISIBILITY_USER_ONLY\x10\x03*\xab\x01\n\nHookAction\x12\x1b\n\x17HOOK_ACTION_UNSPECIFIED\x10\x00\x12\x18\n\x14HOOK_ACTION_CONTINUE\x10\x01\x12\x16\n\x12HOOK_ACTION_MODIFY\x10\x02\x12\x14\n\x10HOOK_ACTION_DENY\x10\x03\x12\x1e\n\x1aHOOK_ACTION_INJECT_CONTEXT\x10\x04\x12\x18\n\x14HOOK_ACTION_ASK_USER\x10\x05*\xa8\x01\n\x14\x43ontextInjectionRole\x12&\n\"CONTEXT_INJECTION_ROLE_UNSPECIFIED\x10\x00\x12!\n\x1d\x43ONTEXT_INJECTION_ROLE_SYSTEM\x10\x01\x12\x1f\n\x1b\x43ONTEXT_INJECTION_ROLE_USER\x10\x02\x12$\n CONTEXT_INJECTION_ROLE_ASSISTANT\x10\x03*l\n\x0f\x41pprovalDefault\x12 \n\x1c\x41PPROVAL_DEFAULT_UNSPECIFIED\x10\x00\x12\x1c\n\x18\x41PPROVAL_DEFAULT_APPROVE\x10\x01\x12\x19\n\x15\x41PPROVAL_DEFAULT_DENY\x10\x02*\x91\x01\n\x10UserMessageLevel\x12\"\n\x1eUSER_MESSAGE_LEVEL_UNSPECIFIED\x10\x00\x12\x1b\n\x17USER_MESSAGE_LEVEL_INFO\x10\x01\x12\x1e\n\x1aUSER_MESSAGE_LEVEL_WARNING\x10\x02\x12\x1c\n\x18USER_MESSAGE_LEVEL_ERROR\x10\x03\x32\xa5\x01\n\x0bToolService\x12>\n\x07GetSpec\x12\x17.amplifier.module.Empty\x1a\x1a.amplifier.module.ToolSpec\x12V\n\x07\x45xecute\x12$.amplifier.module.ToolExecuteRequest\x1a%.amplifier.module.ToolExecuteResponse2\x9f\x03\n\x0fProviderService\x12\x42\n\x07GetInfo\x12\x17.amplifier.module.Empty\x1a\x1e.amplifier.module.ProviderInfo\x12K\n\nListModels\x12\x17.amplifier.module.Empty\x1a$.amplifier.module.ListModelsResponse\x12I\n\x08\x43omplete\x12\x1d.amplifier.module.ChatRequest\x1a\x1e.amplifier.module.ChatResponse\x12T\n\x11\x43ompleteStreaming\x12\x1d.amplifier.module.ChatRequest\x1a\x1e.amplifier.module.ChatResponse0\x01\x12Z\n\x0eParseToolCalls\x12\x1e.amplifier.module.ChatResponse\x1a(.amplifier.module.ParseToolCallsResponse2}\n\x13OrchestratorService\x12\x66\n\x07\x45xecute\x12,.amplifier.module.OrchestratorExecuteRequest\x1a-.amplifier.module.OrchestratorExecuteResponse2\xa3\x03\n\x0e\x43ontextService\x12J\n\nAddMessage\x12#.amplifier.module.AddMessageRequest\x1a\x17.amplifier.module.Empty\x12M\n\x0bGetMessages\x12\x17.amplifier.module.Empty\x1a%.amplifier.module.GetMessagesResponse\x12m\n\x15GetMessagesForRequest\x12-.amplifier.module.GetMessagesForRequestParams\x1a%.amplifier.module.GetMessagesResponse\x12L\n\x0bSetMessages\x12$.amplifier.module.SetMessagesRequest\x1a\x17.amplifier.module.Empty\x12\x39\n\x05\x43lear\x12\x17.amplifier.module.Empty\x1a\x17.amplifier.module.Empty2\xc5\x01\n\x0bHookService\x12K\n\x06Handle\x12#.amplifier.module.HookHandleRequest\x1a\x1c.amplifier.module.HookResult\x12i\n\x10GetSubscriptions\x12).amplifier.module.GetSubscriptionsRequest\x1a*.amplifier.module.GetSubscriptionsResponse2k\n\x0f\x41pprovalService\x12X\n\x0fRequestApproval\x12!.amplifier.module.ApprovalRequest\x1a\".amplifier.module.ApprovalResponse2\xd0\x07\n\rKernelService\x12\x65\n\x14\x43ompleteWithProvider\x12-.amplifier.module.CompleteWithProviderRequest\x1a\x1e.amplifier.module.ChatResponse\x12p\n\x1d\x43ompleteWithProviderStreaming\x12-.amplifier.module.CompleteWithProviderRequest\x1a\x1e.amplifier.module.ChatResponse0\x01\x12Q\n\x0b\x45xecuteTool\x12$.amplifier.module.ExecuteToolRequest\x1a\x1c.amplifier.module.ToolResult\x12K\n\x08\x45mitHook\x12!.amplifier.module.EmitHookRequest\x1a\x1c.amplifier.module.HookResult\x12o\n\x12\x45mitHookAndCollect\x12+.amplifier.module.EmitHookAndCollectRequest\x1a,.amplifier.module.EmitHookAndCollectResponse\x12Z\n\x0bGetMessages\x12$.amplifier.module.GetMessagesRequest\x1a%.amplifier.module.GetMessagesResponse\x12P\n\nAddMessage\x12).amplifier.module.KernelAddMessageRequest\x1a\x17.amplifier.module.Empty\x12i\n\x10GetMountedModule\x12).amplifier.module.GetMountedModuleRequest\x1a*.amplifier.module.GetMountedModuleResponse\x12Z\n\x12RegisterCapability\x12+.amplifier.module.RegisterCapabilityRequest\x1a\x17.amplifier.module.Empty\x12`\n\rGetCapability\x12&.amplifier.module.GetCapabilityRequest\x1a\'.amplifier.module.GetCapabilityResponse2\xaf\x02\n\x0fModuleLifecycle\x12H\n\x05Mount\x12\x1e.amplifier.module.MountRequest\x1a\x1f.amplifier.module.MountResponse\x12;\n\x07\x43leanup\x12\x17.amplifier.module.Empty\x1a\x17.amplifier.module.Empty\x12M\n\x0bHealthCheck\x12\x17.amplifier.module.Empty\x1a%.amplifier.module.HealthCheckResponse\x12\x46\n\rGetModuleInfo\x12\x17.amplifier.module.Empty\x1a\x1c.amplifier.module.ModuleInfob\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x16\x61mplifier_module.proto\x12\x10\x61mplifier.module\"\x07\n\x05\x45mpty\"F\n\x08ToolSpec\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x17\n\x0fparameters_json\x18\x03 \x01(\t\"9\n\x12ToolExecuteRequest\x12\r\n\x05input\x18\x01 \x01(\x0c\x12\x14\n\x0c\x63ontent_type\x18\x02 \x01(\t\"\x93\x01\n\x13ToolExecuteResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0e\n\x06output\x18\x02 \x01(\x0c\x12\x14\n\x0c\x63ontent_type\x18\x03 \x01(\t\x12\r\n\x05\x65rror\x18\x04 \x01(\t\x12\x36\n\x0e\x63ontent_blocks\x18\x05 \x03(\x0b\x32\x1e.amplifier.module.ContentBlock\"\xd6\x01\n\nModuleInfo\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0f\n\x07version\x18\x03 \x01(\t\x12\x31\n\x0bmodule_type\x18\x04 \x01(\x0e\x32\x1c.amplifier.module.ModuleType\x12\x13\n\x0bmount_point\x18\x05 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x06 \x01(\t\x12\x1a\n\x12\x63onfig_schema_json\x18\x07 \x01(\t\x12\x14\n\x0c\x63\x61pabilities\x18\x08 \x03(\t\x12\x0e\n\x06\x61uthor\x18\t \x01(\t\"\x8c\x01\n\x0cMountRequest\x12:\n\x06\x63onfig\x18\x01 \x03(\x0b\x32*.amplifier.module.MountRequest.ConfigEntry\x12\x11\n\tmodule_id\x18\x02 \x01(\t\x1a-\n\x0b\x43onfigEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"_\n\rMountResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12.\n\x06status\x18\x03 \x01(\x0e\x32\x1e.amplifier.module.HealthStatus\"V\n\x13HealthCheckResponse\x12.\n\x06status\x18\x01 \x01(\x0e\x32\x1e.amplifier.module.HealthStatus\x12\x0f\n\x07message\x18\x02 \x01(\t\"\xca\x02\n\x0b\x43onfigField\x12\n\n\x02id\x18\x01 \x01(\t\x12\x14\n\x0c\x64isplay_name\x18\x02 \x01(\t\x12\x35\n\nfield_type\x18\x03 \x01(\x0e\x32!.amplifier.module.ConfigFieldType\x12\x0e\n\x06prompt\x18\x04 \x01(\t\x12\x0f\n\x07\x65nv_var\x18\x05 \x01(\t\x12\x0f\n\x07\x63hoices\x18\x06 \x03(\t\x12\x10\n\x08required\x18\x07 \x01(\x08\x12\x15\n\rdefault_value\x18\x08 \x01(\t\x12>\n\tshow_when\x18\t \x03(\x0b\x32+.amplifier.module.ConfigField.ShowWhenEntry\x12\x16\n\x0erequires_model\x18\n \x01(\x08\x1a/\n\rShowWhenEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xbc\x01\n\rProviderError\x12\x37\n\nerror_type\x18\x01 \x01(\x0e\x32#.amplifier.module.ProviderErrorType\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x15\n\rprovider_name\x18\x03 \x01(\t\x12\r\n\x05model\x18\x04 \x01(\t\x12\x13\n\x0bstatus_code\x18\x05 \x01(\x05\x12\x11\n\tretryable\x18\x06 \x01(\x08\x12\x13\n\x0bretry_after\x18\x07 \x01(\x01\"\x97\x01\n\tToolError\x12\x33\n\nerror_type\x18\x01 \x01(\x0e\x32\x1f.amplifier.module.ToolErrorType\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x11\n\ttool_name\x18\x03 \x01(\t\x12\x0e\n\x06stdout\x18\x04 \x01(\t\x12\x0e\n\x06stderr\x18\x05 \x01(\t\x12\x11\n\texit_code\x18\x06 \x01(\x05\"d\n\tHookError\x12\x33\n\nerror_type\x18\x01 \x01(\x0e\x32\x1f.amplifier.module.HookErrorType\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x11\n\thook_name\x18\x03 \x01(\t\"\xef\x01\n\x0e\x41mplifierError\x12\x39\n\x0eprovider_error\x18\x01 \x01(\x0b\x32\x1f.amplifier.module.ProviderErrorH\x00\x12\x31\n\ntool_error\x18\x02 \x01(\x0b\x32\x1b.amplifier.module.ToolErrorH\x00\x12\x31\n\nhook_error\x18\x03 \x01(\x0b\x32\x1b.amplifier.module.HookErrorH\x00\x12\x17\n\rgeneric_error\x18\x04 \x01(\tH\x00\x12\x1a\n\x10validation_error\x18\x05 \x01(\tH\x00\x42\x07\n\x05\x65rror\"\x19\n\tTextBlock\x12\x0c\n\x04text\x18\x01 \x01(\t\"E\n\rThinkingBlock\x12\x10\n\x08thinking\x18\x01 \x01(\t\x12\x11\n\tsignature\x18\x02 \x01(\t\x12\x0f\n\x07\x63ontent\x18\x03 \x01(\t\"%\n\x15RedactedThinkingBlock\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\t\"=\n\rToolCallBlock\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x12\n\ninput_json\x18\x03 \x01(\t\"<\n\x0fToolResultBlock\x12\x14\n\x0ctool_call_id\x18\x01 \x01(\t\x12\x13\n\x0boutput_json\x18\x02 \x01(\t\"C\n\nImageBlock\x12\x12\n\nmedia_type\x18\x01 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\x12\x13\n\x0bsource_json\x18\x03 \x01(\t\"2\n\x0eReasoningBlock\x12\x0f\n\x07\x63ontent\x18\x01 \x03(\t\x12\x0f\n\x07summary\x18\x02 \x03(\t\"\xf1\x03\n\x0c\x43ontentBlock\x12\x31\n\ntext_block\x18\x01 \x01(\x0b\x32\x1b.amplifier.module.TextBlockH\x00\x12\x39\n\x0ethinking_block\x18\x02 \x01(\x0b\x32\x1f.amplifier.module.ThinkingBlockH\x00\x12J\n\x17redacted_thinking_block\x18\x03 \x01(\x0b\x32\'.amplifier.module.RedactedThinkingBlockH\x00\x12:\n\x0ftool_call_block\x18\x04 \x01(\x0b\x32\x1f.amplifier.module.ToolCallBlockH\x00\x12>\n\x11tool_result_block\x18\x05 \x01(\x0b\x32!.amplifier.module.ToolResultBlockH\x00\x12\x33\n\x0bimage_block\x18\x06 \x01(\x0b\x32\x1c.amplifier.module.ImageBlockH\x00\x12;\n\x0freasoning_block\x18\x07 \x01(\x0b\x32 .amplifier.module.ReasoningBlockH\x00\x12\x30\n\nvisibility\x18\x08 \x01(\x0e\x32\x1c.amplifier.module.VisibilityB\x07\n\x05\x62lock\"B\n\x10\x43ontentBlockList\x12.\n\x06\x62locks\x18\x01 \x03(\x0b\x32\x1e.amplifier.module.ContentBlock\"\xca\x01\n\x07Message\x12$\n\x04role\x18\x01 \x01(\x0e\x32\x16.amplifier.module.Role\x12\x16\n\x0ctext_content\x18\x02 \x01(\tH\x00\x12;\n\rblock_content\x18\x03 \x01(\x0b\x32\".amplifier.module.ContentBlockListH\x00\x12\x0c\n\x04name\x18\x04 \x01(\t\x12\x14\n\x0ctool_call_id\x18\x05 \x01(\t\x12\x15\n\rmetadata_json\x18\x06 \x01(\tB\t\n\x07\x63ontent\"C\n\x0fToolCallMessage\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x16\n\x0e\x61rguments_json\x18\x03 \x01(\t\"K\n\rToolSpecProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x17\n\x0fparameters_json\x18\x03 \x01(\t\"7\n\x10JsonSchemaFormat\x12\x13\n\x0bschema_json\x18\x01 \x01(\t\x12\x0e\n\x06strict\x18\x02 \x01(\x08\"u\n\x0eResponseFormat\x12\x0e\n\x04text\x18\x01 \x01(\x08H\x00\x12\x0e\n\x04json\x18\x02 \x01(\x08H\x00\x12\x39\n\x0bjson_schema\x18\x03 \x01(\x0b\x32\".amplifier.module.JsonSchemaFormatH\x00\x42\x08\n\x06\x66ormat\"\x9b\x02\n\x05Usage\x12\x15\n\rprompt_tokens\x18\x01 \x01(\x05\x12\x19\n\x11\x63ompletion_tokens\x18\x02 \x01(\x05\x12\x14\n\x0ctotal_tokens\x18\x03 \x01(\x05\x12\x1d\n\x10reasoning_tokens\x18\x04 \x01(\x05H\x00\x88\x01\x01\x12\x1e\n\x11\x63\x61\x63he_read_tokens\x18\x05 \x01(\x05H\x01\x88\x01\x01\x12\"\n\x15\x63\x61\x63he_creation_tokens\x18\x06 \x01(\x05H\x02\x88\x01\x01\x12\x15\n\x08\x63ost_usd\x18\x07 \x01(\tH\x03\x88\x01\x01\x42\x13\n\x11_reasoning_tokensB\x14\n\x12_cache_read_tokensB\x18\n\x16_cache_creation_tokensB\x0b\n\t_cost_usd\"@\n\x0b\x44\x65gradation\x12\x11\n\trequested\x18\x01 \x01(\t\x12\x0e\n\x06\x61\x63tual\x18\x02 \x01(\t\x12\x0e\n\x06reason\x18\x03 \x01(\t\"\x81\x03\n\x0b\x43hatRequest\x12+\n\x08messages\x18\x01 \x03(\x0b\x32\x19.amplifier.module.Message\x12.\n\x05tools\x18\x02 \x03(\x0b\x32\x1f.amplifier.module.ToolSpecProto\x12\x39\n\x0fresponse_format\x18\x03 \x01(\x0b\x32 .amplifier.module.ResponseFormat\x12\x13\n\x0btemperature\x18\x04 \x01(\x01\x12\r\n\x05top_p\x18\x05 \x01(\x01\x12\x19\n\x11max_output_tokens\x18\x06 \x01(\x05\x12\x17\n\x0f\x63onversation_id\x18\x07 \x01(\t\x12\x0e\n\x06stream\x18\x08 \x01(\x08\x12\x15\n\rmetadata_json\x18\t \x01(\t\x12\r\n\x05model\x18\n \x01(\t\x12\x13\n\x0btool_choice\x18\x0b \x01(\t\x12\x0c\n\x04stop\x18\x0c \x03(\t\x12\x18\n\x10reasoning_effort\x18\r \x01(\t\x12\x0f\n\x07timeout\x18\x0e \x01(\x01\"\x98\x02\n\x0c\x43hatResponse\x12\x0f\n\x07\x63ontent\x18\x01 \x01(\t\x12\x35\n\ntool_calls\x18\x02 \x03(\x0b\x32!.amplifier.module.ToolCallMessage\x12&\n\x05usage\x18\x03 \x01(\x0b\x32\x17.amplifier.module.Usage\x12\x32\n\x0b\x64\x65gradation\x18\x04 \x01(\x0b\x32\x1d.amplifier.module.Degradation\x12\x15\n\rfinish_reason\x18\x05 \x01(\t\x12\x15\n\rmetadata_json\x18\x06 \x01(\t\x12\x36\n\x0e\x63ontent_blocks\x18\x07 \x03(\x0b\x32\x1e.amplifier.module.ContentBlock\"~\n\nToolResult\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x13\n\x0boutput_json\x18\x02 \x01(\t\x12\x12\n\nerror_json\x18\x03 \x01(\t\x12\x36\n\x0e\x63ontent_blocks\x18\x04 \x03(\x0b\x32\x1e.amplifier.module.ContentBlock\"\xb2\x01\n\x10\x43ontextInjection\x12\x0f\n\x07\x63ontent\x18\x01 \x01(\t\x12\x34\n\x04role\x18\x02 \x01(\x0e\x32&.amplifier.module.ContextInjectionRole\x12\x11\n\tephemeral\x18\x03 \x01(\x08\x12\"\n\x1a\x61ppend_to_last_tool_result\x18\x04 \x01(\x08\x12\x11\n\thook_name\x18\x05 \x01(\t\x12\r\n\x05\x65vent\x18\x06 \x01(\t\"\xe7\x04\n\nHookResult\x12,\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x1c.amplifier.module.HookAction\x12\x11\n\tdata_json\x18\x02 \x01(\t\x12\x0e\n\x06reason\x18\x03 \x01(\t\x12\x19\n\x11\x63ontext_injection\x18\x04 \x01(\t\x12\x46\n\x16\x63ontext_injection_role\x18\x05 \x01(\x0e\x32&.amplifier.module.ContextInjectionRole\x12\x11\n\tephemeral\x18\x06 \x01(\x08\x12\x17\n\x0f\x61pproval_prompt\x18\x07 \x01(\t\x12\x18\n\x10\x61pproval_options\x18\x08 \x03(\t\x12\x1d\n\x10\x61pproval_timeout\x18\t \x01(\x01H\x00\x88\x01\x01\x12;\n\x10\x61pproval_default\x18\n \x01(\x0e\x32!.amplifier.module.ApprovalDefault\x12\x17\n\x0fsuppress_output\x18\x0b \x01(\x08\x12\x14\n\x0cuser_message\x18\x0c \x01(\t\x12>\n\x12user_message_level\x18\r \x01(\x0e\x32\".amplifier.module.UserMessageLevel\x12\x1b\n\x13user_message_source\x18\x0e \x01(\t\x12\"\n\x1a\x61ppend_to_last_tool_result\x18\x0f \x01(\x08\x12>\n\x12\x63ontext_injections\x18\x10 \x03(\x0b\x32\".amplifier.module.ContextInjectionB\x13\n\x11_approval_timeout\"\xa3\x01\n\tModelInfo\x12\n\n\x02id\x18\x01 \x01(\t\x12\x14\n\x0c\x64isplay_name\x18\x02 \x01(\t\x12\x16\n\x0e\x63ontext_window\x18\x03 \x01(\x05\x12\x19\n\x11max_output_tokens\x18\x04 \x01(\x05\x12\x14\n\x0c\x63\x61pabilities\x18\x05 \x03(\t\x12\x15\n\rdefaults_json\x18\x06 \x01(\t\x12\x14\n\x0cpricing_json\x18\x07 \x01(\t\"\xb0\x01\n\x0cProviderInfo\x12\n\n\x02id\x18\x01 \x01(\t\x12\x14\n\x0c\x64isplay_name\x18\x02 \x01(\t\x12\x1b\n\x13\x63redential_env_vars\x18\x03 \x03(\t\x12\x14\n\x0c\x63\x61pabilities\x18\x04 \x03(\t\x12\x15\n\rdefaults_json\x18\x05 \x01(\t\x12\x34\n\rconfig_fields\x18\x06 \x03(\x0b\x32\x1d.amplifier.module.ConfigField\"\x80\x01\n\x0f\x41pprovalRequest\x12\x11\n\ttool_name\x18\x01 \x01(\t\x12\x0e\n\x06\x61\x63tion\x18\x02 \x01(\t\x12\x14\n\x0c\x64\x65tails_json\x18\x03 \x01(\t\x12\x12\n\nrisk_level\x18\x04 \x01(\t\x12\x14\n\x07timeout\x18\x05 \x01(\x01H\x00\x88\x01\x01\x42\n\n\x08_timeout\"F\n\x10\x41pprovalResponse\x12\x10\n\x08\x61pproved\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x10\n\x08remember\x18\x03 \x01(\x08\"A\n\x12ListModelsResponse\x12+\n\x06models\x18\x01 \x03(\x0b\x32\x1b.amplifier.module.ModelInfo\"O\n\x16ParseToolCallsResponse\x12\x35\n\ntool_calls\x18\x01 \x03(\x0b\x32!.amplifier.module.ToolCallMessage\"@\n\x1aOrchestratorExecuteRequest\x12\x0e\n\x06prompt\x18\x01 \x01(\t\x12\x12\n\nsession_id\x18\x02 \x01(\t\">\n\x1bOrchestratorExecuteResponse\x12\x10\n\x08response\x18\x01 \x01(\t\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"?\n\x11\x41\x64\x64MessageRequest\x12*\n\x07message\x18\x01 \x01(\x0b\x32\x19.amplifier.module.Message\"B\n\x13GetMessagesResponse\x12+\n\x08messages\x18\x01 \x03(\x0b\x32\x19.amplifier.module.Message\"J\n\x1bGetMessagesForRequestParams\x12\x14\n\x0ctoken_budget\x18\x01 \x01(\x05\x12\x15\n\rprovider_name\x18\x02 \x01(\t\"A\n\x12SetMessagesRequest\x12+\n\x08messages\x18\x01 \x03(\x0b\x32\x19.amplifier.module.Message\"5\n\x11HookHandleRequest\x12\r\n\x05\x65vent\x18\x01 \x01(\t\x12\x11\n\tdata_json\x18\x02 \x01(\t\".\n\x17GetSubscriptionsRequest\x12\x13\n\x0b\x63onfig_json\x18\x01 \x01(\t\"V\n\x18GetSubscriptionsResponse\x12:\n\rsubscriptions\x18\x01 \x03(\x0b\x32#.amplifier.module.EventSubscription\"B\n\x11\x45ventSubscription\x12\r\n\x05\x65vent\x18\x01 \x01(\t\x12\x10\n\x08priority\x18\x02 \x01(\x05\x12\x0c\n\x04name\x18\x03 \x01(\t\"d\n\x1b\x43ompleteWithProviderRequest\x12\x15\n\rprovider_name\x18\x01 \x01(\t\x12.\n\x07request\x18\x02 \x01(\x0b\x32\x1d.amplifier.module.ChatRequest\";\n\x12\x45xecuteToolRequest\x12\x11\n\ttool_name\x18\x01 \x01(\t\x12\x12\n\ninput_json\x18\x02 \x01(\t\"3\n\x0f\x45mitHookRequest\x12\r\n\x05\x65vent\x18\x01 \x01(\t\x12\x11\n\tdata_json\x18\x02 \x01(\t\"V\n\x19\x45mitHookAndCollectRequest\x12\r\n\x05\x65vent\x18\x01 \x01(\t\x12\x11\n\tdata_json\x18\x02 \x01(\t\x12\x17\n\x0ftimeout_seconds\x18\x03 \x01(\x01\"4\n\x1a\x45mitHookAndCollectResponse\x12\x16\n\x0eresponses_json\x18\x01 \x03(\t\"(\n\x12GetMessagesRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\"Y\n\x17KernelAddMessageRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12*\n\x07message\x18\x02 \x01(\x0b\x32\x19.amplifier.module.Message\"a\n\x17GetMountedModuleRequest\x12\x13\n\x0bmodule_name\x18\x01 \x01(\t\x12\x31\n\x0bmodule_type\x18\x02 \x01(\x0e\x32\x1c.amplifier.module.ModuleType\"U\n\x18GetMountedModuleResponse\x12\r\n\x05\x66ound\x18\x01 \x01(\x08\x12*\n\x04info\x18\x02 \x01(\x0b\x32\x1c.amplifier.module.ModuleInfo\"=\n\x19RegisterCapabilityRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\nvalue_json\x18\x02 \x01(\t\"$\n\x14GetCapabilityRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\":\n\x15GetCapabilityResponse\x12\r\n\x05\x66ound\x18\x01 \x01(\x08\x12\x12\n\nvalue_json\x18\x02 \x01(\t*\xbc\x01\n\nModuleType\x12\x1b\n\x17MODULE_TYPE_UNSPECIFIED\x10\x00\x12\x18\n\x14MODULE_TYPE_PROVIDER\x10\x01\x12\x14\n\x10MODULE_TYPE_TOOL\x10\x02\x12\x14\n\x10MODULE_TYPE_HOOK\x10\x03\x12\x16\n\x12MODULE_TYPE_MEMORY\x10\x04\x12\x19\n\x15MODULE_TYPE_GUARDRAIL\x10\x05\x12\x18\n\x14MODULE_TYPE_APPROVAL\x10\x06*\x82\x01\n\x0cHealthStatus\x12\x1d\n\x19HEALTH_STATUS_UNSPECIFIED\x10\x00\x12\x19\n\x15HEALTH_STATUS_SERVING\x10\x01\x12\x1d\n\x19HEALTH_STATUS_NOT_SERVING\x10\x02\x12\x19\n\x15HEALTH_STATUS_UNKNOWN\x10\x03*\xad\x01\n\x0f\x43onfigFieldType\x12!\n\x1d\x43ONFIG_FIELD_TYPE_UNSPECIFIED\x10\x00\x12\x1c\n\x18\x43ONFIG_FIELD_TYPE_STRING\x10\x01\x12\x1c\n\x18\x43ONFIG_FIELD_TYPE_NUMBER\x10\x02\x12\x1d\n\x19\x43ONFIG_FIELD_TYPE_BOOLEAN\x10\x03\x12\x1c\n\x18\x43ONFIG_FIELD_TYPE_SECRET\x10\x04*\xd8\x02\n\x11ProviderErrorType\x12#\n\x1fPROVIDER_ERROR_TYPE_UNSPECIFIED\x10\x00\x12\x1c\n\x18PROVIDER_ERROR_TYPE_AUTH\x10\x01\x12\"\n\x1ePROVIDER_ERROR_TYPE_RATE_LIMIT\x10\x02\x12&\n\"PROVIDER_ERROR_TYPE_CONTEXT_LENGTH\x10\x03\x12\'\n#PROVIDER_ERROR_TYPE_INVALID_REQUEST\x10\x04\x12&\n\"PROVIDER_ERROR_TYPE_CONTENT_FILTER\x10\x05\x12#\n\x1fPROVIDER_ERROR_TYPE_UNAVAILABLE\x10\x06\x12\x1f\n\x1bPROVIDER_ERROR_TYPE_TIMEOUT\x10\x07\x12\x1d\n\x19PROVIDER_ERROR_TYPE_OTHER\x10\x08*\x8c\x01\n\rToolErrorType\x12\x1f\n\x1bTOOL_ERROR_TYPE_UNSPECIFIED\x10\x00\x12\x1d\n\x19TOOL_ERROR_TYPE_EXECUTION\x10\x01\x12\x1e\n\x1aTOOL_ERROR_TYPE_VALIDATION\x10\x02\x12\x1b\n\x17TOOL_ERROR_TYPE_TIMEOUT\x10\x03*\x8c\x01\n\rHookErrorType\x12\x1f\n\x1bHOOK_ERROR_TYPE_UNSPECIFIED\x10\x00\x12\x1d\n\x19HOOK_ERROR_TYPE_EXECUTION\x10\x01\x12\x1e\n\x1aHOOK_ERROR_TYPE_VALIDATION\x10\x02\x12\x1b\n\x17HOOK_ERROR_TYPE_TIMEOUT\x10\x03*\x86\x01\n\x04Role\x12\x14\n\x10ROLE_UNSPECIFIED\x10\x00\x12\x0f\n\x0bROLE_SYSTEM\x10\x01\x12\r\n\tROLE_USER\x10\x02\x12\x12\n\x0eROLE_ASSISTANT\x10\x03\x12\r\n\tROLE_TOOL\x10\x04\x12\x11\n\rROLE_FUNCTION\x10\x05\x12\x12\n\x0eROLE_DEVELOPER\x10\x06*o\n\nVisibility\x12\x1a\n\x16VISIBILITY_UNSPECIFIED\x10\x00\x12\x12\n\x0eVISIBILITY_ALL\x10\x01\x12\x17\n\x13VISIBILITY_LLM_ONLY\x10\x02\x12\x18\n\x14VISIBILITY_USER_ONLY\x10\x03*\xab\x01\n\nHookAction\x12\x1b\n\x17HOOK_ACTION_UNSPECIFIED\x10\x00\x12\x18\n\x14HOOK_ACTION_CONTINUE\x10\x01\x12\x16\n\x12HOOK_ACTION_MODIFY\x10\x02\x12\x14\n\x10HOOK_ACTION_DENY\x10\x03\x12\x1e\n\x1aHOOK_ACTION_INJECT_CONTEXT\x10\x04\x12\x18\n\x14HOOK_ACTION_ASK_USER\x10\x05*\xa8\x01\n\x14\x43ontextInjectionRole\x12&\n\"CONTEXT_INJECTION_ROLE_UNSPECIFIED\x10\x00\x12!\n\x1d\x43ONTEXT_INJECTION_ROLE_SYSTEM\x10\x01\x12\x1f\n\x1b\x43ONTEXT_INJECTION_ROLE_USER\x10\x02\x12$\n CONTEXT_INJECTION_ROLE_ASSISTANT\x10\x03*l\n\x0f\x41pprovalDefault\x12 \n\x1c\x41PPROVAL_DEFAULT_UNSPECIFIED\x10\x00\x12\x1c\n\x18\x41PPROVAL_DEFAULT_APPROVE\x10\x01\x12\x19\n\x15\x41PPROVAL_DEFAULT_DENY\x10\x02*\x91\x01\n\x10UserMessageLevel\x12\"\n\x1eUSER_MESSAGE_LEVEL_UNSPECIFIED\x10\x00\x12\x1b\n\x17USER_MESSAGE_LEVEL_INFO\x10\x01\x12\x1e\n\x1aUSER_MESSAGE_LEVEL_WARNING\x10\x02\x12\x1c\n\x18USER_MESSAGE_LEVEL_ERROR\x10\x03\x32\xa5\x01\n\x0bToolService\x12>\n\x07GetSpec\x12\x17.amplifier.module.Empty\x1a\x1a.amplifier.module.ToolSpec\x12V\n\x07\x45xecute\x12$.amplifier.module.ToolExecuteRequest\x1a%.amplifier.module.ToolExecuteResponse2\x9f\x03\n\x0fProviderService\x12\x42\n\x07GetInfo\x12\x17.amplifier.module.Empty\x1a\x1e.amplifier.module.ProviderInfo\x12K\n\nListModels\x12\x17.amplifier.module.Empty\x1a$.amplifier.module.ListModelsResponse\x12I\n\x08\x43omplete\x12\x1d.amplifier.module.ChatRequest\x1a\x1e.amplifier.module.ChatResponse\x12T\n\x11\x43ompleteStreaming\x12\x1d.amplifier.module.ChatRequest\x1a\x1e.amplifier.module.ChatResponse0\x01\x12Z\n\x0eParseToolCalls\x12\x1e.amplifier.module.ChatResponse\x1a(.amplifier.module.ParseToolCallsResponse2}\n\x13OrchestratorService\x12\x66\n\x07\x45xecute\x12,.amplifier.module.OrchestratorExecuteRequest\x1a-.amplifier.module.OrchestratorExecuteResponse2\xa3\x03\n\x0e\x43ontextService\x12J\n\nAddMessage\x12#.amplifier.module.AddMessageRequest\x1a\x17.amplifier.module.Empty\x12M\n\x0bGetMessages\x12\x17.amplifier.module.Empty\x1a%.amplifier.module.GetMessagesResponse\x12m\n\x15GetMessagesForRequest\x12-.amplifier.module.GetMessagesForRequestParams\x1a%.amplifier.module.GetMessagesResponse\x12L\n\x0bSetMessages\x12$.amplifier.module.SetMessagesRequest\x1a\x17.amplifier.module.Empty\x12\x39\n\x05\x43lear\x12\x17.amplifier.module.Empty\x1a\x17.amplifier.module.Empty2\xc5\x01\n\x0bHookService\x12K\n\x06Handle\x12#.amplifier.module.HookHandleRequest\x1a\x1c.amplifier.module.HookResult\x12i\n\x10GetSubscriptions\x12).amplifier.module.GetSubscriptionsRequest\x1a*.amplifier.module.GetSubscriptionsResponse2k\n\x0f\x41pprovalService\x12X\n\x0fRequestApproval\x12!.amplifier.module.ApprovalRequest\x1a\".amplifier.module.ApprovalResponse2\xd0\x07\n\rKernelService\x12\x65\n\x14\x43ompleteWithProvider\x12-.amplifier.module.CompleteWithProviderRequest\x1a\x1e.amplifier.module.ChatResponse\x12p\n\x1d\x43ompleteWithProviderStreaming\x12-.amplifier.module.CompleteWithProviderRequest\x1a\x1e.amplifier.module.ChatResponse0\x01\x12Q\n\x0b\x45xecuteTool\x12$.amplifier.module.ExecuteToolRequest\x1a\x1c.amplifier.module.ToolResult\x12K\n\x08\x45mitHook\x12!.amplifier.module.EmitHookRequest\x1a\x1c.amplifier.module.HookResult\x12o\n\x12\x45mitHookAndCollect\x12+.amplifier.module.EmitHookAndCollectRequest\x1a,.amplifier.module.EmitHookAndCollectResponse\x12Z\n\x0bGetMessages\x12$.amplifier.module.GetMessagesRequest\x1a%.amplifier.module.GetMessagesResponse\x12P\n\nAddMessage\x12).amplifier.module.KernelAddMessageRequest\x1a\x17.amplifier.module.Empty\x12i\n\x10GetMountedModule\x12).amplifier.module.GetMountedModuleRequest\x1a*.amplifier.module.GetMountedModuleResponse\x12Z\n\x12RegisterCapability\x12+.amplifier.module.RegisterCapabilityRequest\x1a\x17.amplifier.module.Empty\x12`\n\rGetCapability\x12&.amplifier.module.GetCapabilityRequest\x1a\'.amplifier.module.GetCapabilityResponse2\xaf\x02\n\x0fModuleLifecycle\x12H\n\x05Mount\x12\x1e.amplifier.module.MountRequest\x1a\x1f.amplifier.module.MountResponse\x12;\n\x07\x43leanup\x12\x17.amplifier.module.Empty\x1a\x17.amplifier.module.Empty\x12M\n\x0bHealthCheck\x12\x17.amplifier.module.Empty\x1a%.amplifier.module.HealthCheckResponse\x12\x46\n\rGetModuleInfo\x12\x17.amplifier.module.Empty\x1a\x1c.amplifier.module.ModuleInfob\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -35,170 +35,172 @@ _globals['_MOUNTREQUEST_CONFIGENTRY']._serialized_options = b'8\001' _globals['_CONFIGFIELD_SHOWWHENENTRY']._loaded_options = None _globals['_CONFIGFIELD_SHOWWHENENTRY']._serialized_options = b'8\001' - _globals['_MODULETYPE']._serialized_start=7113 - _globals['_MODULETYPE']._serialized_end=7301 - _globals['_HEALTHSTATUS']._serialized_start=7304 - _globals['_HEALTHSTATUS']._serialized_end=7434 - _globals['_CONFIGFIELDTYPE']._serialized_start=7437 - _globals['_CONFIGFIELDTYPE']._serialized_end=7610 - _globals['_PROVIDERERRORTYPE']._serialized_start=7613 - _globals['_PROVIDERERRORTYPE']._serialized_end=7957 - _globals['_TOOLERRORTYPE']._serialized_start=7960 - _globals['_TOOLERRORTYPE']._serialized_end=8100 - _globals['_HOOKERRORTYPE']._serialized_start=8103 - _globals['_HOOKERRORTYPE']._serialized_end=8243 - _globals['_ROLE']._serialized_start=8246 - _globals['_ROLE']._serialized_end=8380 - _globals['_VISIBILITY']._serialized_start=8382 - _globals['_VISIBILITY']._serialized_end=8493 - _globals['_HOOKACTION']._serialized_start=8496 - _globals['_HOOKACTION']._serialized_end=8667 - _globals['_CONTEXTINJECTIONROLE']._serialized_start=8670 - _globals['_CONTEXTINJECTIONROLE']._serialized_end=8838 - _globals['_APPROVALDEFAULT']._serialized_start=8840 - _globals['_APPROVALDEFAULT']._serialized_end=8948 - _globals['_USERMESSAGELEVEL']._serialized_start=8951 - _globals['_USERMESSAGELEVEL']._serialized_end=9096 + _globals['_MODULETYPE']._serialized_start=7529 + _globals['_MODULETYPE']._serialized_end=7717 + _globals['_HEALTHSTATUS']._serialized_start=7720 + _globals['_HEALTHSTATUS']._serialized_end=7850 + _globals['_CONFIGFIELDTYPE']._serialized_start=7853 + _globals['_CONFIGFIELDTYPE']._serialized_end=8026 + _globals['_PROVIDERERRORTYPE']._serialized_start=8029 + _globals['_PROVIDERERRORTYPE']._serialized_end=8373 + _globals['_TOOLERRORTYPE']._serialized_start=8376 + _globals['_TOOLERRORTYPE']._serialized_end=8516 + _globals['_HOOKERRORTYPE']._serialized_start=8519 + _globals['_HOOKERRORTYPE']._serialized_end=8659 + _globals['_ROLE']._serialized_start=8662 + _globals['_ROLE']._serialized_end=8796 + _globals['_VISIBILITY']._serialized_start=8798 + _globals['_VISIBILITY']._serialized_end=8909 + _globals['_HOOKACTION']._serialized_start=8912 + _globals['_HOOKACTION']._serialized_end=9083 + _globals['_CONTEXTINJECTIONROLE']._serialized_start=9086 + _globals['_CONTEXTINJECTIONROLE']._serialized_end=9254 + _globals['_APPROVALDEFAULT']._serialized_start=9256 + _globals['_APPROVALDEFAULT']._serialized_end=9364 + _globals['_USERMESSAGELEVEL']._serialized_start=9367 + _globals['_USERMESSAGELEVEL']._serialized_end=9512 _globals['_EMPTY']._serialized_start=44 _globals['_EMPTY']._serialized_end=51 _globals['_TOOLSPEC']._serialized_start=53 _globals['_TOOLSPEC']._serialized_end=123 _globals['_TOOLEXECUTEREQUEST']._serialized_start=125 _globals['_TOOLEXECUTEREQUEST']._serialized_end=182 - _globals['_TOOLEXECUTERESPONSE']._serialized_start=184 - _globals['_TOOLEXECUTERESPONSE']._serialized_end=275 - _globals['_MODULEINFO']._serialized_start=278 - _globals['_MODULEINFO']._serialized_end=492 - _globals['_MOUNTREQUEST']._serialized_start=495 - _globals['_MOUNTREQUEST']._serialized_end=635 - _globals['_MOUNTREQUEST_CONFIGENTRY']._serialized_start=590 - _globals['_MOUNTREQUEST_CONFIGENTRY']._serialized_end=635 - _globals['_MOUNTRESPONSE']._serialized_start=637 - _globals['_MOUNTRESPONSE']._serialized_end=732 - _globals['_HEALTHCHECKRESPONSE']._serialized_start=734 - _globals['_HEALTHCHECKRESPONSE']._serialized_end=820 - _globals['_CONFIGFIELD']._serialized_start=823 - _globals['_CONFIGFIELD']._serialized_end=1153 - _globals['_CONFIGFIELD_SHOWWHENENTRY']._serialized_start=1106 - _globals['_CONFIGFIELD_SHOWWHENENTRY']._serialized_end=1153 - _globals['_PROVIDERERROR']._serialized_start=1156 - _globals['_PROVIDERERROR']._serialized_end=1344 - _globals['_TOOLERROR']._serialized_start=1347 - _globals['_TOOLERROR']._serialized_end=1498 - _globals['_HOOKERROR']._serialized_start=1500 - _globals['_HOOKERROR']._serialized_end=1600 - _globals['_AMPLIFIERERROR']._serialized_start=1603 - _globals['_AMPLIFIERERROR']._serialized_end=1842 - _globals['_TEXTBLOCK']._serialized_start=1844 - _globals['_TEXTBLOCK']._serialized_end=1869 - _globals['_THINKINGBLOCK']._serialized_start=1871 - _globals['_THINKINGBLOCK']._serialized_end=1940 - _globals['_REDACTEDTHINKINGBLOCK']._serialized_start=1942 - _globals['_REDACTEDTHINKINGBLOCK']._serialized_end=1979 - _globals['_TOOLCALLBLOCK']._serialized_start=1981 - _globals['_TOOLCALLBLOCK']._serialized_end=2042 - _globals['_TOOLRESULTBLOCK']._serialized_start=2044 - _globals['_TOOLRESULTBLOCK']._serialized_end=2104 - _globals['_IMAGEBLOCK']._serialized_start=2106 - _globals['_IMAGEBLOCK']._serialized_end=2173 - _globals['_REASONINGBLOCK']._serialized_start=2175 - _globals['_REASONINGBLOCK']._serialized_end=2225 - _globals['_CONTENTBLOCK']._serialized_start=2228 - _globals['_CONTENTBLOCK']._serialized_end=2725 - _globals['_CONTENTBLOCKLIST']._serialized_start=2727 - _globals['_CONTENTBLOCKLIST']._serialized_end=2793 - _globals['_MESSAGE']._serialized_start=2796 - _globals['_MESSAGE']._serialized_end=2998 - _globals['_TOOLCALLMESSAGE']._serialized_start=3000 - _globals['_TOOLCALLMESSAGE']._serialized_end=3067 - _globals['_TOOLSPECPROTO']._serialized_start=3069 - _globals['_TOOLSPECPROTO']._serialized_end=3144 - _globals['_JSONSCHEMAFORMAT']._serialized_start=3146 - _globals['_JSONSCHEMAFORMAT']._serialized_end=3201 - _globals['_RESPONSEFORMAT']._serialized_start=3203 - _globals['_RESPONSEFORMAT']._serialized_end=3320 - _globals['_USAGE']._serialized_start=3323 - _globals['_USAGE']._serialized_end=3570 - _globals['_DEGRADATION']._serialized_start=3572 - _globals['_DEGRADATION']._serialized_end=3636 - _globals['_CHATREQUEST']._serialized_start=3639 - _globals['_CHATREQUEST']._serialized_end=4024 - _globals['_CHATRESPONSE']._serialized_start=4027 - _globals['_CHATRESPONSE']._serialized_end=4307 - _globals['_TOOLRESULT']._serialized_start=4309 - _globals['_TOOLRESULT']._serialized_end=4379 - _globals['_HOOKRESULT']._serialized_start=4382 - _globals['_HOOKRESULT']._serialized_end=4933 - _globals['_MODELINFO']._serialized_start=4936 - _globals['_MODELINFO']._serialized_end=5077 - _globals['_PROVIDERINFO']._serialized_start=5080 - _globals['_PROVIDERINFO']._serialized_end=5256 - _globals['_APPROVALREQUEST']._serialized_start=5259 - _globals['_APPROVALREQUEST']._serialized_end=5387 - _globals['_APPROVALRESPONSE']._serialized_start=5389 - _globals['_APPROVALRESPONSE']._serialized_end=5459 - _globals['_LISTMODELSRESPONSE']._serialized_start=5461 - _globals['_LISTMODELSRESPONSE']._serialized_end=5526 - _globals['_PARSETOOLCALLSRESPONSE']._serialized_start=5528 - _globals['_PARSETOOLCALLSRESPONSE']._serialized_end=5607 - _globals['_ORCHESTRATOREXECUTEREQUEST']._serialized_start=5609 - _globals['_ORCHESTRATOREXECUTEREQUEST']._serialized_end=5673 - _globals['_ORCHESTRATOREXECUTERESPONSE']._serialized_start=5675 - _globals['_ORCHESTRATOREXECUTERESPONSE']._serialized_end=5737 - _globals['_ADDMESSAGEREQUEST']._serialized_start=5739 - _globals['_ADDMESSAGEREQUEST']._serialized_end=5802 - _globals['_GETMESSAGESRESPONSE']._serialized_start=5804 - _globals['_GETMESSAGESRESPONSE']._serialized_end=5870 - _globals['_GETMESSAGESFORREQUESTPARAMS']._serialized_start=5872 - _globals['_GETMESSAGESFORREQUESTPARAMS']._serialized_end=5946 - _globals['_SETMESSAGESREQUEST']._serialized_start=5948 - _globals['_SETMESSAGESREQUEST']._serialized_end=6013 - _globals['_HOOKHANDLEREQUEST']._serialized_start=6015 - _globals['_HOOKHANDLEREQUEST']._serialized_end=6068 - _globals['_GETSUBSCRIPTIONSREQUEST']._serialized_start=6070 - _globals['_GETSUBSCRIPTIONSREQUEST']._serialized_end=6116 - _globals['_GETSUBSCRIPTIONSRESPONSE']._serialized_start=6118 - _globals['_GETSUBSCRIPTIONSRESPONSE']._serialized_end=6204 - _globals['_EVENTSUBSCRIPTION']._serialized_start=6206 - _globals['_EVENTSUBSCRIPTION']._serialized_end=6272 - _globals['_COMPLETEWITHPROVIDERREQUEST']._serialized_start=6274 - _globals['_COMPLETEWITHPROVIDERREQUEST']._serialized_end=6374 - _globals['_EXECUTETOOLREQUEST']._serialized_start=6376 - _globals['_EXECUTETOOLREQUEST']._serialized_end=6435 - _globals['_EMITHOOKREQUEST']._serialized_start=6437 - _globals['_EMITHOOKREQUEST']._serialized_end=6488 - _globals['_EMITHOOKANDCOLLECTREQUEST']._serialized_start=6490 - _globals['_EMITHOOKANDCOLLECTREQUEST']._serialized_end=6576 - _globals['_EMITHOOKANDCOLLECTRESPONSE']._serialized_start=6578 - _globals['_EMITHOOKANDCOLLECTRESPONSE']._serialized_end=6630 - _globals['_GETMESSAGESREQUEST']._serialized_start=6632 - _globals['_GETMESSAGESREQUEST']._serialized_end=6672 - _globals['_KERNELADDMESSAGEREQUEST']._serialized_start=6674 - _globals['_KERNELADDMESSAGEREQUEST']._serialized_end=6763 - _globals['_GETMOUNTEDMODULEREQUEST']._serialized_start=6765 - _globals['_GETMOUNTEDMODULEREQUEST']._serialized_end=6862 - _globals['_GETMOUNTEDMODULERESPONSE']._serialized_start=6864 - _globals['_GETMOUNTEDMODULERESPONSE']._serialized_end=6949 - _globals['_REGISTERCAPABILITYREQUEST']._serialized_start=6951 - _globals['_REGISTERCAPABILITYREQUEST']._serialized_end=7012 - _globals['_GETCAPABILITYREQUEST']._serialized_start=7014 - _globals['_GETCAPABILITYREQUEST']._serialized_end=7050 - _globals['_GETCAPABILITYRESPONSE']._serialized_start=7052 - _globals['_GETCAPABILITYRESPONSE']._serialized_end=7110 - _globals['_TOOLSERVICE']._serialized_start=9099 - _globals['_TOOLSERVICE']._serialized_end=9264 - _globals['_PROVIDERSERVICE']._serialized_start=9267 - _globals['_PROVIDERSERVICE']._serialized_end=9682 - _globals['_ORCHESTRATORSERVICE']._serialized_start=9684 - _globals['_ORCHESTRATORSERVICE']._serialized_end=9809 - _globals['_CONTEXTSERVICE']._serialized_start=9812 - _globals['_CONTEXTSERVICE']._serialized_end=10231 - _globals['_HOOKSERVICE']._serialized_start=10234 - _globals['_HOOKSERVICE']._serialized_end=10431 - _globals['_APPROVALSERVICE']._serialized_start=10433 - _globals['_APPROVALSERVICE']._serialized_end=10540 - _globals['_KERNELSERVICE']._serialized_start=10543 - _globals['_KERNELSERVICE']._serialized_end=11519 - _globals['_MODULELIFECYCLE']._serialized_start=11522 - _globals['_MODULELIFECYCLE']._serialized_end=11825 + _globals['_TOOLEXECUTERESPONSE']._serialized_start=185 + _globals['_TOOLEXECUTERESPONSE']._serialized_end=332 + _globals['_MODULEINFO']._serialized_start=335 + _globals['_MODULEINFO']._serialized_end=549 + _globals['_MOUNTREQUEST']._serialized_start=552 + _globals['_MOUNTREQUEST']._serialized_end=692 + _globals['_MOUNTREQUEST_CONFIGENTRY']._serialized_start=647 + _globals['_MOUNTREQUEST_CONFIGENTRY']._serialized_end=692 + _globals['_MOUNTRESPONSE']._serialized_start=694 + _globals['_MOUNTRESPONSE']._serialized_end=789 + _globals['_HEALTHCHECKRESPONSE']._serialized_start=791 + _globals['_HEALTHCHECKRESPONSE']._serialized_end=877 + _globals['_CONFIGFIELD']._serialized_start=880 + _globals['_CONFIGFIELD']._serialized_end=1210 + _globals['_CONFIGFIELD_SHOWWHENENTRY']._serialized_start=1163 + _globals['_CONFIGFIELD_SHOWWHENENTRY']._serialized_end=1210 + _globals['_PROVIDERERROR']._serialized_start=1213 + _globals['_PROVIDERERROR']._serialized_end=1401 + _globals['_TOOLERROR']._serialized_start=1404 + _globals['_TOOLERROR']._serialized_end=1555 + _globals['_HOOKERROR']._serialized_start=1557 + _globals['_HOOKERROR']._serialized_end=1657 + _globals['_AMPLIFIERERROR']._serialized_start=1660 + _globals['_AMPLIFIERERROR']._serialized_end=1899 + _globals['_TEXTBLOCK']._serialized_start=1901 + _globals['_TEXTBLOCK']._serialized_end=1926 + _globals['_THINKINGBLOCK']._serialized_start=1928 + _globals['_THINKINGBLOCK']._serialized_end=1997 + _globals['_REDACTEDTHINKINGBLOCK']._serialized_start=1999 + _globals['_REDACTEDTHINKINGBLOCK']._serialized_end=2036 + _globals['_TOOLCALLBLOCK']._serialized_start=2038 + _globals['_TOOLCALLBLOCK']._serialized_end=2099 + _globals['_TOOLRESULTBLOCK']._serialized_start=2101 + _globals['_TOOLRESULTBLOCK']._serialized_end=2161 + _globals['_IMAGEBLOCK']._serialized_start=2163 + _globals['_IMAGEBLOCK']._serialized_end=2230 + _globals['_REASONINGBLOCK']._serialized_start=2232 + _globals['_REASONINGBLOCK']._serialized_end=2282 + _globals['_CONTENTBLOCK']._serialized_start=2285 + _globals['_CONTENTBLOCK']._serialized_end=2782 + _globals['_CONTENTBLOCKLIST']._serialized_start=2784 + _globals['_CONTENTBLOCKLIST']._serialized_end=2850 + _globals['_MESSAGE']._serialized_start=2853 + _globals['_MESSAGE']._serialized_end=3055 + _globals['_TOOLCALLMESSAGE']._serialized_start=3057 + _globals['_TOOLCALLMESSAGE']._serialized_end=3124 + _globals['_TOOLSPECPROTO']._serialized_start=3126 + _globals['_TOOLSPECPROTO']._serialized_end=3201 + _globals['_JSONSCHEMAFORMAT']._serialized_start=3203 + _globals['_JSONSCHEMAFORMAT']._serialized_end=3258 + _globals['_RESPONSEFORMAT']._serialized_start=3260 + _globals['_RESPONSEFORMAT']._serialized_end=3377 + _globals['_USAGE']._serialized_start=3380 + _globals['_USAGE']._serialized_end=3663 + _globals['_DEGRADATION']._serialized_start=3665 + _globals['_DEGRADATION']._serialized_end=3729 + _globals['_CHATREQUEST']._serialized_start=3732 + _globals['_CHATREQUEST']._serialized_end=4117 + _globals['_CHATRESPONSE']._serialized_start=4120 + _globals['_CHATRESPONSE']._serialized_end=4400 + _globals['_TOOLRESULT']._serialized_start=4402 + _globals['_TOOLRESULT']._serialized_end=4528 + _globals['_CONTEXTINJECTION']._serialized_start=4531 + _globals['_CONTEXTINJECTION']._serialized_end=4709 + _globals['_HOOKRESULT']._serialized_start=4712 + _globals['_HOOKRESULT']._serialized_end=5327 + _globals['_MODELINFO']._serialized_start=5330 + _globals['_MODELINFO']._serialized_end=5493 + _globals['_PROVIDERINFO']._serialized_start=5496 + _globals['_PROVIDERINFO']._serialized_end=5672 + _globals['_APPROVALREQUEST']._serialized_start=5675 + _globals['_APPROVALREQUEST']._serialized_end=5803 + _globals['_APPROVALRESPONSE']._serialized_start=5805 + _globals['_APPROVALRESPONSE']._serialized_end=5875 + _globals['_LISTMODELSRESPONSE']._serialized_start=5877 + _globals['_LISTMODELSRESPONSE']._serialized_end=5942 + _globals['_PARSETOOLCALLSRESPONSE']._serialized_start=5944 + _globals['_PARSETOOLCALLSRESPONSE']._serialized_end=6023 + _globals['_ORCHESTRATOREXECUTEREQUEST']._serialized_start=6025 + _globals['_ORCHESTRATOREXECUTEREQUEST']._serialized_end=6089 + _globals['_ORCHESTRATOREXECUTERESPONSE']._serialized_start=6091 + _globals['_ORCHESTRATOREXECUTERESPONSE']._serialized_end=6153 + _globals['_ADDMESSAGEREQUEST']._serialized_start=6155 + _globals['_ADDMESSAGEREQUEST']._serialized_end=6218 + _globals['_GETMESSAGESRESPONSE']._serialized_start=6220 + _globals['_GETMESSAGESRESPONSE']._serialized_end=6286 + _globals['_GETMESSAGESFORREQUESTPARAMS']._serialized_start=6288 + _globals['_GETMESSAGESFORREQUESTPARAMS']._serialized_end=6362 + _globals['_SETMESSAGESREQUEST']._serialized_start=6364 + _globals['_SETMESSAGESREQUEST']._serialized_end=6429 + _globals['_HOOKHANDLEREQUEST']._serialized_start=6431 + _globals['_HOOKHANDLEREQUEST']._serialized_end=6484 + _globals['_GETSUBSCRIPTIONSREQUEST']._serialized_start=6486 + _globals['_GETSUBSCRIPTIONSREQUEST']._serialized_end=6532 + _globals['_GETSUBSCRIPTIONSRESPONSE']._serialized_start=6534 + _globals['_GETSUBSCRIPTIONSRESPONSE']._serialized_end=6620 + _globals['_EVENTSUBSCRIPTION']._serialized_start=6622 + _globals['_EVENTSUBSCRIPTION']._serialized_end=6688 + _globals['_COMPLETEWITHPROVIDERREQUEST']._serialized_start=6690 + _globals['_COMPLETEWITHPROVIDERREQUEST']._serialized_end=6790 + _globals['_EXECUTETOOLREQUEST']._serialized_start=6792 + _globals['_EXECUTETOOLREQUEST']._serialized_end=6851 + _globals['_EMITHOOKREQUEST']._serialized_start=6853 + _globals['_EMITHOOKREQUEST']._serialized_end=6904 + _globals['_EMITHOOKANDCOLLECTREQUEST']._serialized_start=6906 + _globals['_EMITHOOKANDCOLLECTREQUEST']._serialized_end=6992 + _globals['_EMITHOOKANDCOLLECTRESPONSE']._serialized_start=6994 + _globals['_EMITHOOKANDCOLLECTRESPONSE']._serialized_end=7046 + _globals['_GETMESSAGESREQUEST']._serialized_start=7048 + _globals['_GETMESSAGESREQUEST']._serialized_end=7088 + _globals['_KERNELADDMESSAGEREQUEST']._serialized_start=7090 + _globals['_KERNELADDMESSAGEREQUEST']._serialized_end=7179 + _globals['_GETMOUNTEDMODULEREQUEST']._serialized_start=7181 + _globals['_GETMOUNTEDMODULEREQUEST']._serialized_end=7278 + _globals['_GETMOUNTEDMODULERESPONSE']._serialized_start=7280 + _globals['_GETMOUNTEDMODULERESPONSE']._serialized_end=7365 + _globals['_REGISTERCAPABILITYREQUEST']._serialized_start=7367 + _globals['_REGISTERCAPABILITYREQUEST']._serialized_end=7428 + _globals['_GETCAPABILITYREQUEST']._serialized_start=7430 + _globals['_GETCAPABILITYREQUEST']._serialized_end=7466 + _globals['_GETCAPABILITYRESPONSE']._serialized_start=7468 + _globals['_GETCAPABILITYRESPONSE']._serialized_end=7526 + _globals['_TOOLSERVICE']._serialized_start=9515 + _globals['_TOOLSERVICE']._serialized_end=9680 + _globals['_PROVIDERSERVICE']._serialized_start=9683 + _globals['_PROVIDERSERVICE']._serialized_end=10098 + _globals['_ORCHESTRATORSERVICE']._serialized_start=10100 + _globals['_ORCHESTRATORSERVICE']._serialized_end=10225 + _globals['_CONTEXTSERVICE']._serialized_start=10228 + _globals['_CONTEXTSERVICE']._serialized_end=10647 + _globals['_HOOKSERVICE']._serialized_start=10650 + _globals['_HOOKSERVICE']._serialized_end=10847 + _globals['_APPROVALSERVICE']._serialized_start=10849 + _globals['_APPROVALSERVICE']._serialized_end=10956 + _globals['_KERNELSERVICE']._serialized_start=10959 + _globals['_KERNELSERVICE']._serialized_end=11935 + _globals['_MODULELIFECYCLE']._serialized_start=11938 + _globals['_MODULELIFECYCLE']._serialized_end=12241 # @@protoc_insertion_point(module_scope) diff --git a/python/amplifier_core/_session_init.py b/python/amplifier_core/_session_init.py index 03ede4b7..98002b38 100644 --- a/python/amplifier_core/_session_init.py +++ b/python/amplifier_core/_session_init.py @@ -32,6 +32,7 @@ async def _emit_module_load_failed( failure must never suppress the original WARNING log. """ from .events import MODULE_LOAD_FAILED + from .loader import module_failure_reason try: await coordinator.hooks.emit( @@ -40,6 +41,7 @@ async def _emit_module_load_failed( "module_type": module_type, "module_id": module_id, "error": _safe_exception_str(error), + "reason_code": module_failure_reason(error), }, ) except Exception: diff --git a/python/amplifier_core/hooks.py b/python/amplifier_core/hooks.py index b8c8b795..c6e74929 100644 --- a/python/amplifier_core/hooks.py +++ b/python/amplifier_core/hooks.py @@ -1,5 +1,5 @@ """Thin alias: re-exports RustHookRegistry (as HookRegistry) and HookResult.""" from ._engine import RustHookRegistry as HookRegistry -from .models import HookResult +from .models import ContextInjection, HookResult -__all__ = ["HookRegistry", "HookResult"] +__all__ = ["HookRegistry", "ContextInjection", "HookResult"] diff --git a/python/amplifier_core/loader.py b/python/amplifier_core/loader.py index ef6f0647..b2d85234 100644 --- a/python/amplifier_core/loader.py +++ b/python/amplifier_core/loader.py @@ -42,7 +42,34 @@ class ModuleValidationError(Exception): """Raised when a module fails validation at load time.""" - pass + def __init__(self, message: str, *, reason_code: str = "validation_failed"): + super().__init__(message) + self.reason_code = reason_code + + +class _ModuleDiscoveryError(ValueError): + def __init__(self, message: str, *, reason_code: str): + super().__init__(message) + self.reason_code = reason_code + + +MODULE_FAILURE_REASONS = frozenset({ + "invalid_package_layout", "missing_source", "invalid_entry_point", + "invalid_module_metadata", "validation_failed", "unknown", +}) + + +def module_failure_reason(error: BaseException) -> str: + """Return a bounded category without parsing or copying exception text.""" + from .module_sources import ModuleNotFoundError as SourceNotFoundError + + if isinstance(error, SourceNotFoundError): + return "missing_source" + if isinstance(error, (ModuleValidationError, _ModuleDiscoveryError)): + code = error.reason_code + if isinstance(code, str) and code in MODULE_FAILURE_REASONS: + return code + return "unknown" class ModuleLoader: @@ -73,6 +100,7 @@ def __init__( search_paths: Optional list of filesystem paths for direct discovery """ self._loaded_modules: dict[str, Any] = {} + self._loaded_module_paths: dict[str, Path] = {} self._module_info: dict[str, ModuleInfo] = {} self._search_paths = search_paths self._coordinator = coordinator @@ -204,17 +232,43 @@ async def load( if module_id in self._loaded_modules: logger.debug(f"Module '{module_id}' already loaded, creating fresh closure") raw_fn = self._loaded_modules[module_id] + cached_source_path = self._loaded_module_paths.get(module_id) + source_resolver = None + if self._coordinator: + with contextlib.suppress(ValueError): + source_resolver = self._coordinator.get("module-source-resolver") + if cached_source_path is not None and self._coordinator: + if source_resolver is not None: + if hasattr(source_resolver, "async_resolve"): + source = await source_resolver.async_resolve( + module_id, + source_hint=source_hint, + profile_hint=source_hint, + ) + else: + source = source_resolver.resolve( + module_id, + source_hint=source_hint, + profile_hint=source_hint, + ) + requested_source_path = source.resolve().resolve() + if requested_source_path != cached_source_path: + raise ImportError( + f"Refusing to load '{module_id}' from {requested_source_path}: " + f"it is already loaded from {cached_source_path}" + ) - async def mount_with_config_cached( - coordinator: ModuleCoordinator, fn=raw_fn - ): - return await fn(coordinator, config or {}) + if source_resolver is None or cached_source_path is not None: + async def mount_with_config_cached( + coordinator: ModuleCoordinator, fn=raw_fn + ): + return await fn(coordinator, config or {}) - # B1: propagate __on_session_ready__ to fresh closure - if on_sr := getattr(raw_fn, "__on_session_ready__", None): - setattr(mount_with_config_cached, "__on_session_ready__", on_sr) + # B1: propagate __on_session_ready__ to fresh closure + if on_sr := getattr(raw_fn, "__on_session_ready__", None): + setattr(mount_with_config_cached, "__on_session_ready__", on_sr) - return mount_with_config_cached + return mount_with_config_cached try: # Resolve module source @@ -236,8 +290,9 @@ async def mount_with_config_cached( mount_closure = await self._load_direct(module_id, config) if mount_closure: return mount_closure - raise ValueError( - f"Module '{module_id}' not found via entry points or filesystem" + raise _ModuleDiscoveryError( + f"Module '{module_id}' not found via entry points or filesystem", + reason_code="missing_source", ) # Try async resolution first (supports lazy activation) @@ -304,7 +359,9 @@ async def mount_with_config_cached( ) # Validate module before loading (Python modules only at this point) - await self._validate_module(module_id, module_path, config=config) + package_path = await self._validate_module( + module_id, module_path, config=config + ) except Exception as resolve_error: # Import here to avoid circular dependency @@ -320,26 +377,13 @@ async def mount_with_config_cached( return mount_fn raise resolve_error - # Try to load via entry point first - raw_fn = self._load_entry_point(module_id) - if raw_fn: - self._loaded_modules[module_id] = raw_fn - - async def mount_with_config_ep( - coordinator: ModuleCoordinator, fn=raw_fn - ): - return await fn(coordinator, config or {}) - - # B1: propagate __on_session_ready__ to closure - if on_sr := getattr(raw_fn, "__on_session_ready__", None): - setattr(mount_with_config_ep, "__on_session_ready__", on_sr) - - return mount_with_config_ep - - # Try filesystem loading - raw_fn = self._load_filesystem(module_id) + # Source resolution selected and validated this filesystem package. + # Do not let an installed entry point for the same module id mount a + # different source after validation. + raw_fn = self._load_filesystem(module_id, module_name=package_path.name) if raw_fn: self._loaded_modules[module_id] = raw_fn + self._loaded_module_paths[module_id] = module_path.resolve() async def mount_with_config_fs( coordinator: ModuleCoordinator, fn=raw_fn @@ -352,8 +396,9 @@ async def mount_with_config_fs( return mount_with_config_fs - raise ValueError( - f"Module '{module_id}' found at {module_path} but failed to load" + raise _ModuleDiscoveryError( + f"Module '{module_id}' found at {module_path} but failed to load", + reason_code="invalid_entry_point", ) except Exception as e: @@ -457,7 +502,9 @@ def _load_entry_point(self, module_id: str) -> Callable | None: return None - def _load_filesystem(self, module_id: str) -> Callable | None: + def _load_filesystem( + self, module_id: str, module_name: str | None = None + ) -> Callable | None: """Resolve module from filesystem and return the raw mount function. Returns the raw (un-configured) mount function so callers can cache it @@ -465,7 +512,7 @@ def _load_filesystem(self, module_id: str) -> Callable | None: """ try: # Try to import the module - module_name = f"amplifier_module_{module_id.replace('-', '_')}" + module_name = module_name or f"amplifier_module_{module_id.replace('-', '_')}" module = importlib.import_module(module_name) # Detect on_session_ready lifecycle hook if present. @@ -524,7 +571,7 @@ def _get_module_metadata( package_path = self._find_package_dir(module_id, module_path) if package_path: # Import the module temporarily - module_name = f"amplifier_module_{module_id.replace('-', '_')}" + module_name = package_path.name # Add to sys.path temporarily for import path_str = str(module_path) @@ -541,11 +588,12 @@ def _get_module_metadata( if module_type: # Derive mount point from type (kernel mechanism) - mount_point = TYPE_TO_MOUNT_POINT.get(module_type) + mount_point = TYPE_TO_MOUNT_POINT.get(module_type) if isinstance(module_type, str) else None if not mount_point: raise ModuleValidationError( f"Module '{module_id}' has unknown type '{module_type}'. " - f"Valid types: {list(TYPE_TO_MOUNT_POINT.keys())}" + f"Valid types: {list(TYPE_TO_MOUNT_POINT.keys())}", + reason_code="invalid_module_metadata", ) logger.debug( @@ -558,6 +606,8 @@ def _get_module_metadata( if added: sys.path.remove(path_str) + except ModuleValidationError: + raise except Exception as e: logger.debug(f"Could not inspect module '{module_id}': {e}") @@ -603,7 +653,7 @@ def _guess_from_naming( async def _validate_module( self, module_id: str, module_path: Path, config: dict[str, Any] | None = None - ) -> None: + ) -> Path: """ Validate a module before loading. @@ -643,14 +693,15 @@ async def _validate_module( logger.warning( f"Unknown module type '{module_type}' for '{module_id}', skipping validation" ) - return + return module_path # Find the actual Python package directory within the module root # Module structure: amplifier-module-xyz/ contains amplifier_module_xyz/ package_path = self._find_package_dir(module_id, module_path) if package_path is None: raise ModuleValidationError( - f"Module '{module_id}' has no valid Python package at {module_path}" + f"Module '{module_id}' has no valid Python package at {module_path}", + reason_code="invalid_package_layout" if module_path.exists() else "missing_source", ) # Run validation @@ -659,11 +710,17 @@ async def _validate_module( if not result.passed: error_details = "; ".join(f"{e.name}: {e.message}" for e in result.errors) + reason = "invalid_entry_point" if any( + check.name in {"mount_exists", "mount_signature", "on_session_ready_async"} + for check in result.errors + ) else "validation_failed" raise ModuleValidationError( - f"Module '{module_id}' failed validation: {result.summary()}. Errors: {error_details}" + f"Module '{module_id}' failed validation: {result.summary()}. Errors: {error_details}", + reason_code=reason, ) logger.info(f"[module:validated] {module_id} - {result.summary()}") + return package_path def _find_package_dir(self, module_id: str, module_path: Path) -> Path | None: """ diff --git a/python/amplifier_core/loader_grpc.py b/python/amplifier_core/loader_grpc.py index 41b0c3c4..465c46de 100644 --- a/python/amplifier_core/loader_grpc.py +++ b/python/amplifier_core/loader_grpc.py @@ -9,6 +9,7 @@ """ import json +import base64 import logging from typing import Any @@ -105,6 +106,43 @@ def _deserialize_output(self, output_bytes: bytes, content_type: str) -> Any: logger.warning(f"Unknown content type '{content_type}', attempting JSON decode") return json.loads(output_bytes.decode("utf-8")) + def _deserialize_content_blocks(self, content_blocks: Any) -> list[dict[str, Any]] | None: + """Decode the ToolResult-specific protobuf representation.""" + if not content_blocks: + return None + + raw_content: list[dict[str, Any]] = [] + for block in content_blocks: + if block.visibility != 0: + raise ValueError("invalid tool result content") + block_type = block.WhichOneof("block") + if block_type == "text_block": + raw_content.append({"type": "text", "text": block.text_block.text}) + elif block_type == "image_block": + if block.image_block.source_json: + raise ValueError("invalid tool result content") + raw_content.append( + { + "type": "image", + "source": { + "type": "base64", + "media_type": block.image_block.media_type, + "data": base64.b64encode(block.image_block.data).decode("ascii"), + }, + } + ) + else: + raise ValueError("invalid tool result content") + + from amplifier_core.models import ToolResult + + result = ToolResult(content=raw_content) + return ( + [item.model_dump(exclude_none=True) for item in result.content] + if result.content is not None + else None + ) + async def execute(self, **kwargs: Any) -> dict[str, Any]: """Execute the tool via gRPC. @@ -131,18 +169,32 @@ async def execute(self, **kwargs: Any) -> dict[str, Any]: content_type=content_type, ) response = await self._stub.Execute(request) + try: + content = self._deserialize_content_blocks(response.content_blocks) + except ValueError: + logger.warning( + "gRPC tool '%s' returned invalid tool result content", self._name + ) + return { + "success": False, + "output": None, + "error": {"message": "invalid tool result content from gRPC tool"}, + } if response.success: output = self._deserialize_output( response.output, response.content_type ) - return {"success": True, "output": output, "error": None} + result = {"success": True, "output": output, "error": None} else: - return { + result = { "success": False, "output": None, "error": {"message": response.error}, } + if content is not None: + result["content"] = content + return result except Exception as e: logger.error(f"gRPC tool execution failed for '{self._name}': {e}") @@ -195,8 +247,7 @@ async def load_grpc_module( except ImportError: raise ImportError( "gRPC proto stubs not generated. Run: " - "python -m grpc_tools.protoc -I proto --python_out=python/amplifier_core/_grpc_gen " - "--grpc_python_out=python/amplifier_core/_grpc_gen proto/amplifier_module.proto" + "python scripts/generate_grpc_stubs.py" ) stub = amplifier_module_pb2_grpc.ToolServiceStub(channel) diff --git a/python/amplifier_core/models.py b/python/amplifier_core/models.py index 2bf33300..58d990e7 100644 --- a/python/amplifier_core/models.py +++ b/python/amplifier_core/models.py @@ -11,9 +11,14 @@ from typing import Literal from pydantic import BaseModel +from pydantic import ConfigDict from pydantic import Field from pydantic import field_serializer from pydantic import field_validator +from pydantic import model_serializer + +from .message_models import ImageBlock +from .message_models import TextBlock def _json_default(obj: Any) -> Any: @@ -22,8 +27,9 @@ def _json_default(obj: Any) -> Any: Handles types that flow through tool result payloads but aren't JSON-native. Currently: Decimal -> string (preserves precision; never lossy). - Used by ToolResult.get_serialized_output(). Pydantic models defend their - own boundary via @field_serializer; this is the safety net for the + Used by ToolResult.get_serialized_output() and as the fallback for + ToolResult rich-content serialization. Pydantic models defend their own + boundary via @field_serializer; this is the safety net for the non-Pydantic path where tool outputs may contain Decimal values (e.g., a tool that returns {"price": Decimal("9.99")}). """ @@ -53,14 +59,66 @@ def _sanitize_for_llm(text: str) -> str: return sanitized +def _validated_tool_result_content(content: Any) -> list[dict[str, Any]] | None: + """Canonicalize rich content through the Rust validation boundary.""" + from amplifier_core._engine import _normalize_tool_result_content + + serialized = json.dumps( + content, + default=lambda value: ( + value.model_dump(exclude_none=True) + if isinstance(value, BaseModel) + else _json_default(value) + ), + ) + normalized = _normalize_tool_result_content(serialized) + return json.loads(normalized) if normalized is not None else None + + class ToolResult(BaseModel): """Result from tool execution.""" + model_config = ConfigDict(hide_input_in_errors=True, validate_assignment=True) + success: bool = Field(default=True, description="Whether execution succeeded") output: Any | None = Field(default=None, description="Tool output data") error: dict[str, Any] | None = Field( default=None, description="Error details if failed" ) + content: list[TextBlock | ImageBlock] | None = Field( + default=None, description="Canonical rich tool-result content" + ) + + @field_validator("content", mode="before") + @classmethod + def normalize_content(cls, content: Any) -> Any: + """Normalize untrusted rich content through the Rust validation boundary.""" + if content is None: + return None + + return _validated_tool_result_content(content) + + @field_serializer("content", when_used="unless-none") + def serialize_content(self, content: list[TextBlock | ImageBlock]) -> list[dict[str, Any]]: + """Revalidate content so Pydantic escape hatches cannot serialize a bypass.""" + return _validated_tool_result_content(content) or [] + + @model_serializer(mode="wrap") + def serialize_model(self, handler: Any) -> Any: + """Preserve the legacy no-content key set for every Pydantic dump mode.""" + result = handler(self) + if not self.content: + result.pop("content", None) + return result + + def model_copy( + self, *, update: dict[str, Any] | None = None, deep: bool = False + ) -> "ToolResult": + """Keep copy updates on the same strict ToolResult content boundary.""" + data = self.model_dump() + if update: + data.update(update) + return type(self).model_validate(data) def model_post_init(self, __context: Any) -> None: """Auto-populate output from error when tools forget to set it. @@ -123,6 +181,37 @@ def get_serialized_output(self) -> str: # Success with no output return "Success" + def safe_hook_presentation(self) -> dict[str, Any]: + """Return an image-safe hook event payload from the Rust core.""" + from amplifier_core._engine import _tool_result_safe_hook_presentation + + payload = { + "success": self.success, + "output": self.output, + "error": self.error, + "content": ( + [block.model_dump(exclude_none=True) for block in self.content] + if self.content is not None + else None + ), + } + return json.loads( + _tool_result_safe_hook_presentation( + json.dumps(payload, default=_json_default) + ) + ) + + +class ContextInjection(BaseModel): + """One ordered context injection emitted by a hook.""" + + content: str + role: Literal["system", "user", "assistant"] = "system" + ephemeral: bool = False + append_to_last_tool_result: bool = False + hook_name: str = "unknown" + event: str = "" + class HookResult(BaseModel): """ @@ -323,6 +412,46 @@ class HookResult(BaseModel): "Only applicable when action='inject_context' and ephemeral=True." ), ) + context_injections: list[ContextInjection] = Field( + default_factory=list, + description=( + "Ordered, lossless hook context injections. The hook registry projects " + "the legacy scalar context fields from these items for compatibility." + ), + ) + + +class Pricing(BaseModel): + """Per-model pricing information. + + Rates are per million tokens, in the specified currency. Surfaced via + /v1/models so HTTP-bridge applications (e.g., amplifier-app-opencode) + can display cost estimates without maintaining their own pricing tables. + + Rate fields use `float` (not `Decimal`). These are display-only estimates + surfaced to consumers via `/v1/models` for UI-level cost display. For + per-turn cost accounting, use `Usage.cost_usd`, which is `Decimal` and + uses a field validator that explicitly rejects `float`. + """ + + input_per_million: float = Field(..., description="Cost per million input tokens") + output_per_million: float = Field(..., description="Cost per million output tokens") + cache_read_per_million: float | None = Field( + default=None, + description="Cost per million cache-read input tokens (None if not supported)", + ) + cache_write_per_million: float | None = Field( + default=None, + description="Cost per million cache-write input tokens (None if not supported)", + ) + currency: str = Field(default="USD", description="ISO 4217 currency code") + + @field_validator("currency") + @classmethod + def _validate_currency(cls, v: str) -> str: + if not re.match(r"^[A-Z]{3}$", v): + raise ValueError(f"currency must be a 3-letter ISO 4217 code, got {v!r}") + return v class ModelInfo(BaseModel): @@ -345,6 +474,13 @@ class ModelInfo(BaseModel): default_factory=dict, description="Model-specific default config values (e.g., temperature, max_tokens)", ) + pricing: Pricing | None = Field( + default=None, + description=( + "Per-model pricing information. None when pricing is not available " + "(e.g., local providers like ollama, self-hosted backends like vllm)." + ), + ) class ConfigField(BaseModel): diff --git a/python/amplifier_core/validation/base.py b/python/amplifier_core/validation/base.py index 6a3cbdc5..bf125feb 100644 --- a/python/amplifier_core/validation/base.py +++ b/python/amplifier_core/validation/base.py @@ -11,9 +11,14 @@ ``context/release-mandate.md`` for the v1.4.0 regression that motivated this. """ +import _imp +import importlib import inspect +import sys from dataclasses import dataclass from dataclasses import field +from pathlib import Path +from types import ModuleType from typing import Any from typing import Literal @@ -62,6 +67,97 @@ def summary(self) -> str: return f"{status}: {passed_count}/{len(self.checks)} checks passed ({len(self.errors)} errors, {len(self.warnings)} warnings)" +def import_module_from_path(module_path: str | Path) -> ModuleType: + """Import a Python source path through Python's normal import machinery. + + Validation must use the canonical module object that runtime loading will + mount. If another source already owns the same package name, fail closed + rather than replacing entries in ``sys.modules`` while another importer can + observe them. + """ + path = Path(module_path) + source_path = path / "__init__.py" if path.is_dir() else path + module_name = path.name if path.is_dir() else path.stem + + import_root = path.parent if path.is_dir() else source_path.parent + parent = str(import_root) + _imp.acquire_lock() + try: + # Audit children even when the matching root package is already loaded: + # a canonical root must not hide a child cached from a different source. + package_dir = source_path.parent.resolve() + for cached_name, cached_module in list(sys.modules.items()): + if not cached_name.startswith(f"{module_name}."): + continue + cached_file = getattr(cached_module, "__file__", None) + if cached_file is None or not Path(cached_file).resolve().is_relative_to( + package_dir + ): + raise ImportError( + f"Refusing to import '{module_name}' from {source_path}: " + f"cached submodule '{cached_name}' is from {cached_file}" + ) + + existing = sys.modules.get(module_name) + if existing is not None: + existing_file = getattr(existing, "__file__", None) + if ( + existing_file is not None + and Path(existing_file).resolve() == source_path.resolve() + ): + return existing + raise ImportError( + f"Refusing to import '{module_name}' from {source_path}: " + f"it is already loaded from {existing_file}" + ) + + try: + original_path_index = sys.path.index(parent) + except ValueError: + original_path_index = None + next_path = ( + sys.path[original_path_index + 1] + if original_path_index is not None + and original_path_index + 1 < len(sys.path) + else None + ) + if original_path_index is None: + sys.path.insert(0, parent) + elif original_path_index != 0: + sys.path.pop(original_path_index) + sys.path.insert(0, parent) + try: + module = importlib.import_module(module_name) + finally: + current_path_index = next( + (index for index, value in enumerate(sys.path) if value == parent), + None, + ) + if original_path_index is None: + if current_path_index is not None: + sys.path.pop(current_path_index) + elif original_path_index != 0 and current_path_index is not None: + sys.path.pop(current_path_index) + next_path_index = next( + (index for index, value in enumerate(sys.path) if value == next_path), + None, + ) + if next_path_index is None: + sys.path.append(parent) + else: + sys.path.insert(next_path_index, parent) + finally: + _imp.release_lock() + + imported_file = getattr(module, "__file__", None) + if imported_file is None or Path(imported_file).resolve() != source_path.resolve(): + raise ImportError( + f"Refusing to validate '{module_name}' from {source_path}: " + f"Python imported {imported_file}" + ) + return module + + def check_on_session_ready(module: Any) -> ValidationCheck | None: """Check whether a module's on_session_ready() function, if present, is valid. diff --git a/python/amplifier_core/validation/context.py b/python/amplifier_core/validation/context.py index 54407833..2f150591 100644 --- a/python/amplifier_core/validation/context.py +++ b/python/amplifier_core/validation/context.py @@ -8,7 +8,6 @@ import asyncio import importlib -import importlib.util import inspect from pathlib import Path from typing import Any @@ -16,6 +15,7 @@ from .base import ValidationCheck from .base import ValidationResult from .base import check_on_session_ready +from .base import import_module_from_path def _implements_context_manager_interface(obj: Any) -> bool: @@ -95,11 +95,7 @@ def _check_importable( # File path - find the Python module if path.is_dir(): init_file = path / "__init__.py" - if init_file.exists(): - spec = importlib.util.spec_from_file_location( - path.name, init_file - ) - else: + if not init_file.exists(): result.add( ValidationCheck( name="module_importable", @@ -109,21 +105,16 @@ def _check_importable( ) ) return None - else: - spec = importlib.util.spec_from_file_location(path.stem, path) - - if spec and spec.loader: - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - result.add( - ValidationCheck( - name="module_importable", - passed=True, - message=f"Module loaded from {path}", - severity="info", - ) + module = import_module_from_path(path) + result.add( + ValidationCheck( + name="module_importable", + passed=True, + message=f"Module loaded from {path}", + severity="info", ) - return module + ) + return module else: # Module name - import directly module = importlib.import_module(str(module_path)) diff --git a/python/amplifier_core/validation/hook.py b/python/amplifier_core/validation/hook.py index e3e68eea..5f2b41e1 100644 --- a/python/amplifier_core/validation/hook.py +++ b/python/amplifier_core/validation/hook.py @@ -8,7 +8,6 @@ import asyncio import importlib -import importlib.util import inspect from pathlib import Path from typing import Any @@ -16,6 +15,7 @@ from .base import ValidationCheck from .base import ValidationResult from .base import check_on_session_ready +from .base import import_module_from_path def _implements_hook_handler_interface(obj: Any) -> bool: @@ -86,11 +86,7 @@ def _check_importable( # File path - find the Python module if path.is_dir(): init_file = path / "__init__.py" - if init_file.exists(): - spec = importlib.util.spec_from_file_location( - path.name, init_file - ) - else: + if not init_file.exists(): result.add( ValidationCheck( name="module_importable", @@ -100,21 +96,16 @@ def _check_importable( ) ) return None - else: - spec = importlib.util.spec_from_file_location(path.stem, path) - - if spec and spec.loader: - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - result.add( - ValidationCheck( - name="module_importable", - passed=True, - message=f"Module loaded from {path}", - severity="info", - ) + module = import_module_from_path(path) + result.add( + ValidationCheck( + name="module_importable", + passed=True, + message=f"Module loaded from {path}", + severity="info", ) - return module + ) + return module else: # Module name - import directly module = importlib.import_module(str(module_path)) diff --git a/python/amplifier_core/validation/orchestrator.py b/python/amplifier_core/validation/orchestrator.py index 09a6e985..0a722f32 100644 --- a/python/amplifier_core/validation/orchestrator.py +++ b/python/amplifier_core/validation/orchestrator.py @@ -8,7 +8,6 @@ import asyncio import importlib -import importlib.util import inspect from pathlib import Path from typing import Any @@ -16,6 +15,7 @@ from .base import ValidationCheck from .base import ValidationResult from .base import check_on_session_ready +from .base import import_module_from_path def _implements_orchestrator_interface(obj: Any) -> bool: @@ -86,11 +86,7 @@ def _check_importable( # File path - find the Python module if path.is_dir(): init_file = path / "__init__.py" - if init_file.exists(): - spec = importlib.util.spec_from_file_location( - path.name, init_file - ) - else: + if not init_file.exists(): result.add( ValidationCheck( name="module_importable", @@ -100,21 +96,16 @@ def _check_importable( ) ) return None - else: - spec = importlib.util.spec_from_file_location(path.stem, path) - - if spec and spec.loader: - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - result.add( - ValidationCheck( - name="module_importable", - passed=True, - message=f"Module loaded from {path}", - severity="info", - ) + module = import_module_from_path(path) + result.add( + ValidationCheck( + name="module_importable", + passed=True, + message=f"Module loaded from {path}", + severity="info", ) - return module + ) + return module else: # Module name - import directly module = importlib.import_module(str(module_path)) diff --git a/python/amplifier_core/validation/provider.py b/python/amplifier_core/validation/provider.py index 3626f8a5..8066f632 100644 --- a/python/amplifier_core/validation/provider.py +++ b/python/amplifier_core/validation/provider.py @@ -8,7 +8,6 @@ import asyncio import importlib -import importlib.util import inspect from pathlib import Path from typing import Any @@ -17,6 +16,7 @@ from .base import ValidationCheck from .base import ValidationResult from .base import check_on_session_ready +from .base import import_module_from_path def _implements_provider_interface(obj: Any) -> bool: @@ -96,11 +96,7 @@ def _check_importable( # File path - find the Python module if path.is_dir(): init_file = path / "__init__.py" - if init_file.exists(): - spec = importlib.util.spec_from_file_location( - path.name, init_file - ) - else: + if not init_file.exists(): result.add( ValidationCheck( name="module_importable", @@ -110,21 +106,16 @@ def _check_importable( ) ) return None - else: - spec = importlib.util.spec_from_file_location(path.stem, path) - - if spec and spec.loader: - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - result.add( - ValidationCheck( - name="module_importable", - passed=True, - message=f"Module loaded from {path}", - severity="info", - ) + module = import_module_from_path(path) + result.add( + ValidationCheck( + name="module_importable", + passed=True, + message=f"Module loaded from {path}", + severity="info", ) - return module + ) + return module else: # Module name - import directly module = importlib.import_module(str(module_path)) diff --git a/python/amplifier_core/validation/tool.py b/python/amplifier_core/validation/tool.py index e4bfe02d..a10c7c4d 100644 --- a/python/amplifier_core/validation/tool.py +++ b/python/amplifier_core/validation/tool.py @@ -8,7 +8,6 @@ import asyncio import importlib -import importlib.util import inspect from pathlib import Path from typing import Any @@ -16,6 +15,7 @@ from .base import ValidationCheck from .base import ValidationResult from .base import check_on_session_ready +from .base import import_module_from_path def _implements_tool_interface(obj: Any) -> bool: @@ -91,11 +91,7 @@ def _check_importable( # File path - find the Python module if path.is_dir(): init_file = path / "__init__.py" - if init_file.exists(): - spec = importlib.util.spec_from_file_location( - path.name, init_file - ) - else: + if not init_file.exists(): result.add( ValidationCheck( name="module_importable", @@ -105,21 +101,16 @@ def _check_importable( ) ) return None - else: - spec = importlib.util.spec_from_file_location(path.stem, path) - - if spec and spec.loader: - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - result.add( - ValidationCheck( - name="module_importable", - passed=True, - message=f"Module loaded from {path}", - severity="info", - ) + module = import_module_from_path(path) + result.add( + ValidationCheck( + name="module_importable", + passed=True, + message=f"Module loaded from {path}", + severity="info", ) - return module + ) + return module else: # Module name - import directly module = importlib.import_module(str(module_path)) diff --git a/scripts/e2e-smoke-test.sh b/scripts/e2e-smoke-test.sh index 5aaee9a6..75f92d7e 100755 --- a/scripts/e2e-smoke-test.sh +++ b/scripts/e2e-smoke-test.sh @@ -352,6 +352,10 @@ if [[ "$SMOKE_EXIT_CODE" -eq 124 ]]; then fail "Smoke test TIMED OUT after ${TIMEOUT_SECONDS}s — increase SMOKE_TIMEOUT or investigate" fi +if [[ "$SMOKE_EXIT_CODE" -ne 0 ]]; then + fail "Smoke test FAILED — amplifier exited with status ${SMOKE_EXIT_CODE}" +fi + # If we got here: no exceptions, no tool failures, no timeout echo "" pass "========================================================" diff --git a/scripts/generate_grpc_stubs.py b/scripts/generate_grpc_stubs.py new file mode 100644 index 00000000..11ad49e2 --- /dev/null +++ b/scripts/generate_grpc_stubs.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Generate package-safe Python gRPC stubs from the canonical proto.""" + +from __future__ import annotations + +import argparse +from importlib.metadata import version +import subprocess +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +PROTO_PATH = PROJECT_ROOT / "proto" / "amplifier_module.proto" +RAW_IMPORT = "import amplifier_module_pb2 as amplifier__module__pb2" +PACKAGE_IMPORT = "from . import amplifier_module_pb2 as amplifier__module__pb2" +GRPCIO_TOOLS_VERSION = "1.78.0" + + +def main() -> None: + if version("grpcio-tools") != GRPCIO_TOOLS_VERSION: + raise RuntimeError( + f"grpcio-tools=={GRPCIO_TOOLS_VERSION} is required; install it with " + f"`python -m pip install grpcio-tools=={GRPCIO_TOOLS_VERSION}`." + ) + parser = argparse.ArgumentParser( + description="Generate package-safe Python stubs for amplifier_module.proto." + ) + parser.add_argument( + "--output", + type=Path, + default=Path("python/amplifier_core/_grpc_gen"), + help="Output directory, relative to the repository root by default.", + ) + args = parser.parse_args() + output = args.output if args.output.is_absolute() else PROJECT_ROOT / args.output + output.mkdir(parents=True, exist_ok=True) + + subprocess.run( + [ + sys.executable, + "-m", + "grpc_tools.protoc", + "-Iproto", + f"--python_out={output}", + f"--grpc_python_out={output}", + str(PROTO_PATH.relative_to(PROJECT_ROOT)), + ], + cwd=PROJECT_ROOT, + check=True, + ) + + grpc_stub = output / "amplifier_module_pb2_grpc.py" + generated = grpc_stub.read_text() + if RAW_IMPORT not in generated: + raise RuntimeError( + f"Expected generated import not found in {grpc_stub}: {RAW_IMPORT!r}" + ) + grpc_stub.write_text(generated.replace(RAW_IMPORT, PACKAGE_IMPORT, 1)) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/fixtures/wasm/echo-provider.wasm b/tests/fixtures/wasm/echo-provider.wasm index d047493f..87b0b213 100644 Binary files a/tests/fixtures/wasm/echo-provider.wasm and b/tests/fixtures/wasm/echo-provider.wasm differ diff --git a/tests/fixtures/wasm/echo-tool.wasm b/tests/fixtures/wasm/echo-tool.wasm index 78afa2ce..aa43e367 100644 Binary files a/tests/fixtures/wasm/echo-tool.wasm and b/tests/fixtures/wasm/echo-tool.wasm differ diff --git a/tests/fixtures/wasm/passthrough-orchestrator.wasm b/tests/fixtures/wasm/passthrough-orchestrator.wasm index b5bcdcfd..b8aa8a2a 100644 Binary files a/tests/fixtures/wasm/passthrough-orchestrator.wasm and b/tests/fixtures/wasm/passthrough-orchestrator.wasm differ diff --git a/tests/fixtures/wasm/src/echo-provider/Cargo.lock b/tests/fixtures/wasm/src/echo-provider/Cargo.lock index ac5e60ff..ecda1dbf 100644 --- a/tests/fixtures/wasm/src/echo-provider/Cargo.lock +++ b/tests/fixtures/wasm/src/echo-provider/Cargo.lock @@ -12,6 +12,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" name = "amplifier-guest" version = "0.1.0" dependencies = [ + "base64", "prost", "serde", "serde_json", @@ -36,6 +37,12 @@ dependencies = [ "topological-sort", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.11.0" diff --git a/tests/fixtures/wasm/src/echo-provider/src/lib.rs b/tests/fixtures/wasm/src/echo-provider/src/lib.rs index 4650ecae..933d426d 100644 --- a/tests/fixtures/wasm/src/echo-provider/src/lib.rs +++ b/tests/fixtures/wasm/src/echo-provider/src/lib.rs @@ -1,7 +1,7 @@ #[allow(warnings)] mod bindings; -use amplifier_guest::{ChatResponse, ModelInfo, Provider, ProviderInfo}; +use amplifier_guest::{ChatResponse, ModelInfo, Provider, ProviderInfo, Usage}; use serde_json::Value; use std::collections::HashMap; @@ -31,6 +31,7 @@ impl Provider for EchoProvider { max_output_tokens: 1024, capabilities: vec!["chat".to_string()], defaults: HashMap::new(), + pricing: None, }]) } @@ -41,6 +42,15 @@ impl Provider for EchoProvider { "text": "Echo response from WASM provider" })], tool_calls: None, + usage: Some(Usage { + input_tokens: 10, + output_tokens: 5, + total_tokens: 15, + reasoning_tokens: None, + cache_read_tokens: None, + cache_write_tokens: None, + cost_usd: Some("0.000000000123456789".to_string()), + }), finish_reason: Some("stop".to_string()), extra: HashMap::new(), }) diff --git a/tests/fixtures/wasm/src/echo-tool/Cargo.lock b/tests/fixtures/wasm/src/echo-tool/Cargo.lock new file mode 100644 index 00000000..f9bbd913 --- /dev/null +++ b/tests/fixtures/wasm/src/echo-tool/Cargo.lock @@ -0,0 +1,868 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "amplifier-guest" +version = "0.1.0" +dependencies = [ + "base64", + "prost", + "serde", + "serde_json", + "wit-bindgen", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "auditable-serde" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7bf8143dfc3c0258df908843e169b5cc5fcf76c7718bd66135ef4a9cd558c5" +dependencies = [ + "semver", + "serde", + "serde_json", + "topological-sort", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "echo-tool" +version = "0.1.0" +dependencies = [ + "amplifier-guest", + "serde_json", + "wit-bindgen-rt", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[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 = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[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 = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "spdx" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3" +dependencies = [ + "smallvec", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "topological-sort" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea68304e134ecd095ac6c3574494fc62b909f416c4fca77e440530221e549d3d" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[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 = "wasm-encoder" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bb72f02e7fbf07183443b27b0f3d4144abf8c114189f2e088ed95b696a7822" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1ef0faabbbba6674e97a56bee857ccddf942785a336c8b47b42373c922a91d" +dependencies = [ + "anyhow", + "auditable-serde", + "flate2", + "indexmap", + "serde", + "serde_derive", + "serde_json", + "spdx", + "url", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f51cad774fb3c9461ab9bccc9c62dfb7388397b5deda31bf40e8108ccd678b2" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10fb6648689b3929d56bbc7eb1acf70c9a42a29eb5358c67c10f54dbd5d695de" +dependencies = [ + "wit-bindgen-rt", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92fa781d4f2ff6d3f27f3cc9b74a73327b31ca0dc4a3ef25a0ce2983e0e5af9b" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db52a11d4dfb0a59f194c064055794ee6564eb1ced88c25da2cf76e50c5621" +dependencies = [ + "bitflags", + "futures", + "once_cell", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0809dc5ba19e2e98661bf32fc0addc5a3ca5bf3a6a7083aa6ba484085ff3ce" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad19eec017904e04c60719592a803ee5da76cb51c81e3f6fbf9457f59db49799" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "635c3adc595422cbf2341a17fb73a319669cc8d33deed3a48368a841df86b676" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddf445ed5157046e4baf56f9138c124a0824d4d1657e7204d71886ad8ce2fc11" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tests/fixtures/wasm/src/echo-tool/src/lib.rs b/tests/fixtures/wasm/src/echo-tool/src/lib.rs index 317c6179..c126d49c 100644 --- a/tests/fixtures/wasm/src/echo-tool/src/lib.rs +++ b/tests/fixtures/wasm/src/echo-tool/src/lib.rs @@ -1,7 +1,7 @@ #[allow(warnings)] mod bindings; -use amplifier_guest::{Tool, ToolSpec, ToolResult, Value}; +use amplifier_guest::{Tool, ToolResult, ToolResultContent, ToolSpec, Value}; use std::collections::HashMap; #[derive(Default)] @@ -31,6 +31,18 @@ impl Tool for EchoTool { success: true, output: Some(input), error: None, + content: ToolResultContent::normalize(Some(vec![ + serde_json::json!({"type": "text", "text": "echo result"}), + serde_json::json!({ + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "AA==" + } + }), + ])) + .map_err(str::to_string)?, }) } } diff --git a/tests/fixtures/wasm/src/passthrough-orchestrator/Cargo.lock b/tests/fixtures/wasm/src/passthrough-orchestrator/Cargo.lock index f7d3b53b..5735f68c 100644 --- a/tests/fixtures/wasm/src/passthrough-orchestrator/Cargo.lock +++ b/tests/fixtures/wasm/src/passthrough-orchestrator/Cargo.lock @@ -12,6 +12,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" name = "amplifier-guest" version = "0.1.0" dependencies = [ + "base64", "prost", "serde", "serde_json", @@ -36,6 +37,12 @@ dependencies = [ "topological-sort", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.11.0" diff --git a/tests/fixtures/wasm/src/passthrough-orchestrator/src/bindings.rs b/tests/fixtures/wasm/src/passthrough-orchestrator/src/bindings.rs index e218a637..07470902 100644 --- a/tests/fixtures/wasm/src/passthrough-orchestrator/src/bindings.rs +++ b/tests/fixtures/wasm/src/passthrough-orchestrator/src/bindings.rs @@ -83,6 +83,77 @@ pub mod amplifier { result10 } } + #[allow(unused_unsafe, clippy::all)] + /// Emit a hook event (EmitHookRequest proto, serialized as bytes). + /// Returns serialized HookResult on success. + pub fn emit_hook(request: &[u8]) -> Result<_rt::Vec, _rt::String> { + unsafe { + #[cfg_attr(target_pointer_width = "64", repr(align(8)))] + #[cfg_attr(target_pointer_width = "32", repr(align(4)))] + struct RetArea( + [::core::mem::MaybeUninit< + u8, + >; 3 * ::core::mem::size_of::<*const u8>()], + ); + let mut ret_area = RetArea( + [::core::mem::MaybeUninit::uninit(); 3 + * ::core::mem::size_of::<*const u8>()], + ); + let vec0 = request; + let ptr0 = vec0.as_ptr().cast::(); + let len0 = vec0.len(); + let ptr1 = ret_area.0.as_mut_ptr().cast::(); + #[cfg(target_arch = "wasm32")] + #[link( + wasm_import_module = "amplifier:modules/kernel-service@1.0.0" + )] + unsafe extern "C" { + #[link_name = "emit-hook"] + fn wit_import2(_: *mut u8, _: usize, _: *mut u8); + } + #[cfg(not(target_arch = "wasm32"))] + unsafe extern "C" fn wit_import2(_: *mut u8, _: usize, _: *mut u8) { + unreachable!() + } + unsafe { wit_import2(ptr0.cast_mut(), len0, ptr1) }; + let l3 = i32::from(*ptr1.add(0).cast::()); + let result10 = match l3 { + 0 => { + let e = { + let l4 = *ptr1 + .add(::core::mem::size_of::<*const u8>()) + .cast::<*mut u8>(); + let l5 = *ptr1 + .add(2 * ::core::mem::size_of::<*const u8>()) + .cast::(); + let len6 = l5; + _rt::Vec::from_raw_parts(l4.cast(), len6, len6) + }; + Ok(e) + } + 1 => { + let e = { + let l7 = *ptr1 + .add(::core::mem::size_of::<*const u8>()) + .cast::<*mut u8>(); + let l8 = *ptr1 + .add(2 * ::core::mem::size_of::<*const u8>()) + .cast::(); + let len9 = l8; + let bytes9 = _rt::Vec::from_raw_parts( + l7.cast(), + len9, + len9, + ); + _rt::string_lift(bytes9) + }; + Err(e) + } + _ => _rt::invalid_enum_discriminant(), + }; + result10 + } + } } } } @@ -273,14 +344,15 @@ pub(crate) use __export_orchestrator_module_impl as export; )] #[doc(hidden)] #[allow(clippy::octal_escapes)] -pub static __WIT_BINDGEN_COMPONENT_TYPE: [u8; 357] = *b"\ -\0asm\x0d\0\x01\0\0\x19\x16wit-component-encoding\x04\0\x07\xdb\x01\x01A\x02\x01\ -A\x04\x01B\x04\x01p}\x01j\x01\0\x01s\x01@\x01\x07request\0\0\x01\x04\0\x0cexecut\ -e-tool\x01\x02\x03\0&lifier:modules/kernel-service@1.0.0\x05\0\x01B\x04\x01p}\ -\x01j\x01\0\x01s\x01@\x01\x07request\0\0\x01\x04\0\x07execute\x01\x02\x04\0$ampl\ -ifier:modules/orchestrator@1.0.0\x05\x01\x04\0+amplifier:modules/orchestrator-mo\ -dule@1.0.0\x04\0\x0b\x19\x01\0\x13orchestrator-module\x03\0\0\0G\x09producers\x01\ -\x0cprocessed-by\x02\x0dwit-component\x070.227.1\x10wit-bindgen-rust\x060.41.0"; +pub static __WIT_BINDGEN_COMPONENT_TYPE: [u8; 371] = *b"\ +\0asm\x0d\0\x01\0\0\x19\x16wit-component-encoding\x04\0\x07\xe9\x01\x01A\x02\x01\ +A\x04\x01B\x05\x01p}\x01j\x01\0\x01s\x01@\x01\x07request\0\0\x01\x04\0\x0cexecut\ +e-tool\x01\x02\x04\0\x09emit-hook\x01\x02\x03\0&lifier:modules/kernel-service\ +@1.0.0\x05\0\x01B\x04\x01p}\x01j\x01\0\x01s\x01@\x01\x07request\0\0\x01\x04\0\x07\ +execute\x01\x02\x04\0$amplifier:modules/orchestrator@1.0.0\x05\x01\x04\0+amplifi\ +er:modules/orchestrator-module@1.0.0\x04\0\x0b\x19\x01\0\x13orchestrator-module\x03\ +\0\0\0G\x09producers\x01\x0cprocessed-by\x02\x0dwit-component\x070.227.1\x10wit-\ +bindgen-rust\x060.41.0"; #[inline(never)] #[doc(hidden)] pub fn __link_custom_section_describing_imports() { diff --git a/tests/fixtures/wasm/src/passthrough-orchestrator/src/lib.rs b/tests/fixtures/wasm/src/passthrough-orchestrator/src/lib.rs index 018cc5df..5cb3b2ed 100644 --- a/tests/fixtures/wasm/src/passthrough-orchestrator/src/lib.rs +++ b/tests/fixtures/wasm/src/passthrough-orchestrator/src/lib.rs @@ -10,6 +10,19 @@ struct PassthroughOrchestrator; impl Orchestrator for PassthroughOrchestrator { fn execute(&self, prompt: String) -> Result { + if prompt == "test:emit-hook" { + let request = serde_json::json!({ + "event": "test:async-hook", + "data": {} + }); + let request_bytes = serde_json::to_vec(&request).map_err(|e| e.to_string())?; + let result_bytes = + bindings::amplifier::modules::kernel_service::emit_hook(&request_bytes)?; + let result: serde_json::Value = + serde_json::from_slice(&result_bytes).map_err(|e| e.to_string())?; + return Ok(result.to_string()); + } + // Build a JSON request for the echo-tool via the kernel service. let input = serde_json::json!({ "name": "echo-tool", diff --git a/tests/fixtures/wasm/src/passthrough-orchestrator/wit/orchestrator.wit b/tests/fixtures/wasm/src/passthrough-orchestrator/wit/orchestrator.wit index e8a29d9e..89d9d4f3 100644 --- a/tests/fixtures/wasm/src/passthrough-orchestrator/wit/orchestrator.wit +++ b/tests/fixtures/wasm/src/passthrough-orchestrator/wit/orchestrator.wit @@ -9,6 +9,10 @@ interface kernel-service { /// Execute a tool by name (ExecuteToolRequest proto, serialized as bytes). /// Returns serialized result on success. execute-tool: func(request: list) -> result, string>; + + /// Emit a hook event (EmitHookRequest proto, serialized as bytes). + /// Returns serialized HookResult on success. + emit-hook: func(request: list) -> result, string>; } /// Orchestrator interface — high-level agent-loop execution. diff --git a/tests/test_generated_equivalence.py b/tests/test_generated_equivalence.py index 18e643e4..e71cfcce 100644 --- a/tests/test_generated_equivalence.py +++ b/tests/test_generated_equivalence.py @@ -24,15 +24,19 @@ def test_proto_tool_result_has_error_json(self): tr = pb2.ToolResult() assert hasattr(tr, "error_json") + def test_proto_tool_result_has_content_blocks(self): + tr = pb2.ToolResult() + assert hasattr(tr, "content_blocks") + def test_proto_tool_result_field_count(self): - """ToolResult must have exactly 3 fields (success, output_json, error_json).""" + """ToolResult must have four fields, including canonical rich content.""" tr = pb2.ToolResult() fields = [f.name for f in tr.DESCRIPTOR.fields] - assert len(fields) == 3 + assert fields == ["success", "output_json", "error_json", "content_blocks"] class TestHookResultEquivalence: - """Proto HookResult has all 15 fields matching native HookResult.""" + """Proto HookResult has all 16 fields matching native HookResult.""" def test_proto_hook_result_has_all_fields(self): hr = pb2.HookResult() @@ -52,15 +56,37 @@ def test_proto_hook_result_has_all_fields(self): "user_message_level", "user_message_source", "append_to_last_tool_result", + "context_injections", ] - for field in expected_fields: - assert hasattr(hr, field), f"HookResult missing field: {field}" + assert [field.name for field in hr.DESCRIPTOR.fields] == expected_fields def test_proto_hook_result_field_count(self): - """HookResult must have exactly 15 fields.""" + """HookResult must have exactly 16 append-only fields.""" hr = pb2.HookResult() - fields = [f.name for f in hr.DESCRIPTOR.fields] - assert len(fields) == 15, f"Expected 15 fields, got {len(fields)}: {fields}" + expected_numbers = { + "action": 1, + "data_json": 2, + "reason": 3, + "context_injection": 4, + "context_injection_role": 5, + "ephemeral": 6, + "approval_prompt": 7, + "approval_options": 8, + "approval_timeout": 9, + "approval_default": 10, + "suppress_output": 11, + "user_message": 12, + "user_message_level": 13, + "user_message_source": 14, + "append_to_last_tool_result": 15, + "context_injections": 16, + } + assert {field.name: field.number for field in hr.DESCRIPTOR.fields} == expected_numbers + + injections = hr.DESCRIPTOR.fields_by_name["context_injections"] + assert injections.is_repeated + assert injections.type == injections.TYPE_MESSAGE + assert injections.message_type.full_name == pb2.ContextInjection.DESCRIPTOR.full_name class TestHookActionEnumEquivalence: diff --git a/tests/test_hooks_alias.py b/tests/test_hooks_alias.py index 29449f56..1133a5a1 100644 --- a/tests/test_hooks_alias.py +++ b/tests/test_hooks_alias.py @@ -3,7 +3,8 @@ """ import amplifier_core.hooks as hooks_module -from amplifier_core.hooks import HookRegistry, HookResult +from amplifier_core.hooks import ContextInjection, HookRegistry, HookResult +from amplifier_core.models import ContextInjection as ModelContextInjection def test_import_hook_registry(): @@ -37,7 +38,8 @@ def test_hook_registry_instantiation(): def test_all_exports(): - """__all__ contains exactly HookRegistry and HookResult.""" + """__all__ exposes every public hook result type.""" all_exports = getattr(hooks_module, "__all__", None) assert all_exports is not None, "hooks module should define __all__" - assert set(all_exports) == {"HookRegistry", "HookResult"} + assert set(all_exports) == {"HookRegistry", "ContextInjection", "HookResult"} + assert ContextInjection is ModelContextInjection diff --git a/tests/test_hooks_request_id.py b/tests/test_hooks_request_id.py index 89657a23..c85885f6 100644 --- a/tests/test_hooks_request_id.py +++ b/tests/test_hooks_request_id.py @@ -209,45 +209,77 @@ async def test_emit_carries_request_id_onto_the_error_path(): async def test_concurrent_calls_get_distinct_ids_and_pair_correctly(): """The case FIFO gets wrong. - Interleaving is forced to reproduce the measured trace (agent request, - summarizer request, summarizer response, agent response) where positional - pairing charges each response to the other caller. + Both request callbacks enter and suspend before either can resume. This + deterministically proves concurrent in-flight calls without relying on + task scheduling order. """ registry = HookRegistry() seen = [] - for event in ("llm:request", "llm:response"): - registry.register(event, _recorder(seen), name=f"cap-{event}") + request_ids_before_await = {} + request_ids_after_await = {} + callback_trace = [] + all_request_callbacks_entered = asyncio.Event() + release_request_callbacks = asyncio.Event() + + async def capture(event, data): + payload = dict(data) + caller = payload["caller"] + if event == "llm:request": + request_ids_before_await[caller] = payload[REQUEST_ID_FIELD] + callback_trace.append(("request-start", caller)) + if len(request_ids_before_await) == 2: + all_request_callbacks_entered.set() + await release_request_callbacks.wait() + request_ids_after_await[caller] = payload[REQUEST_ID_FIELD] + callback_trace.append(("request-end", caller)) + seen.append((event, payload)) + return HookResult(action="continue") - summarizer_requested = asyncio.Event() - summarizer_responded = asyncio.Event() + for event in ("llm:request", "llm:response"): + registry.register(event, capture, name=f"cap-{event}") async def agent_call(): await registry.emit("llm:request", {"caller": "agent"}) - await summarizer_responded.wait() await registry.emit("llm:response", {"caller": "agent"}) async def summarizer_call(): - await summarizer_requested.wait() await registry.emit("llm:request", {"caller": "summarizer"}) await registry.emit("llm:response", {"caller": "summarizer"}) - summarizer_responded.set() async def run(): task_agent = asyncio.create_task(agent_call()) task_summarizer = asyncio.create_task(summarizer_call()) - await asyncio.sleep(0) - summarizer_requested.set() - await asyncio.gather(task_agent, task_summarizer) + try: + await asyncio.wait_for(all_request_callbacks_entered.wait(), timeout=10) + except TimeoutError: + release_request_callbacks.set() + await asyncio.gather(task_agent, task_summarizer, return_exceptions=True) + pytest.fail( + "Concurrent emits did not enter both request callbacks before either resumed" + ) + release_request_callbacks.set() + try: + await asyncio.wait_for( + asyncio.gather(task_agent, task_summarizer), + timeout=10, + ) + except TimeoutError: + pytest.fail("Concurrent emits did not complete after callbacks were released") await run() - order = [(event, payload["caller"]) for event, payload in seen] - assert order == [ - ("llm:request", "agent"), - ("llm:request", "summarizer"), - ("llm:response", "summarizer"), - ("llm:response", "agent"), - ], "expected the interleaving that defeats positional pairing" + assert {caller for phase, caller in callback_trace if phase == "request-start"} == { + "agent", + "summarizer", + } + first_end = next( + index + for index, (phase, _) in enumerate(callback_trace) + if phase == "request-end" + ) + assert all(phase == "request-start" for phase, _ in callback_trace[:first_end]) + assert set(request_ids_before_await) == {"agent", "summarizer"} + assert request_ids_before_await == request_ids_after_await by_caller = {} for event, payload in seen: @@ -260,13 +292,6 @@ async def run(): assert summarizer["llm:request"] == summarizer["llm:response"] assert agent["llm:request"] != summarizer["llm:request"] - # And the positional pairing this replaces would have crossed them: - # FIFO joins the first request to the first response, which here belong - # to different callers. - requests = [p[REQUEST_ID_FIELD] for e, p in seen if e == "llm:request"] - responses = [p[REQUEST_ID_FIELD] for e, p in seen if e == "llm:response"] - assert requests[0] != responses[0], "expected FIFO to mis-pair this trace" - @pytest.mark.asyncio async def test_many_concurrent_calls_all_pair_correctly(): diff --git a/tests/test_injection_coordinator_native.py b/tests/test_injection_coordinator_native.py new file mode 100644 index 00000000..0b4d1dd2 --- /dev/null +++ b/tests/test_injection_coordinator_native.py @@ -0,0 +1,119 @@ +"""Exercise ordered injections through the production registry/coordinator.""" + +from types import SimpleNamespace + +import pytest + +from amplifier_core import HookRegistry +from amplifier_core._engine import RustCoordinator +from amplifier_core.models import ContextInjection, HookResult + + +class RecordingContext: + def __init__(self): + self.messages = [] + + async def add_message(self, message): + self.messages.append(message) + + +def coordinator(**settings): + session = SimpleNamespace( + session_id="injection-review", + parent_id=None, + config={"session": {"orchestrator": "loop-basic", **settings}}, + ) + return RustCoordinator(session) + + +@pytest.mark.asyncio +async def test_mixed_hooks_persist_only_durable_items_and_return_ephemeral_residual(): + registry = HookRegistry() + + async def durable(event, data): + return HookResult( + action="inject_context", + context_injection="durable context", + context_injection_role="assistant", + ) + + async def ephemeral(event, data): + return HookResult( + action="inject_context", + context_injections=[ContextInjection( + content="request reminder", role="user", ephemeral=True, + append_to_last_tool_result=True, + hook_name="forged", event="forged", + )], + ) + + registry.register("tool:post", durable, name="durable-hook", priority=0) + registry.register("tool:post", ephemeral, name="ephemeral-hook", priority=10) + merged = await registry.emit("tool:post", {}) + assert [item.hook_name for item in merged.context_injections] == [ + "durable-hook", "ephemeral-hook", + ] + + coord = coordinator() + context = RecordingContext() + await coord.mount("context", context) + residual = await coord.process_hook_result(merged, event="tool:post") + + assert len(context.messages) == 1 + saved = context.messages[0] + assert saved["content"] == "durable context" + assert saved["role"] == "assistant" + assert saved["metadata"]["hook_name"] == "durable-hook" + assert saved["metadata"]["persisted"] is True + assert saved["metadata"]["ephemeral"] is False + assert residual.action == "inject_context" + assert residual.context_injection == "request reminder" + assert residual.context_injection_role == "user" + assert residual.ephemeral is True + assert residual.append_to_last_tool_result is True + assert len(residual.context_injections) == 1 + assert residual.context_injections[0].hook_name == "ephemeral-hook" + assert residual.context_injections[0].event == "tool:post" + + +@pytest.mark.asyncio +async def test_later_invalid_item_prevents_partial_context_write_and_budget_charge(): + coord = coordinator(injection_size_limit=10) + context = RecordingContext() + await coord.mount("context", context) + result = HookResult( + action="inject_context", + context_injections=[ + ContextInjection(content="valid"), + ContextInjection(content="x" * 11, ephemeral=True), + ], + ) + with pytest.raises(ValueError, match="exceeds 10 characters"): + await coord.process_hook_result(result, event="tool:post") + assert context.messages == [] + assert coord._current_turn_injections == 0 + + +@pytest.mark.asyncio +async def test_all_durable_items_are_consumed_in_order_and_preserve_result_metadata(): + coord = coordinator() + context = RecordingContext() + await coord.mount("context", context) + result = HookResult( + action="inject_context", reason="original reason", suppress_output=True, + approval_prompt="preserved metadata", + context_injections=[ + ContextInjection(content="one", role="user"), + ContextInjection(content="two", role="assistant"), + ], + ) + residual = await coord.process_hook_result(result, event="tool:post") + assert [(m["content"], m["role"]) for m in context.messages] == [ + ("one", "user"), ("two", "assistant"), + ] + assert residual.action == "continue" + assert residual.context_injection is None + assert residual.context_injections == [] + assert residual.reason == "original reason" + assert residual.suppress_output is True + assert residual.approval_prompt == "preserved metadata" diff --git a/tests/test_injection_items.py b/tests/test_injection_items.py new file mode 100644 index 00000000..604d6293 --- /dev/null +++ b/tests/test_injection_items.py @@ -0,0 +1,20 @@ +"""Regression coverage for lossless mixed hook context injections.""" + +from amplifier_core.models import ContextInjection, HookResult + + +def test_mixed_durable_and_ephemeral_injections_remain_distinct() -> None: + result = HookResult( + action="inject_context", + context_injections=[ + ContextInjection(content="durable", role="assistant", ephemeral=False), + ContextInjection(content="temporary", role="user", ephemeral=True), + ], + ) + + assert [item.content for item in result.context_injections] == [ + "durable", + "temporary", + ] + assert [item.ephemeral for item in result.context_injections] == [False, True] + assert [item.role for item in result.context_injections] == ["assistant", "user"] \ No newline at end of file diff --git a/tests/test_model_info_pricing.py b/tests/test_model_info_pricing.py new file mode 100644 index 00000000..1f93e88a --- /dev/null +++ b/tests/test_model_info_pricing.py @@ -0,0 +1,108 @@ +"""Serialization contract for ModelInfo.pricing and the Pricing model. + +pricing is optional on ModelInfo: None when a provider has no rate data +(e.g., local providers like ollama, self-hosted backends like vllm), and a +populated Pricing object when a provider can supply rates. Both states must +round-trip cleanly through model_dump() / model_validate() so HTTP bridges +(e.g., amplifier-app-opencode) can rely on the field without special-casing +either branch. +""" + +import json + +from pydantic import ValidationError + +from amplifier_core.models import ModelInfo +from amplifier_core.models import Pricing + + +class TestPricingRoundTrip: + """Pricing model dumps and reconstructs without loss.""" + + def test_round_trip_full(self): + pricing = Pricing( + input_per_million=3.0, + output_per_million=15.0, + cache_read_per_million=0.3, + cache_write_per_million=3.75, + currency="USD", + ) + dumped = pricing.model_dump() + rebuilt = Pricing.model_validate(dumped) + + assert rebuilt == pricing + assert rebuilt.input_per_million == 3.0 + assert rebuilt.output_per_million == 15.0 + assert rebuilt.cache_read_per_million == 0.3 + assert rebuilt.cache_write_per_million == 3.75 + assert rebuilt.currency == "USD" + + def test_optional_fields_default_to_none(self): + pricing = Pricing(input_per_million=1.0, output_per_million=5.0) + + assert pricing.cache_read_per_million is None + assert pricing.cache_write_per_million is None + assert pricing.currency == "USD" + + def test_pricing_json_dumps_survives(self): + p = Pricing(input_per_million=3.0, output_per_million=15.0) + # Verify json.dumps(model_dump()) works without needing mode="json" + # (since we no longer have date fields, this should be trivially safe) + json.dumps(p.model_dump()) + # And explicit mode="json" for consistency + json.dumps(p.model_dump(mode="json")) + # And model_dump_json() directly + Pricing.model_validate_json(p.model_dump_json()) + + def test_currency_must_be_iso_4217(self): + # valid 3-letter uppercase + Pricing(input_per_million=1.0, output_per_million=2.0, currency="EUR") + # invalid + for bad in ["usd", "US", "USDD", "us$", "123"]: + try: + Pricing(input_per_million=1.0, output_per_million=2.0, currency=bad) + raise AssertionError(f"expected ValidationError for currency={bad!r}") + except ValidationError: + pass + + +class TestModelInfoPricingField: + """ModelInfo.pricing: optional, backwards-compatible, round-trips both states.""" + + def test_pricing_none_serializes_cleanly(self): + """Local/self-hosted providers (no rate data) omit pricing without error.""" + model = ModelInfo( + id="local-model", + display_name="Local Model", + context_window=8192, + max_output_tokens=4096, + ) + + dumped = model.model_dump() + + assert dumped["pricing"] is None + rebuilt = ModelInfo.model_validate(dumped) + assert rebuilt.pricing is None + + def test_pricing_populated_round_trips(self): + pricing = Pricing( + input_per_million=3.0, + output_per_million=15.0, + cache_read_per_million=0.3, + cache_write_per_million=3.75, + ) + model = ModelInfo( + id="claude-sonnet-4-5", + display_name="Claude Sonnet 4.5", + context_window=200_000, + max_output_tokens=64_000, + pricing=pricing, + ) + + dumped = model.model_dump() + rebuilt = ModelInfo.model_validate(dumped) + + assert rebuilt.pricing is not None + assert rebuilt.pricing == pricing + assert dumped["pricing"]["input_per_million"] == 3.0 + assert dumped["pricing"]["output_per_million"] == 15.0 diff --git a/tests/test_module_failure_reasons.py b/tests/test_module_failure_reasons.py new file mode 100644 index 00000000..8c848d61 --- /dev/null +++ b/tests/test_module_failure_reasons.py @@ -0,0 +1,56 @@ +"""Safe categories at the failure origin and event boundary; no text parsing.""" +import json +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from amplifier_core._session_init import _emit_module_load_failed +from amplifier_core.loader import ModuleLoader, ModuleValidationError, module_failure_reason +from amplifier_core.module_sources import ModuleNotFoundError as SourceNotFoundError + + +@pytest.mark.asyncio +@pytest.mark.parametrize('content,expected', [ + (None, 'invalid_package_layout'), + ('__amplifier_module_type__ = "tool"\n', 'invalid_entry_point'), + ('__amplifier_module_type__ = "secret-invalid-type"\n', 'invalid_module_metadata'), + ('__amplifier_module_type__ = {"secret": "value"}\n', 'invalid_module_metadata'), +]) +async def test_loader_assigns_safe_reason_at_source(tmp_path, monkeypatch, content, expected): + name = 'amplifier_module_tool_fixture' + package = tmp_path / name + if content is not None: + package.mkdir() + (package / '__init__.py').write_text(content) + monkeypatch.syspath_prepend(str(tmp_path)) + monkeypatch.delitem(__import__('sys').modules, name, raising=False) + with pytest.raises(ModuleValidationError) as caught: + await ModuleLoader()._validate_module('tool-fixture', tmp_path) + assert module_failure_reason(caught.value) == expected + monkeypatch.delitem(__import__('sys').modules, name, raising=False) + + +@pytest.mark.asyncio +async def test_missing_source_and_unknown_exception_have_stable_categories(tmp_path): + with pytest.raises(ModuleValidationError) as caught: + await ModuleLoader()._validate_module('tool-fixture', tmp_path / 'absent') + assert module_failure_reason(caught.value) == 'missing_source' + assert module_failure_reason(SourceNotFoundError('/private/secret?token=credential')) == 'missing_source' + error = RuntimeError('invalid package layout /private/token') + error.reason_code = 'invalid_package_layout' + assert module_failure_reason(error) == 'unknown' + assert module_failure_reason(ModuleValidationError('secret', reason_code='https://secret')) == 'unknown' + + +@pytest.mark.asyncio +async def test_event_adds_only_allowlisted_reason_without_changing_legacy_error(): + coordinator = MagicMock() + coordinator.hooks.emit = AsyncMock() + error = ModuleValidationError('private legacy error', reason_code='invalid_entry_point') + await _emit_module_load_failed(coordinator, 'tool', 'tool-fixture', error) + event, data = coordinator.hooks.emit.call_args.args + assert event == 'module:load_failed' + assert data['error'] == 'private legacy error' # Existing event remains compatible. + assert data['reason_code'] == 'invalid_entry_point' + assert json.dumps({'reason_code': data['reason_code']}) == '{"reason_code": "invalid_entry_point"}' diff --git a/tests/test_proto_compilation.py b/tests/test_proto_compilation.py index 9a0084ec..db8bfdcf 100644 --- a/tests/test_proto_compilation.py +++ b/tests/test_proto_compilation.py @@ -4,6 +4,16 @@ service messages, all 8 gRPC service stubs, and enum value assignments. """ +import shutil +import subprocess +import sys +from pathlib import Path + + +GRPC_GEN_DIR = ( + Path(__file__).parent.parent / "python" / "amplifier_core" / "_grpc_gen" +) + class TestProtoCompilation: """Verify proto-generated modules import cleanly and expose expected symbols.""" @@ -16,6 +26,26 @@ def test_pb2_grpc_module_imports(self): """The pb2_grpc module imports without error.""" from amplifier_core._grpc_gen import amplifier_module_pb2_grpc # noqa: F401 + def test_pb2_grpc_imports_as_a_package(self, tmp_path): + """The stub resolves its sibling pb2 module without a flat module path.""" + package_root = tmp_path / "isolated_package" + package_root.mkdir() + (package_root / "__init__.py").touch() + shutil.copytree(GRPC_GEN_DIR, package_root / "_grpc_gen") + + result = subprocess.run( + [ + sys.executable, + "-c", + "from isolated_package._grpc_gen import amplifier_module_pb2_grpc", + ], + cwd=tmp_path, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + def test_tool_service_messages_exist(self): """Backward-compat: original ToolService messages are present.""" from amplifier_core._grpc_gen import amplifier_module_pb2 as pb2 diff --git a/tests/test_proto_kernel_service.py b/tests/test_proto_kernel_service.py index 6c0c0aaa..68cf7149 100644 --- a/tests/test_proto_kernel_service.py +++ b/tests/test_proto_kernel_service.py @@ -30,8 +30,8 @@ def kernel_service_body(proto_text: str) -> str: return match.group(1) -def _compile_proto() -> subprocess.CompletedProcess[str]: - """Compile the proto file using protoc and return the result.""" +def _compile_proto(output_dir: Path) -> subprocess.CompletedProcess[str]: + """Compile into test storage without changing committed generated files.""" proto_dir = PROTO_PATH.parent result = subprocess.run( [ @@ -39,8 +39,8 @@ def _compile_proto() -> subprocess.CompletedProcess[str]: "-m", "grpc_tools.protoc", f"--proto_path={proto_dir}", - f"--python_out={proto_dir}", - f"--grpc_python_out={proto_dir}", + f"--python_out={output_dir}", + f"--grpc_python_out={output_dir}", str(PROTO_PATH.name), ], capture_output=True, @@ -51,12 +51,14 @@ def _compile_proto() -> subprocess.CompletedProcess[str]: class TestProtoStillCompiles: - def test_proto_compiles_with_exit_code_0(self): - result = _compile_proto() + def test_proto_compiles_with_exit_code_0(self, tmp_path: Path): + result = _compile_proto(tmp_path) assert result.returncode == 0, ( f"Proto compilation failed (exit {result.returncode}):\n" f"stdout: {result.stdout}\nstderr: {result.stderr}" ) + assert (tmp_path / "amplifier_module_pb2.py").is_file() + assert (tmp_path / "amplifier_module_pb2_grpc.py").is_file() class TestKernelServiceExists: diff --git a/tests/test_proto_module_lifecycle.py b/tests/test_proto_module_lifecycle.py index 2524bc6d..972ef5d9 100644 --- a/tests/test_proto_module_lifecycle.py +++ b/tests/test_proto_module_lifecycle.py @@ -29,8 +29,8 @@ def lifecycle_service_body(proto_text: str) -> str: return match.group(1) -def _compile_proto() -> subprocess.CompletedProcess[str]: - """Compile the proto file using protoc and return the result.""" +def _compile_proto(output_dir: Path) -> subprocess.CompletedProcess[str]: + """Compile into test storage without changing committed generated files.""" proto_dir = PROTO_PATH.parent result = subprocess.run( [ @@ -38,8 +38,8 @@ def _compile_proto() -> subprocess.CompletedProcess[str]: "-m", "grpc_tools.protoc", f"--proto_path={proto_dir}", - f"--python_out={proto_dir}", - f"--grpc_python_out={proto_dir}", + f"--python_out={output_dir}", + f"--grpc_python_out={output_dir}", str(PROTO_PATH.name), ], capture_output=True, @@ -50,12 +50,14 @@ def _compile_proto() -> subprocess.CompletedProcess[str]: class TestProtoStillCompiles: - def test_proto_compiles_with_exit_code_0(self): - result = _compile_proto() + def test_proto_compiles_with_exit_code_0(self, tmp_path: Path): + result = _compile_proto(tmp_path) assert result.returncode == 0, ( f"Proto compilation failed (exit {result.returncode}):\n" f"stdout: {result.stdout}\nstderr: {result.stderr}" ) + assert (tmp_path / "amplifier_module_pb2.py").is_file() + assert (tmp_path / "amplifier_module_pb2_grpc.py").is_file() class TestModuleLifecycleExists: diff --git a/tests/test_proto_module_services.py b/tests/test_proto_module_services.py index 3d2a91d9..4cd0c714 100644 --- a/tests/test_proto_module_services.py +++ b/tests/test_proto_module_services.py @@ -28,8 +28,8 @@ def _service_body(proto_text: str, service_name: str) -> str: return match.group(1) -def _compile_proto() -> subprocess.CompletedProcess[str]: - """Compile the proto file using protoc and return the result.""" +def _compile_proto(output_dir: Path) -> subprocess.CompletedProcess[str]: + """Compile into test storage without changing committed generated files.""" proto_dir = PROTO_PATH.parent result = subprocess.run( [ @@ -37,8 +37,8 @@ def _compile_proto() -> subprocess.CompletedProcess[str]: "-m", "grpc_tools.protoc", f"--proto_path={proto_dir}", - f"--python_out={proto_dir}", - f"--grpc_python_out={proto_dir}", + f"--python_out={output_dir}", + f"--grpc_python_out={output_dir}", str(PROTO_PATH.name), ], capture_output=True, @@ -49,12 +49,14 @@ def _compile_proto() -> subprocess.CompletedProcess[str]: class TestProtoCompiles: - def test_proto_compiles_with_exit_code_0(self): - result = _compile_proto() + def test_proto_compiles_with_exit_code_0(self, tmp_path: Path): + result = _compile_proto(tmp_path) assert result.returncode == 0, ( f"Proto compilation failed (exit {result.returncode}):\n" f"stdout: {result.stdout}\nstderr: {result.stderr}" ) + assert (tmp_path / "amplifier_module_pb2.py").is_file() + assert (tmp_path / "amplifier_module_pb2_grpc.py").is_file() class TestProviderService: diff --git a/tests/test_pyo3_proto_bridge.py b/tests/test_pyo3_proto_bridge.py index a8938c58..9eee82b3 100644 --- a/tests/test_pyo3_proto_bridge.py +++ b/tests/test_pyo3_proto_bridge.py @@ -10,6 +10,9 @@ """ import json +from decimal import Decimal + +import pytest class TestProtoChatRequestToJson: @@ -189,3 +192,96 @@ def test_roundtrip_request_response(self): assert isinstance(response_proto_bytes, bytes), "Must return bytes" assert len(response_proto_bytes) > 0, "Proto bytes must be non-empty" + + @pytest.mark.parametrize( + ("cost_usd", "expected_cost_usd", "is_present"), + [ + (Decimal("0.000000000123456789"), "1.23456789E-10", True), + (Decimal("0.123456789123456789"), "0.123456789123456789", True), + (None, None, False), + (Decimal("0"), "0", True), + ], + ) + def test_usage_cost_usd_preserves_decimal_text_and_presence( + self, cost_usd, expected_cost_usd, is_present + ): + """Python Decimal cost crosses PyO3/protobuf as exact optional quoted text.""" + from amplifier_core._engine import json_to_proto_chat_response + from amplifier_core._grpc_gen import amplifier_module_pb2 as pb2 + from amplifier_core.message_models import ChatResponse, Usage + + response = ChatResponse( + content=[{"type": "text", "text": "cost response"}], + usage=Usage( + input_tokens=10, + output_tokens=5, + total_tokens=15, + cost_usd=cost_usd, + ), + ) + serialized = response.model_dump() + assert serialized["usage"]["cost_usd"] == expected_cost_usd + proto_bytes = json_to_proto_chat_response(json.dumps(serialized)) + proto = pb2.ChatResponse() + proto.ParseFromString(proto_bytes) + + assert proto.usage.HasField("cost_usd") is is_present + if is_present: + assert proto.usage.cost_usd == expected_cost_usd + + restored = Usage.model_validate( + { + "input_tokens": proto.usage.prompt_tokens, + "output_tokens": proto.usage.completion_tokens, + "total_tokens": proto.usage.total_tokens, + "cost_usd": proto.usage.cost_usd if is_present else None, + } + ) + assert restored.cost_usd == cost_usd + if is_present: + assert isinstance(proto.usage.cost_usd, str) + + +class TestToolResultContentBridge: + """The private ToolResult bridge normalizes content without input echoing.""" + + def test_normalizes_canonical_content(self): + from amplifier_core._engine import _normalize_tool_result_content + + result = _normalize_tool_result_content( + json.dumps( + [ + {"type": "text", "text": "details", "ignored": True}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "AA==", + "ignored": True, + }, + }, + ] + ) + ) + assert json.loads(result) == [ + {"type": "text", "text": "details"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "AA==", + }, + }, + ] + + def test_rejects_bad_content_without_input_echo(self): + from amplifier_core._engine import _normalize_tool_result_content + + with pytest.raises(ValueError) as exc_info: + _normalize_tool_result_content( + '[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"private-bytes"}}]' + ) + assert str(exc_info.value) == "invalid tool result image data" + assert "private-bytes" not in str(exc_info.value) diff --git a/tests/test_tool_result_autopop.py b/tests/test_tool_result_autopop.py index 434bfa79..9010309f 100644 --- a/tests/test_tool_result_autopop.py +++ b/tests/test_tool_result_autopop.py @@ -1,5 +1,8 @@ """Tests for ToolResult auto-populate output from error message.""" +import json +import pytest + from amplifier_core.models import ToolResult @@ -27,3 +30,140 @@ def test_toolresult_no_autopopulate_without_message_key(self) -> None: """When error dict has no 'message' key, output stays None.""" result = ToolResult(success=False, error={"detail": "no message key"}) assert result.output is None + + +class TestToolResultContent: + """ToolResult rich content is normalized by the Rust ingress boundary.""" + + def test_content_normalizes_and_preserves_legacy_dump_shape(self) -> None: + legacy = ToolResult(output="plain") + assert legacy.model_dump() == { + "success": True, + "output": "plain", + "error": None, + } + + result = ToolResult( + content=[ + {"type": "text", "text": "details", "visibility": "user", "extra": True}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "AA==", + "extra": True, + }, + "extra": True, + }, + ] + ) + assert result.model_dump() == { + "success": True, + "output": None, + "error": None, + "content": [ + {"type": "text", "text": "details"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "AA==", + }, + }, + ], + } + assert ToolResult(content=[]).content is None + assert "content" not in json.loads(legacy.model_dump_json()) + assert "content" not in json.loads(ToolResult(content=[]).model_dump_json()) + + def test_legacy_model_dump_json_preserves_pydantic_serialization_options(self) -> None: + serialized = ToolResult(output={"status": "ok"}).model_dump_json(indent=2) + + assert serialized.startswith("{\n") + assert '"content"' not in serialized + + @pytest.mark.parametrize( + "content, expected", + [ + ([{"type": "thinking", "thinking": "no"}], "invalid tool result content"), + ( + [{"type": "image", "source": {"type": "url", "media_type": "image/png", "data": "AA=="}}], + "invalid tool result image source", + ), + ( + [{"type": "image", "source": {"type": "base64", "media_type": "text/plain", "data": "AA=="}}], + "invalid tool result image media type", + ), + ( + [{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "not-base64"}}], + "invalid tool result image data", + ), + ], + ) + def test_content_rejects_malformed_input_without_echoing_it(self, content, expected) -> None: + secret = "not-base64" + with pytest.raises(ValueError) as exc_info: + ToolResult(content=content) + assert expected in str(exc_info.value) + assert secret not in str(exc_info.value) + + def test_safe_hook_presentation_omits_image_data(self) -> None: + result = ToolResult( + output={"status": "ok"}, + content=[ + {"type": "text", "text": "details"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "AA==", + }, + }, + ], + ) + assert result.safe_hook_presentation() == { + "success": True, + "output": {"status": "ok"}, + "error": None, + "content": [ + {"type": "text", "text": "details"}, + { + "type": "text", + "text": "[Image omitted from tool event; original retained for model request if unmodified.]", + }, + ], + } + + def test_pydantic_escape_hatch_cannot_serialize_noncanonical_content(self) -> None: + result = ToolResult.model_construct( + content=[{"type": "thinking", "thinking": "PYDANTIC-SECRET"}] + ) + + for serialize in (result.model_dump, result.model_dump_json): + with pytest.raises(ValueError) as exc_info: + serialize() + assert "invalid tool result content" in str(exc_info.value) + assert "PYDANTIC-SECRET" not in str(exc_info.value) + + def test_assignment_and_copy_updates_stay_inside_content_boundary(self) -> None: + result = ToolResult(content=[{"type": "text", "text": "details"}]) + result.content = [] + assert result.content is None + + with pytest.raises(ValueError, match="invalid tool result image source"): + result.content = [ + { + "type": "image", + "source": { + "type": "url", + "media_type": "image/png", + "data": "AA==", + }, + } + ] + + with pytest.raises(ValueError, match="invalid tool result content"): + result.model_copy(update={"content": [{"type": "thinking", "thinking": "no"}]}) diff --git a/tests/test_validation_canonical_module.py b/tests/test_validation_canonical_module.py new file mode 100644 index 00000000..f6155a23 --- /dev/null +++ b/tests/test_validation_canonical_module.py @@ -0,0 +1,608 @@ +"""Regression tests for validation sharing the runtime module object.""" + +import importlib +import importlib.metadata +import sys +import threading +import types +from pathlib import Path + +import pytest + +from amplifier_core.loader import ModuleLoader +from amplifier_core.loader import ModuleValidationError +from amplifier_core.validation import ToolValidator +from amplifier_core.validation.base import import_module_from_path + + +class _Source: + def __init__(self, root: Path) -> None: + self.root = root + + def resolve(self) -> Path: + return self.root + + +class _Resolver: + def __init__(self, root: Path) -> None: + self.source = _Source(root) + + def resolve( + self, + module_id: str, + source_hint: str | dict | None = None, + profile_hint: str | dict | None = None, + ) -> _Source: + return self.source + + +class _ResolverCoordinator: + def __init__(self, root: Path) -> None: + self.resolver = _Resolver(root) + + def get(self, mount_point: str) -> _Resolver: + assert mount_point == "module-source-resolver" + return self.resolver + + +class _AsyncResolver: + def __init__(self, root: Path) -> None: + self.source = _Source(root) + self.async_calls = 0 + self.sync_calls = 0 + + async def async_resolve( + self, + module_id: str, + source_hint: str | dict | None = None, + profile_hint: str | dict | None = None, + ) -> _Source: + self.async_calls += 1 + return self.source + + def resolve( + self, + module_id: str, + source_hint: str | dict | None = None, + profile_hint: str | dict | None = None, + ) -> _Source: + self.sync_calls += 1 + return self.source + + +class _AsyncResolverCoordinator: + def __init__(self, root: Path) -> None: + self.resolver = _AsyncResolver(root) + + def get(self, mount_point: str) -> _AsyncResolver: + assert mount_point == "module-source-resolver" + return self.resolver + + +class _RecordingCoordinator: + def __init__(self) -> None: + self.mounted: dict[str, object] = {} + + async def mount(self, mount_point: str, module: object, name: str | None = None) -> None: + assert mount_point == "tools" + assert name is not None + self.mounted[name] = module + + +def _write_tool_package(root: Path, package_name: str, token: str) -> Path: + package = root / package_name + package.mkdir(parents=True) + (package / "marker.py").write_text(f'TOKEN = "{token}"\n') + (package / "__init__.py").write_text( + f""" +from .marker import TOKEN + +IMPORT_COUNT = globals().get("IMPORT_COUNT", 0) + 1 +MODULE_TOKEN = object() +__amplifier_module_type__ = "tool" + +class StatefulTool: + name = "stateful" + description = TOKEN + module_token = MODULE_TOKEN + + async def execute(self, input): + return {{}} + +async def mount(coordinator, config): + await coordinator.mount("tools", StatefulTool(), name="stateful") +""" + ) + return package + + +def _clear_package(package_name: str) -> None: + for name in list(sys.modules): + if name == package_name or name.startswith(f"{package_name}."): + sys.modules.pop(name, None) + + +def _write_conflicting_entry_point(root: Path, module_id: str) -> None: + (root / "installed_entry_point.py").write_text( + """ +async def mount(coordinator, config): + await coordinator.mount("tools", object(), name="installed-entry-point") +""" + ) + dist_info = root / "conflicting_entry_point-1.0.dist-info" + dist_info.mkdir() + (dist_info / "METADATA").write_text("Name: conflicting-entry-point\nVersion: 1.0\n") + (dist_info / "entry_points.txt").write_text( + f"[amplifier.modules]\n{module_id} = installed_entry_point:mount\n" + ) + + +@pytest.mark.asyncio +async def test_loader_validation_and_runtime_share_canonical_package( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A source-resolved module wins over an installed conflicting entry point.""" + module_id = "tool-canonical" + package_name = "amplifier_module_tool_canonical" + source_root = tmp_path / "source" + package = _write_tool_package(source_root, package_name, "canonical") + entry_point_root = tmp_path / "entry-point" + entry_point_root.mkdir() + _write_conflicting_entry_point(entry_point_root, module_id) + seen: dict[str, object] = {} + original = ToolValidator._check_mount_exists + + def capture_validated_module(self, result, module): + seen["module"] = module + return original(self, result, module) + + monkeypatch.setattr(ToolValidator, "_check_mount_exists", capture_validated_module) + monkeypatch.syspath_prepend(str(entry_point_root)) + loader = ModuleLoader(coordinator=_ResolverCoordinator(source_root)) + + try: + assert any( + entry_point.name == module_id + for entry_point in importlib.metadata.entry_points(group="amplifier.modules") + ) + mount = await loader.load(module_id) + canonical = sys.modules[package_name] + + assert seen["module"] is canonical + assert canonical.IMPORT_COUNT == 1 + + runtime_coordinator = _RecordingCoordinator() + await mount(runtime_coordinator) + assert runtime_coordinator.mounted["stateful"].module_token is canonical.MODULE_TOKEN + assert "installed-entry-point" not in runtime_coordinator.mounted + assert "installed_entry_point" not in sys.modules + assert canonical.IMPORT_COUNT == 1 + finally: + loader.cleanup() + _clear_package(package_name) + + +@pytest.mark.asyncio +async def test_independent_loaders_share_canonical_module_via_async_resolution( + tmp_path: Path, +) -> None: + """Independent loaders share one same-source module object and its state.""" + module_id = "tool-cross-session" + package_name = "amplifier_module_tool_cross_session" + source_root = tmp_path / "source" + _write_tool_package(source_root, package_name, "canonical") + parent_coordinator = _AsyncResolverCoordinator(source_root) + child_coordinator = _AsyncResolverCoordinator(source_root) + parent_loader = ModuleLoader(coordinator=parent_coordinator) + child_loader = ModuleLoader(coordinator=child_coordinator) + original_path = list(sys.path) + + try: + parent_mount = await parent_loader.load(module_id) + canonical = sys.modules[package_name] + canonical_marker = sys.modules[f"{package_name}.marker"] + child_mount = await child_loader.load(module_id) + + assert sys.modules[package_name] is canonical + assert sys.modules[f"{package_name}.marker"] is canonical_marker + assert parent_loader._loaded_modules[module_id] is canonical.mount + assert child_loader._loaded_modules[module_id] is canonical.mount + assert canonical.IMPORT_COUNT == 1 + assert parent_coordinator.resolver.async_calls == 1 + assert child_coordinator.resolver.async_calls == 1 + assert parent_coordinator.resolver.sync_calls == 0 + assert child_coordinator.resolver.sync_calls == 0 + + parent_runtime = _RecordingCoordinator() + child_runtime = _RecordingCoordinator() + await parent_mount(parent_runtime) + await child_mount(child_runtime) + + parent_tool = parent_runtime.mounted["stateful"] + child_tool = child_runtime.mounted["stateful"] + assert type(parent_tool) is canonical.StatefulTool + assert type(child_tool) is canonical.StatefulTool + assert parent_tool.module_token is canonical.MODULE_TOKEN + assert child_tool.module_token is canonical.MODULE_TOKEN + assert canonical.IMPORT_COUNT == 1 + finally: + child_loader.cleanup() + parent_loader.cleanup() + _clear_package(package_name) + + assert sys.path == original_path + + +@pytest.mark.asyncio +async def test_independent_loaders_reject_different_async_resolved_sources( + tmp_path: Path, +) -> None: + """A child loader cannot replace another loader's canonical package source.""" + module_id = "tool-cross-session-collision" + package_name = "amplifier_module_tool_cross_session_collision" + first_root = tmp_path / "first" + second_root = tmp_path / "second" + _write_tool_package(first_root, package_name, "first") + _write_tool_package(second_root, package_name, "second") + parent_coordinator = _AsyncResolverCoordinator(first_root) + child_coordinator = _AsyncResolverCoordinator(second_root) + parent_loader = ModuleLoader(coordinator=parent_coordinator) + child_loader = ModuleLoader(coordinator=child_coordinator) + child_runtime = _RecordingCoordinator() + original_path = list(sys.path) + + try: + await parent_loader.load(module_id) + canonical = sys.modules[package_name] + canonical_marker = sys.modules[f"{package_name}.marker"] + + with pytest.raises(ModuleValidationError, match="Refusing to import"): + await child_loader.load(module_id) + + assert sys.modules[package_name] is canonical + assert sys.modules[f"{package_name}.marker"] is canonical_marker + assert canonical.StatefulTool.description == "first" + assert child_loader._loaded_modules == {} + assert child_runtime.mounted == {} + assert parent_coordinator.resolver.async_calls == 1 + assert child_coordinator.resolver.async_calls == 1 + assert parent_coordinator.resolver.sync_calls == 0 + assert child_coordinator.resolver.sync_calls == 0 + finally: + child_loader.cleanup() + parent_loader.cleanup() + _clear_package(package_name) + + assert sys.path == original_path + + +@pytest.mark.asyncio +async def test_path_validation_rejects_same_named_different_source_without_cache_mutation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A conflicting source fails without replacing the canonical package tree.""" + package_name = "amplifier_module_tool_collision" + first_root = tmp_path / "first" + second_root = tmp_path / "second" + _write_tool_package(first_root, package_name, "first") + second_package = _write_tool_package(second_root, package_name, "second") + + monkeypatch.syspath_prepend(str(first_root)) + + try: + canonical = importlib.import_module(package_name) + canonical_marker = sys.modules[f"{package_name}.marker"] + result = await ToolValidator().validate(second_package) + + assert not result.passed + assert any( + check.name == "module_importable" + and not check.passed + and "Refusing to import" in check.message + for check in result.checks + ) + assert sys.modules[package_name] is canonical + assert sys.modules[f"{package_name}.marker"] is canonical_marker + assert canonical.StatefulTool.description == "first" + finally: + _clear_package(package_name) + + +@pytest.mark.asyncio +async def test_path_validation_rejects_a_conflicting_cached_submodule( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A dangling child cache cannot be mixed into a requested package source.""" + package_name = "amplifier_module_tool_submodule_collision" + first_root = tmp_path / "first" + second_root = tmp_path / "second" + _write_tool_package(first_root, package_name, "first") + second_package = _write_tool_package(second_root, package_name, "second") + monkeypatch.syspath_prepend(str(first_root)) + + try: + importlib.import_module(package_name) + cached_marker = sys.modules[f"{package_name}.marker"] + sys.modules.pop(package_name) + + result = await ToolValidator().validate(second_package) + + assert not result.passed + assert any( + check.name == "module_importable" + and not check.passed + and "cached submodule" in check.message + for check in result.checks + ) + assert package_name not in sys.modules + assert sys.modules[f"{package_name}.marker"] is cached_marker + assert cached_marker.TOKEN == "first" + finally: + _clear_package(package_name) + + +@pytest.mark.asyncio +async def test_source_resolved_cache_rejects_a_different_source( + tmp_path: Path, +) -> None: + """A cached source-resolved module cannot silently mount a later source.""" + module_id = "tool-cache-collision" + package_name = "amplifier_module_tool_cache_collision" + first_root = tmp_path / "first" + second_root = tmp_path / "second" + _write_tool_package(first_root, package_name, "first") + _write_tool_package(second_root, package_name, "second") + coordinator = _ResolverCoordinator(first_root) + loader = ModuleLoader(coordinator=coordinator) + + try: + await loader.load(module_id) + coordinator.resolver.source.root = second_root + + with pytest.raises(ImportError, match="already loaded from"): + await loader.load(module_id) + + assert Path(sys.modules[package_name].__file__).resolve() == ( + first_root / package_name / "__init__.py" + ).resolve() + finally: + loader.cleanup() + _clear_package(package_name) + + +@pytest.mark.asyncio +async def test_matching_root_with_foreign_cached_child_cannot_mount( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A cached canonical root does not exempt its children from source checks.""" + module_id = "tool-mixed-cache" + package_name = "amplifier_module_tool_mixed_cache" + source_root = tmp_path / "source" + _write_tool_package(source_root, package_name, "canonical") + foreign_package = _write_tool_package(tmp_path / "foreign", package_name, "foreign") + monkeypatch.syspath_prepend(str(source_root)) + loader = ModuleLoader(coordinator=_ResolverCoordinator(source_root)) + + try: + canonical = importlib.import_module(package_name) + foreign_child = types.ModuleType(f"{package_name}.marker") + foreign_child.__file__ = str(foreign_package / "marker.py") + sys.modules[f"{package_name}.marker"] = foreign_child + + with pytest.raises(ModuleValidationError, match="cached submodule"): + await loader.load(module_id) + + assert sys.modules[package_name] is canonical + assert sys.modules[f"{package_name}.marker"] is foreign_child + assert module_id not in loader._loaded_modules + assert canonical.IMPORT_COUNT == 1 + finally: + loader.cleanup() + _clear_package(package_name) + + +@pytest.mark.asyncio +async def test_direct_cache_does_not_override_a_later_source_resolution( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A direct cache cannot silently satisfy a later conflicting source request.""" + module_id = "tool-direct-cache" + package_name = "amplifier_module_tool_direct_cache" + direct_root = tmp_path / "direct" + source_root = tmp_path / "source" + _write_tool_package(direct_root, package_name, "direct") + _write_tool_package(source_root, package_name, "source") + monkeypatch.syspath_prepend(str(direct_root)) + loader = ModuleLoader() + + try: + await loader.load(module_id) + loader._coordinator = _ResolverCoordinator(source_root) + + with pytest.raises(ModuleValidationError, match="Refusing to import"): + await loader.load(module_id) + + assert Path(sys.modules[package_name].__file__).resolve() == ( + direct_root / package_name / "__init__.py" + ).resolve() + finally: + loader.cleanup() + _clear_package(package_name) + + +@pytest.mark.asyncio +async def test_source_resolved_fallback_package_is_mounted_from_validated_source( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A fallback package name remains the identity used for runtime mounting.""" + module_id = "tool-beta" + package_name = "amplifier_module_alpha" + package = _write_tool_package(tmp_path, package_name, "source") + installed_root = tmp_path / "installed" + installed_package = installed_root / "amplifier_module_tool_beta" + installed_package.mkdir(parents=True) + (installed_package / "__init__.py").write_text( + '__amplifier_module_type__ = "provider"\n' + ) + monkeypatch.syspath_prepend(str(installed_root)) + loader = ModuleLoader(coordinator=_ResolverCoordinator(tmp_path)) + + try: + mount = await loader.load(module_id) + + runtime_coordinator = _RecordingCoordinator() + await mount(runtime_coordinator) + assert runtime_coordinator.mounted["stateful"].description == "source" + assert "amplifier_module_tool_beta" not in sys.modules + assert Path(sys.modules[package_name].__file__).resolve() == ( + package / "__init__.py" + ).resolve() + finally: + loader.cleanup() + _clear_package(package_name) + _clear_package("amplifier_module_tool_beta") + + +@pytest.mark.asyncio +async def test_path_only_validation_imports_requested_package(tmp_path: Path) -> None: + """Standalone path validation remains valid when no package is loaded.""" + package_name = "amplifier_module_tool_standalone" + package = _write_tool_package(tmp_path, package_name, "standalone") + + try: + result = await ToolValidator().validate(package) + assert result.passed + assert Path(sys.modules[package_name].__file__).resolve() == ( + package / "__init__.py" + ).resolve() + assert sys.modules[f"{package_name}.marker"].TOKEN == "standalone" + finally: + _clear_package(package_name) + + +def test_standalone_path_validation_prioritizes_requested_source( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The requested path wins even when it already trails another source path.""" + package_name = "amplifier_module_tool_path_priority" + installed_root = tmp_path / "installed" + requested_root = tmp_path / "requested" + _write_tool_package(installed_root, package_name, "installed") + requested_package = _write_tool_package(requested_root, package_name, "requested") + injected_path = str(tmp_path / "added-by-module") + init_file = requested_package / "__init__.py" + init_file.write_text( + f"import sys\nsys.path.insert(0, {injected_path!r})\n" + init_file.read_text() + ) + monkeypatch.syspath_prepend(str(installed_root)) + sys.path.append(str(requested_root)) + original_path = list(sys.path) + + try: + module = import_module_from_path(requested_package) + + assert module.StatefulTool.description == "requested" + assert sys.path == [injected_path, *original_path] + finally: + _clear_package(package_name) + sys.path.remove(injected_path) + sys.path.remove(str(requested_root)) + + +def test_standalone_path_validation_removes_reconstructed_import_path( + tmp_path: Path, +) -> None: + """Cleanup works when the imported package recreates sys.path strings.""" + package_name = "amplifier_module_tool_path_reconstruction" + import_root = tmp_path / "requested" + package = import_root / package_name + package.mkdir(parents=True) + (package / "__init__.py").write_text( + "import sys\n" + "sys.path[:] = [entry.encode().decode() for entry in sys.path]\n" + ) + + try: + module = import_module_from_path(package) + + assert module.__name__ == package_name + assert str(import_root) not in sys.path + finally: + _clear_package(package_name) + + +def test_standalone_path_validation_does_not_expose_temporary_import_path( + tmp_path: Path, +) -> None: + """Normal imports block until the standalone validation path is removed.""" + package_name = "amplifier_module_tool_blocking" + package = tmp_path / package_name + package.mkdir() + entered = threading.Event() + release = threading.Event() + synchronizer = types.ModuleType("_validation_import_synchronizer") + synchronizer.entered = entered + synchronizer.release = release + sys.modules[synchronizer.__name__] = synchronizer + (package / "__init__.py").write_text( + """ +from _validation_import_synchronizer import entered, release + +entered.set() +assert release.wait(timeout=5) +""" + ) + (tmp_path / "unrelated.py").write_text("VALUE = 'must-not-import'\n") + validation_error: list[Exception] = [] + unrelated_result: list[object] = [] + unrelated_done = threading.Event() + + def validate() -> None: + try: + import_module_from_path(package) + except Exception as error: + validation_error.append(error) + + def import_unrelated() -> None: + try: + unrelated_result.append(importlib.import_module("unrelated")) + except Exception as error: + unrelated_result.append(error) + finally: + unrelated_done.set() + + validation_thread = threading.Thread(target=validate) + validation_thread.start() + assert entered.wait(timeout=5) + unrelated_thread = threading.Thread(target=import_unrelated) + unrelated_thread.start() + assert not unrelated_done.wait(timeout=0.1) + release.set() + validation_thread.join(timeout=5) + unrelated_thread.join(timeout=5) + + try: + assert not validation_thread.is_alive() + assert not unrelated_thread.is_alive() + assert not validation_error + assert isinstance(unrelated_result[0], ModuleNotFoundError) + finally: + _clear_package(package_name) + sys.modules.pop(synchronizer.__name__, None) + sys.modules.pop("unrelated", None) + + +@pytest.mark.asyncio +async def test_path_validation_rejects_malformed_module(tmp_path: Path) -> None: + """Reusing canonical modules does not turn malformed source into a pass.""" + package = tmp_path / "amplifier_module_tool_malformed" + package.mkdir() + (package / "__init__.py").write_text("async def mount(:\n") + + result = await ToolValidator().validate(package) + + assert not result.passed + assert any( + check.name == "module_importable" and not check.passed + for check in result.checks + ) \ No newline at end of file diff --git a/tests/test_wasm_integration.py b/tests/test_wasm_integration.py index 808552e7..5dad24c6 100644 --- a/tests/test_wasm_integration.py +++ b/tests/test_wasm_integration.py @@ -3,6 +3,7 @@ Uses real Rust _engine module (no mocks). May be slow on ARM64 due to WASM compilation. """ +import json import os import shutil import tempfile @@ -83,6 +84,18 @@ async def test_load_echo_tool_wasm(): assert hasattr(tool, "get_spec") assert hasattr(tool, "execute") assert tool.name == "echo-tool" + execution = await tool.execute({"message": "hello"}) + assert execution["content"] == [ + {"type": "text", "text": "echo result"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "AA==", + }, + }, + ] finally: shutil.rmtree(tmpdir, ignore_errors=True) @@ -135,7 +148,46 @@ async def test_load_passthrough_orchestrator_wasm(): shutil.rmtree(tmpdir, ignore_errors=True) -@pytest.mark.slow +@pytest.mark.asyncio +async def test_passthrough_orchestrator_emits_async_python_hook(): + """A WASM host callback preserves an async Python hook's owning loop.""" + import asyncio + + tmpdir = _isolated_wasm_dir("passthrough-orchestrator.wasm") + try: + coord = _get_coordinator() + result = load_and_mount_wasm(coord, tmpdir) + assert result["status"] == "mounted" + + callback_calls = [] + + async def async_deny_handler(event, data): + await asyncio.sleep(0) + callback_calls.append((event, data)) + return {"action": "deny", "reason": "async Python denial"} + + coord.hooks.register( + "test:async-hook", + async_deny_handler, + 0, + name="async-python-deny", + ) + mounted = coord.mount_points["orchestrator"] + response = await mounted.execute("test:emit-hook") + hook_result = json.loads(response) + + assert hook_result["action"] == "deny" + assert hook_result["reason"] == "async Python denial" + assert len(callback_calls) == 1 + event, payload = callback_calls[0] + assert event == "test:async-hook" + # The kernel adds its standard event timestamp to the empty input. + assert isinstance(payload["timestamp"], str) + assert {key: value for key, value in payload.items() if key != "timestamp"} == {} + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + @pytest.mark.asyncio async def test_load_deny_hook_wasm(): """Load deny-hook.wasm via load_and_mount_wasm — hook module.""" @@ -145,8 +197,21 @@ async def test_load_deny_hook_wasm(): result = load_and_mount_wasm(coord, tmpdir) assert result["module_type"] == "hook" - assert result["status"] == "loaded" - assert "wrapper" in result + assert result["status"] == "mounted" + assert result["subscriptions_count"] == 1 + assert coord.hooks.list_handlers("tool:pre") == {"tool:pre": ["deny-all"]} + assert coord.mount_points["hooks"] is coord.hooks + + denial = await coord.hooks.emit("tool:pre", {"tool": "test-tool"}) + assert denial.action == "deny" + assert denial.reason == "Denied by WASM hook" + + # A separately constructed registry must not inherit coordinator hooks. + from amplifier_core._engine import RustHookRegistry + + standalone = RustHookRegistry() + assert standalone.list_handlers("tool:pre") == {"tool:pre": []} + assert (await standalone.emit("tool:pre", {})).action == "continue" finally: shutil.rmtree(tmpdir, ignore_errors=True) diff --git a/uv.lock b/uv.lock index f883d870..1f55b0f9 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.11" [[package]] name = "amplifier-core" -version = "1.5.1" +version = "2.0.0" source = { editable = "." } dependencies = [ { name = "click" }, @@ -439,11 +439,11 @@ wheels = [ [[package]] name = "setuptools" -version = "82.0.1" +version = "83.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, ] [[package]]