From f41678447c265776fdf60d98bbf3d6d3082df4ee Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 20:53:49 +0300 Subject: [PATCH 01/10] feat: replace the module template with the tinytools vocabulary crate Reshapes the rust-template checkout into the crate it was created to hold: the vocabulary an agent tool is written against. The template's two-crate contract+module split does not fit here. A `Tool` is an async trait returning `anyhow::Result`, so it cannot cross a TinyBus wire; the cdylib half and the vendored tinybus submodule would have been dead weight that also dragged a dlopen loader into every consumer's dependency floor. Both are removed, leaving one library crate. What the crate holds and, more importantly, what it refuses to hold is documented in README.md and src/lib.rs: it describes tools, it does not enforce policy on them. Co-authored-by: Medulla --- .github/workflows/ci.yml | 43 +- .github/workflows/release.yml | 591 -------------- .gitmodules | 4 - AGENTS.md | 166 ++-- CONTRIBUTING.md | 3 +- Cargo.lock | 718 +----------------- Cargo.toml | 57 +- MODULE.md | 31 - README.md | 234 +++--- ROADMAP.md | 26 - crates/template-bus/Cargo.toml | 24 - crates/template-bus/README.md | 100 --- crates/template-bus/src/greeting/mod.rs | 17 - crates/template-bus/src/greeting/test.rs | 65 -- crates/template-bus/src/greeting/types.rs | 54 -- crates/template-bus/src/lib.rs | 74 -- crates/template-bus/src/names/mod.rs | 33 - crates/template-bus/src/names/test.rs | 28 - crates/template-bus/src/version/mod.rs | 46 -- crates/template-bus/src/version/test.rs | 34 - crates/template/Cargo.toml | 39 - crates/template/examples/basic.rs | 22 - .../examples/verify_github_release.rs | 93 --- crates/template/examples/verify_module.rs | 74 -- crates/template/src/error/mod.rs | 28 - crates/template/src/error/test.rs | 17 - crates/template/src/greeting/mod.rs | 38 - crates/template/src/greeting/test.rs | 28 - crates/template/src/lib.rs | 62 -- crates/template/src/tinybus_module/README.md | 20 - crates/template/src/tinybus_module/mod.rs | 43 -- crates/template/src/tinybus_module/test.rs | 64 -- crates/template/tests/public_api.rs | 20 - crates/tinytools/Cargo.toml | 31 + crates/tinytools/src/call/mod.rs | 8 + crates/tinytools/src/call/test.rs | 24 + crates/tinytools/src/call/types.rs | 59 ++ crates/tinytools/src/classification/mod.rs | 8 + crates/tinytools/src/classification/test.rs | 38 + crates/tinytools/src/classification/types.rs | 57 ++ crates/tinytools/src/context/mod.rs | 8 + crates/tinytools/src/context/test.rs | 53 ++ crates/tinytools/src/context/types.rs | 54 ++ crates/tinytools/src/lib.rs | 117 +++ crates/tinytools/src/naming/mod.rs | 11 + crates/tinytools/src/naming/test.rs | 122 +++ crates/tinytools/src/naming/types.rs | 165 ++++ crates/tinytools/src/permission/mod.rs | 8 + crates/tinytools/src/permission/test.rs | 42 + crates/tinytools/src/permission/types.rs | 43 ++ crates/tinytools/src/result/mod.rs | 8 + crates/tinytools/src/result/test.rs | 124 +++ crates/tinytools/src/result/types.rs | 150 ++++ crates/tinytools/src/spec/mod.rs | 8 + crates/tinytools/src/spec/test.rs | 17 + crates/tinytools/src/spec/types.rs | 20 + crates/tinytools/src/tool/mod.rs | 8 + crates/tinytools/src/tool/test.rs | 220 ++++++ crates/tinytools/src/tool/types.rs | 225 ++++++ deny.toml | 2 +- docs/plans/tinybus-module-release.md | 11 - docs/specs/tinybus-module-release.md | 33 - vendor/tinybus | 1 - 63 files changed, 1831 insertions(+), 2740 deletions(-) delete mode 100644 .github/workflows/release.yml delete mode 100644 .gitmodules delete mode 100644 MODULE.md delete mode 100644 ROADMAP.md delete mode 100644 crates/template-bus/Cargo.toml delete mode 100644 crates/template-bus/README.md delete mode 100644 crates/template-bus/src/greeting/mod.rs delete mode 100644 crates/template-bus/src/greeting/test.rs delete mode 100644 crates/template-bus/src/greeting/types.rs delete mode 100644 crates/template-bus/src/lib.rs delete mode 100644 crates/template-bus/src/names/mod.rs delete mode 100644 crates/template-bus/src/names/test.rs delete mode 100644 crates/template-bus/src/version/mod.rs delete mode 100644 crates/template-bus/src/version/test.rs delete mode 100644 crates/template/Cargo.toml delete mode 100644 crates/template/examples/basic.rs delete mode 100644 crates/template/examples/verify_github_release.rs delete mode 100644 crates/template/examples/verify_module.rs delete mode 100644 crates/template/src/error/mod.rs delete mode 100644 crates/template/src/error/test.rs delete mode 100644 crates/template/src/greeting/mod.rs delete mode 100644 crates/template/src/greeting/test.rs delete mode 100644 crates/template/src/lib.rs delete mode 100644 crates/template/src/tinybus_module/README.md delete mode 100644 crates/template/src/tinybus_module/mod.rs delete mode 100644 crates/template/src/tinybus_module/test.rs delete mode 100644 crates/template/tests/public_api.rs create mode 100644 crates/tinytools/Cargo.toml create mode 100644 crates/tinytools/src/call/mod.rs create mode 100644 crates/tinytools/src/call/test.rs create mode 100644 crates/tinytools/src/call/types.rs create mode 100644 crates/tinytools/src/classification/mod.rs create mode 100644 crates/tinytools/src/classification/test.rs create mode 100644 crates/tinytools/src/classification/types.rs create mode 100644 crates/tinytools/src/context/mod.rs create mode 100644 crates/tinytools/src/context/test.rs create mode 100644 crates/tinytools/src/context/types.rs create mode 100644 crates/tinytools/src/lib.rs create mode 100644 crates/tinytools/src/naming/mod.rs create mode 100644 crates/tinytools/src/naming/test.rs create mode 100644 crates/tinytools/src/naming/types.rs create mode 100644 crates/tinytools/src/permission/mod.rs create mode 100644 crates/tinytools/src/permission/test.rs create mode 100644 crates/tinytools/src/permission/types.rs create mode 100644 crates/tinytools/src/result/mod.rs create mode 100644 crates/tinytools/src/result/test.rs create mode 100644 crates/tinytools/src/result/types.rs create mode 100644 crates/tinytools/src/spec/mod.rs create mode 100644 crates/tinytools/src/spec/test.rs create mode 100644 crates/tinytools/src/spec/types.rs create mode 100644 crates/tinytools/src/tool/mod.rs create mode 100644 crates/tinytools/src/tool/test.rs create mode 100644 crates/tinytools/src/tool/types.rs delete mode 100644 docs/plans/tinybus-module-release.md delete mode 100644 docs/specs/tinybus-module-release.md delete mode 160000 vendor/tinybus diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba8c2fc..fa49629 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,6 @@ jobs: # This job executes repository code (cargo build/test); don't persist # the token in git config. persist-credentials: false - submodules: recursive - uses: dtolnay/rust-toolchain@stable with: @@ -53,32 +52,33 @@ jobs: - name: Test default features run: cargo test - # `cargo build --all-targets` only *compiles* an example. `AGENTS.md` - # promises `cargo run -p template --example basic` works, and a compiled - # example can still fail on its first line. - - name: Run the bundled example - run: cargo run -p template --example basic - - # `crates/template-bus` exists so a host can name the payload types - # without compiling the module. That promise is invisible in a diff, - # because a forbidden dependency arrives transitively through a feature - # someone enabled one crate away — so it is asserted rather than - # documented. + # This crate is the vocabulary both an agent harness and a host + # application link against, so its dependency list is a promise to both. + # That promise is invisible in a diff, because a forbidden dependency + # arrives transitively through a feature someone enabled one crate away — + # so it is asserted rather than documented. # - # The FORWARD form is required. `cargo tree -i -p template-bus` + # `tinyagents` is on the list for a structural reason, not a size one: it + # depends on *this* crate. An edge back would be a cycle, and the + # `context` module's erasure trait exists precisely to make one + # unnecessary. Everything else on the list is weight a tool author should + # not have to compile to write a tool. + # + # The FORWARD form is required. `cargo tree -i -p tinytools` # discards the `-p` scope, prints the whole-workspace inverse tree, and # exits 0 looking clean even when this crate is the one at fault. - - name: Assert the contract crate stays transport-free + - name: Assert the vocabulary crate stays dependency-light run: | set -euo pipefail - forbidden="$(cargo tree -p template-bus -e normal,build --prefix none \ - | grep -Ei 'tinybus|tokio|reqwest|ureq|hyper|rusqlite|git2' || true)" + forbidden="$(cargo tree -p tinytools -e normal,build --prefix none \ + | grep -Ei 'tinyagents|tinybus|tokio|reqwest|ureq|hyper|rusqlite|git2' || true)" if [ -n "$forbidden" ]; then - echo "template-bus pulled in a dependency its manifest forbids:" >&2 + echo "tinytools pulled in a dependency its manifest forbids:" >&2 echo "$forbidden" >&2 echo >&2 - echo "The contract is what a host compiles against. It must stay free" >&2 - echo "of transports, async runtimes, HTTP clients and native libraries." >&2 + echo "This crate is what a harness and a host both compile against." >&2 + echo "It must stay free of agent harnesses, transports, async" >&2 + echo "runtimes, HTTP clients and native libraries." >&2 exit 1 fi @@ -100,7 +100,6 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - submodules: recursive - uses: dtolnay/rust-toolchain@stable @@ -118,7 +117,6 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - submodules: recursive # `rust-version` is inherited from `[workspace.package]`, so every member # reports the same value. Read it off the package the module ships as @@ -128,7 +126,7 @@ jobs: run: | set -euo pipefail msrv="$(cargo metadata --format-version 1 --no-deps \ - | jq -r '.packages[] | select(.name == "template") | .rust_version')" + | jq -r '.packages[] | select(.name == "tinytools") | .rust_version')" if [[ -z "$msrv" || "$msrv" == "null" ]]; then echo "workspace.package.rust-version is not set in Cargo.toml" >&2 exit 1 @@ -151,7 +149,6 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - submodules: recursive - name: Check advisories, licenses, bans, and sources uses: EmbarkStudios/cargo-deny-action@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 4acf379..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,591 +0,0 @@ -name: Release - -on: - workflow_dispatch: - inputs: - bump: - description: Version bump to release - type: choice - required: true - options: - - patch - - minor - - major - - current - -concurrency: - group: release-${{ github.ref_name }} - cancel-in-progress: false - -permissions: - contents: write - -env: - # The workspace member that ships as the loadable module. Its package name is - # the artifact name and the library name; `crates/template-bus` rides along on - # the same inherited version and is not packaged separately. - RELEASE_PACKAGE: template - -jobs: - prepare: - name: Prepare release - if: ${{ github.ref == 'refs/heads/main' }} - runs-on: ubuntu-latest - outputs: - crate_name: ${{ steps.version.outputs.crate_name }} - next_version: ${{ steps.version.outputs.next_version }} - tag: ${{ steps.version.outputs.tag }} - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - submodules: recursive - - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - - uses: taiki-e/install-action@v2 - with: - tool: cargo-llvm-cov - - - uses: Swatinem/rust-cache@v2 - - - name: Check formatting - run: cargo fmt --all -- --check - - - name: Clippy - run: cargo clippy --all-targets --all-features -- -D warnings - - - name: Build - run: cargo build --all-targets --all-features - - - name: Test - run: cargo test --all-features - - - name: Require 90% line coverage in every source file - run: .github/scripts/check-file-coverage.sh 90 target/coverage.json - - - name: Build documentation - env: - RUSTDOCFLAGS: -D warnings - run: cargo doc --no-deps --all-features - - - name: Compute next version - id: version - shell: bash - run: | - set -euo pipefail - - metadata="$(cargo metadata --format-version 1 --no-deps)" - crate_name="$(jq -r --arg name "$RELEASE_PACKAGE" \ - '.packages[] | select(.name == $name) | .name' <<< "$metadata")" - current_version="$(jq -r --arg name "$RELEASE_PACKAGE" \ - '.packages[] | select(.name == $name) | .version' <<< "$metadata")" - if [[ -z "$crate_name" || "$crate_name" == "null" ]]; then - echo "Could not resolve the crate name" >&2 - exit 1 - fi - if [[ -z "$current_version" || "$current_version" == "null" ]]; then - echo "Could not resolve the current crate version" >&2 - exit 1 - fi - - IFS=. read -r major minor patch <<< "$current_version" - case "${{ inputs.bump }}" in - current) - ;; - major) - major=$((major + 1)) - minor=0 - patch=0 - ;; - minor) - minor=$((minor + 1)) - patch=0 - ;; - patch) - patch=$((patch + 1)) - ;; - *) - echo "Unsupported bump: ${{ inputs.bump }}" >&2 - exit 1 - ;; - esac - - next_version="${major}.${minor}.${patch}" - tag="v${next_version}" - git fetch --tags origin - - if git rev-parse --verify --quiet "refs/tags/${tag}"; then - if [[ "${{ inputs.bump }}" != "current" ]]; then - echo "Tag ${tag} already exists" >&2 - exit 1 - fi - tagged_version="$( - git show "${tag}:Cargo.toml" \ - | sed -n '/^\[workspace\.package\]/,/^\[/ s/^version = "\([^"]*\)"/\1/p' \ - | head -n 1 - )" - if [[ "$tagged_version" != "$current_version" ]]; then - echo "Tag ${tag} does not contain version ${current_version}" >&2 - exit 1 - fi - elif [[ "${{ inputs.bump }}" == "current" ]]; then - echo "Tag ${tag} does not exist; choose a semantic version bump" >&2 - exit 1 - fi - - { - echo "crate_name=${crate_name}" - echo "current_version=${current_version}" - echo "next_version=${next_version}" - echo "tag=${tag}" - } >> "$GITHUB_OUTPUT" - - - name: Update crate version - if: ${{ inputs.bump != 'current' }} - env: - CRATE_NAME: ${{ steps.version.outputs.crate_name }} - NEXT_VERSION: ${{ steps.version.outputs.next_version }} - run: | - set -euo pipefail - # One version for the whole workspace: every member inherits it with - # `version.workspace = true`, so this is the only edit needed. - perl -0pi -e 's/(\[workspace\.package\][\s\S]*?\nversion = ")[^"]+(")/$1$ENV{NEXT_VERSION}$2/' Cargo.toml - # `--workspace` re-resolves the local packages only, which is what a - # version bump changes. `-p --precise` cannot express "and the - # other member moved too". - cargo update --workspace - released="$(cargo metadata --format-version 1 --no-deps \ - | jq -r --arg name "$CRATE_NAME" \ - '.packages[] | select(.name == $name) | .version')" - if [[ "$released" != "$NEXT_VERSION" ]]; then - echo "version bump did not take: expected ${NEXT_VERSION}, got ${released}" >&2 - exit 1 - fi - - - name: Commit version bump and tag - if: ${{ inputs.bump != 'current' }} - env: - RELEASE_TAG: ${{ steps.version.outputs.tag }} - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add Cargo.toml Cargo.lock - git commit -m "Release ${RELEASE_TAG}" - git tag -a "${RELEASE_TAG}" -m "Release ${RELEASE_TAG}" - git push origin "HEAD:${GITHUB_REF_NAME}" - git push origin "${RELEASE_TAG}" - - native-bundles: - name: Rust module bundle (${{ matrix.id }}) - needs: prepare - strategy: - fail-fast: false - matrix: - include: - - id: ubuntu-22.04-x86_64 - os: ubuntu-22.04 - target: x86_64-unknown-linux-gnu - - id: ubuntu-22.04-arm64 - os: ubuntu-22.04-arm - target: aarch64-unknown-linux-gnu - - id: ubuntu-24.04-x86_64 - os: ubuntu-24.04 - target: x86_64-unknown-linux-gnu - - id: ubuntu-24.04-arm64 - os: ubuntu-24.04-arm - target: aarch64-unknown-linux-gnu - - id: macos-15-x86_64 - os: macos-15-intel - target: x86_64-apple-darwin - - id: macos-15-arm64 - os: macos-15 - target: aarch64-apple-darwin - - id: macos-26-x86_64 - os: macos-26-intel - target: x86_64-apple-darwin - - id: macos-26-arm64 - os: macos-26 - target: aarch64-apple-darwin - - id: windows-2022-x86_64 - os: windows-2022 - target: x86_64-pc-windows-msvc - - id: windows-2025-x86_64 - os: windows-2025 - target: x86_64-pc-windows-msvc - - id: windows-11-arm64 - os: windows-11-arm - target: aarch64-pc-windows-msvc - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ needs.prepare.outputs.tag }} - persist-credentials: false - submodules: recursive - - - uses: dtolnay/rust-toolchain@stable - - - uses: Swatinem/rust-cache@v2 - - - name: Verify native Rust target - shell: bash - env: - EXPECTED_TARGET: ${{ matrix.target }} - run: | - set -euo pipefail - actual_target="$(rustc -vV | sed -n 's/^host: //p')" - if [[ "$actual_target" != "$EXPECTED_TARGET" ]]; then - echo "expected ${EXPECTED_TARGET}, got ${actual_target}" >&2 - exit 1 - fi - - - name: Build installable module - run: cargo build --locked --release --lib --package ${{ env.RELEASE_PACKAGE }} - - - name: Verify Unix module through TinyBus loader - if: ${{ runner.os != 'Windows' }} - shell: bash - env: - CRATE_NAME: ${{ needs.prepare.outputs.crate_name }} - run: | - set -euo pipefail - library_name="${CRATE_NAME//-/_}" - case "$RUNNER_OS" in - Linux) module="target/release/lib${library_name}.so" ;; - macOS) module="target/release/lib${library_name}.dylib" ;; - *) echo "unsupported Unix runner: ${RUNNER_OS}" >&2; exit 1 ;; - esac - cargo run --locked --package template --example verify_module -- "$module" - - - name: Verify Windows module through TinyBus loader - if: ${{ runner.os == 'Windows' }} - shell: pwsh - env: - CRATE_NAME: ${{ needs.prepare.outputs.crate_name }} - run: | - $ErrorActionPreference = 'Stop' - $libraryName = $env:CRATE_NAME.Replace('-', '_') - $module = "target/release/$libraryName.dll" - $verifyRoot = Join-Path $env:RUNNER_TEMP 'template-module-verify' - New-Item -ItemType Directory -Force $verifyRoot | Out-Null - - $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() - $security = [System.Security.AccessControl.DirectorySecurity]::new() - $security.SetOwner($identity.User) - $security.SetAccessRuleProtection($true, $false) - $rights = [System.Security.AccessControl.FileSystemRights]::FullControl - $inheritance = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' - $propagation = [System.Security.AccessControl.PropagationFlags]::None - $access = [System.Security.AccessControl.AccessControlType]::Allow - foreach ($sidValue in @( - $identity.User.Value, - 'S-1-5-18', - 'S-1-5-32-544' - )) { - $sid = [System.Security.Principal.SecurityIdentifier]::new($sidValue) - $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( - $sid, - $rights, - $inheritance, - $propagation, - $access - ) - [void]$security.AddAccessRule($rule) - } - Set-Acl -LiteralPath $verifyRoot -AclObject $security - - $verifiedModule = Join-Path $verifyRoot "$libraryName.dll" - Copy-Item -LiteralPath $module -Destination $verifiedModule - cargo run --locked --package template --example verify_module -- $verifiedModule - - - name: Assemble Unix module package - if: ${{ runner.os != 'Windows' }} - id: unix_package - shell: bash - env: - BUNDLE_ID: ${{ matrix.id }} - CRATE_NAME: ${{ needs.prepare.outputs.crate_name }} - VERSION: ${{ needs.prepare.outputs.next_version }} - run: | - set -euo pipefail - - library_name="${CRATE_NAME//-/_}" - case "$RUNNER_OS" in - Linux) module="target/release/lib${library_name}.so" ;; - macOS) module="target/release/lib${library_name}.dylib" ;; - *) echo "unsupported Unix runner: ${RUNNER_OS}" >&2; exit 1 ;; - esac - if [[ ! -f "$module" ]]; then - echo "module artifact is missing: ${module}" >&2 - exit 1 - fi - - package_name="${CRATE_NAME}-${VERSION}-${BUNDLE_ID}" - package_root="dist/${package_name}" - mkdir -p "$package_root" - install -m 755 "$module" "$package_root/" - install -m 644 LICENSE MODULE.md "$package_root/" - - module_name="$(basename "$module")" - module_hash="$(shasum -a 256 "$package_root/$module_name" | awk '{print $1}')" - printf '"%s" = "%s"\n' "$module_name" "$module_hash" \ - > "$package_root/modules.toml" - - tar -C "$package_root" -czf "dist/${package_name}.tar.gz" . - echo "archive=dist/${package_name}.tar.gz" >> "$GITHUB_OUTPUT" - - - name: Assemble Windows module package - if: ${{ runner.os == 'Windows' }} - id: windows_package - shell: pwsh - env: - BUNDLE_ID: ${{ matrix.id }} - CRATE_NAME: ${{ needs.prepare.outputs.crate_name }} - VERSION: ${{ needs.prepare.outputs.next_version }} - run: | - $ErrorActionPreference = 'Stop' - - $libraryName = $env:CRATE_NAME.Replace('-', '_') - $module = "target/release/$libraryName.dll" - if (-not (Test-Path -LiteralPath $module -PathType Leaf)) { - throw "module artifact is missing: $module" - } - - $packageName = "$env:CRATE_NAME-$env:VERSION-$env:BUNDLE_ID" - $packageRoot = "dist/$packageName" - New-Item -ItemType Directory -Force $packageRoot | Out-Null - Copy-Item -LiteralPath $module, 'LICENSE', 'MODULE.md' -Destination $packageRoot - - $moduleName = Split-Path -Leaf $module - $hash = (Get-FileHash -LiteralPath "$packageRoot/$moduleName" -Algorithm SHA256).Hash.ToLowerInvariant() - $utf8 = [System.Text.UTF8Encoding]::new($false) - [System.IO.File]::WriteAllText( - "$packageRoot/modules.toml", - ('"{0}" = "{1}"' -f $moduleName, $hash) + [Environment]::NewLine, - $utf8 - ) - - $archive = "dist/$packageName.zip" - Compress-Archive -Path "$packageRoot/*" -DestinationPath $archive - "archive=$archive" >> $env:GITHUB_OUTPUT - - - name: Upload Unix package - if: ${{ runner.os != 'Windows' }} - uses: actions/upload-artifact@v7 - with: - name: ${{ needs.prepare.outputs.crate_name }}-${{ matrix.id }} - path: ${{ steps.unix_package.outputs.archive }} - if-no-files-found: error - - - name: Upload Windows package - if: ${{ runner.os == 'Windows' }} - uses: actions/upload-artifact@v7 - with: - name: ${{ needs.prepare.outputs.crate_name }}-${{ matrix.id }} - path: ${{ steps.windows_package.outputs.archive }} - if-no-files-found: error - - distro-bundles: - name: Rust module bundle (${{ matrix.id }}) - needs: prepare - strategy: - fail-fast: false - matrix: - include: - - id: fedora-43-x86_64 - os: ubuntu-24.04 - container: fedora:43 - target: x86_64-unknown-linux-gnu - family: fedora - - id: fedora-43-arm64 - os: ubuntu-24.04-arm - container: fedora:43 - target: aarch64-unknown-linux-gnu - family: fedora - - id: fedora-44-x86_64 - os: ubuntu-24.04 - container: fedora:44 - target: x86_64-unknown-linux-gnu - family: fedora - - id: fedora-44-arm64 - os: ubuntu-24.04-arm - container: fedora:44 - target: aarch64-unknown-linux-gnu - family: fedora - - id: archlinux-rolling-x86_64 - os: ubuntu-24.04 - container: archlinux:base-devel - target: x86_64-unknown-linux-gnu - family: archlinux - runs-on: ${{ matrix.os }} - container: ${{ matrix.container }} - steps: - - name: Install Fedora build tools - if: ${{ matrix.family == 'fedora' }} - run: dnf install -y gcc git gzip make perl tar - - - name: Install Arch Linux build tools - if: ${{ matrix.family == 'archlinux' }} - run: pacman -Syu --noconfirm base-devel git - - - uses: actions/checkout@v7 - with: - ref: ${{ needs.prepare.outputs.tag }} - persist-credentials: false - submodules: recursive - - - uses: dtolnay/rust-toolchain@stable - - - name: Verify native Rust target - env: - EXPECTED_TARGET: ${{ matrix.target }} - run: | - set -euo pipefail - actual_target="$(rustc -vV | sed -n 's/^host: //p')" - if [[ "$actual_target" != "$EXPECTED_TARGET" ]]; then - echo "expected ${EXPECTED_TARGET}, got ${actual_target}" >&2 - exit 1 - fi - - - name: Build installable module - run: cargo build --locked --release --lib --package ${{ env.RELEASE_PACKAGE }} - - - name: Verify module through TinyBus loader - env: - CRATE_NAME: ${{ needs.prepare.outputs.crate_name }} - run: | - set -euo pipefail - library_name="${CRATE_NAME//-/_}" - verify_root="/opt/${CRATE_NAME}-module-verify" - install -d -m 700 "$verify_root" - install -m 755 "target/release/lib${library_name}.so" "$verify_root/" - cargo run --locked --package template --example verify_module -- \ - "$verify_root/lib${library_name}.so" - - - name: Assemble distribution module package - id: package - env: - BUNDLE_ID: ${{ matrix.id }} - CRATE_NAME: ${{ needs.prepare.outputs.crate_name }} - VERSION: ${{ needs.prepare.outputs.next_version }} - run: | - set -euo pipefail - - library_name="${CRATE_NAME//-/_}" - module="target/release/lib${library_name}.so" - if [[ ! -f "$module" ]]; then - echo "module artifact is missing: ${module}" >&2 - exit 1 - fi - - package_name="${CRATE_NAME}-${VERSION}-${BUNDLE_ID}" - package_root="dist/${package_name}" - mkdir -p "$package_root" - install -m 755 "$module" "$package_root/" - install -m 644 LICENSE MODULE.md "$package_root/" - - module_name="$(basename "$module")" - module_hash="$(sha256sum "$package_root/$module_name" | awk '{print $1}')" - printf '"%s" = "%s"\n' "$module_name" "$module_hash" \ - > "$package_root/modules.toml" - - tar -C "$package_root" -czf "dist/${package_name}.tar.gz" . - echo "archive=dist/${package_name}.tar.gz" >> "$GITHUB_OUTPUT" - - - name: Upload distribution package - uses: actions/upload-artifact@v7 - with: - name: ${{ needs.prepare.outputs.crate_name }}-${{ matrix.id }} - path: ${{ steps.package.outputs.archive }} - if-no-files-found: error - - github-release: - name: Create GitHub release - needs: - - prepare - - native-bundles - - distro-bundles - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ needs.prepare.outputs.tag }} - persist-credentials: false - submodules: recursive - - - uses: dtolnay/rust-toolchain@stable - - - uses: Swatinem/rust-cache@v2 - - - uses: actions/download-artifact@v8 - with: - pattern: '*' - path: release-assets - merge-multiple: true - - - name: Create release checksum manifest with TinyBus - shell: bash - run: | - set -euo pipefail - shopt -s nullglob - assets=(release-assets/*.tar.gz release-assets/*.zip) - if [[ ${#assets[@]} -ne 16 ]]; then - echo "expected 16 module archives, found ${#assets[@]}" >&2 - exit 1 - fi - - checksum_args=() - for asset in "${assets[@]}"; do - checksum_args+=(--path "$asset") - done - cargo run --manifest-path vendor/tinybus/Cargo.toml --locked \ - --package tinybus --all-features --bin tinybus -- \ - modules checksum "${checksum_args[@]}" \ - --output release-assets/checksum.toml - - - name: Create immutable release with module packages - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ needs.prepare.outputs.tag }} - REPOSITORY: ${{ github.repository }} - run: | - set -euo pipefail - if gh release view "$RELEASE_TAG" --repo "$REPOSITORY" >/dev/null 2>&1; then - echo "Release ${RELEASE_TAG} already exists; immutable assets are unchanged." - else - gh release create "$RELEASE_TAG" release-assets/* \ - --repo "$REPOSITORY" \ - --verify-tag \ - --title "$RELEASE_TAG" \ - --generate-notes - fi - - - name: Verify the published module through TinyBus - shell: bash - env: - RELEASE_TAG: ${{ needs.prepare.outputs.tag }} - REPOSITORY: ${{ github.repository }} - CRATE_NAME: ${{ needs.prepare.outputs.crate_name }} - VERSION: ${{ needs.prepare.outputs.next_version }} - run: | - set -euo pipefail - archive="${CRATE_NAME}-${VERSION}-ubuntu-24.04-x86_64.tar.gz" - release_url="https://github.com/${REPOSITORY}/releases/tag/${RELEASE_TAG}" - sha256="$( - sed -n "s/^\"${archive}\" = \"\([0-9a-f]\{64\}\)\"$/\1/p" \ - release-assets/checksum.toml - )" - if [[ -z "$sha256" ]]; then - echo "checksum missing for ${archive}" >&2 - exit 1 - fi - - cargo run --manifest-path vendor/tinybus/Cargo.toml --locked \ - --package tinybus --all-features --example github_module_host -- \ - "$release_url" "$archive" "$sha256" - cargo run --locked --package template --example verify_github_release -- \ - "$release_url" "$archive" "$sha256" diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index da09a74..0000000 --- a/.gitmodules +++ /dev/null @@ -1,4 +0,0 @@ -[submodule "vendor/tinybus"] - path = vendor/tinybus - url = https://github.com/tinyhumansai/tinybus - branch = main diff --git a/AGENTS.md b/AGENTS.md index ee8fdfc..f09d6c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,87 +4,59 @@ This file is the single source of truth for how humans and coding agents work in this repository. `CLAUDE.md` is a symlink to this file, so every agent reads the same instructions. -When you generate a new project from this template, keep this file and adapt -the project-specific parts (crate name, module map, feature flags, commands). -Delete guidance that no longer applies rather than leaving it to rot. - -## Template Checklist - -Do this once, in a single commit, before writing feature code: - -- [ ] Rename `crates/template` and `crates/template-bus` to the project's crate - names, and update `name` in each manifest plus the `template-bus` entry in - the root `[workspace.dependencies]`. -- [ ] Set `description`, `keywords`, and `categories` in each manifest, and - `repository` in the root `[workspace.package]`. -- [ ] Rename the crate references in `README.md`, both `src/lib.rs` files, - `crates/template/examples/`, and `crates/template/tests/` (search for - `template` and `template_bus`). -- [ ] Replace the placeholder `greeting` module in both crates with the first - real feature area — payload types in the contract crate, behavior in the - module crate — keeping the `mod.rs` / `types.rs` / `test.rs` layout. -- [ ] Confirm `license` and `LICENSE` match the project's intended license. -- [ ] Update the security contact in `SECURITY.md`. -- [ ] Rename the TinyBus interface, object path, and member constants in - `crates/template-bus/src/names/`, and the matching `provides` / `methods` - declarations in `crates/template/src/tinybus_module/`, while keeping - `vendor/tinybus` pinned. -- [ ] Reset `CONTRACT_VERSION` in `crates/template-bus/src/version/` for the new - contract. -- [ ] Replace `ROADMAP.md` with the real plan, or delete it. -- [ ] Rewrite the "Project Structure" section below to describe this workspace. +TinyTools is the vocabulary an agent tool is written against: the `Tool` +trait, the `ToolResult` it returns, and the classifications a host enforces +around a call. It holds no enforcement, no registry, and no execution loop — +see `README.md` for why that line is where it is. ## Project Structure This is a Rust 2024 cargo workspace rooted at a virtual `Cargo.toml`. Every crate lives under `crates/`, one directory per package, each directory named for -the package it holds. There is no root package: the crate that ships as the -loadable module is `crates/template`, the same as any other member. +the package it holds. There is no root package. ```text Cargo.toml # virtual workspace: members, [workspace.package], # [workspace.dependencies], [workspace.lints] crates/ -├── template-bus/ # the wire contract: what crosses the bus, nothing else -│ ├── README.md # why the contract is its own crate -│ └── src/ -│ ├── lib.rs # crate docs + the entire public re-export surface -│ ├── names/ # interface, object path, one constant per member -│ ├── version/ # contract version and the host bind rule -│ └── / # one directory per payload family -└── template/ # the module: behavior, adapter, and the cdylib - ├── src/ - │ ├── lib.rs # crate docs + public surface, re-exporting the contract - │ ├── error/mod.rs # crate-wide `Error` and `Result` - │ ├── tinybus_module/ # TinyBus interface, ABI exports, integration tests - │ └── / # one directory per feature area - │ ├── mod.rs # module docs, wiring, smallest useful public API - │ ├── types.rs # substantial type definitions - │ └── test.rs # module-local unit tests - ├── tests/ # integration tests against the public API only - └── examples/ # runnable, compiled-in-CI usage examples -vendor/tinybus/ # pinned TinyBus host types and module SDK +└── tinytools/ # the vocabulary crate + └── src/ + ├── lib.rs # crate docs + the entire public re-export surface + ├── tool/ # the `Tool` trait + ├── result/ # `ToolResult`, `ToolContent` + ├── spec/ # `ToolSpec` + ├── permission/ # `PermissionLevel` + ├── classification/ # `ToolScope`, `ToolCategory` + ├── call/ # `ToolCallOptions`, `ToolTimeout` + ├── context/ # `ToolRunContext` + └── naming/ # rendering a call for a human + # each: mod.rs / types.rs / test.rs docs/ ├── specs/ # behavior and architecture specifications ├── plans/ # test-first implementation plans └── adr/ # immutable architecture decision records ``` -### The two-crate split +### The dependency edge points one way -`crates/template-bus` holds every type that crosses the bus and the names of the -members that carry them. It has no transport, no runtime, and no behavior, and -CI asserts it stays that way. A host that only makes calls depends on it alone. +An agent harness depends on this crate, not the other way round. That is the +single invariant an edit here can break, and CI asserts it: the "Assert the +vocabulary crate stays dependency-light" step fails the build if `tinyagents` +(or a transport, runtime, HTTP client, or native library) appears anywhere in +this crate's forward dependency tree. -`crates/template` depends on it and re-exports all of it, so -`template::GreetRequest` and `template_bus::GreetRequest` are the *same* type -rather than structural twins. That direction is load-bearing: a parallel set of -payload types for hosts would mean a conversion at every call site that nothing -checks. +The consequence shows up when a tool needs something a *run* knows — an +isolated workspace root, the caller's thread. Naming the harness's context type +here would be a cycle. `src/context/` erases it behind `ToolRunContext`, a +narrow trait the harness implements for its own type. Widening that trait means +a tool has grown a dependency on run internals; treat it as a signal, not a +routine change. -The rule for deciding where something goes: a payload type describes what a -frame carries and belongs in the contract; anything that answers a frame, holds -a connection, or touches an engine belongs in the module crate. +The rule for deciding where something goes: if it describes a tool or its +result, it belongs here. If it *decides* something — whether a permission level +is sufficient, whether a timeout applies, whether an external effect needs +approval — it belongs to the host, whose threat model and configuration the +decision depends on. Add a crate by creating `crates//` — `members = ["crates/*"]` picks it up by existing. Inherit `version`, `edition`, `rust-version`, `license`, and @@ -113,9 +85,7 @@ missing module. Prefer many small modules that each do one thing well over few broad ones. Keep public exports centralized in each crate's `src/lib.rs` so downstream users -have one predictable surface. Put shared error variants in -`crates/template/src/error/mod.rs` and return the crate-wide `Result` from -fallible public APIs. +have one predictable surface. ## Build And Test @@ -133,8 +103,6 @@ Supporting commands: - `cargo fmt --all` — format before committing. - `cargo test ` — run a focused subset while iterating. -- `cargo test -p template-bus` — run one crate's suite. -- `cargo run -p template --example basic` — run the bundled example. - `cargo doc --no-deps --all-features` — build the rustdoc CI also builds with `RUSTDOCFLAGS="-D warnings"`. - `cargo test --doc` — run doctests alone when editing documentation examples. @@ -162,11 +130,10 @@ Use standard `rustfmt` output and Rust 2024 idioms. Do not hand-format around ### Errors -- One crate-wide `Error` enum per crate, in `src/error/mod.rs`, built with - `thiserror`. -- Fallible public functions return `Result`, the crate alias. -- Add a specific variant instead of stuffing context into a string; error - messages are lowercase, without trailing punctuation. +- This crate defines no error type. `Tool::execute` returns + `anyhow::Result` because a tool body calls arbitrary host code and + has no useful closed error set of its own; a tool that ran and decided no + returns `Ok(ToolResult::error(..))` instead, so the model sees the reason. - Do not `unwrap()`, `expect()`, or `panic!` in library code paths. They are fine in tests, examples, and genuinely unreachable states — where `expect` must carry a message explaining the invariant. @@ -185,8 +152,8 @@ add one: - gate anything optional behind a Cargo feature, documented in `Cargo.toml`; - declare it once in the root `[workspace.dependencies]` when more than one crate needs it, and take it with `{ workspace = true }`; -- never add one to `crates/template-bus` that pulls in a transport, an async - runtime, an HTTP client, or a native library — CI fails the build if you do; +- never add one that pulls in an agent harness, a transport, an async runtime, + an HTTP client, or a native library — CI fails the build if you do; - leave a comment above the entry explaining *why* the crate is needed and what uses it — see the existing entries for the expected tone; - prefer well-maintained crates with a compatible license. @@ -194,21 +161,6 @@ add one: Keep `Cargo.lock` committed; this workspace ships a single lockfile so CI and releases are reproducible. -### Vendored dependencies - -TinyBus is registered as the `vendor/tinybus` git submodule and pinned by its -gitlink. It supplies the host types and module-side SDK required to build this -crate's `cdylib`. Initialize it after cloning with: - -```sh -git submodule update --init --recursive -``` - -Do not edit vendored code from the parent repository. Make TinyBus changes in -its own repository, push them there, then update this repository's gitlink in a -separate commit. Keep the exact path dependencies and minimal features unless a -new module capability requires more. - ## Testing - Module-local unit tests live in `crates//src//test.rs` and may @@ -290,25 +242,23 @@ explicitly declined with a reason. ## Releases -Releases run from `.github/workflows/release.yml` via a manual -`workflow_dispatch` with a `patch` / `minor` / `major` bump; `current` resumes -an interrupted release after its version commit and tag exist. The workflow -re-runs the full validation suite, computes the next version, updates -the root `[workspace.package]` version and `Cargo.lock`, commits and tags -`vX.Y.Z`, builds `crates/template` as a TinyBus module for every supported -platform, pushes, and creates an immutable GitHub release with installable -native packages. - -Consequently: - -- Do not hand-edit the `version` field in the root `[workspace.package]`; the - release workflow owns it. Every member inherits it with - `version.workspace = true`, so the whole workspace releases as one version. -- Follow semantic versioning. Any change to the public surface that is not - purely additive is a breaking change and needs a major bump (pre-1.0: a minor - bump). -- The module must be packageable for every release target — `main` should - always be green. +There is no release workflow yet. The template's one built and packaged a +loadable TinyBus module for every platform, which this repository does not +produce, so it was removed rather than left to fail. + +Until one lands, consumers take this crate by path — `tinyagents` vendors it as +`vendor/tinytools` and depends on `crates/tinytools`. Two consequences: + +- **A change here is visible to a consumer only when its gitlink moves.** Land + the change here, then bump the submodule pointer in the consuming repository + as a separate commit. +- **`tinyagents` cannot be published to crates.io while its dependency on this + crate is path-only.** Publishing this crate is the prerequisite for that, and + it needs a `version` requirement alongside the path on the consumer side. + +Follow semantic versioning in the root `[workspace.package]` version. Any change +to the public surface that is not purely additive is a breaking change and needs +a major bump (pre-1.0: a minor bump). ## Agent Working Agreement diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1424f9d..47a7fa6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,10 +8,9 @@ this document is the short path through them. Install a stable Rust toolchain with Rust 2024 support (see `rust-version` in `Cargo.toml` for the minimum supported version), initialize the vendored -submodules, then run the four checks CI runs: +then run the four checks CI runs: ```sh -git submodule update --init --recursive cargo fmt --all -- --check cargo clippy --all-targets --all-features -- -D warnings cargo build --all-targets --all-features diff --git a/Cargo.lock b/Cargo.lock index b4f454e..8bf7bbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,19 +3,10 @@ version = 4 [[package]] -name = "adler2" -version = "2.0.1" +name = "anyhow" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "async-trait" @@ -25,186 +16,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", -] - -[[package]] -name = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - -[[package]] -name = "bitflags" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "cc" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[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 = "crossbeam-utils" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" - -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "displaydoc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - -[[package]] -name = "filetime" -version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" -dependencies = [ - "cfg-if", - "libc", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" - -[[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 = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "http" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown", + "syn", ] [[package]] @@ -213,52 +25,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "log" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" - [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" -[[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.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[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" @@ -283,74 +55,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "serde" version = "1.0.229" @@ -378,7 +82,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn", ] [[package]] @@ -394,49 +98,11 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "simd-adler32" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -444,107 +110,14 @@ dependencies = [ ] [[package]] -name = "tar" -version = "0.4.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" -dependencies = [ - "filetime", - "libc", - "xattr", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "template" -version = "0.2.1" -dependencies = [ - "serde_json", - "template-bus", - "thiserror", - "tinybus", - "tinybus-module", - "tokio", -] - -[[package]] -name = "template-bus" -version = "0.2.1" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "thiserror" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "tinybus" -version = "0.1.0" -dependencies = [ - "async-trait", - "flate2", - "serde", - "serde_json", - "tar", - "tempfile", - "thiserror", - "tinybus-macros", - "tokio", - "toml", - "tracing", - "ureq", - "zip", -] - -[[package]] -name = "tinybus-macros" -version = "0.1.0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "tinybus-module" +name = "tinytools" version = "0.1.0" dependencies = [ + "anyhow", "async-trait", "serde", "serde_json", - "tinybus", "tokio", - "tracing", ] [[package]] @@ -553,7 +126,6 @@ version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ - "bytes", "pin-project-lite", "tokio-macros", ] @@ -566,79 +138,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", -] - -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", - "winnow", -] - -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", + "syn", ] [[package]] @@ -647,206 +147,8 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "ureq" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" -dependencies = [ - "base64", - "flate2", - "log", - "percent-encoding", - "rustls", - "rustls-pki-types", - "ureq-proto", - "utf8-zero", - "webpki-roots", -] - -[[package]] -name = "ureq-proto" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" -dependencies = [ - "base64", - "http", - "httparse", - "log", -] - -[[package]] -name = "utf8-zero" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "webpki-roots" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix", -] - -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - -[[package]] -name = "zip" -version = "2.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" -dependencies = [ - "arbitrary", - "crc32fast", - "crossbeam-utils", - "displaydoc", - "flate2", - "indexmap", - "memchr", - "thiserror", - "zopfli", -] - [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" - -[[package]] -name = "zopfli" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] diff --git a/Cargo.toml b/Cargo.toml index fae3c38..21c817c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,55 +2,40 @@ resolver = "3" # Every crate in this repository lives under `crates/`, one directory per # package, each directory named for the package it holds. There is no root -# package: the crate a host loads is `crates/template`, the same as any other -# member. Keeping the root virtual is what makes that uniform — a root package -# would make one crate structurally different from the rest for no reason other -# than history, and it is the arrangement this template moved away from. +# package: the crate a host links is `crates/tinytools`, the same as any other +# member. members = ["crates/*"] -# `vendor/` holds the pinned TinyBus submodule, which is its own workspace with -# its own lockfile. `worktrees/` holds `git worktree` checkouts of this same -# repository; each contains a full copy of this manifest and every crate under -# it, so without this entry cargo walks into them and reports duplicate -# packages. -exclude = ["vendor", "worktrees"] +# `worktrees/` holds `git worktree` checkouts of this same repository; each +# contains a full copy of this manifest and every crate under it, so without +# this entry cargo walks into them and reports duplicate packages. +exclude = ["worktrees"] # Shared package metadata. A member inherits a field with `field.workspace = # true`, so the version the release workflow bumps is written in exactly one # place and every crate moves together. [workspace.package] -version = "0.2.1" +version = "0.1.0" edition = "2024" rust-version = "1.88" license = "GPL-3.0-only" -repository = "https://github.com/tinyhumansai/rust-template" +repository = "https://github.com/tinyhumansai/tinytools" [workspace.dependencies] -# The wire contract. `crates/template` depends on it and re-exports it, so a -# host that only makes calls takes this crate alone. -# No `version` requirement on purpose: the workspace version moves on every -# release, and a pinned requirement here would stop resolving the moment it did. -# Nothing in this workspace is published, so the path is the whole address. -template-bus = { path = "crates/template-bus" } -# TinyBus defines the message types, interface macro, and frozen module ABI -# used by the generated integration. Socket and CLI features are unnecessary -# here. -tinybus = { path = "vendor/tinybus/crates/tinybus", version = "0.1.0", default-features = false, features = [ - "macros", - "modules", -] } -# The module-side SDK owns the isolated runtime and exports the ABI entrypoints -# required by TinyBus's dynamic loader. -tinybus-module = { path = "vendor/tinybus/crates/tinybus-module", version = "0.1.0" } -# Derive macros for the crate-wide error type in `crates/template/src/error/`. -# Every dependency entry should carry a comment like this one saying why it is -# here. -thiserror = "2" -# The bus payload types are serialized into TinyBus frames. +# The tool trait is `async`, so it needs the object-safety shim until native +# async-in-trait supports `dyn`. This is the single heaviest thing this crate +# asks of a host, and it is already in every consumer's graph. +async-trait = "0.1" +# Tool results, specs and the permission vocabulary are serialized: into agent +# transcripts, into RPC replies, and into the JSONL session records hosts keep +# on disk. serde = { version = "1", features = ["derive"] } -# Positional argument arrays and the module configuration blob. +# Tool arguments and JSON Schema parameter shapes are untyped by nature. serde_json = "1" -# Module integration tests exercise the real asynchronous in-memory TinyBus. -tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } +# `Tool::execute` returns `anyhow::Result` because a tool body calls arbitrary +# host code and has no useful closed error set of its own. +anyhow = "1" +# Unit tests for the async trait defaults drive a real executor. +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } # Lints apply to every member that opts in with `[lints] workspace = true`, and # to every target of that member. CI runs clippy with `-D warnings`, so anything diff --git a/MODULE.md b/MODULE.md deleted file mode 100644 index 651906e..0000000 --- a/MODULE.md +++ /dev/null @@ -1,31 +0,0 @@ -# Template TinyBus Module - -This package contains the native `template` module for TinyBus module ABI -v1. Install only the archive matching the host operating system and -architecture. - -The module claims `ai.tinyhumans.template.Greeting`, serves the object at -`/ai/tinyhumans/template/Greeting`, and provides the `Greet` method. The -method accepts a `GreetRequest` and returns a `GreetResponse` carrying -`Hello, !`; empty names are rejected. Both payload types, the interface -name, the object path, and the member names are published as the `template-bus` -crate, so a host names them from a library rather than by string literal. - -The archive contains one `.so`, `.dylib`, or `.dll` plus `modules.toml`. Keep -those files together when copying them into a TinyBus module directory. The -allowlist binds the native library filename to its SHA-256 digest so TinyBus can -reject a missing, renamed, or modified artifact before initialization. - -The GitHub release also publishes `checksum.toml` as a separate asset. TinyBus -checks that manifest before downloading and extracting the selected platform -archive. Install directly from a tagged release with: - -```sh -tinybus modules load-github \ - https://github.com/tinyhumansai/rust-template/releases/tag/v0.1.5 \ - template-0.1.5-ubuntu-24.04-x86_64.tar.gz \ - -``` - -TinyBus modules are trusted in-process code. Install release artifacts only -from a trusted source and restart the host after replacing a loaded module. diff --git a/README.md b/README.md index 67a4e39..70351e7 100644 --- a/README.md +++ b/README.md @@ -1,156 +1,106 @@ -# Rust Template - -A production-ready Rust 2024 TinyBus module template used by TinyHumans AI. It -ships the workspace layout, TinyBus ABI adapter, error handling, testing, -documentation, CI, and multi-platform release workflow that every new -integration in this organization starts from. - -It is a two-crate cargo workspace. `crates/template-bus` is the wire contract — -member names, payload types, and the contract version, with no transport and no -behavior — and `crates/template` is the implementation, built as both an `rlib` -and the `cdylib` TinyBus loads. A host that only makes calls depends on the -contract crate alone and compiles neither the module nor `tinybus` itself. - -## Use This Template - -Choose **Use this template** on GitHub, create a repository, then work through -the checklist at the top of [`AGENTS.md`](AGENTS.md): - -- rename the `crates/template` and `crates/template-bus` directories and the - `name` fields in their manifests, and set the shared `description`, - `repository`, `keywords`, and `categories`; -- update this README and the crate documentation in `crates/template/src/lib.rs`; -- replace the placeholder `greeting` module with the first real feature area, in - both crates: the payload types in the contract, the behavior in the module; -- rename the TinyBus interface, object path, and member constants in - `crates/template-bus/src/names/`, and the matching `provides` / `methods` - declarations in `crates/template/src/tinybus_module/`; -- update the security contact and repository links in the community files; -- replace `ROADMAP.md` with the real plan, or delete it; -- change the license if GPL-3.0-only is not appropriate. - -Search for `template` and `template_bus` to find every remaining -template-specific value. - -## What You Get - -| Area | What is configured | -| --- | --- | -| Layout | A cargo workspace under `crates/`, split into a dependency-light wire contract and the module that implements it; directory modules with `mod.rs` / `types.rs` / `test.rs`, a crate-wide error type, integration tests, and a runnable example | -| Lints | `unsafe_code` forbidden, `missing_docs`, clippy `all` + `pedantic`, no `unwrap`/`expect`/`panic`/`todo` in library code — all declared once in `[workspace.lints]` so every crate, local run, and CI run agree | -| CI | Format, clippy, build, test (default and all features), a run of the bundled example, an assertion that the contract crate stays transport-free, at least 90% line coverage in every source file, rustdoc with `-D warnings`, an MSRV build, and a `cargo-deny` supply-chain check | -| Release | Manual `workflow_dispatch` bump that validates, versions, tags, and creates installable native module packages for every supported platform | -| Community | Issue and pull request templates, Dependabot, contributing, security, support, and code of conduct docs | -| Agents | [`AGENTS.md`](AGENTS.md) as the single source of truth, symlinked as `CLAUDE.md`, plus a `.claude/settings.json` allowlist for the standard commands | -| Vendor | TinyBus host types and module SDK pinned as the `vendor/tinybus` build-time submodule | - -## Layout - -```text -Cargo.toml # virtual workspace: members, shared metadata, lints -crates/ -├── template-bus/ # the wire contract — what crosses the bus -│ ├── README.md # why the contract is its own crate -│ └── src/ -│ ├── lib.rs # crate docs + the entire public re-export surface -│ ├── names/ # interface, object path, one constant per member -│ ├── greeting/ # payload types, one directory per family -│ │ ├── mod.rs -│ │ ├── types.rs -│ │ └── test.rs -│ └── version/ # contract version and the host bind rule -└── template/ # the module — behavior, adapter, and the cdylib - ├── src/ - │ ├── lib.rs # crate docs + public surface, re-exporting the contract - │ ├── error/ # crate-wide `Error` and `Result` - │ ├── greeting/ # one directory per feature area - │ └── tinybus_module/ # bus interface, setup, and ABI v1 exports - ├── tests/ - │ └── public_api.rs # integration tests against the public API only - └── examples/ - ├── basic.rs # ordinary library API usage - ├── verify_module.rs # local dynamic-module verification - └── verify_github_release.rs # tagged-release download and bus call -vendor/ -└── tinybus/ # pinned TinyBus git submodule -docs/ -├── README.md # documentation index and conventions -├── specs/ # behavior and architecture specifications -├── plans/ # implementation-ordered delivery plans -└── adr/ # immutable architecture decision records +# TinyTools + +The vocabulary an agent tool is written against: the `Tool` trait, the +`ToolResult` it returns, and the classifications a host enforces around a call. + +```rust +use tinytools::{Tool, ToolResult}; + +struct Echo; + +#[async_trait::async_trait] +impl Tool for Echo { + fn name(&self) -> &str { "echo" } + fn description(&self) -> &str { "Returns its input unchanged." } + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { "text": { "type": "string" } }, + "required": ["text"], + }) + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + Ok(ToolResult::success(args["text"].as_str().unwrap_or_default())) + } +} ``` -The split is the point. A payload type describes what a frame carries; the -behavior that answers it is a different obligation. `template` depends on -`template-bus` and re-exports all of it, so `template::GreetRequest` and -`template_bus::GreetRequest` are the *same* type rather than structural twins, -and a host is never forced to choose between linking the whole module and -redefining the vocabulary. See -[`crates/template-bus/README.md`](crates/template-bus/README.md). +That is a complete tool. Everything else in the trait has a default. -Within each crate, feature areas use directory modules: implementation and -exports live in `mod.rs`, substantial types move to `types.rs`, and unit tests -live in `test.rs`. [`AGENTS.md`](AGENTS.md) holds the complete repository -guidance, and `CLAUDE.md` is a symlink to it so every coding agent reads one -source of truth. +## Why this is its own crate -## Development +Two crates need these types and neither can own them. An agent harness has to +name a tool's result to run a loop over it; a host application has to name the +same result to implement one. When both declare their own, the conversions +between them get written by hand at every seam — which is how an error flag ends +up inverted in one direction with nothing to catch it. -Clone with submodules, or initialize them before building: +So the vocabulary sits underneath both. A harness depends on this crate and +re-exports it, so `harness::ToolResult` and `tinytools::ToolResult` are the +*same type*, not structural twins. A tool author depends on this crate alone and +compiles neither the harness nor the host. -```sh -git submodule update --init --recursive -``` +## What is here -```sh -cargo fmt --all -- --check -cargo clippy --all-targets --all-features -- -D warnings -cargo build --all-targets --all-features -cargo test --all-features -cargo run -p template --example basic -cargo build -p template --release --lib # produces the installable cdylib -``` +| Module | Holds | +| --- | --- | +| `tool` | `Tool` — four required methods, and defaulted declarations describing what the tool needs and touches | +| `result` | `ToolResult`, `ToolContent` — the MCP-shaped block list a tool hands back | +| `spec` | `ToolSpec` — the declaration a model is shown | +| `permission` | `PermissionLevel` — the privilege ladder, ordered `None` → `Dangerous` | +| `classification` | `ToolScope`, `ToolCategory` — where a tool may run, and which belt it is on | +| `call` | `ToolCallOptions`, `ToolTimeout` — per-invocation inputs that are not arguments | +| `context` | `ToolRunContext` — the narrow seam onto a live run | +| `naming` | `humanize_tool_name`, `context_detail_from_args` — rendering a call for a human | + +## What is deliberately not here + +**No enforcement.** Nothing in this crate checks a `PermissionLevel`, applies a +`ToolTimeout`, or decides whether an `external_effect` needs approval. A tool +*describes* itself and a host *decides*, because the decision depends on that +host's threat model, its configuration, and who is asking — none of which +generalize. Putting the check here would mean every host inherits one host's +policy. + +**No registry, no dispatch, no execution loop.** Those belong to whoever owns +the run. + +**No dependency on an agent harness.** The harness depends on this crate. +`ToolRunContext` exists precisely so a tool can read run-scoped facts — the +isolated-workspace root being the common one — without this crate naming the +harness type that carries them. CI asserts the edge stays pointing one way. + +## The trait is a declaration, not an enforcement point + +Beyond `name` / `description` / `parameters_schema` / `execute`, every method on +`Tool` answers a question a host asks *before* it calls the tool: what privilege +does this need, does it reach outside the machine, how long may it run, how +should it read in a timeline. The defaults are the conservative answer in every +case except `permission_level`, which defaults to `ReadOnly` because most tools +genuinely read. + +Two consequences worth knowing: + +- **A tool that exposes several actions should declare the *minimum* privilege + any of them needs** from `permission_level`, and the exact one from + `permission_level_with_args`. Declaring the maximum statically blocks the tool + for callers that could legitimately run its read-only half. +- **The argument-aware variants are the ones a host calls** at the enforcement + point. Overriding only `external_effect` on a tool whose classification + depends on its arguments leaves the per-call case unhandled. -Those four checks are exactly what CI runs. Optional extras: +## Development -```sh -cargo doc --no-deps --all-features # CI builds this with RUSTDOCFLAGS="-D warnings" -cargo deny check all # supply-chain check; see deny.toml -cargo install cargo-llvm-cov # once, before running the coverage gate -.github/scripts/check-file-coverage.sh 90 coverage.json +```bash +cargo test +cargo clippy --all-targets --all-features -- -D warnings +cargo fmt --all ``` -## Releasing - -Run the **Release** workflow from the Actions tab with a `patch`, `minor`, or -`major` bump. Use `current` only to resume an interrupted release whose version -commit and tag already exist. The workflow revalidates the workspace, versions -and tags it — one `[workspace.package]` version that every member inherits — -builds `crates/template` as a TinyBus `cdylib`, and creates a GitHub release. -Assets follow `template--.` and contain the -native module, its SHA-256 `modules.toml`, license, and -[`MODULE.md`](MODULE.md). Every release also publishes `checksum.toml`, which -TinyBus uses to verify an archive before extraction. The workflow loads the -published Ubuntu archive through TinyBus's GitHub release API and calls its -`Greet` method before declaring the release successful. TinyBus itself is not -shipped by this repository; the pinned submodule is the build-time SDK. The stable native -matrix covers Ubuntu 22.04 and 24.04 on x86_64 and ARM64; Fedora 43 and 44 on -x86_64 and ARM64; rolling Arch Linux on its officially supported x86_64 -architecture; macOS 15 and 26 on Intel and Apple Silicon; Windows Server 2022 -and 2025 on x86_64; and Windows 11 on ARM64. Preview, deprecated, and unofficial -architecture images are not release gates. Do not hand-edit the version in the -root `Cargo.toml`. - -## Documentation - -- [`AGENTS.md`](AGENTS.md) — repository guidelines for humans and agents -- [`CONTRIBUTING.md`](CONTRIBUTING.md) — how to propose a change -- [`docs/specs/`](docs/specs/README.md) — behavior and architecture specs -- [`docs/plans/`](docs/plans/README.md) — test-first implementation plans -- [`docs/adr/`](docs/adr/0001-record-architecture-decisions.md) — architecture - decision records -- [`SECURITY.md`](SECURITY.md) — how to report a vulnerability +Lint levels live in `[workspace.lints]` so local and CI runs agree. Library code +may not `unwrap`, `expect`, or `panic`; test modules opt out at the top of the +file. ## License -GPL-3.0-only. See [LICENSE](LICENSE). +GPL-3.0-only. See [`LICENSE`](LICENSE). diff --git a/ROADMAP.md b/ROADMAP.md deleted file mode 100644 index 1134024..0000000 --- a/ROADMAP.md +++ /dev/null @@ -1,26 +0,0 @@ -# Roadmap - -Replace this file with the real plan for the crate generated from this -template, or delete it if the project does not need a public roadmap. - -Keep it short and honest: what exists, what is next, and what is deliberately -out of scope. A roadmap that lists everything is a roadmap nobody trusts. - -## Shipped - -- module layout, crate-wide error type, and the public re-export surface -- lint configuration in `[lints]`, enforced identically locally and in CI -- CI: format, clippy, build, test, per-file coverage, rustdoc, MSRV, and - supply-chain checks -- a manual release workflow that versions, tags, publishes to crates.io, and - creates a GitHub release with crate and TinyBus runtime/module assets - -## Next - -- the first real feature area, replacing the placeholder `greeting` module -- module-level `README.md` and `docs/spec/` entries as modules grow - -## Out Of Scope - -- anything that cannot be tested deterministically -- convenience wrappers that hide the crate's error taxonomy from callers diff --git a/crates/template-bus/Cargo.toml b/crates/template-bus/Cargo.toml deleted file mode 100644 index a30dd85..0000000 --- a/crates/template-bus/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "template-bus" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "The TinyBus wire contract for the template module: member names, payload types, and the contract version." -documentation = "https://docs.rs/template-bus" -readme = "README.md" -keywords = ["tinybus", "module", "contract", "template"] -categories = ["development-tools"] -publish = false - -# Deliberately dependency-light: this is the crate a host links to talk to the -# loadable module, so it must cost that host almost nothing. Nothing here may -# pull in `tinybus`, an async runtime, an HTTP client, or a native library — -# see `src/lib.rs` for why the transport in particular is absent. CI asserts it. -[dependencies] -serde = { workspace = true } -serde_json = { workspace = true } - -[lints] -workspace = true diff --git a/crates/template-bus/README.md b/crates/template-bus/README.md deleted file mode 100644 index 7f8e99c..0000000 --- a/crates/template-bus/README.md +++ /dev/null @@ -1,100 +0,0 @@ -# template-bus - -Every type that crosses the template module's `TinyBus` boundary, and the names -of the members that carry them. - -The template ships as a loadable module so a host does not compile the -implementation: `crates/template` is built as a `cdylib` and exports one object. -A host can load that binary but cannot `use` anything out of it, so the payload -vocabulary has to be published as an ordinary library. This is it. - -| module | what it holds | -| ---------- | ------------------------------------------------------------ | -| `names` | interface name, object path, one constant per member | -| `greeting` | the value vocabulary: the `Greet` request and response | -| `version` | `CONTRACT_VERSION` and the bind rule a host applies to it | - -Two dependencies, both pure Rust: `serde` and `serde_json`. - -## This crate sits underneath `template` - -`template` **depends on this crate and re-exports all of it**. That direction -matters, and it is the opposite of the obvious one. - -A *host* needs the payload types and needs nothing else: it loads the module and -makes calls, so it names `GreetRequest` and `GreetResponse` but implements no -behavior and links no transport. Making it depend on the whole module crate — and -through it on `tinybus`, `tokio`, and the module SDK — to spell a payload type -would be the wrong shape. - -The alternative, a parallel set of payload types for hosts, is worse: a -`GreetRequest` defined twice is two distinct types, with a conversion at every -call site that nothing checks. One definition, here, at the bottom. - -Because the re-export is by module as well as by item, `template::GreetRequest`, -`template::names::OBJECT_PATH`, and `template_bus::greeting::GreetRequest` all -resolve to the same items, not twins. - -So: a module author depends on `template` and gets behavior and vocabulary. A -host depends on `template-bus` and gets vocabulary alone. - -## What is deliberately absent - -**No behavior.** `greet` lives in `crates/template`. A payload type describes -what a frame carries, not what the module does with it. The split is readable -off the path: a name here is data, a name there is an obligation. - -**No transport.** This crate does not depend on `tinybus` and holds no -connection, client, or codec. A host already owns its connection — its reconnect -policy, its timeouts, its tracing — and the useful part is the vocabulary. - -That is also structural, not just preference: `tinybus` is vendored as a -submodule whose manifest inherits fields from its own nested -`[workspace.package]`. Keeping the contract crate transport-free is what keeps -it down to two dependencies and what lets anything in the workspace — or outside -it — depend on it freely. CI asserts the dependency tree stays that way. - -## Making a call - -Arguments travel as a positional JSON array — `#[tinybus::interface]` decodes -them into a tuple — and the member name comes from `names`: - -```rust,ignore -use template_bus::{names, GreetRequest, GreetResponse}; - -let proxy = connection.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; -let reply: GreetResponse = proxy - .call(names::methods::GREET, (GreetRequest::new("Ferris"),)) - .await?; -assert_eq!(reply.greeting, "Hello, Ferris!"); -``` - -Nothing above is a string literal at a call site. Renaming the interface, the -path, or a member is therefore a compile error in every consumer rather than an -`UnknownMethod` discovered at runtime. - -## Staying in step with the module - -`names::METHODS` lists every member in dispatch order. `crates/template` asserts -its served members against that list, so a method added to the interface without -an entry here fails that crate's tests rather than surfacing in a host. - -## Versioning - -`CONTRACT_VERSION` describes *this vocabulary*, not the package. Bump its major -component when a payload's wire form changes incompatibly or a member is removed -or renamed, and its minor component when a member or an optional field is added. -It is deliberately independent of the package version the release workflow owns, -which tracks the shipped artifact. - -The payload tests pin the serde representation, because that representation is -the wire form: a host and a module that disagree about a field name fail at -runtime with a decode error, so the shape is asserted rather than assumed. - -## Generating a project from the template - -Rename the interface, the object path, and the member constants in `names` -together, replace `greeting` with the first real payload family, and reset -`CONTRACT_VERSION` to `(1, 0)` for the new contract. Keep the crate -dependency-light: the moment it links a transport or a runtime, the reason it -exists is gone. diff --git a/crates/template-bus/src/greeting/mod.rs b/crates/template-bus/src/greeting/mod.rs deleted file mode 100644 index f810aab..0000000 --- a/crates/template-bus/src/greeting/mod.rs +++ /dev/null @@ -1,17 +0,0 @@ -//! The payloads the `Greet` member exchanges. -//! -//! A module root like this one documents the module, wires its pieces together, -//! and exposes the smallest useful API. The type definitions live in the -//! sibling `types.rs`, and the unit tests in `test.rs`, wired in at the bottom -//! of this file. -//! -//! Replace this module with the first real payload family the module carries. -//! Payload types are `serde`-derived, `#[non_exhaustive]`, and hold owned data: -//! they are decoded from a frame, so they can borrow nothing from the caller. - -mod types; - -pub use types::{GreetRequest, GreetResponse}; - -#[cfg(test)] -mod test; diff --git a/crates/template-bus/src/greeting/test.rs b/crates/template-bus/src/greeting/test.rs deleted file mode 100644 index 1a30000..0000000 --- a/crates/template-bus/src/greeting/test.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! Unit tests for the `Greet` payloads. -//! -//! These pin the serde representation. It is the wire form: a host and a module -//! that disagree about a field name fail at runtime with a decode error, so the -//! shape is asserted here rather than assumed. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::{GreetRequest, GreetResponse}; - -#[test] -fn a_request_serializes_to_its_wire_form() { - let encoded = serde_json::to_value(GreetRequest::new("Ferris")).unwrap(); - assert_eq!(encoded, serde_json::json!({ "name": "Ferris" })); -} - -#[test] -fn a_response_serializes_to_its_wire_form() { - let encoded = serde_json::to_value(GreetResponse::new("Hello, Ferris!")).unwrap(); - assert_eq!(encoded, serde_json::json!({ "greeting": "Hello, Ferris!" })); -} - -#[test] -fn a_request_round_trips_through_json() { - let request = GreetRequest::new(" Ferris "); - let encoded = serde_json::to_string(&request).unwrap(); - assert_eq!( - serde_json::from_str::(&encoded).unwrap(), - request - ); -} - -#[test] -fn a_response_round_trips_through_json() { - let response = GreetResponse::new("Hello, Ferris!"); - let encoded = serde_json::to_string(&response).unwrap(); - assert_eq!( - serde_json::from_str::(&encoded).unwrap(), - response - ); -} - -#[test] -fn a_request_missing_its_name_is_rejected() { - let decoded = serde_json::from_value::(serde_json::json!({})); - assert!(decoded.is_err()); -} - -#[test] -fn a_response_missing_its_greeting_is_rejected() { - let decoded = serde_json::from_value::(serde_json::json!({})); - assert!(decoded.is_err()); -} - -#[test] -fn constructors_accept_both_borrowed_and_owned_names() { - assert_eq!( - GreetRequest::new(String::from("Ferris")), - GreetRequest::new("Ferris") - ); - assert_eq!( - GreetResponse::new(String::from("Hi")), - GreetResponse::new("Hi") - ); -} diff --git a/crates/template-bus/src/greeting/types.rs b/crates/template-bus/src/greeting/types.rs deleted file mode 100644 index d70b376..0000000 --- a/crates/template-bus/src/greeting/types.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! Request and response types for the `Greet` member. - -use serde::{Deserialize, Serialize}; - -/// The argument to [`crate::names::methods::GREET`]. -/// -/// The module trims surrounding whitespace from [`GreetRequest::name`] and -/// rejects a name that is empty once trimmed. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[non_exhaustive] -pub struct GreetRequest { - /// The name to greet. - pub name: String, -} - -impl GreetRequest { - /// Builds a request greeting `name`. - /// - /// # Examples - /// - /// ``` - /// # use template_bus::GreetRequest; - /// assert_eq!(GreetRequest::new("Ferris").name, "Ferris"); - /// ``` - #[must_use] - pub fn new(name: impl Into) -> Self { - Self { name: name.into() } - } -} - -/// The reply from [`crate::names::methods::GREET`]. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[non_exhaustive] -pub struct GreetResponse { - /// The rendered greeting. - pub greeting: String, -} - -impl GreetResponse { - /// Builds a reply carrying `greeting`. - /// - /// # Examples - /// - /// ``` - /// # use template_bus::GreetResponse; - /// assert_eq!(GreetResponse::new("Hello, Ferris!").greeting, "Hello, Ferris!"); - /// ``` - #[must_use] - pub fn new(greeting: impl Into) -> Self { - Self { - greeting: greeting.into(), - } - } -} diff --git a/crates/template-bus/src/lib.rs b/crates/template-bus/src/lib.rs deleted file mode 100644 index a1857d1..0000000 --- a/crates/template-bus/src/lib.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! Every type that crosses the template module's `TinyBus` boundary, and the -//! names of the members that carry them. -//! -//! This crate ships as a loadable `TinyBus` module: `crates/template` is built -//! as a `cdylib` and exports one object. A host that loads that binary can call -//! into it but cannot `use` anything out of it, so the payload vocabulary has -//! to be published as an ordinary library. This is that library. -//! -//! # What is here -//! -//! - [`names`] — the interface name, the object path, and one constant per -//! member, plus [`names::METHODS`] listing them in dispatch order. -//! - [`greeting`] — the value vocabulary: the request and response payloads the -//! `Greet` member exchanges. -//! - [`version`] — [`CONTRACT_VERSION`] and the [`is_compatible`] bind rule. -//! -//! # What is deliberately not here -//! -//! **No behavior.** The `greet` implementation lives in `crates/template`, -//! which depends on this crate and re-exports it. A payload type describes what -//! a frame carries, not what the module does with it. -//! -//! **No transport.** This crate does not depend on `tinybus` and holds no -//! connection, client, or codec. A host already owns its connection — its -//! reconnect policy, its timeouts, its tracing — and the useful part is the -//! vocabulary, not another wrapper around it. -//! -//! That is also a structural necessity, not only a preference: `tinybus` is -//! vendored as a submodule whose manifest inherits fields from its own nested -//! `[workspace.package]`. A crate that every workspace member can depend on has -//! to stay transport-free, and staying transport-free is what keeps this crate -//! down to two pure-Rust dependencies. -//! -//! # This crate sits underneath the implementation, not beside it -//! -//! `template` **depends on this crate and re-exports all of it**, so -//! `template::GreetRequest` and `template_bus::greeting::GreetRequest` are the -//! *same type*, not structural twins. Defining a parallel set of payload types -//! for hosts would mean a conversion at every call site that nothing checks. -//! One definition, here, at the bottom. -//! -//! So: a module author depends on `template` and gets behavior and vocabulary. -//! A host depends on `template-bus` and gets vocabulary alone. -//! -//! # Staying in step with the module -//! -//! [`names::METHODS`] lists every member. `crates/template` asserts its served -//! members against that list, in order, so a method added to the interface -//! without an entry here fails that crate's tests rather than surfacing as an -//! unknown method in a host at runtime. -//! -//! # Example -//! -//! ``` -//! use template_bus::{names, GreetRequest, GreetResponse}; -//! -//! let body = serde_json::to_value([GreetRequest::new("Ferris")])?; -//! assert_eq!(names::methods::GREET, "Greet"); -//! assert_eq!(names::OBJECT_PATH, "/ai/tinyhumans/template/Greeting"); -//! -//! let reply: GreetResponse = serde_json::from_value( -//! serde_json::json!({ "greeting": "Hello, Ferris!" }), -//! )?; -//! assert_eq!(reply.greeting, "Hello, Ferris!"); -//! # Ok::<(), serde_json::Error>(()) -//! ``` - -pub mod greeting; -pub mod names; -pub mod version; - -pub use greeting::{GreetRequest, GreetResponse}; -pub use names::{INTERFACE, METHODS, OBJECT_PATH}; -pub use version::{CONTRACT_VERSION, is_compatible}; diff --git a/crates/template-bus/src/names/mod.rs b/crates/template-bus/src/names/mod.rs deleted file mode 100644 index 4da1547..0000000 --- a/crates/template-bus/src/names/mod.rs +++ /dev/null @@ -1,33 +0,0 @@ -//! The bus identity of the template module: interface name, object path, and -//! one constant per member. -//! -//! Nothing here is a string literal at a call site. A host names a member -//! through [`methods`] and the object through [`OBJECT_PATH`], so a rename is a -//! compile error in every consumer rather than a runtime "unknown method". -//! -//! When generating a project from this template, rename all three together — -//! the interface, the path, and the member constants — and keep -//! [`METHODS`] in the same order as the interface's dispatch table. - -/// The well-known interface name the module claims on the bus. -pub const INTERFACE: &str = "ai.tinyhumans.template.Greeting"; - -/// The object path the module serves its interface at. -pub const OBJECT_PATH: &str = "/ai/tinyhumans/template/Greeting"; - -/// One constant per member of [`INTERFACE`]. -pub mod methods { - /// Builds a greeting for a name. - /// - /// Takes a [`crate::GreetRequest`] and returns a [`crate::GreetResponse`]. - pub const GREET: &str = "Greet"; -} - -/// Every member of [`INTERFACE`], in the order the interface dispatches them. -/// -/// `crates/template` asserts its declared manifest methods against this list, -/// so the two cannot drift. -pub const METHODS: &[&str] = &[methods::GREET]; - -#[cfg(test)] -mod test; diff --git a/crates/template-bus/src/names/test.rs b/crates/template-bus/src/names/test.rs deleted file mode 100644 index bf7bea2..0000000 --- a/crates/template-bus/src/names/test.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Unit tests for the bus name table. - -use super::{INTERFACE, METHODS, OBJECT_PATH, methods}; - -#[test] -fn the_object_path_is_the_interface_in_path_form() { - let expected = format!("/{}", INTERFACE.replace('.', "/")); - assert_eq!(OBJECT_PATH, expected); -} - -#[test] -fn every_member_is_listed_exactly_once() { - let mut sorted = METHODS.to_vec(); - sorted.sort_unstable(); - let mut deduplicated = sorted.clone(); - deduplicated.dedup(); - assert_eq!(sorted, deduplicated); -} - -#[test] -fn the_method_table_holds_the_declared_members() { - assert_eq!(METHODS, [methods::GREET]); -} - -#[test] -fn no_member_name_is_empty() { - assert!(METHODS.iter().all(|method| !method.is_empty())); -} diff --git a/crates/template-bus/src/version/mod.rs b/crates/template-bus/src/version/mod.rs deleted file mode 100644 index ada372d..0000000 --- a/crates/template-bus/src/version/mod.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! The contract version, and the rule a host uses to decide whether it can bind -//! to a module that reports one. -//! -//! The version describes *this vocabulary*, not the crate: bump the major -//! component when a payload's wire form changes incompatibly or a member is -//! removed or renamed, and the minor component when a member or an optional -//! field is added. It is deliberately independent of the package version the -//! release workflow bumps, which tracks the shipped artifact. - -/// The wire contract version this crate defines. -pub const CONTRACT_VERSION: (u32, u32) = (1, 0); - -/// Returns whether a host holding [`CONTRACT_VERSION`] can bind to a module -/// reporting `module`. -/// -/// Compatibility is the ordinary semantic-version rule for a pre-release-free -/// contract: the majors must match, and the module must be at least as new as -/// the host, because a host cannot call a member a module does not serve. -/// -/// # Examples -/// -/// ``` -/// # use template_bus::{is_compatible, CONTRACT_VERSION}; -/// assert!(is_compatible(CONTRACT_VERSION)); -/// assert!(is_compatible((1, 4))); -/// assert!(!is_compatible((2, 0))); -/// ``` -#[must_use] -pub fn is_compatible(module: (u32, u32)) -> bool { - binds(CONTRACT_VERSION, module) -} - -/// The bind rule with the host version supplied explicitly. -/// -/// [`is_compatible`] is this function applied to [`CONTRACT_VERSION`]. It is -/// split out so the unit tests can exercise both directions of the comparison -/// without pinning them to whatever the shipped version happens to be. -fn binds(host: (u32, u32), module: (u32, u32)) -> bool { - let (host_major, host_minor) = host; - let (module_major, module_minor) = module; - - module_major == host_major && module_minor >= host_minor -} - -#[cfg(test)] -mod test; diff --git a/crates/template-bus/src/version/test.rs b/crates/template-bus/src/version/test.rs deleted file mode 100644 index 3fd3edf..0000000 --- a/crates/template-bus/src/version/test.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Unit tests for the contract version and its bind rule. - -use super::{CONTRACT_VERSION, binds, is_compatible}; - -#[test] -fn the_shipped_contract_version_is_pinned() { - assert_eq!(CONTRACT_VERSION, (1, 0)); -} - -#[test] -fn the_contract_binds_to_itself() { - assert!(is_compatible(CONTRACT_VERSION)); -} - -#[test] -fn a_newer_minor_on_the_module_side_binds() { - assert!(is_compatible((1, 1))); - assert!(is_compatible((1, 97))); -} - -#[test] -fn an_older_minor_on_the_module_side_is_rejected() { - // A host built against 1.4 cannot call a 1.2 module: the members it names - // may not be served. - assert!(!binds((1, 4), (1, 2))); - assert!(binds((1, 4), (1, 4))); -} - -#[test] -fn a_different_major_is_rejected() { - assert!(!is_compatible((0, 0))); - assert!(!is_compatible((2, 0))); - assert!(!is_compatible((2, 97))); -} diff --git a/crates/template/Cargo.toml b/crates/template/Cargo.toml deleted file mode 100644 index e1bcdf4..0000000 --- a/crates/template/Cargo.toml +++ /dev/null @@ -1,39 +0,0 @@ -[package] -name = "template" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "A production-ready template for installable TinyBus modules." -documentation = "https://docs.rs/template" -readme = "../../README.md" -keywords = ["tinybus", "module", "plugin", "template"] -categories = ["development-tools"] -publish = false - -[lib] -# Keep the ordinary Rust library for tests and downstream reuse while also -# producing the native module artifact that TinyBus loads at runtime. -crate-type = ["rlib", "cdylib"] - -[dependencies] -# The wire contract: member names, payload types, and the contract version. -# Re-exported wholesale from `src/lib.rs` so a consumer takes one dependency -# rather than two, and so `template::GreetRequest` and -# `template_bus::GreetRequest` are the same type. -template-bus = { workspace = true } -tinybus = { workspace = true } -tinybus-module = { workspace = true } -thiserror = { workspace = true } - -[dev-dependencies] -tokio = { workspace = true } -# The GitHub release verifier passes an explicit empty module configuration. -serde_json = { workspace = true } - -[features] -default = [] - -[lints] -workspace = true diff --git a/crates/template/examples/basic.rs b/crates/template/examples/basic.rs deleted file mode 100644 index 99233ec..0000000 --- a/crates/template/examples/basic.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! Minimal end-to-end usage of the crate. -//! -//! Examples are compiled and linted in CI, so they cannot drift from the API. -//! Run it with: -//! -//! ```sh -//! cargo run --example basic -//! ``` - -use template::{Result, greet}; - -fn main() -> Result<()> { - println!("{}", greet("Rust")?); - - // Failure modes are part of the public contract; show them too. - match greet(" ") { - Ok(greeting) => println!("{greeting}"), - Err(error) => println!("expected failure: {error}"), - } - - Ok(()) -} diff --git a/crates/template/examples/verify_github_release.rs b/crates/template/examples/verify_github_release.rs deleted file mode 100644 index 9b173fe..0000000 --- a/crates/template/examples/verify_github_release.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! Downloads a tagged release asset and calls the loaded `TinyBus` module. -//! -//! Run it with the release tag URL, platform archive, and archive SHA-256: -//! -//! ```text -//! cargo run --example verify_github_release -- \ -//! https://github.com/tinyhumansai/template/releases/tag/v0.1.4 \ -//! template-0.1.4-ubuntu-24.04-x86_64.tar.gz \ -//! -//! ``` - -use std::io; -use std::time::Duration; - -use template::{GreetRequest, GreetResponse, names}; -use tinybus::Connection; -use tinybus::broker::Broker; -use tinybus::module::ModuleHost; -use tinybus::transport::memory::MemoryBus; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let (release_url, archive, sha256) = arguments()?; - let bus = MemoryBus::new(); - let broker = Broker::new(); - let broker_task = broker.spawn(bus.clone()); - let module_host = ModuleHost::new(broker); - let info = module_host.load_github_release( - &release_url, - &archive, - Some(&sha256), - serde_json::Value::default(), - )?; - - if info.name != env!("CARGO_PKG_NAME") { - return Err(io::Error::other(format!( - "loaded module `{}` instead of `{}`", - info.name, - env!("CARGO_PKG_NAME") - )) - .into()); - } - - let client = Connection::connect(bus.connect().await?).await?; - tokio::time::timeout(Duration::from_secs(5), async { - loop { - let claimed = client.list_names().await?; - if claimed.iter().any(|name| name.as_str() == names::INTERFACE) { - return tinybus::Result::Ok(()); - } - tokio::task::yield_now().await; - } - }) - .await??; - - let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; - let reply: GreetResponse = proxy - .call(names::methods::GREET, (GreetRequest::new("TinyBus"),)) - .await?; - if reply.greeting != "Hello, TinyBus!" { - return Err(io::Error::other(format!( - "module returned an unexpected greeting: {}", - reply.greeting - )) - .into()); - } - - println!( - "verified {archive} from {release_url} as TinyBus module `{}`", - info.name - ); - broker_task.abort(); - Ok(()) -} - -fn arguments() -> Result<(String, String, String), io::Error> { - let mut args = std::env::args().skip(1); - let usage = "usage: cargo run --example verify_github_release -- \ - "; - let release_url = args - .next() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, usage))?; - let archive = args - .next() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, usage))?; - let sha256 = args - .next() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, usage))?; - if args.next().is_some() { - return Err(io::Error::new(io::ErrorKind::InvalidInput, usage)); - } - Ok((release_url, archive, sha256)) -} diff --git a/crates/template/examples/verify_module.rs b/crates/template/examples/verify_module.rs deleted file mode 100644 index 6e3856e..0000000 --- a/crates/template/examples/verify_module.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! Loads a built module through the real `TinyBus` dynamic loader. - -use std::io; -use std::path::PathBuf; -use std::time::Duration; - -use template::{GreetRequest, GreetResponse, names}; -use tinybus::Connection; -use tinybus::broker::Broker; -use tinybus::module::ModuleHost; -use tinybus::transport::memory::MemoryBus; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let module = module_argument()?; - let bus = MemoryBus::new(); - let broker = Broker::new(); - let broker_task = broker.spawn(bus.clone()); - let module_host = ModuleHost::new(broker); - let info = module_host.load_file(&module)?; - - if info.name != env!("CARGO_PKG_NAME") { - return Err(io::Error::other(format!( - "loaded module `{}` instead of `{}`", - info.name, - env!("CARGO_PKG_NAME") - )) - .into()); - } - - let client = Connection::connect(bus.connect().await?).await?; - tokio::time::timeout(Duration::from_secs(5), async { - loop { - let claimed = client.list_names().await?; - if claimed.iter().any(|name| name.as_str() == names::INTERFACE) { - return tinybus::Result::Ok(()); - } - tokio::task::yield_now().await; - } - }) - .await??; - - let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; - let reply: GreetResponse = proxy - .call(names::methods::GREET, (GreetRequest::new("TinyBus"),)) - .await?; - if reply.greeting != "Hello, TinyBus!" { - return Err(io::Error::other(format!( - "module returned an unexpected greeting: {}", - reply.greeting - )) - .into()); - } - - println!( - "verified {} as TinyBus module `{}`", - module.display(), - info.name - ); - broker_task.abort(); - Ok(()) -} - -fn module_argument() -> Result { - std::env::args_os() - .nth(1) - .map(PathBuf::from) - .ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidInput, - "usage: cargo run --example verify_module -- ", - ) - }) -} diff --git a/crates/template/src/error/mod.rs b/crates/template/src/error/mod.rs deleted file mode 100644 index b8ddbe0..0000000 --- a/crates/template/src/error/mod.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Crate-wide error and result types. -//! -//! Every fallible public function in this crate returns [`Result`], and every -//! failure mode is a distinct [`Error`] variant. Add a variant rather than -//! encoding new context into an existing message: callers match on variants, -//! and message text is not a stable API. -//! -//! Variants carry the data a caller needs to react, keep their `#[error]` -//! message lowercase and free of trailing punctuation, and are documented so -//! the rendered rustdoc explains when each one occurs. - -/// Errors returned by this crate. -#[derive(Debug, thiserror::Error, PartialEq, Eq)] -#[non_exhaustive] -pub enum Error { - /// A required name was empty or contained only whitespace. - #[error("name must not be empty")] - EmptyName, -} - -/// The crate's standard result type. -/// -/// Use this alias in public signatures instead of spelling out -/// `std::result::Result`. -pub type Result = std::result::Result; - -#[cfg(test)] -mod test; diff --git a/crates/template/src/error/test.rs b/crates/template/src/error/test.rs deleted file mode 100644 index 4c5d609..0000000 --- a/crates/template/src/error/test.rs +++ /dev/null @@ -1,17 +0,0 @@ -//! Unit tests for the crate-wide error type. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::*; - -#[test] -fn renders_a_human_readable_message() { - assert_eq!(Error::EmptyName.to_string(), "name must not be empty"); -} - -#[test] -fn is_a_standard_error() { - fn assert_error(_: &E) {} - - assert_error(&Error::EmptyName); -} diff --git a/crates/template/src/greeting/mod.rs b/crates/template/src/greeting/mod.rs deleted file mode 100644 index 862fa21..0000000 --- a/crates/template/src/greeting/mod.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Greeting behavior used to demonstrate the template's module layout. -//! -//! A module root like this one documents the module, wires its pieces -//! together, and exposes the smallest useful API. Substantial type definitions -//! belong in a sibling `types.rs`, and unit tests belong in `test.rs`, wired in -//! at the bottom of this file. -//! -//! Replace this module with the crate's first real feature area. - -use crate::{Error, Result}; - -/// Returns a friendly greeting for `name`. -/// -/// Surrounding whitespace is trimmed before the greeting is built. -/// -/// # Examples -/// -/// ``` -/// # use template::greet; -/// assert_eq!(greet(" Ferris ")?, "Hello, Ferris!"); -/// # Ok::<(), template::Error>(()) -/// ``` -/// -/// # Errors -/// -/// Returns [`Error::EmptyName`] when `name` is empty or contains only -/// whitespace. -pub fn greet(name: &str) -> Result { - let name = name.trim(); - if name.is_empty() { - return Err(Error::EmptyName); - } - - Ok(format!("Hello, {name}!")) -} - -#[cfg(test)] -mod test; diff --git a/crates/template/src/greeting/test.rs b/crates/template/src/greeting/test.rs deleted file mode 100644 index de04ef4..0000000 --- a/crates/template/src/greeting/test.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Unit tests for the greeting module. -//! -//! Unit tests live next to the code they cover and may reach into private -//! items. Tests of the public contract belong in `tests/` instead. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::*; - -#[test] -fn greets_a_named_person() { - assert_eq!(greet("Ferris").unwrap(), "Hello, Ferris!"); -} - -#[test] -fn trims_the_name() { - assert_eq!(greet(" Ferris ").unwrap(), "Hello, Ferris!"); -} - -#[test] -fn rejects_an_empty_name() { - assert_eq!(greet("").unwrap_err(), Error::EmptyName); -} - -#[test] -fn rejects_a_whitespace_only_name() { - assert_eq!(greet(" \t\n ").unwrap_err(), Error::EmptyName); -} diff --git a/crates/template/src/lib.rs b/crates/template/src/lib.rs deleted file mode 100644 index 566fa7e..0000000 --- a/crates/template/src/lib.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! A production-ready starting point for an installable `TinyBus` module. -//! -//! This crate is a template. It ships the layout, lint configuration, error -//! handling, testing, and documentation conventions described in `AGENTS.md`. -//! The compiled `cdylib` exports `TinyBus` module ABI v1 and serves the example -//! [`greet`] behavior over the bus. -//! -//! # Layout -//! -//! This is the implementation half of a two-crate workspace: -//! -//! - [`template_bus`] — the wire contract. Member names, payload types, and the -//! contract version, with no transport and no behavior. A host that only -//! makes calls depends on that crate alone. -//! - `template` — this crate. The behavior, the crate-wide error type, and the -//! `TinyBus` adapter that serves them, built as both an `rlib` and the -//! `cdylib` the loader consumes. -//! -//! Within this crate: -//! -//! - `src/error/` holds the crate-wide [`Error`] enum and the [`Result`] alias -//! returned by every fallible public function. -//! - Each feature area lives in its own module directory with a `mod.rs` -//! module root, an optional `types.rs`, and a `test.rs` holding its unit -//! tests. -//! - Every public item is re-exported from here — including all of -//! [`template_bus`] — so downstream users have a single predictable surface -//! and `template::GreetRequest` is the *same type* as -//! `template_bus::GreetRequest`, not a structural twin. -//! - `tinybus_module` adapts the public behavior to `TinyBus` and exports the -//! module descriptor, embedded manifest, and initialization entrypoint. -//! -//! # Example -//! -//! ``` -//! use template::{greet, Error, GreetRequest}; -//! -//! assert_eq!(greet("Ferris")?, "Hello, Ferris!"); -//! assert_eq!(greet(" ").unwrap_err(), Error::EmptyName); -//! assert_eq!(GreetRequest::new("Ferris").name, "Ferris"); -//! # Ok::<(), template::Error>(()) -//! ``` -//! -//! Replace the `greeting` module with the first real feature area, keep the -//! conventions, and update this documentation to describe the new crate. - -mod error; -mod greeting; -mod tinybus_module; - -pub use error::{Error, Result}; -pub use greeting::greet; - -// The wire contract, re-exported by module rather than by item so every path -// through this crate resolves to the same definitions the contract crate -// publishes. A host may depend on `template-bus` directly and get exactly these -// types; nothing here redefines them. -pub use template_bus; -pub use template_bus::{ - CONTRACT_VERSION, GreetRequest, GreetResponse, INTERFACE, METHODS, OBJECT_PATH, is_compatible, - names, version, -}; diff --git a/crates/template/src/tinybus_module/README.md b/crates/template/src/tinybus_module/README.md deleted file mode 100644 index 2c05772..0000000 --- a/crates/template/src/tinybus_module/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# TinyBus Adapter - -This module is the boundary between ordinary feature code and TinyBus module -ABI v1. `GreetingService` converts the crate's public `greet` function into the typed -`Greet` bus method, while `setup` registers its object and claims the well-known -interface name. Neither the name, the object path, nor the payload types are -spelled here: they come from `template-bus`, so a rename is a compile error in -every consumer instead of an `UnknownMethod` at runtime. - -`tinybus_module::module_export!` emits the descriptor, embedded manifest, and -initialization symbols consumed by the dynamic loader. The manifest method list -must stay aligned with the interface macro's dispatch table and with -`template_bus::names::METHODS`; the unit tests check both relationships. -Integration tests use TinyBus's in-memory transport, and -`crates/template/examples/verify_module.rs` loads a compiled `cdylib` through -the real dynamic loader before a release archive is accepted. - -Generated projects should replace the example interface, object path, and method -declarations together — here and in `crates/template-bus/src/names/`. They must not retain Rust-owned data across the -ABI boundary or bypass the SDK exports with an ad hoc FFI surface. diff --git a/crates/template/src/tinybus_module/mod.rs b/crates/template/src/tinybus_module/mod.rs deleted file mode 100644 index 1c9c2f0..0000000 --- a/crates/template/src/tinybus_module/mod.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! `TinyBus` module entrypoint and bus-facing interface. -//! -//! This adapter keeps the feature implementation independent from `TinyBus` -//! while exposing it as an installable, dynamically loaded integration. The -//! names and payload types it serves come from [`template_bus`], so a host -//! spells them from the contract crate instead of repeating string literals. - -use template_bus::{GreetRequest, GreetResponse, names}; -use tinybus::{Connection, Result as TinyBusResult}; - -struct GreetingService; - -#[tinybus::interface(name = "ai.tinyhumans.template.Greeting")] -impl GreetingService { - async fn greet(&self, request: GreetRequest) -> TinyBusResult { - std::future::ready(crate::greet(&request.name)) - .await - .map(GreetResponse::new) - .map_err(|error| tinybus::Error::failed(error.to_string())) - } -} - -async fn setup(connection: Connection) -> TinyBusResult<()> { - connection - .serve_at(names::OBJECT_PATH.try_into()?, GreetingService) - .await?; - connection.request_name(names::INTERFACE).await?; - Ok(()) -} - -tinybus_module::module_export! { - setup = setup, - worker_threads = 1, - provides = ["ai.tinyhumans.template.Greeting"], - methods = ["Greet"], - signals = [], - requires = [], - optional = [], - lazy = false, -} - -#[cfg(test)] -mod test; diff --git a/crates/template/src/tinybus_module/test.rs b/crates/template/src/tinybus_module/test.rs deleted file mode 100644 index d5fe71a..0000000 --- a/crates/template/src/tinybus_module/test.rs +++ /dev/null @@ -1,64 +0,0 @@ -//! Tests for the `TinyBus` module adapter and its declared surface. - -use super::{GreetingService, setup}; -use template_bus::{GreetRequest, GreetResponse, names}; -use tinybus::broker::Broker; -use tinybus::transport::memory::MemoryBus; -use tinybus::{Connection, Interface}; - -#[test] -fn declared_methods_match_the_dispatch_table() { - let methods = GreetingService - .members() - .into_iter() - .map(|member| member.to_string()) - .collect::>(); - - assert_eq!(methods, names::METHODS.to_vec()); -} - -#[test] -fn the_served_interface_name_matches_the_contract() { - assert_eq!(GreetingService.name().to_string(), names::INTERFACE); -} - -#[tokio::test] -async fn module_serves_greetings_over_a_real_bus() -> tinybus::Result<()> { - let bus = MemoryBus::new(); - Broker::new().spawn(bus.clone()); - - let service = Connection::connect(bus.connect().await?).await?; - setup(service.clone()).await?; - - let client = Connection::connect(bus.connect().await?).await?; - let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; - let reply: GreetResponse = proxy - .call(names::methods::GREET, (GreetRequest::new("Ferris"),)) - .await?; - - assert_eq!(reply, GreetResponse::new("Hello, Ferris!")); - Ok(()) -} - -#[tokio::test] -async fn module_rejects_an_empty_name_over_the_bus() -> tinybus::Result<()> { - let bus = MemoryBus::new(); - Broker::new().spawn(bus.clone()); - - let service = Connection::connect(bus.connect().await?).await?; - setup(service.clone()).await?; - - let client = Connection::connect(bus.connect().await?).await?; - let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; - let result = proxy - .call::(names::methods::GREET, (GreetRequest::new(" "),)) - .await; - - let Err(error) = result else { - return Err(tinybus::Error::failed( - "whitespace-only names unexpectedly succeeded", - )); - }; - assert!(error.to_string().contains("name must not be empty")); - Ok(()) -} diff --git a/crates/template/tests/public_api.rs b/crates/template/tests/public_api.rs deleted file mode 100644 index 256b71c..0000000 --- a/crates/template/tests/public_api.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! Integration tests for the public crate surface. -//! -//! These tests link against the crate as a downstream consumer would: they can -//! only use what `src/lib.rs` re-exports. Treat them as the regression suite -//! for the crate's public contract — if a change breaks a test here, it is a -//! breaking change for users. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use template::{Error, greet}; - -#[test] -fn greeting_is_available_to_consumers() { - assert_eq!(greet("Rust").unwrap(), "Hello, Rust!"); -} - -#[test] -fn errors_are_available_to_consumers() { - assert_eq!(greet("").unwrap_err(), Error::EmptyName); -} diff --git a/crates/tinytools/Cargo.toml b/crates/tinytools/Cargo.toml new file mode 100644 index 0000000..58b80d0 --- /dev/null +++ b/crates/tinytools/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "tinytools" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "The agent tool vocabulary: the `Tool` trait, its result and spec types, and the permission, scope and timeout classifications a host enforces around a call." +documentation = "https://docs.rs/tinytools" +readme = "README.md" +keywords = ["llm", "agents", "tools", "tool-calling"] +categories = ["asynchronous", "api-bindings"] + +# Deliberately dependency-light. This crate is the vocabulary two other crates +# and every tool author link against, so it must cost them almost nothing. +# Nothing here may pull in an HTTP client, an agent harness, a native library, +# or a runtime beyond the executor-agnostic `async-trait` shim. In particular it +# must never depend on `tinyagents`: `tinyagents` depends on *this* crate, and +# the whole point of the `context` module's erasure trait is to keep that edge +# pointing one way. CI asserts the dependency list. +[dependencies] +anyhow = { workspace = true } +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } + +[lints] +workspace = true diff --git a/crates/tinytools/src/call/mod.rs b/crates/tinytools/src/call/mod.rs new file mode 100644 index 0000000..06e5586 --- /dev/null +++ b/crates/tinytools/src/call/mod.rs @@ -0,0 +1,8 @@ +//! Inputs a caller supplies alongside a tool's declared arguments. + +mod types; + +pub use types::{ToolCallOptions, ToolTimeout}; + +#[cfg(test)] +mod test; diff --git a/crates/tinytools/src/call/test.rs b/crates/tinytools/src/call/test.rs new file mode 100644 index 0000000..ce3331b --- /dev/null +++ b/crates/tinytools/src/call/test.rs @@ -0,0 +1,24 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::{ToolCallOptions, ToolTimeout}; + +#[test] +fn options_default_to_no_preference() { + let options = ToolCallOptions::default(); + assert!(!options.prefer_markdown); + assert!(ToolCallOptions::prefer_markdown().prefer_markdown); +} + +#[test] +fn timeout_defaults_to_inherit() { + assert_eq!(ToolTimeout::default(), ToolTimeout::Inherit); + assert!(ToolTimeout::Inherit.is_inherit()); + assert!(!ToolTimeout::Unbounded.is_inherit()); + assert!(!ToolTimeout::Secs(30).is_inherit()); +} + +#[test] +fn timeout_variants_are_distinct() { + assert_ne!(ToolTimeout::Inherit, ToolTimeout::Unbounded); + assert_ne!(ToolTimeout::Secs(1), ToolTimeout::Secs(2)); +} diff --git a/crates/tinytools/src/call/types.rs b/crates/tinytools/src/call/types.rs new file mode 100644 index 0000000..d4af65c --- /dev/null +++ b/crates/tinytools/src/call/types.rs @@ -0,0 +1,59 @@ +//! Per-invocation inputs that are not part of a tool's argument schema. + +/// Per-invocation options threaded from the agent loop into a tool. +/// +/// These let a caller hint at how the tool should shape its output without +/// polluting the tool's model-visible parameter schema — the model never sees +/// these, and never has to be told not to set them. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct ToolCallOptions { + /// The caller prefers a markdown rendering of the result, because markdown + /// is materially cheaper than JSON in model context. + /// + /// A tool that honours this populates + /// [`ToolResult::markdown_formatted`][crate::ToolResult::markdown_formatted] + /// and advertises the capability from + /// [`Tool::supports_markdown`][crate::Tool::supports_markdown]. A tool that + /// ignores it stays correct — the caller falls back to the rendered blocks. + pub prefer_markdown: bool, +} + +impl ToolCallOptions { + /// Options requesting a markdown rendering. + #[must_use] + pub fn prefer_markdown() -> Self { + Self { + prefer_markdown: true, + } + } +} + +/// How the harness should bound a single tool invocation in wall-clock time. +/// +/// Returned by [`Tool::timeout_policy`][crate::Tool::timeout_policy]. The three +/// arms exist because scripting tools and network tools want opposite defaults: +/// a hung HTTP call must not wedge a session, but a build or test run +/// legitimately takes minutes and must not be hard-killed by a network-shaped +/// cap. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ToolTimeout { + /// Use the global, operator- and config-driven tool timeout. The right + /// default for most tools. + #[default] + Inherit, + /// Run without any harness-imposed deadline. Scripting tools return this + /// when the caller did not request an explicit budget. + Unbounded, + /// Enforce exactly this many seconds. A host is expected to clamp the value + /// into its own valid range rather than trust it. + Secs(u64), +} + +impl ToolTimeout { + /// Returns `true` for the default inherited behaviour. + #[must_use] + pub fn is_inherit(&self) -> bool { + matches!(self, Self::Inherit) + } +} diff --git a/crates/tinytools/src/classification/mod.rs b/crates/tinytools/src/classification/mod.rs new file mode 100644 index 0000000..b2ff7f9 --- /dev/null +++ b/crates/tinytools/src/classification/mod.rs @@ -0,0 +1,8 @@ +//! Availability and belt classification for a tool. + +mod types; + +pub use types::{ToolCategory, ToolScope}; + +#[cfg(test)] +mod test; diff --git a/crates/tinytools/src/classification/test.rs b/crates/tinytools/src/classification/test.rs new file mode 100644 index 0000000..e7664c5 --- /dev/null +++ b/crates/tinytools/src/classification/test.rs @@ -0,0 +1,38 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::{ToolCategory, ToolScope}; + +#[test] +fn scope_variants_are_distinct_and_default_to_all() { + assert_ne!(ToolScope::All, ToolScope::AgentOnly); + assert_ne!(ToolScope::All, ToolScope::CliRpcOnly); + assert_ne!(ToolScope::AgentOnly, ToolScope::CliRpcOnly); + assert_eq!(ToolScope::default(), ToolScope::All); +} + +#[test] +fn category_defaults_to_system() { + assert_eq!(ToolCategory::default(), ToolCategory::System); +} + +#[test] +fn category_display_matches_its_wire_form() { + assert_eq!(ToolCategory::System.to_string(), "system"); + assert_eq!(ToolCategory::Workflow.to_string(), "skill"); +} + +#[test] +fn workflow_stays_pinned_to_the_skill_wire_name() { + // Agent definition files on disk carry `"skill"`. Renaming the wire form to + // match the Rust ident would stop those files parsing. + assert_eq!( + serde_json::to_string(&ToolCategory::System).expect("serializable"), + "\"system\"" + ); + assert_eq!( + serde_json::to_string(&ToolCategory::Workflow).expect("serializable"), + "\"skill\"" + ); + let back: ToolCategory = serde_json::from_str("\"skill\"").expect("deserializable"); + assert_eq!(back, ToolCategory::Workflow); +} diff --git a/crates/tinytools/src/classification/types.rs b/crates/tinytools/src/classification/types.rs new file mode 100644 index 0000000..1a9ec5b --- /dev/null +++ b/crates/tinytools/src/classification/types.rs @@ -0,0 +1,57 @@ +//! Where a tool may run, and which belt it belongs to. + +use serde::{Deserialize, Serialize}; + +/// Controls where a tool is available. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum ToolScope { + /// Available in the agent loop, the CLI, and over RPC. + #[default] + All, + /// Intended to mark tools available only in the autonomous agent loop. + /// + /// Not yet enforced by any known host: no execution path filters on this + /// variant, so it currently behaves like [`Self::All`]. It is kept because + /// tools already annotate themselves with it, and losing those annotations + /// would mean re-deriving them when the filter lands. + AgentOnly, + /// Only available via explicit CLI or RPC invocation, never from the + /// autonomous loop. + CliRpcOnly, +} + +/// Category of a tool — used to scope which tools a given sub-agent may see. +/// +/// The distinction is about *where the work happens*: a [`Self::System`] tool +/// is a built-in implementation running in the host process with direct host +/// access, while a [`Self::Workflow`] tool reaches an external service on the +/// user's behalf. A host typically spawns dedicated tool-execution sub-agents +/// per category, because the two want different models and different +/// approval policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolCategory { + /// Built-in tools with direct host access. + #[default] + System, + /// Integration-facing tools that reach external services. + /// + /// The wire format is pinned to `"skill"` rather than the variant name: + /// agent definition files on disk already carry that string, and renaming + /// it would stop those files parsing. The Rust ident was swept to + /// `Workflow` during a naming change the wire format deliberately did not + /// follow. + #[serde(rename = "skill")] + Workflow, +} + +impl std::fmt::Display for ToolCategory { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Matches the serde representation, including the pinned `skill`. + let name = match self { + Self::System => "system", + Self::Workflow => "skill", + }; + f.write_str(name) + } +} diff --git a/crates/tinytools/src/context/mod.rs b/crates/tinytools/src/context/mod.rs new file mode 100644 index 0000000..9362b59 --- /dev/null +++ b/crates/tinytools/src/context/mod.rs @@ -0,0 +1,8 @@ +//! The run-scoped seam between a tool and the harness driving it. + +mod types; + +pub use types::ToolRunContext; + +#[cfg(test)] +mod test; diff --git a/crates/tinytools/src/context/test.rs b/crates/tinytools/src/context/test.rs new file mode 100644 index 0000000..ec294ec --- /dev/null +++ b/crates/tinytools/src/context/test.rs @@ -0,0 +1,53 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::path::{Path, PathBuf}; + +use super::ToolRunContext; + +/// A context that answers nothing, exercising every default. +struct Bare; +impl ToolRunContext for Bare {} + +/// A context shaped like a harness's real one. +struct Isolated { + root: PathBuf, +} + +impl ToolRunContext for Isolated { + fn workspace_root(&self) -> Option<&Path> { + Some(&self.root) + } + + fn workspace_policy_id(&self) -> Option<&str> { + Some("worktree-isolation") + } + + fn thread_id(&self) -> Option<&str> { + Some("thread-7") + } + + fn max_turn_output_tokens(&self) -> Option { + Some(4096) + } +} + +#[test] +fn the_defaults_answer_nothing() { + let bare = Bare; + assert!(bare.workspace_root().is_none()); + assert!(bare.workspace_policy_id().is_none()); + assert!(bare.thread_id().is_none()); + assert!(bare.max_turn_output_tokens().is_none()); +} + +#[test] +fn an_implementor_is_readable_through_the_trait_object() { + let isolated = Isolated { + root: PathBuf::from("/tmp/worktree"), + }; + let erased: &dyn ToolRunContext = &isolated; + assert_eq!(erased.workspace_root(), Some(Path::new("/tmp/worktree"))); + assert_eq!(erased.workspace_policy_id(), Some("worktree-isolation")); + assert_eq!(erased.thread_id(), Some("thread-7")); + assert_eq!(erased.max_turn_output_tokens(), Some(4096)); +} diff --git a/crates/tinytools/src/context/types.rs b/crates/tinytools/src/context/types.rs new file mode 100644 index 0000000..4c53d1b --- /dev/null +++ b/crates/tinytools/src/context/types.rs @@ -0,0 +1,54 @@ +//! The run-scoped facts a tool may read, without naming the harness that owns +//! them. + +use std::path::Path; + +/// The parts of a live agent run a tool is allowed to see. +/// +/// # Why this is a trait and not a struct +/// +/// A tool sometimes needs to know *where* it is running: an edit-capable worker +/// given an isolated worktree must resolve relative paths against that +/// worktree's root, not against the host's shared action directory. That fact +/// originates in the agent harness's run context. +/// +/// Naming the harness's context type here would make this crate depend on the +/// harness — and the harness depends on *this* crate, for the vocabulary every +/// tool is written against. Erasing the context behind a trait keeps that edge +/// pointing one way: the harness implements this for its own context type, and +/// a tool reads the three facts it actually uses without either crate having to +/// know the other's shape. +/// +/// It is deliberately narrow. Widening it means a tool has grown a dependency +/// on run internals, which is worth noticing rather than accommodating. +pub trait ToolRunContext: Send + Sync { + /// Root of the isolated workspace this call may operate in, when the run + /// was configured with one. + /// + /// `None` means no workspace policy is in effect and the tool should fall + /// back to whatever root its host configured. A tool must not treat `None` + /// as permission to escape a root — the host's own path policy is what + /// enforces that, and it applies either way. + fn workspace_root(&self) -> Option<&Path> { + None + } + + /// Identifier of the policy that granted [`Self::workspace_root`], for + /// logging and audit. `None` when there is no workspace. + fn workspace_policy_id(&self) -> Option<&str> { + None + } + + /// Caller thread id, when the parent run is threaded. + fn thread_id(&self) -> Option<&str> { + None + } + + /// Maximum output tokens requested for each model turn in the caller's run. + /// + /// A tool that itself calls a model — a sub-agent, a summarizer — uses this + /// to stay inside the caller's budget instead of picking its own. + fn max_turn_output_tokens(&self) -> Option { + None + } +} diff --git a/crates/tinytools/src/lib.rs b/crates/tinytools/src/lib.rs new file mode 100644 index 0000000..d9492c1 --- /dev/null +++ b/crates/tinytools/src/lib.rs @@ -0,0 +1,117 @@ +//! The vocabulary an agent tool is written against: the [`Tool`] trait, the +//! [`ToolResult`] it returns, and the classifications a host enforces around a +//! call. +//! +//! # Why this is its own crate +//! +//! Two crates need these types and neither can own them. An agent harness has +//! to name a tool's result to run a loop over it; a host application has to +//! name the same result to implement one. Before this crate existed, both +//! declared their own, and the conversions between them were written by hand at +//! every seam — which is how an error flag ends up inverted in one direction +//! and nothing catches it. +//! +//! So the vocabulary sits underneath both. A harness depends on this crate and +//! re-exports it, so `harness::ToolResult` and [`tinytools::ToolResult`] are the +//! *same type*, not structural twins. A tool author depends on this crate alone +//! and compiles neither the harness nor the host. +//! +//! # What is here +//! +//! - [`tool`] — the [`Tool`] trait: four required methods, and a set of +//! defaulted declarations describing what the tool needs and what it touches. +//! - [`result`] — [`ToolResult`] and [`ToolContent`], the MCP-shaped block list +//! a tool hands back. +//! - [`spec`] — [`ToolSpec`], the declaration a model is shown. +//! - [`permission`] — [`PermissionLevel`], the privilege ladder. +//! - [`classification`] — [`ToolScope`] and [`ToolCategory`]. +//! - [`call`] — [`ToolCallOptions`] and [`ToolTimeout`], the per-invocation +//! inputs that are not arguments. +//! - [`context`] — [`ToolRunContext`], the narrow seam onto a live run. +//! - [`naming`] — rendering a call for a human. +//! +//! # What is deliberately not here +//! +//! **No enforcement.** Nothing in this crate checks a [`PermissionLevel`], +//! applies a [`ToolTimeout`], or decides whether an +//! [`external_effect`][Tool::external_effect] needs approval. A tool +//! *describes* itself and a host *decides*, because the decision depends on +//! that host's threat model, its configuration, and who is asking — none of +//! which generalize. Putting the check here would mean every host inherits one +//! host's policy. +//! +//! **No registry, no dispatch, no execution loop.** Those belong to whoever +//! owns the run. +//! +//! **No dependency on an agent harness.** The harness depends on this crate. +//! [`ToolRunContext`] exists precisely so a tool can read run-scoped facts +//! without this crate naming the harness type that carries them — see that +//! module for why the edge has to point one way. +//! +//! # Example +//! +//! ``` +//! use tinytools::{PermissionLevel, Tool, ToolResult}; +//! +//! struct Echo; +//! +//! #[async_trait::async_trait] +//! impl Tool for Echo { +//! fn name(&self) -> &str { +//! "echo" +//! } +//! +//! fn description(&self) -> &str { +//! "Returns its input unchanged." +//! } +//! +//! fn parameters_schema(&self) -> serde_json::Value { +//! serde_json::json!({ +//! "type": "object", +//! "properties": { "text": { "type": "string" } }, +//! "required": ["text"], +//! }) +//! } +//! +//! async fn execute(&self, args: serde_json::Value) -> anyhow::Result { +//! let text = args["text"].as_str().unwrap_or_default(); +//! Ok(ToolResult::success(text)) +//! } +//! } +//! +//! # tokio_test_shim(async { +//! let echo = Echo; +//! let out = echo.execute(serde_json::json!({ "text": "hi" })).await?; +//! assert_eq!(out.output(), "hi"); +//! +//! // Declarations a host reads before it ever calls `execute`. +//! assert_eq!(echo.permission_level(), PermissionLevel::ReadOnly); +//! assert!(!echo.external_effect()); +//! assert_eq!(echo.display_label(&serde_json::Value::Null).as_deref(), Some("Echo")); +//! # Ok::<(), anyhow::Error>(()) +//! # }); +//! # fn tokio_test_shim>>(f: F) { +//! # tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(f).unwrap(); +//! # } +//! ``` + +pub mod call; +pub mod classification; +pub mod context; +pub mod naming; +pub mod permission; +pub mod result; +pub mod spec; +pub mod tool; + +pub use call::{ToolCallOptions, ToolTimeout}; +pub use classification::{ToolCategory, ToolScope}; +pub use context::ToolRunContext; +pub use naming::{ + ContextDetailOptions, context_detail_from_args, context_detail_from_args_with, + humanize_tool_name, +}; +pub use permission::PermissionLevel; +pub use result::{ToolContent, ToolResult}; +pub use spec::ToolSpec; +pub use tool::Tool; diff --git a/crates/tinytools/src/naming/mod.rs b/crates/tinytools/src/naming/mod.rs new file mode 100644 index 0000000..3b2cb69 --- /dev/null +++ b/crates/tinytools/src/naming/mod.rs @@ -0,0 +1,11 @@ +//! Rendering a tool call for a human. + +mod types; + +pub use types::{ + ContextDetailOptions, context_detail_from_args, context_detail_from_args_with, + humanize_tool_name, +}; + +#[cfg(test)] +mod test; diff --git a/crates/tinytools/src/naming/test.rs b/crates/tinytools/src/naming/test.rs new file mode 100644 index 0000000..32618dd --- /dev/null +++ b/crates/tinytools/src/naming/test.rs @@ -0,0 +1,122 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use serde_json::json; + +use super::{ + ContextDetailOptions, context_detail_from_args, context_detail_from_args_with, + humanize_tool_name, +}; + +#[test] +fn snake_and_kebab_case_become_title_case() { + assert_eq!( + humanize_tool_name("gmail_read_message"), + "Gmail Read Message" + ); + assert_eq!(humanize_tool_name("web_fetch"), "Web Fetch"); + assert_eq!(humanize_tool_name("shell"), "Shell"); + assert_eq!(humanize_tool_name("read-diff"), "Read Diff"); +} + +#[test] +fn machine_prefixes_are_stripped() { + // A timeline row should read as the action, not the transport that carried + // it. + assert_eq!( + humanize_tool_name("composio_gmail_send_email"), + "Gmail Send Email" + ); + assert_eq!( + humanize_tool_name("mcp_notion_create_page"), + "Notion Create Page" + ); +} + +#[test] +fn degenerate_names_fall_back_to_the_input() { + assert_eq!(humanize_tool_name(""), ""); + assert_eq!(humanize_tool_name("___"), "___"); +} + +#[test] +fn the_most_specific_recognized_key_wins() { + // A messaging call carries both; the recipient is the useful half. + assert_eq!( + context_detail_from_args(&json!({ "name": "ignored", "to": "steven@example.com" })) + .as_deref(), + Some("steven@example.com") + ); +} + +#[test] +fn non_objects_and_unrecognized_keys_yield_nothing() { + assert!(context_detail_from_args(&serde_json::Value::Null).is_none()); + assert!(context_detail_from_args(&json!(["a"])).is_none()); + assert!(context_detail_from_args(&json!({ "unrecognized": "x" })).is_none()); + assert!(context_detail_from_args(&json!({ "path": "" })).is_none()); + assert!(context_detail_from_args(&json!({ "path": { "nested": 1 } })).is_none()); +} + +#[test] +fn scalars_and_string_arrays_render() { + assert_eq!( + context_detail_from_args(&json!({ "id": 42 })).as_deref(), + Some("42") + ); + assert_eq!( + context_detail_from_args(&json!({ "name": true })).as_deref(), + Some("true") + ); + assert_eq!( + context_detail_from_args(&json!({ "to": ["a@x.com", "b@x.com"] })).as_deref(), + Some("a@x.com, b@x.com") + ); +} + +#[test] +fn whitespace_is_collapsed() { + assert_eq!( + context_detail_from_args(&json!({ "command": " ls -la " })).as_deref(), + Some("ls -la") + ); +} + +#[test] +fn long_values_are_elided_within_the_cap() { + let long = "x".repeat(200); + let detail = context_detail_from_args(&json!({ "query": long })).expect("a detail"); + assert!(detail.chars().count() <= 80); + assert!(detail.ends_with("...")); +} + +#[test] +fn a_custom_ellipsis_and_cap_are_honoured() { + let long = "x".repeat(200); + let detail = context_detail_from_args_with( + &json!({ "query": long }), + ContextDetailOptions::new(10, "…"), + ) + .expect("a detail"); + assert_eq!(detail.chars().count(), 10); + assert!(detail.ends_with('…')); +} + +#[test] +fn an_ellipsis_longer_than_the_cap_cannot_overflow_it() { + // A misconfigured caller must not be able to push the rendered value past + // the cap it asked for. + let long = "x".repeat(200); + let detail = context_detail_from_args_with( + &json!({ "query": long }), + ContextDetailOptions::new(2, "..."), + ) + .expect("a detail"); + assert_eq!(detail.chars().count(), 2); +} + +#[test] +fn the_default_options_are_eighty_chars_and_three_dots() { + let defaults = ContextDetailOptions::default(); + assert_eq!(defaults.max_chars, 80); + assert_eq!(defaults.ellipsis, "..."); +} diff --git a/crates/tinytools/src/naming/types.rs b/crates/tinytools/src/naming/types.rs new file mode 100644 index 0000000..00a96d1 --- /dev/null +++ b/crates/tinytools/src/naming/types.rs @@ -0,0 +1,165 @@ +//! Turning a machine tool name and its arguments into something a person can +//! read in a timeline row. + +use serde_json::Value; + +/// How a context detail is trimmed for display. +/// +/// Exists because the cap and the ellipsis are **presentation**, and a host +/// that renders tool activity in its own timeline has already picked both. The +/// key-scanning rule underneath is what is actually shared; forcing a host to +/// re-implement the whole function to change one character is how two copies of +/// it end up in a codebase. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct ContextDetailOptions { + /// Maximum rendered length, in characters, including the ellipsis. + pub max_chars: usize, + /// Appended when the value is trimmed. + pub ellipsis: &'static str, +} + +impl ContextDetailOptions { + /// Options with an explicit cap and ellipsis. + #[must_use] + pub fn new(max_chars: usize, ellipsis: &'static str) -> Self { + Self { + max_chars, + ellipsis, + } + } +} + +impl Default for ContextDetailOptions { + fn default() -> Self { + Self { + max_chars: 80, + ellipsis: "...", + } + } +} + +/// Derives a title-cased, human-readable label from a raw tool name. +/// +/// Common machine prefixes are stripped and `snake_case` / `kebab-case` becomes +/// spaced title case, so `gmail_read_message` reads as "Gmail Read Message". +/// Degenerate names fall back to the original input, so a caller never receives +/// an empty label unless the input itself was empty. +/// +/// The prefix list is shared deliberately: two copies of it is how one of them +/// silently stops stripping a prefix the other does, and the symptom — a +/// timeline row reading `composio_gmail_send_email` — surfaces far from the +/// cause. +#[must_use] +pub fn humanize_tool_name(name: &str) -> String { + let trimmed = name + .strip_prefix("composio_") + .or_else(|| name.strip_prefix("mcp_")) + .unwrap_or(name); + + let mut out = String::with_capacity(trimmed.len()); + let mut capitalize = true; + for ch in trimmed.chars() { + if ch == '_' || ch == '-' { + if !out.is_empty() && !out.ends_with(' ') { + out.push(' '); + } + capitalize = true; + } else if capitalize { + out.extend(ch.to_uppercase()); + capitalize = false; + } else { + out.push(ch); + } + } + + let label = out.trim(); + if label.is_empty() { + name.to_string() + } else { + label.to_string() + } +} + +/// Extracts a compact human-facing detail from common tool argument keys. +/// +/// The first recognized scalar value wins, using keys that usually identify the +/// resource being acted on (`path`, `query`, `to`, `url`, and similar). Returns +/// `None` for non-object arguments, empty values, and complex values. +/// +/// Uses [`ContextDetailOptions::default`]; see +/// [`context_detail_from_args_with`] to choose the cap and ellipsis. +#[must_use] +pub fn context_detail_from_args(args: &Value) -> Option { + context_detail_from_args_with(args, ContextDetailOptions::default()) +} + +/// [`context_detail_from_args`] with explicit trimming. +#[must_use] +pub fn context_detail_from_args_with( + args: &Value, + options: ContextDetailOptions, +) -> Option { + // Ordered by specificity: a messaging call carries both `to` and `name`, + // and the recipient is the useful half. + const CONTEXT_KEYS: &[&str] = &[ + "to", + "recipient", + "recipient_email", + "to_email", + "email", + "query", + "q", + "search", + "search_query", + "url", + "file_path", + "path", + "command", + "cmd", + "subject", + "title", + "channel", + "channel_id", + "repo", + "repository", + "name", + "id", + ]; + + let obj = args.as_object()?; + CONTEXT_KEYS + .iter() + .filter_map(|key| obj.get(*key)) + .find_map(|value| render_context_value(value, options)) +} + +fn render_context_value(value: &Value, options: ContextDetailOptions) -> Option { + let raw = match value { + Value::String(s) => s.trim().to_string(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Array(items) => items + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(", "), + Value::Null | Value::Object(_) => String::new(), + }; + let raw = raw.split_whitespace().collect::>().join(" "); + if raw.is_empty() { + return None; + } + if raw.chars().count() > options.max_chars { + // Clamp the ellipsis itself to `max_chars` first: an ellipsis longer + // than the cap (a misconfigured caller) would otherwise survive + // `saturating_sub`'s zero and still be appended in full, pushing the + // rendered value past the cap it was supposed to enforce. + let ellipsis: String = options.ellipsis.chars().take(options.max_chars).collect(); + let keep = options.max_chars.saturating_sub(ellipsis.chars().count()); + let truncated: String = raw.chars().take(keep).collect(); + Some(format!("{truncated}{ellipsis}")) + } else { + Some(raw) + } +} diff --git a/crates/tinytools/src/permission/mod.rs b/crates/tinytools/src/permission/mod.rs new file mode 100644 index 0000000..7cdbbf3 --- /dev/null +++ b/crates/tinytools/src/permission/mod.rs @@ -0,0 +1,8 @@ +//! The privilege ladder a host enforces around a tool call. + +mod types; + +pub use types::PermissionLevel; + +#[cfg(test)] +mod test; diff --git a/crates/tinytools/src/permission/test.rs b/crates/tinytools/src/permission/test.rs new file mode 100644 index 0000000..95eae26 --- /dev/null +++ b/crates/tinytools/src/permission/test.rs @@ -0,0 +1,42 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::PermissionLevel; + +#[test] +fn levels_are_totally_ordered_from_none_to_dangerous() { + // Enforcement compares levels with `<` to reject a tool whose required + // level exceeds the caller's maximum, so this ordering is load-bearing. + assert!(PermissionLevel::None < PermissionLevel::ReadOnly); + assert!(PermissionLevel::ReadOnly < PermissionLevel::Write); + assert!(PermissionLevel::Write < PermissionLevel::Execute); + assert!(PermissionLevel::Execute < PermissionLevel::Dangerous); +} + +#[test] +fn default_is_read_only() { + assert_eq!(PermissionLevel::default(), PermissionLevel::ReadOnly); +} + +#[test] +fn display_matches_the_variant_name() { + assert_eq!(PermissionLevel::None.to_string(), "None"); + assert_eq!(PermissionLevel::ReadOnly.to_string(), "ReadOnly"); + assert_eq!(PermissionLevel::Write.to_string(), "Write"); + assert_eq!(PermissionLevel::Execute.to_string(), "Execute"); + assert_eq!(PermissionLevel::Dangerous.to_string(), "Dangerous"); +} + +#[test] +fn levels_round_trip_as_json_numbers() { + for level in [ + PermissionLevel::None, + PermissionLevel::ReadOnly, + PermissionLevel::Write, + PermissionLevel::Execute, + PermissionLevel::Dangerous, + ] { + let encoded = serde_json::to_string(&level).expect("serializable"); + let back: PermissionLevel = serde_json::from_str(&encoded).expect("deserializable"); + assert_eq!(back, level); + } +} diff --git a/crates/tinytools/src/permission/types.rs b/crates/tinytools/src/permission/types.rs new file mode 100644 index 0000000..a81809e --- /dev/null +++ b/crates/tinytools/src/permission/types.rs @@ -0,0 +1,43 @@ +//! The privilege a tool call requires. + +use serde::{Deserialize, Serialize}; + +/// Permission level required to execute a tool. +/// +/// A caller (a chat channel, a scheduled job, a sub-agent) can declare a +/// maximum level; a tool whose required level exceeds it is rejected before any +/// argument is parsed. +/// +/// The ordering is load-bearing: enforcement compares levels with `<`, so the +/// discriminants must stay monotonically increasing in privilege. Adding a +/// variant means deciding where in that order it sits, not appending to the +/// end. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default, Hash, +)] +pub enum PermissionLevel { + /// No permission needed — metadata-only operations. + None = 0, + /// Read-only operations: file reads, memory recall, listing. + #[default] + ReadOnly = 1, + /// Write operations: file writes, memory stores. + Write = 2, + /// Command execution: shells, scripts. + Execute = 3, + /// Destructive or system-level operations. + Dangerous = 4, +} + +impl std::fmt::Display for PermissionLevel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let name = match self { + Self::None => "None", + Self::ReadOnly => "ReadOnly", + Self::Write => "Write", + Self::Execute => "Execute", + Self::Dangerous => "Dangerous", + }; + f.write_str(name) + } +} diff --git a/crates/tinytools/src/result/mod.rs b/crates/tinytools/src/result/mod.rs new file mode 100644 index 0000000..46decef --- /dev/null +++ b/crates/tinytools/src/result/mod.rs @@ -0,0 +1,8 @@ +//! What a tool hands back. + +mod types; + +pub use types::{ToolContent, ToolResult}; + +#[cfg(test)] +mod test; diff --git a/crates/tinytools/src/result/test.rs b/crates/tinytools/src/result/test.rs new file mode 100644 index 0000000..a26ae80 --- /dev/null +++ b/crates/tinytools/src/result/test.rs @@ -0,0 +1,124 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use serde_json::json; + +use super::{ToolContent, ToolResult}; + +#[test] +fn success_carries_one_text_block() { + let r = ToolResult::success("done"); + assert!(!r.is_error); + assert_eq!(r.text(), "done"); + assert_eq!(r.output(), "done"); +} + +#[test] +fn error_sets_the_flag_and_keeps_the_message() { + let r = ToolResult::error("failed"); + assert!(r.is_error); + assert_eq!(r.text(), "failed"); +} + +#[test] +fn text_skips_json_blocks_but_output_renders_them() { + let r = ToolResult::json(json!({"key": "value"})); + assert!(!r.is_error); + assert!(r.text().is_empty()); + assert!(r.output().contains("key")); +} + +#[test] +fn mixed_content_joins_in_order() { + let r = ToolResult { + content: vec![ + ToolContent::Text { + text: "line1".into(), + }, + ToolContent::Json { + data: json!({"a": 1}), + }, + ToolContent::Text { + text: "line2".into(), + }, + ], + is_error: false, + markdown_formatted: None, + }; + assert_eq!(r.text(), "line1\nline2"); + let output = r.output(); + assert!(output.contains("line1")); + assert!(output.contains("line2")); + assert!(output.contains("\"a\"")); +} + +#[test] +fn empty_content_renders_empty() { + let r = ToolResult { + content: vec![], + is_error: false, + markdown_formatted: None, + }; + assert!(r.text().is_empty()); + assert!(r.output().is_empty()); +} + +#[test] +fn result_round_trips_through_json() { + let r = ToolResult::success("hello"); + let encoded = serde_json::to_string(&r).expect("serializable"); + let back: ToolResult = serde_json::from_str(&encoded).expect("deserializable"); + assert!(!back.is_error); + assert_eq!(back.text(), "hello"); +} + +#[test] +fn content_blocks_are_tagged_by_type() { + let text = serde_json::to_string(&ToolContent::Text { + text: "test".into(), + }) + .expect("serializable"); + assert!(text.contains("\"type\":\"text\"")); + + let data = serde_json::to_string(&ToolContent::Json { + data: json!({"x": 1}), + }) + .expect("serializable"); + assert!(data.contains("\"type\":\"json\"")); + + match serde_json::from_str::(&text).expect("deserializable") { + ToolContent::Text { text } => assert_eq!(text, "test"), + ToolContent::Json { .. } => unreachable!("tagged as text"), + } + match serde_json::from_str::(&data).expect("deserializable") { + ToolContent::Json { data } => assert_eq!(data["x"], 1), + ToolContent::Text { .. } => unreachable!("tagged as json"), + } +} + +#[test] +fn output_for_llm_prefers_markdown_when_requested() { + let r = ToolResult::success_with_markdown(json!({"items": [{"id": 1}, {"id": 2}]}), "- 1\n- 2"); + assert_eq!(r.output_for_llm(true), "- 1\n- 2"); + assert!(r.output_for_llm(false).contains("\"items\"")); +} + +#[test] +fn output_for_llm_falls_back_when_markdown_is_absent_or_blank() { + let plain = ToolResult::success("plain"); + assert_eq!(plain.output_for_llm(true), "plain"); + assert_eq!(plain.output_for_llm(false), "plain"); + + // A tool that set the field but rendered nothing is a bug in the tool; + // sending the model an empty turn would hide it. + let blank = ToolResult::success("plain").with_markdown(" \n "); + assert_eq!(blank.output_for_llm(true), "plain"); +} + +#[test] +fn the_markdown_field_keeps_its_composio_wire_name() { + let r = ToolResult::success_with_markdown(json!({"a": 1}), "**a**: 1"); + let encoded = serde_json::to_string(&r).expect("serializable"); + assert!(encoded.contains("markdownFormatted")); + let back: ToolResult = serde_json::from_str(&encoded).expect("deserializable"); + assert_eq!(back.markdown_formatted.as_deref(), Some("**a**: 1")); +} diff --git a/crates/tinytools/src/result/types.rs b/crates/tinytools/src/result/types.rs new file mode 100644 index 0000000..29cf19d --- /dev/null +++ b/crates/tinytools/src/result/types.rs @@ -0,0 +1,150 @@ +//! The outcome of running a tool, and the content blocks it carries. + +use serde::{Deserialize, Serialize}; + +/// Result of executing a tool: content blocks plus an error flag. +/// +/// The block list mirrors the Model Context Protocol's result shape, which is +/// what makes a tool backed by an MCP server and one implemented in Rust +/// interchangeable to the caller. [`Self::is_error`] is a *reported* failure — +/// the tool ran and said no — and is distinct from the `Err` arm of +/// [`Tool::execute`][crate::Tool::execute], which means the tool could not run +/// at all. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolResult { + /// List of content blocks returned by the tool. + pub content: Vec, + /// Indicates if the tool encountered an error during execution. + #[serde(default)] + pub is_error: bool, + /// Optional markdown rendering of the result. + /// + /// When the agent loop is configured with + /// [`prefer_markdown`][crate::ToolCallOptions::prefer_markdown], this is + /// sent to the model instead of the JSON-serialised content blocks: + /// markdown is significantly cheaper than JSON in the context window. The + /// wire name matches Composio's `markdownFormatted` response field so a + /// proxied result needs no renaming. + #[serde( + default, + rename = "markdownFormatted", + skip_serializing_if = "Option::is_none" + )] + pub markdown_formatted: Option, +} + +impl ToolResult { + /// A successful result carrying a single text block. + pub fn success(text: impl Into) -> Self { + Self { + content: vec![ToolContent::Text { text: text.into() }], + is_error: false, + markdown_formatted: None, + } + } + + /// A failed result carrying the message as its only text block. + /// + /// This is the *reported* failure path: the tool ran and refused, and the + /// model is expected to read the message and adapt. + pub fn error(message: impl Into) -> Self { + Self { + content: vec![ToolContent::Text { + text: message.into(), + }], + is_error: true, + markdown_formatted: None, + } + } + + /// A successful result carrying a single JSON block. + #[must_use] + pub fn json(data: serde_json::Value) -> Self { + Self { + content: vec![ToolContent::Json { data }], + is_error: false, + markdown_formatted: None, + } + } + + /// A successful result carrying both a JSON payload (for programmatic + /// consumers and debugging) and a markdown rendering (preferred by the + /// agent loop when `prefer_markdown` is on). + pub fn success_with_markdown(data: serde_json::Value, markdown: impl Into) -> Self { + Self { + content: vec![ToolContent::Json { data }], + is_error: false, + markdown_formatted: Some(markdown.into()), + } + } + + /// Attaches (or replaces) the markdown rendering on an existing result. + #[must_use] + pub fn with_markdown(mut self, markdown: impl Into) -> Self { + self.markdown_formatted = Some(markdown.into()); + self + } + + /// The markdown rendering when present and non-blank, otherwise + /// [`Self::output`]. + /// + /// A blank markdown field falls back rather than sending the model an empty + /// turn: a tool that set the field but rendered nothing is a bug in the + /// tool, and swallowing the real output would hide it. + #[must_use] + pub fn output_for_llm(&self, prefer_markdown: bool) -> String { + if prefer_markdown + && let Some(md) = self.markdown_formatted.as_deref() + && !md.trim().is_empty() + { + return md.to_string(); + } + self.output() + } + + /// The text blocks alone, newline-joined. JSON blocks are skipped. + #[must_use] + pub fn text(&self) -> String { + self.content + .iter() + .filter_map(|c| match c { + ToolContent::Text { text } => Some(text.as_str()), + ToolContent::Json { .. } => None, + }) + .collect::>() + .join("\n") + } + + /// Every block rendered and newline-joined, with JSON blocks + /// pretty-printed. This is what a model sees when no markdown rendering is + /// preferred. + #[must_use] + pub fn output(&self) -> String { + self.content + .iter() + .map(|c| match c { + ToolContent::Text { text } => text.clone(), + ToolContent::Json { data } => { + serde_json::to_string_pretty(data).unwrap_or_default() + } + }) + .collect::>() + .join("\n") + } +} + +/// A single content block within a [`ToolResult`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum ToolContent { + /// Plain text, rendered verbatim. + Text { + /// The text body. + text: String, + }, + /// Structured data, pretty-printed when rendered for a model. + Json { + /// The JSON body. + data: serde_json::Value, + }, +} diff --git a/crates/tinytools/src/spec/mod.rs b/crates/tinytools/src/spec/mod.rs new file mode 100644 index 0000000..34a025a --- /dev/null +++ b/crates/tinytools/src/spec/mod.rs @@ -0,0 +1,8 @@ +//! How a tool declares itself to a model. + +mod types; + +pub use types::ToolSpec; + +#[cfg(test)] +mod test; diff --git a/crates/tinytools/src/spec/test.rs b/crates/tinytools/src/spec/test.rs new file mode 100644 index 0000000..2acba27 --- /dev/null +++ b/crates/tinytools/src/spec/test.rs @@ -0,0 +1,17 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::ToolSpec; + +#[test] +fn spec_round_trips_through_json() { + let spec = ToolSpec { + name: "echo".into(), + description: "Returns its input.".into(), + parameters: serde_json::json!({ "type": "object" }), + }; + let encoded = serde_json::to_string(&spec).expect("serializable"); + let back: ToolSpec = serde_json::from_str(&encoded).expect("deserializable"); + assert_eq!(back.name, "echo"); + assert_eq!(back.description, "Returns its input."); + assert_eq!(back.parameters["type"], "object"); +} diff --git a/crates/tinytools/src/spec/types.rs b/crates/tinytools/src/spec/types.rs new file mode 100644 index 0000000..48e5867 --- /dev/null +++ b/crates/tinytools/src/spec/types.rs @@ -0,0 +1,20 @@ +//! The declaration a model is shown for a tool. + +use serde::{Deserialize, Serialize}; + +/// A tool as the model sees it: a name, a description, and a JSON Schema for +/// its arguments. +/// +/// This is the *host-facing* declaration. It is deliberately narrower than a +/// harness's model-visible schema type, which additionally carries the +/// tool-call dialect a provider should be given. A host builds one of these +/// from a [`Tool`][crate::Tool] and lets the harness decide how to render it. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolSpec { + /// Canonical tool name, ASCII `snake_case` by convention. + pub name: String, + /// Human- and model-readable description of what the tool does. + pub description: String, + /// JSON Schema describing the tool's arguments. + pub parameters: serde_json::Value, +} diff --git a/crates/tinytools/src/tool/mod.rs b/crates/tinytools/src/tool/mod.rs new file mode 100644 index 0000000..14476b7 --- /dev/null +++ b/crates/tinytools/src/tool/mod.rs @@ -0,0 +1,8 @@ +//! The trait every agent capability implements. + +mod types; + +pub use types::Tool; + +#[cfg(test)] +mod test; diff --git a/crates/tinytools/src/tool/test.rs b/crates/tinytools/src/tool/test.rs new file mode 100644 index 0000000..e8ffadd --- /dev/null +++ b/crates/tinytools/src/tool/test.rs @@ -0,0 +1,220 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +#![allow(clippy::unnecessary_literal_bound)] + +use std::any::Any; +use std::path::{Path, PathBuf}; + +use async_trait::async_trait; +use serde_json::{Value, json}; + +use super::Tool; +use crate::{ + PermissionLevel, ToolCallOptions, ToolCategory, ToolResult, ToolRunContext, ToolScope, + ToolTimeout, +}; + +/// A tool implementing only the four required methods, so every default is +/// exercised as written. +struct DummyTool; + +#[async_trait] +impl Tool for DummyTool { + fn name(&self) -> &str { + "dummy_tool" + } + + fn description(&self) -> &str { + "A deterministic test tool" + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { "value": { "type": "string" } } + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let text = args + .get("value") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + Ok(ToolResult::success(text)) + } +} + +#[test] +fn spec_is_built_from_the_tool_metadata_and_schema() { + let spec = DummyTool.spec(); + assert_eq!(spec.name, "dummy_tool"); + assert_eq!(spec.description, "A deterministic test tool"); + assert_eq!(spec.parameters["type"], "object"); + assert_eq!(spec.parameters["properties"]["value"]["type"], "string"); +} + +#[tokio::test] +async fn execute_returns_the_expected_output() { + let result = DummyTool + .execute(json!({ "value": "hello-tool" })) + .await + .expect("the tool runs"); + assert!(!result.is_error); + assert_eq!(result.output(), "hello-tool"); +} + +#[tokio::test] +async fn the_options_and_context_overloads_default_through_to_execute() { + let with_options = DummyTool + .execute_with_options(json!({ "value": "a" }), ToolCallOptions::prefer_markdown()) + .await + .expect("the tool runs"); + assert_eq!(with_options.output(), "a"); + + let with_context = DummyTool + .execute_with_context(json!({ "value": "b" }), ToolCallOptions::default(), None) + .await + .expect("the tool runs"); + assert_eq!(with_context.output(), "b"); +} + +#[test] +fn the_declaration_defaults_are_the_conservative_answer() { + let tool = DummyTool; + assert_eq!(tool.permission_level(), PermissionLevel::ReadOnly); + assert_eq!( + tool.permission_level_with_args(&Value::Null), + PermissionLevel::ReadOnly + ); + assert_eq!(tool.scope(), ToolScope::All); + assert_eq!(tool.category(), ToolCategory::System); + assert!(!tool.supports_markdown()); + assert!(!tool.is_concurrency_safe(&Value::Null)); + assert!(!tool.external_effect()); + assert!(!tool.external_effect_with_args(&Value::Null)); + assert!(tool.max_result_size_chars().is_none()); + assert_eq!(tool.timeout_policy(&Value::Null), ToolTimeout::Inherit); + assert!(tool.host_extension().is_none()); + assert!(tool.host_call_extension(&Value::Null).is_none()); +} + +#[test] +fn display_defaults_humanize_the_name_and_pull_a_context_argument() { + let tool = DummyTool; + assert_eq!( + tool.display_label(&Value::Null).as_deref(), + Some("Dummy Tool") + ); + assert!(tool.display_detail(&Value::Null).is_none()); + assert_eq!( + tool.display_detail(&json!({ "path": "src/main.rs" })) + .as_deref(), + Some("src/main.rs") + ); +} + +/// A tool overriding the seams a host actually reaches for. +struct WorkspaceTool; + +/// Host-defined metadata, standing in for whatever a real host attaches. +#[derive(Debug, PartialEq)] +struct HostTag(&'static str); + +#[async_trait] +impl Tool for WorkspaceTool { + fn name(&self) -> &str { + "workspace_tool" + } + + fn description(&self) -> &str { + "Reports the root it was given" + } + + fn parameters_schema(&self) -> Value { + json!({ "type": "object" }) + } + + async fn execute(&self, _args: Value) -> anyhow::Result { + Ok(ToolResult::success("no workspace")) + } + + async fn execute_with_context( + &self, + _args: Value, + _options: ToolCallOptions, + context: Option<&dyn ToolRunContext>, + ) -> anyhow::Result { + match context.and_then(ToolRunContext::workspace_root) { + Some(root) => Ok(ToolResult::success(root.display().to_string())), + None => Ok(ToolResult::success("no workspace")), + } + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Execute + } + + fn external_effect(&self) -> bool { + true + } + + fn timeout_policy(&self, _args: &Value) -> ToolTimeout { + ToolTimeout::Unbounded + } + + fn host_extension(&self) -> Option<&(dyn Any + Send + Sync)> { + static TAG: HostTag = HostTag("pack-registry"); + Some(&TAG) + } + + fn host_call_extension(&self, _args: &Value) -> Option> { + Some(Box::new(HostTag("per-call"))) + } +} + +struct Isolated(PathBuf); + +impl ToolRunContext for Isolated { + fn workspace_root(&self) -> Option<&Path> { + Some(&self.0) + } +} + +#[tokio::test] +async fn a_tool_reads_its_workspace_root_through_the_erased_context() { + let context = Isolated(PathBuf::from("/tmp/worktree")); + let result = WorkspaceTool + .execute_with_context(Value::Null, ToolCallOptions::default(), Some(&context)) + .await + .expect("the tool runs"); + assert_eq!(result.output(), "/tmp/worktree"); + + // Without a context the tool falls back rather than failing. + let bare = WorkspaceTool + .execute_with_context(Value::Null, ToolCallOptions::default(), None) + .await + .expect("the tool runs"); + assert_eq!(bare.output(), "no workspace"); +} + +#[test] +fn a_host_recovers_its_own_metadata_by_downcasting() { + let tool = WorkspaceTool; + let tagged = tool + .host_extension() + .and_then(|any| any.downcast_ref::()); + assert_eq!(tagged, Some(&HostTag("pack-registry"))); + + let per_call = tool + .host_call_extension(&Value::Null) + .and_then(|any| any.downcast::().ok()); + assert_eq!(per_call.as_deref(), Some(&HostTag("per-call"))); +} + +#[test] +fn overridden_declarations_are_visible_through_a_trait_object() { + let erased: &dyn Tool = &WorkspaceTool; + assert_eq!(erased.permission_level(), PermissionLevel::Execute); + assert!(erased.external_effect()); + assert_eq!(erased.timeout_policy(&Value::Null), ToolTimeout::Unbounded); +} diff --git a/crates/tinytools/src/tool/types.rs b/crates/tinytools/src/tool/types.rs new file mode 100644 index 0000000..207f1be --- /dev/null +++ b/crates/tinytools/src/tool/types.rs @@ -0,0 +1,225 @@ +//! The tool trait itself. + +use std::any::Any; + +use async_trait::async_trait; +use serde_json::Value; + +use crate::call::{ToolCallOptions, ToolTimeout}; +use crate::classification::{ToolCategory, ToolScope}; +use crate::context::ToolRunContext; +use crate::naming::{context_detail_from_args, humanize_tool_name}; +use crate::permission::PermissionLevel; +use crate::result::ToolResult; +use crate::spec::ToolSpec; + +/// A capability an agent can invoke. +/// +/// Everything beyond [`Self::name`], [`Self::description`], +/// [`Self::parameters_schema`] and [`Self::execute`] has a default, so the +/// smallest useful tool is four short methods. The rest of the trait is +/// **declaration**: a tool states what privilege it needs, whether it reaches +/// outside the machine, how long it may run, and how it should read in a +/// timeline. A host reads those declarations and decides what to allow. +/// +/// That split is the point. A tool never enforces policy on itself — it +/// describes itself accurately and the host enforces. Which is why the +/// declarations are cheap to override and the defaults are the conservative +/// answer in every case except [`Self::permission_level`], where the default is +/// [`PermissionLevel::ReadOnly`] because most tools genuinely read. +#[async_trait] +pub trait Tool: Send + Sync { + /// Canonical tool name, used in model function calling. + fn name(&self) -> &str; + + /// Human- and model-readable description of what the tool does. + fn description(&self) -> &str; + + /// JSON Schema for the tool's arguments. + fn parameters_schema(&self) -> Value; + + /// Runs the tool. + /// + /// # Errors + /// + /// Returns `Err` when the tool could not run at all. A tool that ran and + /// decided no returns `Ok` with + /// [`ToolResult::error`][crate::ToolResult::error] instead, so the model + /// sees the reason and can adapt. + async fn execute(&self, args: Value) -> anyhow::Result; + + /// Runs the tool with caller-supplied options. + /// + /// The default forwards to [`Self::execute`], so a tool that does not care + /// about options needs no change. Override to honour + /// [`ToolCallOptions::prefer_markdown`]. + /// + /// # Errors + /// + /// As [`Self::execute`]. + async fn execute_with_options( + &self, + args: Value, + _options: ToolCallOptions, + ) -> anyhow::Result { + self.execute(args).await + } + + /// Runs the tool with the caller's run context. + /// + /// The default forwards to [`Self::execute_with_options`], so a tool stays + /// context-agnostic unless it needs to know where it is running — the + /// isolated-workspace case being the common one. + /// + /// # Errors + /// + /// As [`Self::execute`]. + async fn execute_with_context( + &self, + args: Value, + options: ToolCallOptions, + context: Option<&dyn ToolRunContext>, + ) -> anyhow::Result { + let _ = context; + self.execute_with_options(args, options).await + } + + /// Whether this tool can produce a markdown rendering when + /// [`ToolCallOptions::prefer_markdown`] is set. + /// + /// A tool that overrides [`Self::execute_with_options`] to honour the flag + /// should override this too: it is what lets a host attribute the token + /// saving to the right tool. + fn supports_markdown(&self) -> bool { + false + } + + /// Privilege this tool requires. + /// + /// For a tool exposing several actions at different privileges, return the + /// **minimum** any action needs, so the tool is not statically blocked on a + /// caller that could legitimately run its read-only half. The per-call + /// level is [`Self::permission_level_with_args`]. + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::ReadOnly + } + + /// Argument-aware [`Self::permission_level`]. + /// + /// A host calls *this* at the enforcement point, so a tool with mixed + /// actions (`list` versus `create`) should override here. The default + /// defers to the argument-less answer. + fn permission_level_with_args(&self, _args: &Value) -> PermissionLevel { + self.permission_level() + } + + /// Where this tool may be executed. + fn scope(&self) -> ToolScope { + ToolScope::All + } + + /// Which belt this tool belongs to. + fn category(&self) -> ToolCategory { + ToolCategory::System + } + + /// Whether two concurrent invocations are safe to run in parallel within a + /// single model turn. + /// + /// Read-only tools touching no shared mutable state should return `true`; a + /// host can then dispatch a batch of reads together instead of serially. + /// Tools that mutate the workspace, write to disk, or talk to a service + /// that throttles by caller keep the default `false`. + /// + /// The arguments are supplied so a tool can refine the answer per call — a + /// generic shell could allow parallel `ls` and refuse parallel installs — + /// but most tools ignore them. + fn is_concurrency_safe(&self, _args: &Value) -> bool { + false + } + + /// Whether this tool produces an externally observable side effect: an + /// outbound message, an email, a calendar write, a webhook. + /// + /// A host routes such calls through its approval gate before + /// [`Self::execute`] runs. Local file writes and memory writes stay `false` + /// — they are reversible inside the user's own machine. + fn external_effect(&self) -> bool { + false + } + + /// Argument-aware [`Self::external_effect`]. + /// + /// A host calls *this* at the gate decision point, so a tool whose + /// classification depends on its arguments should override here rather than + /// the argument-less variant. + fn external_effect_with_args(&self, _args: &Value) -> bool { + self.external_effect() + } + + /// Per-tool cap on the character length of the result body sent back to the + /// model. + /// + /// Set this on tools whose output is *bounded but unpredictable* — a shell, + /// a fetch. Leave it unset where callers genuinely want the whole thing, as + /// with a file read: truncating those hides data the caller asked for. When + /// `None`, the host's global handling applies. + fn max_result_size_chars(&self) -> Option { + None + } + + /// How the host should bound this invocation in wall-clock time. + fn timeout_policy(&self, _args: &Value) -> ToolTimeout { + ToolTimeout::Inherit + } + + /// Host-defined metadata this tool carries, for a host that needs to + /// recognise its own tool kinds through a `dyn Tool`. + /// + /// Erased rather than typed because the answer is *host* policy — a pack + /// registry handle, a generated-tool provenance record — and this crate has + /// no business naming either. A host downcasts to its own type; every other + /// tool returns `None` and pays nothing. + fn host_extension(&self) -> Option<&(dyn Any + Send + Sync)> { + None + } + + /// Host-defined per-call metadata, for policy that depends on the + /// arguments. + /// + /// Erased for the same reason as [`Self::host_extension`], but returned + /// owned because it is derived from the call rather than held by the tool. + fn host_call_extension(&self, _args: &Value) -> Option> { + None + } + + /// The full declaration to register with a model. + fn spec(&self) -> ToolSpec { + ToolSpec { + name: self.name().to_string(), + description: self.description().to_string(), + parameters: self.parameters_schema(), + } + } + + /// Short verb phrase describing this call for an activity timeline — + /// "Reading file", "Running command". + /// + /// The default title-cases [`Self::name`]. Dynamic and integration tools + /// override with a curated phrase so a row never reads as raw + /// `snake_case`. + fn display_label(&self, _args: &Value) -> Option { + Some(humanize_tool_name(self.name())) + } + + /// The specific argument for this call — the path, address, command or + /// query — shown after [`Self::display_label`], so a row reads + /// `Read(src/main.rs)`. + /// + /// The default pulls the most relevant common argument, which is right for + /// nearly every tool. Override when the meaningful argument sits under an + /// unusual key. + fn display_detail(&self, args: &Value) -> Option { + context_detail_from_args(args) + } +} diff --git a/deny.toml b/deny.toml index 1134b6a..e105108 100644 --- a/deny.toml +++ b/deny.toml @@ -11,7 +11,7 @@ ignore = [] [licenses] # Licenses accepted for this crate and its dependencies. Keep GPL-3.0-only for -# the template crate itself; the remaining entries cover compatible dependency +# this crate itself; the remaining entries cover compatible dependency # licenses commonly encountered by Rust projects. allow = [ "Apache-2.0", diff --git a/docs/plans/tinybus-module-release.md b/docs/plans/tinybus-module-release.md deleted file mode 100644 index f9eaa18..0000000 --- a/docs/plans/tinybus-module-release.md +++ /dev/null @@ -1,11 +0,0 @@ -# Implement TinyBus Module Releases - -Linked specification: [`../specs/tinybus-module-release.md`](../specs/tinybus-module-release.md) - -1. Add the pinned TinyBus host types and module SDK as path dependencies. -2. Export the template greeting behavior through TinyBus module ABI v1. -3. Exercise the declared interface over the real in-memory bus. -4. Replace TinyBus host bundles with tagged `template` module archives for - every supported platform runner and distribution container. -5. Run the repository validation and coverage contracts, push `main`, and - trigger a patch release. diff --git a/docs/specs/tinybus-module-release.md b/docs/specs/tinybus-module-release.md deleted file mode 100644 index adae9b4..0000000 --- a/docs/specs/tinybus-module-release.md +++ /dev/null @@ -1,33 +0,0 @@ -# TinyBus Module Release - -## Purpose - -Generated projects must be usable as native TinyBus integrations and -distributable without also shipping the TinyBus host runtime. - -## Contract - -- The library builds as both an `rlib` and a native `cdylib`. -- The `cdylib` exports TinyBus module ABI v1, an embedded manifest, and the - initialization entrypoint. -- The example module provides `ai.tinyhumans.template.Greeting.Greet` at - `/ai/tinyhumans/template/Greeting`. -- Each release archive is named - `template--.` and contains only this - module, its SHA-256 `modules.toml`, license, and installation documentation. -- Each GitHub release publishes a separate `checksum.toml` mapping every - archive filename to its SHA-256 digest for TinyBus's release loader. -- Release builds cover the stable native Ubuntu, macOS, and Windows runners, - Fedora 43/44 containers, and rolling Arch Linux where official runners or - images exist for the architecture. -- TinyBus itself remains a pinned SDK submodule and is not shipped as a release - asset from this repository. - -## Verification - -CI exercises the bus interface through TinyBus's in-memory transport, enforces -90% line coverage in every source file, and builds the `cdylib`. The release -workflow builds each native module from the tagged source and records its exact -digest in the adjacent allowlist. After publishing, it downloads the Ubuntu -x86_64 archive through TinyBus's GitHub release API and calls `Greet` over an -in-memory bus. diff --git a/vendor/tinybus b/vendor/tinybus deleted file mode 160000 index c35105f..0000000 --- a/vendor/tinybus +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c35105f95b5efd49f63aec3f82f8bc2154694977 From a057831dab464845890e43707ea6cb18de912f38 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:02:13 +0300 Subject: [PATCH 02/10] feat: own WorkspaceDescriptor and SandboxMode The descriptor is what tells a tool which filesystem root it may touch, so it belongs beside the trait that reads it rather than in the harness that happens to build it. ToolRunContext::workspace now hands it back whole; workspace_root and workspace_policy_id stay as conveniences over it for the common case. Only the pure half moves. The fail-closed enforce() gate needs an event sink and an error type, both of which are harness concerns, so it stays upstream as a free function over this type. Co-authored-by: Medulla --- crates/tinytools/src/call/types.rs | 1 - crates/tinytools/src/context/test.rs | 18 ++-- crates/tinytools/src/context/types.rs | 40 ++++--- crates/tinytools/src/lib.rs | 3 + crates/tinytools/src/naming/types.rs | 1 - crates/tinytools/src/workspace/mod.rs | 8 ++ crates/tinytools/src/workspace/test.rs | 92 ++++++++++++++++ crates/tinytools/src/workspace/types.rs | 136 ++++++++++++++++++++++++ 8 files changed, 274 insertions(+), 25 deletions(-) create mode 100644 crates/tinytools/src/workspace/mod.rs create mode 100644 crates/tinytools/src/workspace/test.rs create mode 100644 crates/tinytools/src/workspace/types.rs diff --git a/crates/tinytools/src/call/types.rs b/crates/tinytools/src/call/types.rs index d4af65c..165ba31 100644 --- a/crates/tinytools/src/call/types.rs +++ b/crates/tinytools/src/call/types.rs @@ -6,7 +6,6 @@ /// polluting the tool's model-visible parameter schema — the model never sees /// these, and never has to be told not to set them. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -#[non_exhaustive] pub struct ToolCallOptions { /// The caller prefers a markdown rendering of the result, because markdown /// is materially cheaper than JSON in model context. diff --git a/crates/tinytools/src/context/test.rs b/crates/tinytools/src/context/test.rs index ec294ec..a51bd92 100644 --- a/crates/tinytools/src/context/test.rs +++ b/crates/tinytools/src/context/test.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; use super::ToolRunContext; +use crate::workspace::WorkspaceDescriptor; /// A context that answers nothing, exercising every default. struct Bare; @@ -10,16 +11,12 @@ impl ToolRunContext for Bare {} /// A context shaped like a harness's real one. struct Isolated { - root: PathBuf, + workspace: WorkspaceDescriptor, } impl ToolRunContext for Isolated { - fn workspace_root(&self) -> Option<&Path> { - Some(&self.root) - } - - fn workspace_policy_id(&self) -> Option<&str> { - Some("worktree-isolation") + fn workspace(&self) -> Option<&WorkspaceDescriptor> { + Some(&self.workspace) } fn thread_id(&self) -> Option<&str> { @@ -34,6 +31,7 @@ impl ToolRunContext for Isolated { #[test] fn the_defaults_answer_nothing() { let bare = Bare; + assert!(bare.workspace().is_none()); assert!(bare.workspace_root().is_none()); assert!(bare.workspace_policy_id().is_none()); assert!(bare.thread_id().is_none()); @@ -43,11 +41,15 @@ fn the_defaults_answer_nothing() { #[test] fn an_implementor_is_readable_through_the_trait_object() { let isolated = Isolated { - root: PathBuf::from("/tmp/worktree"), + workspace: WorkspaceDescriptor::new("/tmp/worktree").with_policy_id("worktree-isolation"), }; let erased: &dyn ToolRunContext = &isolated; assert_eq!(erased.workspace_root(), Some(Path::new("/tmp/worktree"))); assert_eq!(erased.workspace_policy_id(), Some("worktree-isolation")); assert_eq!(erased.thread_id(), Some("thread-7")); assert_eq!(erased.max_turn_output_tokens(), Some(4096)); + assert_eq!( + erased.workspace().map(|w| w.root.clone()), + Some(PathBuf::from("/tmp/worktree")) + ); } diff --git a/crates/tinytools/src/context/types.rs b/crates/tinytools/src/context/types.rs index 4c53d1b..b14a60a 100644 --- a/crates/tinytools/src/context/types.rs +++ b/crates/tinytools/src/context/types.rs @@ -1,7 +1,7 @@ //! The run-scoped facts a tool may read, without naming the harness that owns //! them. -use std::path::Path; +use crate::workspace::WorkspaceDescriptor; /// The parts of a live agent run a tool is allowed to see. /// @@ -16,26 +16,22 @@ use std::path::Path; /// harness — and the harness depends on *this* crate, for the vocabulary every /// tool is written against. Erasing the context behind a trait keeps that edge /// pointing one way: the harness implements this for its own context type, and -/// a tool reads the three facts it actually uses without either crate having to -/// know the other's shape. +/// a tool reads the facts it actually uses without either crate having to know +/// the other's shape. /// -/// It is deliberately narrow. Widening it means a tool has grown a dependency -/// on run internals, which is worth noticing rather than accommodating. +/// It is deliberately narrow. The run id, event sink, cancellation token and +/// streaming flag are all absent, because a tool that wanted them would be +/// reaching into the run rather than doing its job. Widening this trait is +/// worth noticing rather than accommodating. pub trait ToolRunContext: Send + Sync { - /// Root of the isolated workspace this call may operate in, when the run - /// was configured with one. + /// The isolated workspace this call may operate in, when the run was + /// configured with one. /// /// `None` means no workspace policy is in effect and the tool should fall - /// back to whatever root its host configured. A tool must not treat `None` + /// back to whatever root its host configured. A tool must not read `None` /// as permission to escape a root — the host's own path policy is what /// enforces that, and it applies either way. - fn workspace_root(&self) -> Option<&Path> { - None - } - - /// Identifier of the policy that granted [`Self::workspace_root`], for - /// logging and audit. `None` when there is no workspace. - fn workspace_policy_id(&self) -> Option<&str> { + fn workspace(&self) -> Option<&WorkspaceDescriptor> { None } @@ -51,4 +47,18 @@ pub trait ToolRunContext: Send + Sync { fn max_turn_output_tokens(&self) -> Option { None } + + /// Root of the isolated workspace, when there is one. + /// + /// A convenience over [`Self::workspace`] for the common case: most tools + /// want the root and nothing else. Not intended to be overridden. + fn workspace_root(&self) -> Option<&std::path::Path> { + self.workspace().map(|w| w.root.as_path()) + } + + /// Identifier of the policy that granted the workspace, for logging and + /// audit. + fn workspace_policy_id(&self) -> Option<&str> { + self.workspace().map(|w| w.policy_id.as_str()) + } } diff --git a/crates/tinytools/src/lib.rs b/crates/tinytools/src/lib.rs index d9492c1..a4fb51c 100644 --- a/crates/tinytools/src/lib.rs +++ b/crates/tinytools/src/lib.rs @@ -28,6 +28,7 @@ //! - [`call`] — [`ToolCallOptions`] and [`ToolTimeout`], the per-invocation //! inputs that are not arguments. //! - [`context`] — [`ToolRunContext`], the narrow seam onto a live run. +//! - [`workspace`] — [`WorkspaceDescriptor`], the root a tool may touch. //! - [`naming`] — rendering a call for a human. //! //! # What is deliberately not here @@ -103,6 +104,7 @@ pub mod permission; pub mod result; pub mod spec; pub mod tool; +pub mod workspace; pub use call::{ToolCallOptions, ToolTimeout}; pub use classification::{ToolCategory, ToolScope}; @@ -115,3 +117,4 @@ pub use permission::PermissionLevel; pub use result::{ToolContent, ToolResult}; pub use spec::ToolSpec; pub use tool::Tool; +pub use workspace::{SandboxMode, WorkspaceDescriptor}; diff --git a/crates/tinytools/src/naming/types.rs b/crates/tinytools/src/naming/types.rs index 00a96d1..53c3147 100644 --- a/crates/tinytools/src/naming/types.rs +++ b/crates/tinytools/src/naming/types.rs @@ -11,7 +11,6 @@ use serde_json::Value; /// re-implement the whole function to change one character is how two copies of /// it end up in a codebase. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[non_exhaustive] pub struct ContextDetailOptions { /// Maximum rendered length, in characters, including the ellipsis. pub max_chars: usize, diff --git a/crates/tinytools/src/workspace/mod.rs b/crates/tinytools/src/workspace/mod.rs new file mode 100644 index 0000000..60e712c --- /dev/null +++ b/crates/tinytools/src/workspace/mod.rs @@ -0,0 +1,8 @@ +//! Where a tool is allowed to operate. + +mod types; + +pub use types::{SandboxMode, WorkspaceDescriptor}; + +#[cfg(test)] +mod test; diff --git a/crates/tinytools/src/workspace/test.rs b/crates/tinytools/src/workspace/test.rs new file mode 100644 index 0000000..4965b15 --- /dev/null +++ b/crates/tinytools/src/workspace/test.rs @@ -0,0 +1,92 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::path::{Path, PathBuf}; + +use super::{SandboxMode, WorkspaceDescriptor}; + +#[test] +fn a_new_descriptor_is_rooted_with_no_extras() { + let ws = WorkspaceDescriptor::new("/work/agent-a"); + assert_eq!(ws.root, PathBuf::from("/work/agent-a")); + assert!(ws.trusted_roots.is_empty()); + assert!(ws.policy_id.is_empty()); + assert_eq!(ws.sandbox, SandboxMode::Inherit); +} + +#[test] +fn the_builders_set_each_field() { + let ws = WorkspaceDescriptor::new("/work/agent-a") + .with_trusted_root("/shared/cache") + .with_policy_id("worktree") + .with_sandbox(SandboxMode::Required); + assert_eq!(ws.trusted_roots, vec![PathBuf::from("/shared/cache")]); + assert_eq!(ws.policy_id, "worktree"); + assert_eq!(ws.sandbox, SandboxMode::Required); +} + +#[test] +fn paths_inside_the_root_or_a_trusted_root_are_allowed() { + let ws = WorkspaceDescriptor::new("/work/agent-a").with_trusted_root("/shared/cache"); + assert!(ws.allows(Path::new("/work/agent-a/src/main.rs"))); + assert!(ws.allows(Path::new("/shared/cache/pkg"))); +} + +#[test] +fn paths_outside_every_root_are_refused() { + let ws = WorkspaceDescriptor::new("/work/agent-a"); + assert!(!ws.allows(Path::new("/etc/passwd"))); + assert!(!ws.allows(Path::new("/work/agent-b/src/main.rs"))); +} + +#[test] +fn dot_segments_are_resolved_before_the_comparison() { + let ws = WorkspaceDescriptor::new("/work/agent-a"); + assert!(ws.allows(Path::new("/work/agent-a/./src/../src/main.rs"))); + assert!(!ws.allows(Path::new("/work/agent-a/../agent-b/secret"))); +} + +#[test] +fn a_parent_traversal_cannot_spoof_re_entry_into_a_same_named_sibling() { + // `..` must not collapse a path back onto a same-named directory outside + // the root: dropping the escaping components is what would let + // `agent-a/../../agent-a/secret` read as inside `/work/agent-a`. + let ws = WorkspaceDescriptor::new("/work/nested/agent-a"); + assert!(!ws.allows(Path::new("/work/nested/agent-a/../../agent-a/secret"))); +} + +#[test] +fn a_parent_traversal_at_the_filesystem_root_cannot_go_higher() { + let ws = WorkspaceDescriptor::new("/"); + assert!(ws.allows(Path::new("/../etc"))); +} + +#[test] +fn the_descriptor_round_trips_through_json() { + let ws = WorkspaceDescriptor::new("/work/agent-a") + .with_trusted_root("/shared") + .with_policy_id("worktree") + .with_sandbox(SandboxMode::Disabled); + let encoded = serde_json::to_string(&ws).expect("serializable"); + let back: WorkspaceDescriptor = serde_json::from_str(&encoded).expect("deserializable"); + assert_eq!(back, ws); +} + +#[test] +fn sandbox_mode_uses_snake_case_on_the_wire() { + assert_eq!( + serde_json::to_string(&SandboxMode::Inherit).expect("serializable"), + "\"inherit\"" + ); + assert_eq!( + serde_json::to_string(&SandboxMode::Required).expect("serializable"), + "\"required\"" + ); + assert_eq!(SandboxMode::default(), SandboxMode::Inherit); +} + +#[test] +fn omitted_optional_fields_default_on_decode() { + let back: WorkspaceDescriptor = + serde_json::from_str(r#"{"root":"/work"}"#).expect("deserializable"); + assert_eq!(back, WorkspaceDescriptor::new("/work")); +} diff --git a/crates/tinytools/src/workspace/types.rs b/crates/tinytools/src/workspace/types.rs new file mode 100644 index 0000000..32026c4 --- /dev/null +++ b/crates/tinytools/src/workspace/types.rs @@ -0,0 +1,136 @@ +//! The isolated execution environment a tool is allowed to operate in. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// How strictly a tool must be sandboxed when it executes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SandboxMode { + /// Inherit whatever the run's execution environment provides. + #[default] + Inherit, + /// The tool is safe to run without any sandbox. + Disabled, + /// The tool must run inside an isolated execution environment; policy + /// enforcement fails closed if no sandbox is available. + Required, +} + +/// Describes the isolated execution environment a tool may operate in. +/// +/// A tool discovers its allowed root from this descriptor — reached through +/// [`ToolRunContext::workspace`][crate::ToolRunContext::workspace] — instead of +/// reaching for an application global. That is what lets two agents run over +/// the same repository in separate worktrees without either one's tools knowing +/// anything about the arrangement. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceDescriptor { + /// The primary root the agent or tool may read and write under. + pub root: PathBuf, + /// Additional roots the tool is explicitly trusted to touch. + #[serde(default)] + pub trusted_roots: Vec, + /// Identity of the policy that produced this descriptor, for audit. + #[serde(default)] + pub policy_id: String, + /// How strictly the environment is sandboxed. + #[serde(default)] + pub sandbox: SandboxMode, +} + +impl WorkspaceDescriptor { + /// A descriptor rooted at `root` with no extra trusted roots. + pub fn new(root: impl Into) -> Self { + Self { + root: root.into(), + trusted_roots: Vec::new(), + policy_id: String::new(), + sandbox: SandboxMode::Inherit, + } + } + + /// Adds a trusted root the tool may also touch. + #[must_use] + pub fn with_trusted_root(mut self, root: impl Into) -> Self { + self.trusted_roots.push(root.into()); + self + } + + /// Sets the audit policy identity. + #[must_use] + pub fn with_policy_id(mut self, id: impl Into) -> Self { + self.policy_id = id.into(); + self + } + + /// Sets the sandbox mode. + #[must_use] + pub fn with_sandbox(mut self, sandbox: SandboxMode) -> Self { + self.sandbox = sandbox; + self + } + + /// Returns `true` when `path` is contained within the root or any trusted + /// root. + /// + /// Comparison is lexical, after normalizing `.` and `..` components, so it + /// does not require the path to exist: this is a policy gate, not a + /// canonicalizing filesystem call. Relative candidates and roots are first + /// anchored to the current working directory, so a relative path cannot use + /// leading `..` components to spoof re-entry into a same-named sibling of + /// the root. If the current directory cannot be read, the gate fails closed. + #[must_use] + pub fn allows(&self, path: &Path) -> bool { + let Some(candidate) = anchored_normalize(path) else { + return false; + }; + std::iter::once(&self.root) + .chain(self.trusted_roots.iter()) + .filter_map(|root| anchored_normalize(root)) + .any(|root| candidate.starts_with(&root)) + } +} + +/// Anchors `path` to an absolute base (the current working directory when +/// relative) and lexically normalizes it. Returns `None` when a relative path +/// cannot be anchored because the current directory is unavailable, so callers +/// fail closed. +fn anchored_normalize(path: &Path) -> Option { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir().ok()?.join(path) + }; + Some(normalize(&absolute)) +} + +/// Lexically normalizes a path by resolving `.` and `..` components without +/// touching the filesystem. +/// +/// A `..` only pops a preceding *named* segment; a `..` that would escape the +/// accumulated prefix (leading, or after another `..`) is preserved rather than +/// discarded. Dropping such components would let a relative path like +/// `ws/../../ws/secret` collapse back onto `ws` and spoof re-entry into a +/// same-named sibling directory outside the workspace. +fn normalize(path: &Path) -> PathBuf { + use std::path::Component; + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::ParentDir => match out.components().next_back() { + Some(Component::Normal(_)) => { + out.pop(); + } + Some(Component::RootDir | Component::Prefix(_)) => { + // At a filesystem root; `..` cannot go higher. + } + _ => out.push(Component::ParentDir), + }, + Component::CurDir => {} + other => out.push(other.as_os_str()), + } + } + out +} From b8f3f8a3a9726d233bbca64dafd53b6d8c456e4f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 22:00:32 +0300 Subject: [PATCH 03/10] fix(ci): compare crate names in the dependency gate, and a self-link Two failures found by running the CI gates locally rather than trusting them. The dependency gate grepped whole cargo tree lines, which include each package's checkout path. A consumer may vendor this repository underneath one of the forbidden crates -- tinyagents does exactly that, at vendor/tinyagents/vendor/tinytools -- so every line's path contained 'tinyagents' and the gate reported a dependency that is not there. It now cuts the version and path off and matches names exactly. Verified against the real tree: anyhow, async-trait, serde, serde_json and their proc-macro machinery, nothing else. The crate docs also linked to tinytools::ToolResult, which does not resolve from inside the crate itself. Co-authored-by: Medulla --- .github/workflows/ci.yml | 9 ++++++++- crates/tinytools/src/lib.rs | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa49629..d594e82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,8 +70,15 @@ jobs: - name: Assert the vocabulary crate stays dependency-light run: | set -euo pipefail + # Compare crate NAMES only. `cargo tree` prints each package as + # `name vX.Y.Z (/path/to/checkout)`, and a consumer may vendor this + # repository *underneath* one of the forbidden crates — tinyagents + # does exactly that — so grepping the raw line matches the path and + # reports a dependency that is not there. Cut the version and path off + # first. forbidden="$(cargo tree -p tinytools -e normal,build --prefix none \ - | grep -Ei 'tinyagents|tinybus|tokio|reqwest|ureq|hyper|rusqlite|git2' || true)" + | awk '{print $1}' | sort -u \ + | grep -Exi 'tinyagents|tinybus|tokio|reqwest|ureq|hyper|rusqlite|git2' || true)" if [ -n "$forbidden" ]; then echo "tinytools pulled in a dependency its manifest forbids:" >&2 echo "$forbidden" >&2 diff --git a/crates/tinytools/src/lib.rs b/crates/tinytools/src/lib.rs index a4fb51c..561a03f 100644 --- a/crates/tinytools/src/lib.rs +++ b/crates/tinytools/src/lib.rs @@ -12,7 +12,7 @@ //! and nothing catches it. //! //! So the vocabulary sits underneath both. A harness depends on this crate and -//! re-exports it, so `harness::ToolResult` and [`tinytools::ToolResult`] are the +//! re-exports it, so `harness::ToolResult` and [`ToolResult`] are the //! *same type*, not structural twins. A tool author depends on this crate alone //! and compiles neither the harness nor the host. //! From c64e9bdbda32cc3704be7aa7a0d7fa8e41b879b9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:11:41 +0300 Subject: [PATCH 04/10] fix: address CodeRabbit/Codex review feedback on tinytools PR #1 - ci.yml: stop masking cargo tree/awk/sort failures behind a blanket '|| true'; capture grep's exit status explicitly and only tolerate its documented no-match status (1) - CONTRIBUTING.md: remove the dangling 'initialize the vendored' fragment left over from the submodule removal - crates/tinytools/Cargo.toml: point 'readme' at the root README.md that actually exists, instead of an absent crate-local file - workspace/types.rs: document explicitly that WorkspaceDescriptor::allows is a lexical, non-canonicalizing gate and does not resolve symlinks, and that a host must layer its own canonicalizing enforcement (tinyagents::enforce_workspace_path, OpenHuman's path policy) on top - result/types.rs: clarify ToolResult's doc comment so it no longer reads as byte-for-wire MCP compatibility; it is this crate's own internal wire shape, conceptually MCP-shaped but snake_case and independent of any specific server's structuredContent handling - README.md: document async-trait as a required direct dependency for implementing Tool, since tinytools does not re-export the macro - permission/test.rs: rename a test to match what it actually asserts (a JSON round-trip, not a numeric wire representation) Co-authored-by: Medulla --- .github/workflows/ci.yml | 21 ++++++++++++++++++--- CONTRIBUTING.md | 4 ++-- README.md | 10 ++++++++++ crates/tinytools/Cargo.toml | 2 +- crates/tinytools/src/permission/test.rs | 2 +- crates/tinytools/src/result/types.rs | 22 ++++++++++++++++------ crates/tinytools/src/workspace/types.rs | 19 +++++++++++++++++++ 7 files changed, 67 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d594e82..4a49f72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,9 +76,24 @@ jobs: # does exactly that — so grepping the raw line matches the path and # reports a dependency that is not there. Cut the version and path off # first. - forbidden="$(cargo tree -p tinytools -e normal,build --prefix none \ - | awk '{print $1}' | sort -u \ - | grep -Exi 'tinyagents|tinybus|tokio|reqwest|ureq|hyper|rusqlite|git2' || true)" + package_names="$(cargo tree -p tinytools -e normal,build --prefix none \ + | awk '{print $1}' | sort -u)" + # `grep -E` exits 1 on "no match", which is the success case here, so + # it cannot share a `set -e` pipeline with the commands that produced + # its input: that would abort the script on the very outcome we want. + # Capture its exit status explicitly and only tolerate exactly 1; + # anything else (a real grep failure, or a non-1 pipeline failure from + # cargo tree/awk/sort surfaced through `pipefail`) still fails the job. + forbidden="" + set +e + forbidden="$(printf '%s\n' "$package_names" \ + | grep -Exi 'tinyagents|tinybus|tokio|reqwest|ureq|hyper|rusqlite|git2')" + grep_status=$? + set -e + if [ "$grep_status" -ne 0 ] && [ "$grep_status" -ne 1 ]; then + echo "dependency scan failed (grep exit $grep_status)" >&2 + exit "$grep_status" + fi if [ -n "$forbidden" ]; then echo "tinytools pulled in a dependency its manifest forbids:" >&2 echo "$forbidden" >&2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 47a7fa6..2dc4f9b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,8 +7,8 @@ this document is the short path through them. ## Development Setup Install a stable Rust toolchain with Rust 2024 support (see `rust-version` in -`Cargo.toml` for the minimum supported version), initialize the vendored -then run the four checks CI runs: +`Cargo.toml` for the minimum supported version), then run the four checks CI +runs: ```sh cargo fmt --all -- --check diff --git a/README.md b/README.md index 70351e7..5480b74 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,16 @@ impl Tool for Echo { That is a complete tool. Everything else in the trait has a default. +`Tool` is async, so implementing it needs the `async-trait` shim above and +beyond `tinytools` itself — this crate depends on it internally but does not +re-export the macro. Add it as a direct dependency alongside `tinytools`: + +```toml +[dependencies] +tinytools = "0.1" +async-trait = "0.1" +``` + ## Why this is its own crate Two crates need these types and neither can own them. An agent harness has to diff --git a/crates/tinytools/Cargo.toml b/crates/tinytools/Cargo.toml index 58b80d0..b7c42f6 100644 --- a/crates/tinytools/Cargo.toml +++ b/crates/tinytools/Cargo.toml @@ -7,7 +7,7 @@ license.workspace = true repository.workspace = true description = "The agent tool vocabulary: the `Tool` trait, its result and spec types, and the permission, scope and timeout classifications a host enforces around a call." documentation = "https://docs.rs/tinytools" -readme = "README.md" +readme = "../../README.md" keywords = ["llm", "agents", "tools", "tool-calling"] categories = ["asynchronous", "api-bindings"] diff --git a/crates/tinytools/src/permission/test.rs b/crates/tinytools/src/permission/test.rs index 95eae26..ab619d4 100644 --- a/crates/tinytools/src/permission/test.rs +++ b/crates/tinytools/src/permission/test.rs @@ -27,7 +27,7 @@ fn display_matches_the_variant_name() { } #[test] -fn levels_round_trip_as_json_numbers() { +fn levels_round_trip_through_json() { for level in [ PermissionLevel::None, PermissionLevel::ReadOnly, diff --git a/crates/tinytools/src/result/types.rs b/crates/tinytools/src/result/types.rs index 29cf19d..e6cca0e 100644 --- a/crates/tinytools/src/result/types.rs +++ b/crates/tinytools/src/result/types.rs @@ -4,12 +4,22 @@ use serde::{Deserialize, Serialize}; /// Result of executing a tool: content blocks plus an error flag. /// -/// The block list mirrors the Model Context Protocol's result shape, which is -/// what makes a tool backed by an MCP server and one implemented in Rust -/// interchangeable to the caller. [`Self::is_error`] is a *reported* failure — -/// the tool ran and said no — and is distinct from the `Err` arm of -/// [`Tool::execute`][crate::Tool::execute], which means the tool could not run -/// at all. +/// The block list is *conceptually* shaped like the Model Context Protocol's +/// result — a list of content blocks plus a reported-error flag — which is what +/// lets a tool backed by an MCP server and one implemented in Rust share one +/// internal representation. **This is this crate's own on-the-wire shape for +/// agent transcripts, RPC replies and JSONL session records, not a literal MCP +/// `CallToolResult`**: field names are `snake_case` here (`is_error`, not MCP's +/// `isError`) to match every other type in this vocabulary, and +/// [`ToolContent::Json`] is a block kind of this crate's own, not MCP's +/// `structuredContent`. A host that actually speaks the MCP wire protocol to a +/// real MCP server is responsible for translating between that server's +/// `CallToolResult` and this type — same as it already must for whichever +/// content types each specific server chooses to send — rather than this crate +/// picking one server's exact JSON casing as its own internal format. +/// [`Self::is_error`] is a *reported* failure — the tool ran and said no — and +/// is distinct from the `Err` arm of [`Tool::execute`][crate::Tool::execute], +/// which means the tool could not run at all. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolResult { /// List of content blocks returned by the tool. diff --git a/crates/tinytools/src/workspace/types.rs b/crates/tinytools/src/workspace/types.rs index 32026c4..ca520c0 100644 --- a/crates/tinytools/src/workspace/types.rs +++ b/crates/tinytools/src/workspace/types.rs @@ -81,6 +81,25 @@ impl WorkspaceDescriptor { /// anchored to the current working directory, so a relative path cannot use /// leading `..` components to spoof re-entry into a same-named sibling of /// the root. If the current directory cannot be read, the gate fails closed. + /// + /// **This does not resolve symlinks, and that is a real, deliberate limit, + /// not an oversight.** A symlink already present inside an allowed root and + /// pointing outside it (`/outside -> /etc`) makes `allows` return + /// `true` for `/outside/passwd`, because `starts_with` compares path + /// *components*, not resolved targets. `canonicalize` would close that gap + /// but was rejected here on purpose: it requires the path to already exist + /// (this gate must also answer for a file a tool is about to create), and it + /// costs a syscall per check on a function called from every tool + /// invocation. Resolving that trade-off is a host decision, not this + /// crate's — a host that must be robust against a symlink planted inside + /// the workspace (an untrusted or compromised tool output, a shared + /// filesystem) is expected to canonicalize the resolved path itself and + /// re-check containment before it opens the file, in addition to calling + /// `allows`. `tinyagents`'s `enforce_workspace_path` is exactly that + /// fail-closed host-side gate, and `OpenHuman` layers its own path policy + /// (`is_workspace_internal_path`, the sandbox backends) on top for the same + /// reason: this method is the first, cheap, existence-independent check, + /// never the last word on containment. #[must_use] pub fn allows(&self, path: &Path) -> bool { let Some(candidate) = anchored_normalize(path) else { From 8db59ae8049bf06289824223e3e5287fac1c8301 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:19:15 +0300 Subject: [PATCH 05/10] fix: address second-round review feedback on tinytools PR #1 - permission/test.rs: pin the exact serialized wire string for each PermissionLevel (in both directions), not just a round-trip, so a future serde rename fails the test instead of silently changing what gets persisted in a transcript or RPC payload - naming/types.rs: a zero (or otherwise degenerate) max_chars truncated a genuinely present value down to an empty string and still returned Some(""); treat an empty render the same as no detail and return None, with a regression test - README.md, lib.rs: replace the panicking args["text"] index in the Tool example with a safe get().and_then() chain, so the example doesn't train tool authors toward indexing into untrusted JSON Co-authored-by: Medulla --- README.md | 3 ++- crates/tinytools/src/lib.rs | 2 +- crates/tinytools/src/naming/test.rs | 12 ++++++++++++ crates/tinytools/src/naming/types.rs | 17 ++++++++++++++--- crates/tinytools/src/permission/test.rs | 23 +++++++++++++++++++++++ 5 files changed, 52 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5480b74..b7872a7 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ impl Tool for Echo { }) } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - Ok(ToolResult::success(args["text"].as_str().unwrap_or_default())) + let text = args.get("text").and_then(|v| v.as_str()).unwrap_or(""); + Ok(ToolResult::success(text)) } } ``` diff --git a/crates/tinytools/src/lib.rs b/crates/tinytools/src/lib.rs index 561a03f..d8cf6d6 100644 --- a/crates/tinytools/src/lib.rs +++ b/crates/tinytools/src/lib.rs @@ -75,7 +75,7 @@ //! } //! //! async fn execute(&self, args: serde_json::Value) -> anyhow::Result { -//! let text = args["text"].as_str().unwrap_or_default(); +//! let text = args.get("text").and_then(|v| v.as_str()).unwrap_or(""); //! Ok(ToolResult::success(text)) //! } //! } diff --git a/crates/tinytools/src/naming/test.rs b/crates/tinytools/src/naming/test.rs index 32618dd..9671e8c 100644 --- a/crates/tinytools/src/naming/test.rs +++ b/crates/tinytools/src/naming/test.rs @@ -114,6 +114,18 @@ fn an_ellipsis_longer_than_the_cap_cannot_overflow_it() { assert_eq!(detail.chars().count(), 2); } +#[test] +fn a_zero_cap_yields_no_detail_rather_than_a_blank_one() { + // A degenerate `max_chars` of 0 truncates a genuinely present value down to + // nothing; that must read as "no detail", not as "a detail, and it's + // empty" (`Some("")`), which a caller could render as a hollow decoration. + let detail = context_detail_from_args_with( + &json!({ "query": "hello" }), + ContextDetailOptions::new(0, "..."), + ); + assert_eq!(detail, None); +} + #[test] fn the_default_options_are_eighty_chars_and_three_dots() { let defaults = ContextDetailOptions::default(); diff --git a/crates/tinytools/src/naming/types.rs b/crates/tinytools/src/naming/types.rs index 53c3147..f5ebf74 100644 --- a/crates/tinytools/src/naming/types.rs +++ b/crates/tinytools/src/naming/types.rs @@ -149,7 +149,7 @@ fn render_context_value(value: &Value, options: ContextDetailOptions) -> Option< if raw.is_empty() { return None; } - if raw.chars().count() > options.max_chars { + let rendered = if raw.chars().count() > options.max_chars { // Clamp the ellipsis itself to `max_chars` first: an ellipsis longer // than the cap (a misconfigured caller) would otherwise survive // `saturating_sub`'s zero and still be appended in full, pushing the @@ -157,8 +157,19 @@ fn render_context_value(value: &Value, options: ContextDetailOptions) -> Option< let ellipsis: String = options.ellipsis.chars().take(options.max_chars).collect(); let keep = options.max_chars.saturating_sub(ellipsis.chars().count()); let truncated: String = raw.chars().take(keep).collect(); - Some(format!("{truncated}{ellipsis}")) + format!("{truncated}{ellipsis}") } else { - Some(raw) + raw + }; + + // A zero (or otherwise degenerate) `max_chars` can truncate a genuinely + // present, nonempty value down to nothing. Callers distinguish `None` (no + // detail to show) from `Some(String)` (a detail to render), so surfacing + // `Some("")` here would read as "there is a detail, and it's blank" rather + // than "there is no detail" — treat an empty render the same as absence. + if rendered.is_empty() { + None + } else { + Some(rendered) } } diff --git a/crates/tinytools/src/permission/test.rs b/crates/tinytools/src/permission/test.rs index ab619d4..1919f85 100644 --- a/crates/tinytools/src/permission/test.rs +++ b/crates/tinytools/src/permission/test.rs @@ -40,3 +40,26 @@ fn levels_round_trip_through_json() { assert_eq!(back, level); } } + +#[test] +fn levels_are_pinned_to_their_exact_wire_names() { + // A round-trip alone only proves the encoder and decoder currently agree + // with *each other* — it still passes if a `serde` rename quietly changes + // what gets persisted. Pin the literal strings, in both directions, so a + // rename that would silently corrupt an on-disk transcript or an RPC + // payload already carrying `"ReadOnly"` fails here instead. + let cases = [ + (PermissionLevel::None, "\"None\""), + (PermissionLevel::ReadOnly, "\"ReadOnly\""), + (PermissionLevel::Write, "\"Write\""), + (PermissionLevel::Execute, "\"Execute\""), + (PermissionLevel::Dangerous, "\"Dangerous\""), + ]; + for (level, wire) in cases { + assert_eq!(serde_json::to_string(&level).expect("serializable"), wire); + assert_eq!( + serde_json::from_str::(wire).expect("deserializable"), + level + ); + } +} From 47db6d45b7691c6d4ab6f3d2b5967e631865376d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:26:01 +0300 Subject: [PATCH 06/10] fix: prefer unwrap_or_default over unwrap_or("") in the Tool example Both are equivalent for &str, but unwrap_or_default sidesteps the unwrap_or naming pattern the reviewer flagged as something that could slip into a real (fallible) tool implementation if copied carelessly. Co-authored-by: Medulla --- README.md | 2 +- crates/tinytools/src/lib.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b7872a7..347d69c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ impl Tool for Echo { }) } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - let text = args.get("text").and_then(|v| v.as_str()).unwrap_or(""); + let text = args.get("text").and_then(|v| v.as_str()).unwrap_or_default(); Ok(ToolResult::success(text)) } } diff --git a/crates/tinytools/src/lib.rs b/crates/tinytools/src/lib.rs index d8cf6d6..3ee4448 100644 --- a/crates/tinytools/src/lib.rs +++ b/crates/tinytools/src/lib.rs @@ -75,7 +75,7 @@ //! } //! //! async fn execute(&self, args: serde_json::Value) -> anyhow::Result { -//! let text = args.get("text").and_then(|v| v.as_str()).unwrap_or(""); +//! let text = args.get("text").and_then(|v| v.as_str()).unwrap_or_default(); //! Ok(ToolResult::success(text)) //! } //! } From f899200759f92febb34aa122bdb68ada372b0652 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:34:00 +0300 Subject: [PATCH 07/10] docs, test: address third-round review feedback on tinytools PR #1 - add a module-level //! doc comment to every test.rs (9 files), matching the documented convention that every mod.rs and test.rs starts with one - pin the exact literal JSON wire shape (encode and decode a fixed literal) for ToolSpec, ToolResult, and WorkspaceDescriptor, extending the same pinning already done for PermissionLevel, so a silent field rename in any of these fails a test instead of passing a round-trip that only proves the encoder and decoder still agree with each other - docs/README.md: remove the dead links to the deleted tinybus-module-release spec/plan - .github/ISSUE_TEMPLATE/config.yml: point the security-report contact link at tinyhumansai/tinytools instead of the old rust-template repo Co-authored-by: Medulla --- .github/ISSUE_TEMPLATE/config.yml | 2 +- crates/tinytools/src/call/test.rs | 3 ++ crates/tinytools/src/classification/test.rs | 3 ++ crates/tinytools/src/context/test.rs | 3 ++ crates/tinytools/src/naming/test.rs | 4 +++ crates/tinytools/src/permission/test.rs | 3 ++ crates/tinytools/src/result/test.rs | 28 ++++++++++++++++ crates/tinytools/src/spec/test.rs | 31 +++++++++++++++++ crates/tinytools/src/tool/test.rs | 4 +++ crates/tinytools/src/workspace/test.rs | 37 +++++++++++++++++++++ docs/README.md | 5 --- 11 files changed, 117 insertions(+), 6 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index c808685..f7f5be7 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: true contact_links: - name: Security reports - url: https://github.com/tinyhumansai/rust-template/security/policy + url: https://github.com/tinyhumansai/tinytools/security/policy about: Please do not report vulnerabilities through public issues. diff --git a/crates/tinytools/src/call/test.rs b/crates/tinytools/src/call/test.rs index ce3331b..85c7f5d 100644 --- a/crates/tinytools/src/call/test.rs +++ b/crates/tinytools/src/call/test.rs @@ -1,3 +1,6 @@ +//! Unit tests for `ToolCallOptions` and `ToolTimeout`: their defaults and +//! that each variant stays distinct. + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use super::{ToolCallOptions, ToolTimeout}; diff --git a/crates/tinytools/src/classification/test.rs b/crates/tinytools/src/classification/test.rs index e7664c5..fd9adfe 100644 --- a/crates/tinytools/src/classification/test.rs +++ b/crates/tinytools/src/classification/test.rs @@ -1,3 +1,6 @@ +//! Unit tests for `ToolScope` and `ToolCategory`: their defaults and the +//! wire representation each variant serializes to. + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use super::{ToolCategory, ToolScope}; diff --git a/crates/tinytools/src/context/test.rs b/crates/tinytools/src/context/test.rs index a51bd92..ad3a9b6 100644 --- a/crates/tinytools/src/context/test.rs +++ b/crates/tinytools/src/context/test.rs @@ -1,3 +1,6 @@ +//! Unit tests for `ToolRunContext`: the trait's defaults, and that a real +//! implementor is reachable through the erased trait object. + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::path::{Path, PathBuf}; diff --git a/crates/tinytools/src/naming/test.rs b/crates/tinytools/src/naming/test.rs index 9671e8c..60610e2 100644 --- a/crates/tinytools/src/naming/test.rs +++ b/crates/tinytools/src/naming/test.rs @@ -1,3 +1,7 @@ +//! Unit tests for `humanize_tool_name` and `context_detail_from_args`: the +//! prefix-stripping and title-casing rules, and the key-scanning, trimming, +//! and empty-value handling that produce a rendered detail. + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use serde_json::json; diff --git a/crates/tinytools/src/permission/test.rs b/crates/tinytools/src/permission/test.rs index 1919f85..c9422d2 100644 --- a/crates/tinytools/src/permission/test.rs +++ b/crates/tinytools/src/permission/test.rs @@ -1,3 +1,6 @@ +//! Unit tests for `PermissionLevel`: its total order, default, `Display` +//! form, and the exact wire representation each variant is pinned to. + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use super::PermissionLevel; diff --git a/crates/tinytools/src/result/test.rs b/crates/tinytools/src/result/test.rs index a26ae80..4ddd50e 100644 --- a/crates/tinytools/src/result/test.rs +++ b/crates/tinytools/src/result/test.rs @@ -1,3 +1,6 @@ +//! Unit tests for `ToolResult` and `ToolContent`: constructing successes +//! and errors, rendering content for a model, and the JSON wire shape. + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use serde_json::json; @@ -71,6 +74,31 @@ fn result_round_trips_through_json() { assert_eq!(back.text(), "hello"); } +#[test] +fn result_is_pinned_to_its_literal_wire_shape() { + // Same reasoning as the permission and spec pinning tests: a round-trip + // alone doesn't catch a silent field rename, since the same serializer and + // deserializer that changed still agree with each other. Assert the exact + // JSON a persisted transcript or RPC reply would carry, in both + // directions. + let r = ToolResult::success_with_markdown(json!({"a": 1}), "**a**: 1"); + let encoded: serde_json::Value = serde_json::to_value(&r).expect("serializable"); + assert_eq!( + encoded, + json!({ + "content": [{ "type": "json", "data": { "a": 1 } }], + "is_error": false, + "markdownFormatted": "**a**: 1", + }) + ); + + let literal = r#"{"content":[{"type":"text","text":"hi"}],"is_error":true}"#; + let decoded: ToolResult = serde_json::from_str(literal).expect("deserializable"); + assert!(decoded.is_error); + assert_eq!(decoded.text(), "hi"); + assert_eq!(decoded.markdown_formatted, None); +} + #[test] fn content_blocks_are_tagged_by_type() { let text = serde_json::to_string(&ToolContent::Text { diff --git a/crates/tinytools/src/spec/test.rs b/crates/tinytools/src/spec/test.rs index 2acba27..7a9361f 100644 --- a/crates/tinytools/src/spec/test.rs +++ b/crates/tinytools/src/spec/test.rs @@ -1,3 +1,5 @@ +//! Unit tests for `ToolSpec`: that it round-trips through JSON. + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use super::ToolSpec; @@ -15,3 +17,32 @@ fn spec_round_trips_through_json() { assert_eq!(back.description, "Returns its input."); assert_eq!(back.parameters["type"], "object"); } + +#[test] +fn spec_is_pinned_to_its_literal_wire_shape() { + // A round-trip alone only proves the encoder and decoder agree with each + // other; it still passes if a field is silently renamed. Pin the exact + // field names a persisted transcript or RPC payload would carry, and also + // decode a fixed literal, so a rename is caught in both directions. + let spec = ToolSpec { + name: "echo".into(), + description: "Returns its input.".into(), + parameters: serde_json::json!({ "type": "object" }), + }; + let encoded: serde_json::Value = serde_json::to_value(&spec).expect("serializable"); + assert_eq!( + encoded, + serde_json::json!({ + "name": "echo", + "description": "Returns its input.", + "parameters": { "type": "object" }, + }) + ); + + let literal = + r#"{"name":"echo","description":"Returns its input.","parameters":{"type":"object"}}"#; + let decoded: ToolSpec = serde_json::from_str(literal).expect("deserializable"); + assert_eq!(decoded.name, "echo"); + assert_eq!(decoded.description, "Returns its input."); + assert_eq!(decoded.parameters, serde_json::json!({ "type": "object" })); +} diff --git a/crates/tinytools/src/tool/test.rs b/crates/tinytools/src/tool/test.rs index e8ffadd..6d69d87 100644 --- a/crates/tinytools/src/tool/test.rs +++ b/crates/tinytools/src/tool/test.rs @@ -1,3 +1,7 @@ +//! Unit tests for the `Tool` trait: its defaults, how a host recovers tool +//! metadata through the erased trait object, and how display helpers pull +//! context out of a call's arguments. + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] #![allow(clippy::unnecessary_literal_bound)] diff --git a/crates/tinytools/src/workspace/test.rs b/crates/tinytools/src/workspace/test.rs index 4965b15..84a109b 100644 --- a/crates/tinytools/src/workspace/test.rs +++ b/crates/tinytools/src/workspace/test.rs @@ -1,3 +1,6 @@ +//! Unit tests for `WorkspaceDescriptor` and `SandboxMode`: the builders, +//! the JSON wire shape, and the lexical containment checks in `allows`. + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::path::{Path, PathBuf}; @@ -71,6 +74,40 @@ fn the_descriptor_round_trips_through_json() { assert_eq!(back, ws); } +#[test] +fn the_descriptor_is_pinned_to_its_literal_wire_shape() { + // Same reasoning as the other pinning tests in this crate: a round-trip + // alone doesn't catch a silent field rename, since encoder and decoder + // still agree with each other after the rename. Assert the exact JSON + // object a persisted config or RPC payload would carry, in both + // directions. + let ws = WorkspaceDescriptor::new("/work/agent-a") + .with_trusted_root("/shared") + .with_policy_id("worktree") + .with_sandbox(SandboxMode::Disabled); + let encoded: serde_json::Value = serde_json::to_value(&ws).expect("serializable"); + assert_eq!( + encoded, + serde_json::json!({ + "root": "/work/agent-a", + "trusted_roots": ["/shared"], + "policy_id": "worktree", + "sandbox": "disabled", + }) + ); + + let literal = + r#"{"root":"/work","trusted_roots":["/shared"],"policy_id":"p","sandbox":"required"}"#; + let decoded: WorkspaceDescriptor = serde_json::from_str(literal).expect("deserializable"); + assert_eq!(decoded.root, std::path::PathBuf::from("/work")); + assert_eq!( + decoded.trusted_roots, + vec![std::path::PathBuf::from("/shared")] + ); + assert_eq!(decoded.policy_id, "p"); + assert_eq!(decoded.sandbox, SandboxMode::Required); +} + #[test] fn sandbox_mode_uses_snake_case_on_the_wire() { assert_eq!( diff --git a/docs/README.md b/docs/README.md index 0c0f2b1..a6ebe42 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,11 +28,6 @@ docs/ Complex modules also carry a module-level `README.md` inside `src//` covering their design, public surface, and important constraints. -The current module-release contract is in -[`specs/tinybus-module-release.md`](specs/tinybus-module-release.md), with its -implementation sequence in -[`plans/tinybus-module-release.md`](plans/tinybus-module-release.md). - ## Conventions - Keep every Markdown file at 500 lines or fewer. When a topic outgrows that, From e1ead05e9a5cf56e99a5af7c8e055259be100761 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:47:14 +0300 Subject: [PATCH 08/10] fix, docs: address fourth-round review feedback on tinytools PR #1 - ci.yml: replace the dependency-light gate's blocklist (8 named crates) with an allowlist of tinytools' actual reviewed forward dependency tree, so any newly introduced package fails the gate until it is explicitly reviewed and added, instead of only catching 8 named ones - naming/types.rs: filter blank/whitespace-only array elements before joining a recognized array argument, so an array of only empty strings now returns None instead of the bare punctuation "," that whitespace normalization used to leave behind; regression test added - add crates/tinytools/src/{workspace,tool}/README.md, the module-level docs AGENTS.md requires for complex modules (workspace's containment and symlink-resolution constraints; tool's public surface and the argument-aware-override rules) - add docs/specs/tinytools-vocabulary.md and docs/plans/tinytools-vocabulary.md, documenting (post-hoc, since the crate was reshaped from rust-template in one change) the accepted behavior and the implementation sequence, per the repository's spec-then-plan convention for a public contract - CONTRIBUTING.md: remove the remaining template-only steps a contributor can no longer follow (the deleted cargo run --example basic, and adding a crate error-type variant this crate deliberately has none of), and point the local coverage command at the same coverage.json path CI actually uses Co-authored-by: Medulla --- .github/workflows/ci.yml | 52 ++++++---- CONTRIBUTING.md | 15 ++- crates/tinytools/src/naming/test.rs | 13 +++ crates/tinytools/src/naming/types.rs | 2 + crates/tinytools/src/tool/README.md | 51 ++++++++++ crates/tinytools/src/workspace/README.md | 64 ++++++++++++ docs/plans/tinytools-vocabulary.md | 97 ++++++++++++++++++ docs/specs/tinytools-vocabulary.md | 122 +++++++++++++++++++++++ 8 files changed, 389 insertions(+), 27 deletions(-) create mode 100644 crates/tinytools/src/tool/README.md create mode 100644 crates/tinytools/src/workspace/README.md create mode 100644 docs/plans/tinytools-vocabulary.md create mode 100644 docs/specs/tinytools-vocabulary.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a49f72..c904a02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,29 +78,45 @@ jobs: # first. package_names="$(cargo tree -p tinytools -e normal,build --prefix none \ | awk '{print $1}' | sort -u)" - # `grep -E` exits 1 on "no match", which is the success case here, so - # it cannot share a `set -e` pipeline with the commands that produced - # its input: that would abort the script on the very outcome we want. - # Capture its exit status explicitly and only tolerate exactly 1; - # anything else (a real grep failure, or a non-1 pipeline failure from - # cargo tree/awk/sort surfaced through `pipefail`) still fails the job. - forbidden="" - set +e - forbidden="$(printf '%s\n' "$package_names" \ - | grep -Exi 'tinyagents|tinybus|tokio|reqwest|ureq|hyper|rusqlite|git2')" - grep_status=$? - set -e - if [ "$grep_status" -ne 0 ] && [ "$grep_status" -ne 1 ]; then - echo "dependency scan failed (grep exit $grep_status)" >&2 - exit "$grep_status" - fi + + # An ALLOWLIST, not a blocklist: naming eight forbidden crates only + # catches those eight. Adding `surf`, `async-std`, an arbitrary + # `*-sys` native binding, or any other transport/runtime would pass + # silently under a blocklist. Every package this crate's forward tree + # is reviewed to actually contain is named here instead, so *any* + # newly introduced package — forbidden or merely unreviewed — fails + # the gate until this list is updated in the same commit. + allowed=' + tinytools + anyhow + async-trait + serde + serde_core + serde_derive + serde_json + proc-macro2 + quote + syn + unicode-ident + itoa + memchr + zmij + ' + + # Anything in package_names that is not a line of $allowed. + forbidden="$(comm -23 \ + <(printf '%s\n' "$package_names") \ + <(printf '%s\n' "$allowed" | sort -u))" + if [ -n "$forbidden" ]; then - echo "tinytools pulled in a dependency its manifest forbids:" >&2 + echo "tinytools pulled in a dependency its manifest doesn't review for:" >&2 echo "$forbidden" >&2 echo >&2 echo "This crate is what a harness and a host both compile against." >&2 echo "It must stay free of agent harnesses, transports, async" >&2 - echo "runtimes, HTTP clients and native libraries." >&2 + echo "runtimes, HTTP clients and native libraries. If this package" >&2 + echo "is a genuinely reviewed addition, add it to the allowlist in" >&2 + echo "this step in the same commit that adds the dependency." >&2 exit 1 fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2dc4f9b..9e3f23f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,13 +21,7 @@ CI also requires at least 90% line coverage in every source file. After installing `cargo-llvm-cov`, run the same gate locally: ```sh -.github/scripts/check-file-coverage.sh 90 target/coverage.json -``` - -The bundled example should also run: - -```sh -cargo run --example basic +.github/scripts/check-file-coverage.sh 90 coverage.json ``` ## Making A Change @@ -37,8 +31,11 @@ cargo run --example basic 2. Put each feature area in its own module directory: `mod.rs` for the module root and public surface, `types.rs` for substantial types, `test.rs` for module-local unit tests. Integration tests belong in `tests/`. -3. Add a specific variant to the crate error type rather than encoding new - failure context into a message string. +3. This crate deliberately defines no error type of its own — `Tool::execute` + returns `anyhow::Result` because a tool body calls arbitrary host code and + has no useful closed error set to name. Prefer `anyhow::Context` for + failure messages rather than a new error type; a tool that ran and decided + no returns `Ok(ToolResult::error(..))` instead of an `Err`. 4. Add or update tests with every behavior change, covering the failure paths. 5. Document public items, including `# Errors` and `# Panics` sections. 6. Update `README.md` and `docs/` in the same commit when behavior, the public diff --git a/crates/tinytools/src/naming/test.rs b/crates/tinytools/src/naming/test.rs index 60610e2..69a47e8 100644 --- a/crates/tinytools/src/naming/test.rs +++ b/crates/tinytools/src/naming/test.rs @@ -77,6 +77,19 @@ fn scalars_and_string_arrays_render() { ); } +#[test] +fn blank_array_elements_do_not_survive_as_bare_punctuation() { + // An array of only empty/whitespace strings must not render as a + // detail at all: joining survivors with ", " and then collapsing + // whitespace used to leave a bare "," for input like ["", " "]. + assert_eq!(context_detail_from_args(&json!({ "to": ["", " "] })), None); + // A mix of blank and real entries keeps only the real ones. + assert_eq!( + context_detail_from_args(&json!({ "to": ["", "a@x.com", " "] })).as_deref(), + Some("a@x.com") + ); +} + #[test] fn whitespace_is_collapsed() { assert_eq!( diff --git a/crates/tinytools/src/naming/types.rs b/crates/tinytools/src/naming/types.rs index f5ebf74..071da48 100644 --- a/crates/tinytools/src/naming/types.rs +++ b/crates/tinytools/src/naming/types.rs @@ -141,6 +141,8 @@ fn render_context_value(value: &Value, options: ContextDetailOptions) -> Option< Value::Array(items) => items .iter() .filter_map(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) .collect::>() .join(", "), Value::Null | Value::Object(_) => String::new(), diff --git a/crates/tinytools/src/tool/README.md b/crates/tinytools/src/tool/README.md new file mode 100644 index 0000000..e7805c1 --- /dev/null +++ b/crates/tinytools/src/tool/README.md @@ -0,0 +1,51 @@ +# `tool` + +The `Tool` trait — the single interface every agent capability implements. + +## Design + +Only four methods are required: `name`, `description`, `parameters_schema`, +and `execute`. Everything else on the trait has a default, so the smallest +useful tool is four short methods, and the rest of the trait is +**declaration**: a tool states what privilege it needs, whether it reaches +outside the machine, how long it may run, and how it should read in a +timeline. A host reads those declarations and decides what to allow — the +trait never enforces policy on itself. See the crate's top-level `README.md` +("What is deliberately not here") for why that split exists. + +Every default is the conservative answer except `permission_level`, which +defaults to `PermissionLevel::ReadOnly` because most tools genuinely read. + +## Public surface + +Grouped by what a caller does with the answer: + +- **Execution**, layered with defaults that forward inward: + `execute` (required) ← `execute_with_options` ← `execute_with_context`. + A tool overrides the outermost layer it cares about; a context-agnostic tool + needs no change beyond `execute`. +- **Declarations a host reads before calling**: `permission_level` / + `permission_level_with_args`, `scope`, `category`, `is_concurrency_safe`, + `external_effect` / `external_effect_with_args`, `max_result_size_chars`, + `timeout_policy`. +- **Model-facing**: `spec()` builds the `ToolSpec` a model is shown from + `name` / `description` / `parameters_schema`. +- **Human-facing**: `display_label` / `display_detail` render a call for a + timeline row; the defaults call into `naming::humanize_tool_name` and + `naming::context_detail_from_args`. +- **Host escape hatch**: `host_extension` / `host_call_extension` are type-erased + (`&(dyn Any + Send + Sync)`), because the answer is host policy this crate has + no business naming — a pack registry handle, a generated-tool provenance + record. A host downcasts to its own type; every other tool returns `None` and + pays nothing. + +## Two consequences worth knowing before implementing a tool + +- **A tool exposing several actions at different privileges should declare the + *minimum* any of them needs** from `permission_level`, and the exact one from + `permission_level_with_args`. Declaring the maximum statically blocks the + tool for callers that could legitimately run its read-only half. +- **The argument-aware variants (`permission_level_with_args`, + `external_effect_with_args`) are the ones a host calls at its enforcement + point.** Overriding only the argument-less variant on a tool whose + classification depends on its arguments leaves the per-call case unhandled. diff --git a/crates/tinytools/src/workspace/README.md b/crates/tinytools/src/workspace/README.md new file mode 100644 index 0000000..e4be333 --- /dev/null +++ b/crates/tinytools/src/workspace/README.md @@ -0,0 +1,64 @@ +# `workspace` + +`WorkspaceDescriptor` and `SandboxMode` — the isolated execution environment a +tool is allowed to operate in, and how strictly it must be sandboxed. + +## Design + +A tool discovers its allowed root through [`ToolRunContext::workspace`][ctx] +instead of reaching for an application global. That is what lets two agents run +over the same repository in separate worktrees without either one's tools +knowing anything about the arrangement: each gets its own `WorkspaceDescriptor` +naming its own root. + +`WorkspaceDescriptor` carries four fields, all serializable so a descriptor +survives a config file, an RPC payload, or a persisted session: + +- `root` — the primary directory the tool may read and write under. +- `trusted_roots` — additional directories explicitly trusted alongside `root` + (a shared cache, a sibling worktree). +- `policy_id` — an opaque identity of the policy that produced this + descriptor, carried for audit rather than interpreted here. +- `sandbox` — a [`SandboxMode`], set by the host and read, never decided, by a + tool. + +[ctx]: ../context/mod.rs + +## Public surface + +- `WorkspaceDescriptor::new` / `with_trusted_root` / `with_policy_id` / + `with_sandbox` — a small builder; every field defaults to the conservative + answer (no trusted roots, no policy id, `SandboxMode::Inherit`). +- `WorkspaceDescriptor::allows(&self, path: &Path) -> bool` — the one piece of + logic this module owns. + +## Important operational constraint: `allows` is lexical, not a filesystem call + +`allows` normalizes `.` and `..` components and checks whether the result falls +under `root` or a trusted root. It does **not** call `canonicalize` and does +**not** resolve symlinks. That is deliberate, not an oversight: + +- It has to answer for a path that does not exist yet — a tool about to + *create* a file — and `canonicalize` requires the target to exist. +- It runs on every tool invocation, so a filesystem syscall per check is real + cost paid by every consumer, not only the ones with a hostile symlink to + worry about. + +The consequence: a symlink already present inside an allowed root and pointing +outside it (`/outside -> /etc`) makes `allows` return `true` for +`/outside/passwd`, because the check compares path *components*, not +resolved targets. See the doc comment on `allows` in `types.rs` for the full +reasoning. + +**This means `allows` is the first, cheap, existence-independent check — never +the last word on containment.** A host that must be robust against a symlink +planted inside the workspace (an untrusted or compromised tool output, a shared +filesystem) is expected to layer its own canonicalizing enforcement on top and +re-check containment before it actually opens the file: + +- `tinyagents`'s `enforce_workspace_path` is the fail-closed host-side gate. +- OpenHuman layers its own path policy (`is_workspace_internal_path`, the + sandbox backends) on top for the same reason. + +This module holds no enforcement of its own — see the crate's top-level +`README.md` for why that line is where it is. diff --git a/docs/plans/tinytools-vocabulary.md b/docs/plans/tinytools-vocabulary.md new file mode 100644 index 0000000..c2d2ad1 --- /dev/null +++ b/docs/plans/tinytools-vocabulary.md @@ -0,0 +1,97 @@ +# Plan: TinyTools vocabulary crate + +- **Status:** Implemented +- **Specification:** + [`../specs/tinytools-vocabulary.md`](../specs/tinytools-vocabulary.md) + +This plan documents the implementation sequence actually followed +(post-hoc, since the crate was reshaped from `rust-template` in one change and +this document is being added to satisfy the repository's spec-then-plan +convention for an already-landed public contract). Use it as the reference +sequence for the next module added to this crate. + +## Task 1: Establish the workspace and remove the template's module surface + +**Files:** `Cargo.toml`, `.gitmodules`, `crates/template*`, `.github/workflows/ci.yml`, `AGENTS.md` + +1. Repoint the virtual workspace at `crates/tinytools`, removing the TinyBus + module template (`crates/template`, `crates/template-bus`, + `vendor/tinybus`) and its release workflow — a `Tool` is an async trait + returning `anyhow::Result` and cannot cross a bus wire, so the module half + of the template does not apply here. +2. Replace the dependency-light CI check's target crate and forbidden-name + list with `tinytools` and this crate's actual constraints (no harness, no + transport, no async runtime beyond the `async-trait` shim). + +## Task 2: Add the core tool vocabulary contracts + +**Files:** `crates/tinytools/Cargo.toml`, +`crates/tinytools/src/{call,classification,permission,result,spec}/*` + +1. Add `ToolCallOptions` / `ToolTimeout` (`call`), `ToolScope` / `ToolCategory` + (`classification`), `PermissionLevel` (`permission`), `ToolResult` / + `ToolContent` (`result`), and `ToolSpec` (`spec`) — each as a + `mod.rs` / `types.rs` / `test.rs` triple. +2. Pin every serializable type's wire shape: not just a round-trip (which only + proves the encoder and decoder still agree with each other after a rename), + but the literal encoded JSON in both directions. +3. Run `cargo test` after each module and `cargo clippy --all-targets --all-features -- -D warnings`. + +## Task 3: Add the execution context and workspace contracts + +**Files:** `crates/tinytools/src/{context,workspace}/*` + +1. Add `ToolRunContext`, a narrow trait erasing a harness's run-scoped context, + with a trait-object test proving a real implementor is reachable through + it. +2. Add `WorkspaceDescriptor` / `SandboxMode`, with `allows` implemented as a + lexical, non-canonicalizing containment check — anchored to the current + working directory for a relative path or root, normalizing `.`/`..` + components without touching the filesystem. +3. Add tests for parent-traversal spoofing, filesystem-root traversal, and the + lexical-vs-canonicalizing tradeoff documented on `allows` itself. +4. Add `crates/tinytools/src/workspace/README.md` covering the module's design + and the symlink-resolution limitation explicitly, since AGENTS.md requires + a module README for complex modules. + +## Task 4: Add the `Tool` trait and display integration + +**Files:** `crates/tinytools/src/{tool,naming}/*` + +1. Define `Tool` with four required methods and defaulted declarations + layered so each forwards to the next (`execute` ← `execute_with_options` ← + `execute_with_context`). +2. Add `humanize_tool_name` and `context_detail_from_args` / + `context_detail_from_args_with` in `naming`, with tests covering prefix + stripping, title-casing, key-scanning precedence, trimming, and the + empty-value/zero-cap edge cases that must yield `None` rather than an + empty `Some`. +3. Add `crates/tinytools/src/tool/README.md` covering the trait's public + surface and the two argument-aware-vs-argument-less override rules. + +## Task 5: Publish the crate surface and project guidance + +**Files:** `crates/tinytools/src/lib.rs`, `README.md`, `AGENTS.md`, `CONTRIBUTING.md`, `deny.toml` + +1. Re-export the public surface from `src/lib.rs` with a crate-level overview, + a runnable doctest, and an explicit "what is deliberately not here" + section. +2. Point `crates/tinytools/Cargo.toml`'s `readme` at the actual README (the + repo root's, since this is a single-crate workspace) and verify with + `cargo package --list -p tinytools`. +3. Retarget every repository-identity reference (`CONTRIBUTING.md`, + `.github/ISSUE_TEMPLATE/config.yml`, `docs/README.md`) from the + `rust-template` origin to `tinyhumansai/tinytools`, and remove any + remaining template-only instructions (a deleted example, a nonexistent + error-type variant) rather than leaving them to bit-rot. + +## Task 6: Full verification + +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo clippy --all-targets --all-features -- -D warnings` +- [ ] `cargo build --all-targets --all-features` +- [ ] `cargo test --all-features` +- [ ] `.github/scripts/check-file-coverage.sh 90 coverage.json` (≥ 90% per file) +- [ ] `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features` +- [ ] `cargo deny check all` +- [ ] the dependency-light CI gate passes against the reviewed allowlist diff --git a/docs/specs/tinytools-vocabulary.md b/docs/specs/tinytools-vocabulary.md new file mode 100644 index 0000000..4814541 --- /dev/null +++ b/docs/specs/tinytools-vocabulary.md @@ -0,0 +1,122 @@ +# TinyTools: the agent tool vocabulary + +- **Status:** Implemented +- **Owner:** Maintainers +- **Plan:** [`../plans/tinytools-vocabulary.md`](../plans/tinytools-vocabulary.md) + +## Problem + +Two consumers need the same tool vocabulary and neither can own it. An agent +harness (`tinyagents`) has to name a tool's result to run a loop over it; a +host application has to name the same result to implement one. Before this +crate existed, each declared its own `Tool` trait and result type, and the +conversions between them were written by hand at every seam — which is how an +error flag ends up inverted in one direction with nothing to catch it. + +## Goals + +- Define the `Tool` trait every agent capability implements: four required + methods, plus a set of defaulted declarations describing what a tool needs + and what it touches (privilege, scope, category, concurrency safety, + external effect, timeout, result size cap, human-facing rendering). +- Define `ToolResult` / `ToolContent`, the block-list result shape a tool + hands back, plus `ToolSpec`, the declaration a model is shown. +- Define the permission ladder (`PermissionLevel`), the classification types + (`ToolScope`, `ToolCategory`), and the per-invocation inputs that are not + arguments (`ToolCallOptions`, `ToolTimeout`). +- Provide `ToolRunContext`, a narrow trait erasing a harness's run-scoped + context (the isolated-workspace root being the common case) so a tool can + read run facts without this crate naming the harness type that carries them. +- Provide `WorkspaceDescriptor` / `SandboxMode`, describing the isolated + execution environment a tool may operate in. +- Provide naming helpers (`humanize_tool_name`, `context_detail_from_args`) for + rendering a tool call in a human-facing timeline. +- Stay dependency-light: `anyhow`, `async-trait`, `serde`, `serde_json` only, + with `tokio` as a dev-dependency for async test bodies. CI asserts the full + forward dependency tree against a reviewed allowlist. + +## Non-goals + +- **No enforcement.** Nothing in this crate checks a `PermissionLevel`, + applies a `ToolTimeout`, or decides whether an `external_effect` needs + approval. A tool describes itself; a host decides, because the decision + depends on that host's threat model, configuration, and caller — none of + which generalize. +- **No registry, no dispatch, no execution loop.** Those belong to whoever + owns the run. +- **No dependency on an agent harness.** The harness depends on this crate, + never the reverse; `context` module exists precisely to make that + unnecessary. CI asserts the edge stays pointing one way. +- **No canonicalizing filesystem enforcement in `WorkspaceDescriptor::allows`.** + It is a lexical policy gate that must also answer for paths that do not yet + exist; a host that must be robust against symlink escapes is expected to + layer its own canonicalizing check on top (see + `crates/tinytools/src/workspace/README.md`). + +## Proposed behavior + +```rust +use tinytools::{Tool, ToolResult}; + +struct Echo; + +#[async_trait::async_trait] +impl Tool for Echo { + fn name(&self) -> &str { "echo" } + fn description(&self) -> &str { "Returns its input unchanged." } + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { "text": { "type": "string" } }, + "required": ["text"], + }) + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let text = args.get("text").and_then(|v| v.as_str()).unwrap_or_default(); + Ok(ToolResult::success(text)) + } +} +``` + +That is a complete tool. Every other method on `Tool` defaults to the +conservative answer. + +## Invariants and constraints + +- `tinyagents` (or any transport, runtime, HTTP client, or native library) + never appears in this crate's forward dependency tree. CI's dependency-light + gate asserts an allowlist of the reviewed tree, not a blocklist of forbidden + names, so an unreviewed addition fails the gate rather than merely a named + one. +- `unsafe_code` is `forbid`-level workspace-wide. +- Library code paths do not `unwrap()`, `expect()`, or `panic!()`; tests and + examples may. +- Every public fallible/panicking API documents its failure mode + (`# Errors` / `# Panics`). +- Every public item carries rustdoc; `missing_docs` is a CI-blocking warning. +- Wire-shape-bearing types (`PermissionLevel`, `ToolSpec`, `ToolResult`, + `WorkspaceDescriptor`) are pinned by a literal-JSON test, not merely a + round-trip, so a silent field rename fails a test instead of a downstream + consumer's persisted data. + +## Acceptance criteria + +- `cargo fmt --all -- --check`, `cargo clippy --all-targets --all-features -- -D warnings`, + `cargo build --all-targets --all-features`, and `cargo test --all-features` + all pass. +- Every source file carries at least 90% line coverage + (`.github/scripts/check-file-coverage.sh 90 coverage.json`). +- `cargo doc --no-deps --all-features` and `cargo deny check all` pass. +- The dependency-light CI gate passes against the reviewed allowlist. +- `crates/tinytools/README.md` and this specification stay aligned with the + public surface as it evolves. + +## Open questions + +- Whether `ToolResult`/`ToolContent` should ever adopt an actual MCP + `CallToolResult` wire shape (camelCase `isError`, `structuredContent`) is + deferred: today this type is this crate's own internal transcript/RPC shape, + conceptually MCP-shaped but not byte-compatible, and any real MCP-server + interop is expected to translate at the point that actually speaks the MCP + protocol. Revisit if a maintainer decides byte-level interop through this + exact type is a goal. From 487a87f5ac7d281b468d2d72acddc29c2d8466bf Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:54:02 +0300 Subject: [PATCH 09/10] docs: stop calling the effect defaults conservative Codex is right: the trait docs claimed the defaults were 'the conservative answer in every case except permission_level', and that is false in a way that matters. external_effect defaults to false, which means 'no approval needed' -- a tool that sends an email and forgets to override it is routed past the host's approval gate, and a missing override is indistinguishable from an honest false. Names the three defaults that are not cautious (external_effect, max_result_size_chars, permission_level), says which way each fails, and points a reviewer at what to check for absence in a Tool impl. Also repeats the warning on external_effect itself, where someone writing a tool will actually be looking. Documentation only; no behaviour change. Failing closed instead was considered and rejected: it would prompt on every file read, and this crate cannot tell a local tool from an effectful one. Co-authored-by: Medulla --- crates/tinytools/src/tool/types.rs | 34 ++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/crates/tinytools/src/tool/types.rs b/crates/tinytools/src/tool/types.rs index 207f1be..ba93e6f 100644 --- a/crates/tinytools/src/tool/types.rs +++ b/crates/tinytools/src/tool/types.rs @@ -23,10 +23,31 @@ use crate::spec::ToolSpec; /// timeline. A host reads those declarations and decides what to allow. /// /// That split is the point. A tool never enforces policy on itself — it -/// describes itself accurately and the host enforces. Which is why the -/// declarations are cheap to override and the defaults are the conservative -/// answer in every case except [`Self::permission_level`], where the default is -/// [`PermissionLevel::ReadOnly`] because most tools genuinely read. +/// describes itself accurately and the host enforces. +/// +/// # The defaults are not uniformly safe, and two of them fail OPEN +/// +/// Most defaults are the cautious answer — [`Self::scope`] is `All`, +/// [`Self::is_concurrency_safe`] is `false`, [`Self::timeout_policy`] inherits +/// the host's bound. Three are not, and a tool author who assumes otherwise +/// ships a hole: +/// +/// - **[`Self::external_effect`] defaults to `false`.** A tool that sends an +/// email, posts a message or fires a webhook and does *not* override it is +/// declaring that it has no outside effect, and a host honouring that +/// declaration will route it **past** its approval gate. This default exists +/// because most tools genuinely are local and the alternative would prompt on +/// every file read — but it means **an effectful tool MUST override it**. +/// There is no way for this crate to detect the omission: a missing override +/// and an honest `false` are the same bytes. +/// - **[`Self::max_result_size_chars`] defaults to `None`**, meaning no cap. A +/// chatty tool takes the host's global handling, if it has any. +/// - **[`Self::permission_level`] defaults to +/// [`PermissionLevel::ReadOnly`]**, not [`PermissionLevel::None`], because +/// most tools genuinely read — but a writing tool must say so. +/// +/// If you are reviewing a `Tool` impl, those three are what to check for +/// absence. The rest are safe to leave alone. #[async_trait] pub trait Tool: Send + Sync { /// Canonical tool name, used in model function calling. @@ -144,6 +165,11 @@ pub trait Tool: Send + Sync { /// A host routes such calls through its approval gate before /// [`Self::execute`] runs. Local file writes and memory writes stay `false` /// — they are reversible inside the user's own machine. + /// + /// **This default fails open.** `false` means "no approval needed", so a + /// tool that reaches outside the machine and forgets to override this is + /// silently exempted from the gate. Overriding it is the tool author's + /// responsibility; nothing here can infer it. fn external_effect(&self) -> bool { false } From 6b922f7ea1e45b97f620b31b1c2785ef34d237e8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:57:08 +0300 Subject: [PATCH 10/10] docs, fix: address fifth-round review feedback on tinytools PR #1 - README.md, crates/tinytools/src/tool/README.md, docs/specs/tinytools-vocabulary.md: stop repeating the now-inaccurate 'every default is conservative except permission_level' claim in three more places after tool/types.rs's own doc comment was already corrected (487a87f); scope also defaults to the broad ToolScope::All, and external_effect/max_result_size_chars/permission_level are the three that actually fail open, not just permission_level - ci.yml: run the dependency-tree check with --all-features, matching the feature set every other CI job already validates with, so a future dependency gated behind a non-default feature can't bypass the allowlist unnoticed (currently a no-op: tinytools has no [features] table, verified with a diff against the non---all-features tree) - docs/specs/tinytools-vocabulary.md: point the README acceptance criterion at the actual packaged README.md (repo root), not an crates/tinytools/README.md that doesn't exist - docs/plans/tinytools-vocabulary.md: check off the verification checklist items that were actually run and passed, matching the Implemented status Co-authored-by: Medulla --- .github/workflows/ci.yml | 2 +- README.md | 10 +++++++--- crates/tinytools/src/tool/README.md | 12 ++++++++++-- docs/plans/tinytools-vocabulary.md | 19 +++++++++++-------- docs/specs/tinytools-vocabulary.md | 12 ++++++++---- 5 files changed, 37 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c904a02..6368346 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,7 +76,7 @@ jobs: # does exactly that — so grepping the raw line matches the path and # reports a dependency that is not there. Cut the version and path off # first. - package_names="$(cargo tree -p tinytools -e normal,build --prefix none \ + package_names="$(cargo tree -p tinytools --all-features -e normal,build --prefix none \ | awk '{print $1}' | sort -u)" # An ALLOWLIST, not a blocklist: naming eight forbidden crates only diff --git a/README.md b/README.md index 347d69c..42438cf 100644 --- a/README.md +++ b/README.md @@ -86,9 +86,13 @@ harness type that carries them. CI asserts the edge stays pointing one way. Beyond `name` / `description` / `parameters_schema` / `execute`, every method on `Tool` answers a question a host asks *before* it calls the tool: what privilege does this need, does it reach outside the machine, how long may it run, how -should it read in a timeline. The defaults are the conservative answer in every -case except `permission_level`, which defaults to `ReadOnly` because most tools -genuinely read. +should it read in a timeline. Most defaults are the cautious answer (`scope` is +`All`, `is_concurrency_safe` is `false`, `timeout_policy` inherits the host's +bound), but three fail *open* rather than closed and are what to check for when +reviewing a `Tool` impl: `external_effect` defaults to `false` (an effectful +tool that doesn't override it slips past a host's approval gate), +`max_result_size_chars` defaults to `None` (no cap), and `permission_level` +defaults to `ReadOnly`, not `None`, because most tools genuinely read. Two consequences worth knowing: diff --git a/crates/tinytools/src/tool/README.md b/crates/tinytools/src/tool/README.md index e7805c1..22fe2de 100644 --- a/crates/tinytools/src/tool/README.md +++ b/crates/tinytools/src/tool/README.md @@ -13,8 +13,16 @@ timeline. A host reads those declarations and decides what to allow — the trait never enforces policy on itself. See the crate's top-level `README.md` ("What is deliberately not here") for why that split exists. -Every default is the conservative answer except `permission_level`, which -defaults to `PermissionLevel::ReadOnly` because most tools genuinely read. +Most defaults are the cautious answer — `scope` is `ToolScope::All`, +`is_concurrency_safe` is `false`, `timeout_policy` inherits the host's bound. +Three fail *open* instead, and are what to check for when reviewing a `Tool` +impl: `external_effect` defaults to `false` (an effectful tool that doesn't +override it is declaring it has none, and a host honouring that skips its +approval gate), `max_result_size_chars` defaults to `None` (no cap), and +`permission_level` defaults to `PermissionLevel::ReadOnly`, not `None`, because +most tools genuinely read. See the `# The defaults are not uniformly safe, and +two of them fail OPEN` section on the trait itself in `types.rs` for the full +reasoning. ## Public surface diff --git a/docs/plans/tinytools-vocabulary.md b/docs/plans/tinytools-vocabulary.md index c2d2ad1..9d9c92e 100644 --- a/docs/plans/tinytools-vocabulary.md +++ b/docs/plans/tinytools-vocabulary.md @@ -87,11 +87,14 @@ sequence for the next module added to this crate. ## Task 6: Full verification -- [ ] `cargo fmt --all -- --check` -- [ ] `cargo clippy --all-targets --all-features -- -D warnings` -- [ ] `cargo build --all-targets --all-features` -- [ ] `cargo test --all-features` -- [ ] `.github/scripts/check-file-coverage.sh 90 coverage.json` (≥ 90% per file) -- [ ] `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features` -- [ ] `cargo deny check all` -- [ ] the dependency-light CI gate passes against the reviewed allowlist +All items below were run and passed locally as of this commit, and CI +re-verifies the same commands on every push: + +- [x] `cargo fmt --all -- --check` +- [x] `cargo clippy --all-targets --all-features -- -D warnings` +- [x] `cargo build --all-targets --all-features` +- [x] `cargo test --all-features` +- [x] `.github/scripts/check-file-coverage.sh 90 coverage.json` (≥ 90% per file) +- [x] `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features` +- [x] `cargo deny check all` +- [x] the dependency-light CI gate passes against the reviewed allowlist diff --git a/docs/specs/tinytools-vocabulary.md b/docs/specs/tinytools-vocabulary.md index 4814541..7dac8ba 100644 --- a/docs/specs/tinytools-vocabulary.md +++ b/docs/specs/tinytools-vocabulary.md @@ -78,8 +78,11 @@ impl Tool for Echo { } ``` -That is a complete tool. Every other method on `Tool` defaults to the -conservative answer. +That is a complete tool. Most other methods on `Tool` default to the cautious +answer, but `external_effect`, `max_result_size_chars`, and `permission_level` +fail *open* rather than closed — see the "defaults are not uniformly safe" +section on the trait itself in `crates/tinytools/src/tool/types.rs`, and +`crates/tinytools/src/tool/README.md`. ## Invariants and constraints @@ -108,8 +111,9 @@ conservative answer. (`.github/scripts/check-file-coverage.sh 90 coverage.json`). - `cargo doc --no-deps --all-features` and `cargo deny check all` pass. - The dependency-light CI gate passes against the reviewed allowlist. -- `crates/tinytools/README.md` and this specification stay aligned with the - public surface as it evolves. +- `README.md` (the repository root's, which is this crate's packaged + README — see `crates/tinytools/Cargo.toml`'s `readme` field) and this + specification stay aligned with the public surface as it evolves. ## Open questions