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/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba8c2fc..6368346 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,71 @@ 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)" + # 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. + 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 + # 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 "template-bus 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 "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. 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 @@ -100,7 +138,6 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - submodules: recursive - uses: dtolnay/rust-toolchain@stable @@ -118,7 +155,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 +164,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 +187,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..9e3f23f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,11 +7,10 @@ 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 -submodules, then run the four checks CI runs: +`Cargo.toml` for the minimum supported version), 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 @@ -22,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 @@ -38,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/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..42438cf 100644 --- a/README.md +++ b/README.md @@ -1,156 +1,121 @@ -# 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 { + let text = args.get("text").and_then(|v| v.as_str()).unwrap_or_default(); + Ok(ToolResult::success(text)) + } +} ``` -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. +`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`: -## Development +```toml +[dependencies] +tinytools = "0.1" +async-trait = "0.1" +``` -Clone with submodules, or initialize them before building: +## Why this is its own crate -```sh -git submodule update --init --recursive -``` +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. -```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 -``` +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. -Those four checks are exactly what CI runs. Optional extras: +## What is here -```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 +| 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. 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: + +- **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. + +## Development + +```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..b7c42f6 --- /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..85c7f5d --- /dev/null +++ b/crates/tinytools/src/call/test.rs @@ -0,0 +1,27 @@ +//! 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}; + +#[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..165ba31 --- /dev/null +++ b/crates/tinytools/src/call/types.rs @@ -0,0 +1,58 @@ +//! 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)] +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..fd9adfe --- /dev/null +++ b/crates/tinytools/src/classification/test.rs @@ -0,0 +1,41 @@ +//! 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}; + +#[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..ad3a9b6 --- /dev/null +++ b/crates/tinytools/src/context/test.rs @@ -0,0 +1,58 @@ +//! 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}; + +use super::ToolRunContext; +use crate::workspace::WorkspaceDescriptor; + +/// 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 { + workspace: WorkspaceDescriptor, +} + +impl ToolRunContext for Isolated { + fn workspace(&self) -> Option<&WorkspaceDescriptor> { + Some(&self.workspace) + } + + 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().is_none()); + 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 { + 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 new file mode 100644 index 0000000..b14a60a --- /dev/null +++ b/crates/tinytools/src/context/types.rs @@ -0,0 +1,64 @@ +//! The run-scoped facts a tool may read, without naming the harness that owns +//! them. + +use crate::workspace::WorkspaceDescriptor; + +/// 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 facts it actually uses without either crate having to know +/// the other's shape. +/// +/// 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 { + /// 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 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(&self) -> Option<&WorkspaceDescriptor> { + 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 + } + + /// 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 new file mode 100644 index 0000000..3ee4448 --- /dev/null +++ b/crates/tinytools/src/lib.rs @@ -0,0 +1,120 @@ +//! 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 [`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. +//! - [`workspace`] — [`WorkspaceDescriptor`], the root a tool may touch. +//! - [`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.get("text").and_then(|v| v.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 mod workspace; + +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; +pub use workspace::{SandboxMode, WorkspaceDescriptor}; 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..69a47e8 --- /dev/null +++ b/crates/tinytools/src/naming/test.rs @@ -0,0 +1,151 @@ +//! 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; + +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 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!( + 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 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(); + 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..071da48 --- /dev/null +++ b/crates/tinytools/src/naming/types.rs @@ -0,0 +1,177 @@ +//! 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)] +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) + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect::>() + .join(", "), + Value::Null | Value::Object(_) => String::new(), + }; + let raw = raw.split_whitespace().collect::>().join(" "); + if raw.is_empty() { + return None; + } + 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 + // 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(); + format!("{truncated}{ellipsis}") + } else { + 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/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..c9422d2 --- /dev/null +++ b/crates/tinytools/src/permission/test.rs @@ -0,0 +1,68 @@ +//! 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; + +#[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_through_json() { + 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); + } +} + +#[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 + ); + } +} 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..4ddd50e --- /dev/null +++ b/crates/tinytools/src/result/test.rs @@ -0,0 +1,152 @@ +//! 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; + +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 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 { + 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..e6cca0e --- /dev/null +++ b/crates/tinytools/src/result/types.rs @@ -0,0 +1,160 @@ +//! 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 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. + 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..7a9361f --- /dev/null +++ b/crates/tinytools/src/spec/test.rs @@ -0,0 +1,48 @@ +//! Unit tests for `ToolSpec`: that it round-trips through JSON. + +#![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"); +} + +#[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/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/README.md b/crates/tinytools/src/tool/README.md new file mode 100644 index 0000000..22fe2de --- /dev/null +++ b/crates/tinytools/src/tool/README.md @@ -0,0 +1,59 @@ +# `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. + +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 + +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/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..6d69d87 --- /dev/null +++ b/crates/tinytools/src/tool/test.rs @@ -0,0 +1,224 @@ +//! 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)] + +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..ba93e6f --- /dev/null +++ b/crates/tinytools/src/tool/types.rs @@ -0,0 +1,251 @@ +//! 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. +/// +/// # 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. + 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. + /// + /// **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 + } + + /// 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/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/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..84a109b --- /dev/null +++ b/crates/tinytools/src/workspace/test.rs @@ -0,0 +1,129 @@ +//! 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}; + +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 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!( + 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..ca520c0 --- /dev/null +++ b/crates/tinytools/src/workspace/types.rs @@ -0,0 +1,155 @@ +//! 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. + /// + /// **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 { + 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 +} 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/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, 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/plans/tinytools-vocabulary.md b/docs/plans/tinytools-vocabulary.md new file mode 100644 index 0000000..9d9c92e --- /dev/null +++ b/docs/plans/tinytools-vocabulary.md @@ -0,0 +1,100 @@ +# 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 + +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/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/docs/specs/tinytools-vocabulary.md b/docs/specs/tinytools-vocabulary.md new file mode 100644 index 0000000..7dac8ba --- /dev/null +++ b/docs/specs/tinytools-vocabulary.md @@ -0,0 +1,126 @@ +# 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. 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 + +- `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. +- `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 + +- 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. 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