From 140158c7564e3cfc9ac18e24844011812aec243d Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Sun, 5 Jul 2026 18:15:54 -0700 Subject: [PATCH] add provider bindings MVP to goose-sdk, and add python wheel publishing (#10208) --- .github/workflows/python-sdk-wheels.yml | 118 ++++++++++ Cargo.lock | 16 +- crates/goose-download-manager/Cargo.toml | 2 +- crates/goose-local-inference/Cargo.toml | 8 +- crates/goose-provider-types/Cargo.toml | 2 +- crates/goose-providers/Cargo.toml | 6 +- crates/goose-sdk-types/Cargo.toml | 2 +- crates/goose-sdk/.gitignore | 4 + crates/goose-sdk/Cargo.toml | 19 +- crates/goose-sdk/README.md | 22 +- crates/goose-sdk/examples/deepseek.json | 30 +++ crates/goose-sdk/examples/uniffi/Ping.kt | 9 - crates/goose-sdk/examples/uniffi/Provider.kt | 31 +++ crates/goose-sdk/examples/uniffi/README.md | 53 +++++ crates/goose-sdk/examples/uniffi/ping.py | 21 -- crates/goose-sdk/examples/uniffi/provider.py | 38 +++ crates/goose-sdk/justfile | 145 +++++++++++- crates/goose-sdk/python/README.md | 15 ++ crates/goose-sdk/python/pyproject.toml | 34 +++ crates/goose-sdk/python/setup.py | 15 ++ crates/goose-sdk/src/bindings.rs | 236 ++++++++++++++++--- crates/goose-sdk/src/lib.rs | 12 +- crates/goose-sdk/uniffi.toml | 2 +- 23 files changed, 727 insertions(+), 113 deletions(-) create mode 100644 .github/workflows/python-sdk-wheels.yml create mode 100644 crates/goose-sdk/examples/deepseek.json delete mode 100644 crates/goose-sdk/examples/uniffi/Ping.kt create mode 100644 crates/goose-sdk/examples/uniffi/Provider.kt create mode 100644 crates/goose-sdk/examples/uniffi/README.md delete mode 100644 crates/goose-sdk/examples/uniffi/ping.py create mode 100755 crates/goose-sdk/examples/uniffi/provider.py create mode 100644 crates/goose-sdk/python/README.md create mode 100644 crates/goose-sdk/python/pyproject.toml create mode 100644 crates/goose-sdk/python/setup.py diff --git a/.github/workflows/python-sdk-wheels.yml b/.github/workflows/python-sdk-wheels.yml new file mode 100644 index 000000000..d01d4008c --- /dev/null +++ b/.github/workflows/python-sdk-wheels.yml @@ -0,0 +1,118 @@ +name: Python SDK Wheels + +on: + workflow_dispatch: + inputs: + publish: + description: "Publish wheels to PyPI after building" + required: true + default: false + type: boolean + +permissions: + contents: read + id-token: write + +jobs: + build-wheels: + name: Build wheel (${{ matrix.name }}) + runs-on: ${{ matrix.os }} + container: ${{ matrix.container || null }} + strategy: + fail-fast: false + matrix: + include: + - name: macos-arm64 + os: macos-14 + - name: macos-x86_64 + os: macos-13 + - name: linux-x86_64 + os: ubuntu-latest + container: quay.io/pypa/manylinux_2_28_x86_64@sha256:441c35fdc6ee809ff9260894f8468ab4fea8c15dc880f8700a3f81b7922c1cda + - name: windows-x86_64 + os: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up Rust + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + + - name: Install Linux tools + if: runner.os == 'Linux' + shell: bash + run: | + python3 -m pip install --upgrade pip + python3 -m pip install uv + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin + + - name: Install just + if: runner.os != 'Linux' + uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4 + + - name: Cache Cargo artifacts + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + + - name: Build wheel + shell: bash + run: just --justfile crates/goose-sdk/justfile python-wheel + + - name: Repair Linux wheel + if: runner.os == 'Linux' + shell: bash + run: | + python3 -m pip install auditwheel + mkdir -p crates/goose-sdk/python/wheelhouse + auditwheel repair --plat manylinux_2_28_x86_64 -w crates/goose-sdk/python/wheelhouse crates/goose-sdk/python/dist/*.whl + rm crates/goose-sdk/python/dist/*.whl + mv crates/goose-sdk/python/wheelhouse/*.whl crates/goose-sdk/python/dist/ + + - name: Smoke test wheel + shell: bash + run: | + python -m venv .venv-wheel-test + if [ "${{ runner.os }}" = "Windows" ]; then + python_bin=".venv-wheel-test/Scripts/python.exe" + else + python_bin=".venv-wheel-test/bin/python" + fi + UV_NO_CONFIG=1 uv pip install --default-index https://pypi.org/simple --python "$python_bin" crates/goose-sdk/python/dist/*.whl + "$python_bin" -c 'import goose; print(goose.__name__)' + + - name: Upload wheel artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: goose-sdk-wheel-${{ matrix.name }} + path: crates/goose-sdk/python/dist/*.whl + if-no-files-found: error + + publish: + name: Publish wheels to PyPI + needs: build-wheels + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' && inputs.publish + environment: pypi + steps: + - name: Download wheel artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: goose-sdk-wheel-* + path: dist + merge-multiple: true + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + with: + packages-dir: dist diff --git a/Cargo.lock b/Cargo.lock index 596a64d8b..b192e892b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5065,7 +5065,7 @@ dependencies = [ [[package]] name = "goose-download-manager" -version = "1.41.0" +version = "0.1.0-alpha.0" dependencies = [ "anyhow", "once_cell", @@ -5078,7 +5078,7 @@ dependencies = [ [[package]] name = "goose-local-inference" -version = "1.41.0" +version = "0.1.0-alpha.0" dependencies = [ "anyhow", "async-stream", @@ -5144,7 +5144,7 @@ dependencies = [ [[package]] name = "goose-provider-types" -version = "1.41.0" +version = "0.1.0-alpha.0" dependencies = [ "anyhow", "async-stream", @@ -5174,7 +5174,7 @@ dependencies = [ [[package]] name = "goose-providers" -version = "1.41.0" +version = "0.1.0-alpha.0" dependencies = [ "anyhow", "async-stream", @@ -5207,11 +5207,15 @@ dependencies = [ [[package]] name = "goose-sdk" -version = "1.41.0" +version = "0.1.0-alpha.0" dependencies = [ "agent-client-protocol", "agent-client-protocol-schema", + "anyhow", + "futures", + "goose-providers", "goose-sdk-types", + "serde_json", "thiserror 2.0.18", "tokio", "tokio-util", @@ -5220,7 +5224,7 @@ dependencies = [ [[package]] name = "goose-sdk-types" -version = "1.41.0" +version = "0.1.0-alpha.0" dependencies = [ "agent-client-protocol", "agent-client-protocol-schema", diff --git a/crates/goose-download-manager/Cargo.toml b/crates/goose-download-manager/Cargo.toml index 1d99b49ed..e0b99e8d7 100644 --- a/crates/goose-download-manager/Cargo.toml +++ b/crates/goose-download-manager/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "goose-download-manager" -version.workspace = true +version = "0.1.0-alpha.0" edition.workspace = true rust-version.workspace = true authors.workspace = true diff --git a/crates/goose-local-inference/Cargo.toml b/crates/goose-local-inference/Cargo.toml index f69db30c2..b2c6c42d5 100644 --- a/crates/goose-local-inference/Cargo.toml +++ b/crates/goose-local-inference/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "goose-local-inference" -version.workspace = true +version = "0.1.0-alpha.0" edition.workspace = true rust-version.workspace = true authors.workspace = true @@ -27,9 +27,9 @@ etcetera = { workspace = true } encoding_rs = { version = "0.8.35", default-features = false } fs2 = { workspace = true } futures = { workspace = true } -goose-download-manager = { path = "../goose-download-manager" } -goose-provider-types = { path = "../goose-provider-types", default-features = false } -goose-sdk-types = { path = "../goose-sdk-types", default-features = false } +goose-download-manager = { version = "0.1.0-alpha.0", path = "../goose-download-manager" } +goose-provider-types = { version = "0.1.0-alpha.0", path = "../goose-provider-types", default-features = false } +goose-sdk-types = { version = "0.1.0-alpha.0", path = "../goose-sdk-types", default-features = false } hf-hub = { version = "1.0.0-rc.1", default-features = false } include_dir = { workspace = true } llama-cpp-2 = { workspace = true } diff --git a/crates/goose-provider-types/Cargo.toml b/crates/goose-provider-types/Cargo.toml index ce0ead3fe..0e651492b 100644 --- a/crates/goose-provider-types/Cargo.toml +++ b/crates/goose-provider-types/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "goose-provider-types" -version.workspace = true +version = "0.1.0-alpha.0" edition.workspace = true rust-version.workspace = true authors.workspace = true diff --git a/crates/goose-providers/Cargo.toml b/crates/goose-providers/Cargo.toml index 4f203080a..5dd8c3e34 100644 --- a/crates/goose-providers/Cargo.toml +++ b/crates/goose-providers/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "goose-providers" -version.workspace = true +version = "0.1.0-alpha.0" edition.workspace = true rust-version.workspace = true authors.workspace = true @@ -35,8 +35,8 @@ anyhow = { workspace = true } async-stream = { workspace = true } chrono = { workspace = true } futures = { workspace = true } -goose-provider-types = { path = "../goose-provider-types", default-features = false } -goose-local-inference = { path = "../goose-local-inference", default-features = false, optional = true } +goose-provider-types = { version = "0.1.0-alpha.0", path = "../goose-provider-types", default-features = false } +goose-local-inference = { version = "0.1.0-alpha.0", path = "../goose-local-inference", default-features = false, optional = true } reqwest = { workspace = true } rmcp = { workspace = true, features = ["server", "macros"] } serde = { workspace = true } diff --git a/crates/goose-sdk-types/Cargo.toml b/crates/goose-sdk-types/Cargo.toml index 0414365d0..afda65dd8 100644 --- a/crates/goose-sdk-types/Cargo.toml +++ b/crates/goose-sdk-types/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "goose-sdk-types" -version.workspace = true +version = "0.1.0-alpha.0" edition.workspace = true rust-version.workspace = true authors.workspace = true diff --git a/crates/goose-sdk/.gitignore b/crates/goose-sdk/.gitignore index 4d0904c04..0916a2813 100644 --- a/crates/goose-sdk/.gitignore +++ b/crates/goose-sdk/.gitignore @@ -1,2 +1,6 @@ generated examples/uniffi/*.jar +python/build/ +python/dist/ +python/src/*.egg-info/ +python/src/goose/ diff --git a/crates/goose-sdk/Cargo.toml b/crates/goose-sdk/Cargo.toml index 6bd1e5a34..43f8eb26c 100644 --- a/crates/goose-sdk/Cargo.toml +++ b/crates/goose-sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "goose-sdk" -version.workspace = true +version = "0.1.0-alpha.0" edition.workspace = true rust-version.workspace = true authors.workspace = true @@ -19,15 +19,28 @@ required-features = ["uniffi"] [features] default = [] -uniffi = ["dep:uniffi", "dep:thiserror"] +uniffi = [ + "dep:uniffi", + "dep:thiserror", + "dep:anyhow", + "dep:goose-providers", + "dep:futures", + "dep:serde_json", + "dep:tokio", +] [dependencies] -goose-sdk-types = { path = "../goose-sdk-types" } +goose-sdk-types = { version = "0.1.0-alpha.0", path = "../goose-sdk-types" } agent-client-protocol = { workspace = true, features = ["unstable"] } agent-client-protocol-schema = { workspace = true } uniffi = { version = "0.31", features = ["cli"], optional = true } thiserror = { version = "2", optional = true } +goose-providers = { version = "0.1.0-alpha.0", path = "../goose-providers", features = ["rustls-tls"], optional = true } +futures = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } +tokio = { workspace = true, features = ["rt-multi-thread", "sync"], optional = true } +anyhow = { workspace = true, optional = true } [dev-dependencies] tokio = { workspace = true, features = ["rt-multi-thread", "macros", "process", "io-std", "io-util"] } diff --git a/crates/goose-sdk/README.md b/crates/goose-sdk/README.md index 3dfb839e3..e10329d35 100644 --- a/crates/goose-sdk/README.md +++ b/crates/goose-sdk/README.md @@ -4,13 +4,23 @@ The bindings layer for Goose. It houses the shared types used for both ACP and SDK access, and exposes a cross-language version of the Goose API. With `--features uniffi` the crate compiles to native bindings for Python and -Kotlin (namespace `aaif_goose` / `aaif.goose`). The published surface is -currently a `ping` -> `pong` stub in `src/bindings.rs` — the scaffold for the -real implementation. +Kotlin (namespace `goose` / `io.aaif.goose`). The UniFFI surface currently lets +callers construct declarative providers from JSON and stream provider +completions. ```bash -just python # build bindings + run examples/uniffi/ping.py -just kotlin # build bindings + run examples/uniffi/Ping.kt +just python # build bindings + run examples/uniffi/provider.py +just kotlin # build bindings + run examples/uniffi/Provider.kt ``` -Both print `pong: aaif.io`. +## Python package + +The PyPI package is published as `goose-sdk` and imports as `goose`. +Build a local wheel from the repository root with: + +```bash +just --justfile crates/goose-sdk/justfile python-wheel +``` + +This regenerates the UniFFI Python bindings, copies the release native library +into the package, and writes the wheel to `crates/goose-sdk/python/dist/`. diff --git a/crates/goose-sdk/examples/deepseek.json b/crates/goose-sdk/examples/deepseek.json new file mode 100644 index 000000000..1d2207443 --- /dev/null +++ b/crates/goose-sdk/examples/deepseek.json @@ -0,0 +1,30 @@ +{ + "name": "deepseek", + "engine": "openai", + "display_name": "DeepSeek", + "description": "Custom DeepSeek provider", + "api_key_env": "DEEPSEEK_API_KEY", + "base_url": "https://api.deepseek.com", + "models": [ + { + "name": "deepseek-chat", + "context_limit": 128000, + "input_token_cost": null, + "output_token_cost": null, + "currency": null, + "supports_cache_control": null + }, + { + "name": "deepseek-reasoner", + "context_limit": 128000, + "input_token_cost": null, + "output_token_cost": null, + "currency": null, + "supports_cache_control": null + } + ], + "headers": null, + "timeout_seconds": null, + "preserves_thinking": true, + "supports_streaming": true +} diff --git a/crates/goose-sdk/examples/uniffi/Ping.kt b/crates/goose-sdk/examples/uniffi/Ping.kt deleted file mode 100644 index 0f043ee00..000000000 --- a/crates/goose-sdk/examples/uniffi/Ping.kt +++ /dev/null @@ -1,9 +0,0 @@ -package aaif.example - -import aaif.goose.Client - -fun main() { - val client = Client() - val pong = client.ping("aaif.io") - println(pong.message) -} diff --git a/crates/goose-sdk/examples/uniffi/Provider.kt b/crates/goose-sdk/examples/uniffi/Provider.kt new file mode 100644 index 000000000..6e23e2f04 --- /dev/null +++ b/crates/goose-sdk/examples/uniffi/Provider.kt @@ -0,0 +1,31 @@ +package aaif.example + +import io.aaif.goose.DeclarativeProvider +import io.aaif.goose.MessageRole +import io.aaif.goose.ProviderMessage +import io.aaif.goose.ProviderModelConfig +import java.nio.file.Paths + +fun main() { + val examplesDir = Paths.get("crates/goose-sdk/examples") + val provider = DeclarativeProvider.fromJson(examplesDir.resolve("deepseek.json").toFile().readText()) + val model = ProviderModelConfig(modelName = "deepseek-v4-flash") + val messages = listOf( + ProviderMessage( + role = MessageRole.USER, + text = "what is the capital of France?", + ), + ) + val stream = provider.stream( + model, + "You are a knowledgable geography expert", + messages, + ) + + while (true) { + val chunk = stream.next() ?: break + chunk.text?.let { print(it) } + chunk.usageJson?.let { println("\nusage: $it") } + } + println() +} diff --git a/crates/goose-sdk/examples/uniffi/README.md b/crates/goose-sdk/examples/uniffi/README.md new file mode 100644 index 000000000..9b426e6a8 --- /dev/null +++ b/crates/goose-sdk/examples/uniffi/README.md @@ -0,0 +1,53 @@ +# UniFFI examples + +These examples exercise the in-process Goose SDK UniFFI bindings from Python and Kotlin. + +## Prerequisites + +```bash +source bin/activate-hermit +export DEEPSEEK_API_KEY=... +``` + +## Generate bindings + +Regenerate the Python and Kotlin bindings before running the examples: + +```bash +just --justfile crates/goose-sdk/justfile _generate python +just --justfile crates/goose-sdk/justfile _generate kotlin +``` + +This writes generated bindings and the debug native library under `crates/goose-sdk/generated/`. + +## Python provider example + +```bash +DYLD_LIBRARY_PATH=target/debug LD_LIBRARY_PATH=target/debug \ + uv run --script crates/goose-sdk/examples/uniffi/provider.py +``` + +## Kotlin provider example + +Download JNA if it is not already present: + +```bash +curl -sSL -o crates/goose-sdk/examples/uniffi/jna.jar \ + https://repo1.maven.org/maven2/net/java/dev/jna/jna/5.14.0/jna-5.14.0.jar +``` + +Compile and run: + +```bash +kotlinc -cp crates/goose-sdk/examples/uniffi/jna.jar -nowarn \ + crates/goose-sdk/generated/io/aaif/goose/goose.kt \ + crates/goose-sdk/examples/uniffi/Provider.kt \ + -include-runtime -d crates/goose-sdk/examples/uniffi/provider.jar + +java -Djna.library.path=target/debug \ + --enable-native-access=ALL-UNNAMED \ + -cp crates/goose-sdk/examples/uniffi/provider.jar:crates/goose-sdk/examples/uniffi/jna.jar \ + aaif.example.ProviderKt +``` + +On Linux, use the same command; `LD_LIBRARY_PATH=target/debug` can also be set if needed. On macOS, `-Djna.library.path=target/debug` is usually enough, but `DYLD_LIBRARY_PATH=target/debug` can also be set if JNA cannot find `libgoose_sdk.dylib`. diff --git a/crates/goose-sdk/examples/uniffi/ping.py b/crates/goose-sdk/examples/uniffi/ping.py deleted file mode 100644 index 9503b0480..000000000 --- a/crates/goose-sdk/examples/uniffi/ping.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Minimal Goose SDK demo: ping the SDK and print the pong.""" - -from __future__ import annotations - -import sys -from pathlib import Path - -HERE = Path(__file__).resolve().parent -sys.path.insert(0, str(HERE.parent.parent / "generated")) - -from aaif_goose import Client # noqa: E402 - - -def main() -> None: - client = Client() - pong = client.ping("aaif.io") - print(pong.message) - - -if __name__ == "__main__": - main() diff --git a/crates/goose-sdk/examples/uniffi/provider.py b/crates/goose-sdk/examples/uniffi/provider.py new file mode 100755 index 000000000..0112f0b4b --- /dev/null +++ b/crates/goose-sdk/examples/uniffi/provider.py @@ -0,0 +1,38 @@ +#!/usr/bin/env -S uv run --script +"""Goose SDK demo: build a declarative provider and stream a completion.""" +import json +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parent.parent / "generated")) + +from goose import ( # noqa: E402 + DeclarativeProvider, + MessageRole, + ProviderMessage, + ProviderModelConfig, +) + + +def main() -> None: + provider = DeclarativeProvider.from_json((HERE.parent / "deepseek.json").read_text()) + model = ProviderModelConfig(model_name="deepseek-v4-flash") + messages = [ProviderMessage(role=MessageRole.USER, text="what is the capital of France?")] + stream = provider.stream( + model, + "You are a knowledgable geography expert", + messages, + ) + + while chunk := stream.next(): + if chunk.text: + print(chunk.text, end="") + if chunk.usage_json: + usage = json.loads(chunk.usage_json) + print(f"\nusage: {usage}") + print() + + +if __name__ == "__main__": + main() diff --git a/crates/goose-sdk/justfile b/crates/goose-sdk/justfile index 4d7acf015..407b48ef0 100644 --- a/crates/goose-sdk/justfile +++ b/crates/goose-sdk/justfile @@ -2,27 +2,37 @@ set shell := ["bash", "-cu"] set working-directory := '../..' lib_ext := if os() == "macos" { "dylib" } else if os() == "windows" { "dll" } else { "so" } -lib_dir := "./target/debug" -lib_path := lib_dir / "libgoose_sdk." + lib_ext -bindgen := "./target/debug/goose-uniffi-bindgen" +lib_prefix := if os() == "windows" { "" } else { "lib" } +lib_name := lib_prefix + "goose_sdk." + lib_ext +debug_lib_dir := "./target/debug" +release_lib_dir := "./target/release" +debug_lib_path := debug_lib_dir / lib_name +release_lib_path := release_lib_dir / lib_name +debug_bindgen := "./target/debug/goose-uniffi-bindgen" +release_bindgen := "./target/release/goose-uniffi-bindgen" config := "./crates/goose-sdk/uniffi.toml" gen_dir := "./crates/goose-sdk/generated" examples_dir := "./crates/goose-sdk/examples/uniffi" +python_dir := "./crates/goose-sdk/python" +python_package_dir := python_dir / "src/goose" -default: +_default: @just --list --justfile {{justfile()}} _build: cargo build -p goose-sdk --features uniffi -q +_build-release: + cargo build -p goose-sdk --features uniffi --release -q + _generate lang: _build - {{bindgen}} generate --library {{lib_path}} --config {{config}} --language {{lang}} --no-format --out-dir {{gen_dir}} 2>/dev/null - cp {{lib_path}} {{gen_dir}}/ + {{debug_bindgen}} generate --library {{debug_lib_path}} --config {{config}} --language {{lang}} --no-format --out-dir {{gen_dir}} 2>/dev/null + cp {{debug_lib_path}} {{gen_dir}}/ touch {{gen_dir}}/__init__.py python: (_generate "python") - DYLD_LIBRARY_PATH={{lib_dir}} LD_LIBRARY_PATH={{lib_dir}} \ - python3 {{examples_dir}}/ping.py + DYLD_LIBRARY_PATH={{debug_lib_dir}} LD_LIBRARY_PATH={{debug_lib_dir}} \ + python3 {{examples_dir}}/provider.py kotlin: (_generate "kotlin") @if [ ! -f {{examples_dir}}/jna.jar ]; then \ @@ -30,9 +40,118 @@ kotlin: (_generate "kotlin") https://repo1.maven.org/maven2/net/java/dev/jna/jna/5.14.0/jna-5.14.0.jar; \ fi kotlinc -cp {{examples_dir}}/jna.jar -nowarn \ - {{gen_dir}}/aaif/goose/aaif_goose.kt \ - {{examples_dir}}/Ping.kt \ - -include-runtime -d {{examples_dir}}/ping.jar 2>/dev/null - java -Djna.library.path={{lib_dir}} \ + {{gen_dir}}/io/aaif/goose/goose.kt \ + {{examples_dir}}/Provider.kt \ + -include-runtime -d {{examples_dir}}/provider.jar 2>/dev/null + java -Djna.library.path={{debug_lib_dir}} \ --enable-native-access=ALL-UNNAMED \ - -cp {{examples_dir}}/ping.jar:{{examples_dir}}/jna.jar aaif.example.PingKt + -cp {{examples_dir}}/provider.jar:{{examples_dir}}/jna.jar aaif.example.ProviderKt + +python-bindings profile="debug": + @case "{{profile}}" in \ + debug) cargo build -p goose-sdk --features uniffi -q; bindgen={{debug_bindgen}}; lib_path={{debug_lib_path}} ;; \ + release) cargo build -p goose-sdk --features uniffi --release -q; bindgen={{release_bindgen}}; lib_path={{release_lib_path}} ;; \ + *) echo 'profile must be debug or release' >&2; exit 1 ;; \ + esac; \ + rm -rf {{python_package_dir}}; \ + mkdir -p {{python_package_dir}}; \ + "$bindgen" generate --library "$lib_path" --config {{config}} --language python --no-format --out-dir {{python_package_dir}} 2>/dev/null; \ + mv {{python_package_dir}}/goose.py {{python_package_dir}}/__init__.py; \ + cp "$lib_path" {{python_package_dir}}/; \ + touch {{python_package_dir}}/py.typed + +python-wheel: (python-bindings "release") + rm -rf {{python_dir}}/build {{python_dir}}/dist {{python_dir}}/*.egg-info {{python_dir}}/src/*.egg-info + cd {{python_dir}} && UV_NO_CONFIG=1 PIP_CONFIG_FILE=/dev/null PIP_INDEX_URL=https://pypi.org/simple uvx --default-index https://pypi.org/simple --from build pyproject-build --wheel + +python-check: python-wheel + python3 -m pip install --force-reinstall {{python_dir}}/dist/*.whl + python3 -c 'import goose; print(goose.__name__)' + +python-publish repository="pypi": python-wheel + UV_NO_CONFIG=1 PIP_CONFIG_FILE=/dev/null PIP_INDEX_URL=https://pypi.org/simple uvx --default-index https://pypi.org/simple twine check {{python_dir}}/dist/*.whl + @if [ "{{repository}}" = "pypi" ]; then \ + UV_NO_CONFIG=1 PIP_CONFIG_FILE=/dev/null PIP_INDEX_URL=https://pypi.org/simple uvx --default-index https://pypi.org/simple twine upload {{python_dir}}/dist/*.whl; \ + else \ + UV_NO_CONFIG=1 PIP_CONFIG_FILE=/dev/null PIP_INDEX_URL=https://pypi.org/simple uvx --default-index https://pypi.org/simple twine upload --repository {{repository}} {{python_dir}}/dist/*.whl; \ + fi + +crates-publish dry_run="true": + @set -euo pipefail; \ + dry_run_flag=""; \ + if [ "{{dry_run}}" = "true" ]; then \ + dry_run_flag="--dry-run"; \ + elif [ "{{dry_run}}" != "false" ]; then \ + echo 'dry_run must be true or false' >&2; \ + exit 1; \ + fi; \ + for crate in \ + goose-provider-types \ + goose-sdk-types \ + goose-download-manager \ + goose-local-inference \ + goose-providers \ + goose-sdk; do \ + cargo publish -p "$crate" $dry_run_flag --allow-dirty; \ + done + +bump-version rust_version: + #!/usr/bin/env bash + set -euo pipefail + python3 - "{{rust_version}}" <<'PY' + import re + import sys + from pathlib import Path + + rust_version = sys.argv[1] + match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)(?:-alpha\.(\d+))?", rust_version) + if not match: + raise SystemExit("rust_version must look like 0.1.0 or 0.1.0-alpha.0") + + major, minor, patch, alpha = match.groups() + python_version = f"{major}.{minor}.{patch}" if alpha is None else f"{major}.{minor}.{patch}a{alpha}" + + crate_names = [ + "goose-provider-types", + "goose-sdk-types", + "goose-download-manager", + "goose-local-inference", + "goose-providers", + "goose-sdk", + ] + crate_paths = {name: Path("crates") / name / "Cargo.toml" for name in crate_names} + + def replace_unique(path: Path, pattern: str, replacement: str) -> None: + text = path.read_text() + new_text, count = re.subn(pattern, replacement, text, count=1, flags=re.MULTILINE) + if count != 1: + raise SystemExit(f"expected one match for {pattern!r} in {path}") + path.write_text(new_text) + + for path in crate_paths.values(): + replace_unique(path, r'^version\s*=\s*"[^"]+"', f'version = "{rust_version}"') + + dependency_names = [ + "goose-provider-types", + "goose-sdk-types", + "goose-download-manager", + "goose-local-inference", + "goose-providers", + ] + for path in crate_paths.values(): + text = path.read_text() + for dependency in dependency_names: + text = re.sub( + rf'({re.escape(dependency)}\s*=\s*[^\n]*version\s*=\s*)"[^"]+"', + rf'\1"{rust_version}"', + text, + ) + path.write_text(text) + + pyproject = Path("crates/goose-sdk/python/pyproject.toml") + replace_unique(pyproject, r'^version\s*=\s*"[^"]+"', f'version = "{python_version}"') + + print(f"Rust crates: {rust_version}") + print(f"Python package: {python_version}") + PY + cargo fmt --all diff --git a/crates/goose-sdk/python/README.md b/crates/goose-sdk/python/README.md new file mode 100644 index 000000000..492d4d4fc --- /dev/null +++ b/crates/goose-sdk/python/README.md @@ -0,0 +1,15 @@ +# goose-sdk + +Python bindings for the Goose SDK. + +This package is generated from the Rust `goose-sdk` crate using UniFFI. + +## Build a local wheel + +From the repository root: + +```bash +just --justfile crates/goose-sdk/justfile python-wheel +``` + +The wheel is written to `crates/goose-sdk/python/dist/`. diff --git a/crates/goose-sdk/python/pyproject.toml b/crates/goose-sdk/python/pyproject.toml new file mode 100644 index 000000000..c12f5b9b3 --- /dev/null +++ b/crates/goose-sdk/python/pyproject.toml @@ -0,0 +1,34 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "goose-sdk" +version = "0.1.0a0" +description = "Python bindings for the Goose SDK" +readme = "README.md" +requires-python = ">=3.9" +license = "Apache-2.0" +authors = [{ name = "AAIF", email = "ai-oss-tools@block.xyz" }] +keywords = ["goose", "sdk", "ai", "agent", "uniffi"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Rust", +] + +[project.urls] +Homepage = "https://github.com/aaif-goose/goose" +Repository = "https://github.com/aaif-goose/goose" +Issues = "https://github.com/aaif-goose/goose/issues" + +[tool.setuptools] +package-dir = { "" = "src" } + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +goose = ["*.so", "*.dylib", "*.dll", "py.typed"] diff --git a/crates/goose-sdk/python/setup.py b/crates/goose-sdk/python/setup.py new file mode 100644 index 000000000..5166d0ee9 --- /dev/null +++ b/crates/goose-sdk/python/setup.py @@ -0,0 +1,15 @@ +from setuptools import setup +from wheel.bdist_wheel import bdist_wheel + + +class BinaryWheel(bdist_wheel): + def finalize_options(self): + super().finalize_options() + self.root_is_pure = False + + def get_tag(self): + _, _, platform_tag = super().get_tag() + return "py3", "none", platform_tag + + +setup(cmdclass={"bdist_wheel": BinaryWheel}) diff --git a/crates/goose-sdk/src/bindings.rs b/crates/goose-sdk/src/bindings.rs index a601a744c..b44dd6601 100644 --- a/crates/goose-sdk/src/bindings.rs +++ b/crates/goose-sdk/src/bindings.rs @@ -1,14 +1,18 @@ //! In-process uniffi bindings for the Goose SDK. //! -//! This is the published API surface exposed to Python and Kotlin. Right now it -//! is a minimal `ping` -> `pong` round-trip that proves the uniffi -//! infrastructure end to end without depending on the `goose` core crate. -//! -//! To build the real SDK, add `goose` (and whatever else you need) as -//! dependencies and replace the [`Client`] methods below with the actual -//! agent surface. +//! This is the API surface exposed to Python and Kotlin. It currently focuses +//! on declarative providers: consumers can construct a provider from JSON and +//! stream completions from it. -use std::sync::Arc; +use std::sync::{Arc, Mutex}; + +use futures::StreamExt; +use goose_providers::{ + base::{MessageStream, Provider}, + conversation::message::Message, + declarative::EnvKeyResolver, + model::ModelConfig, +}; /// Errors surfaced across the uniffi boundary. #[derive(Debug, thiserror::Error, uniffi::Error)] @@ -17,36 +21,180 @@ pub enum GooseError { Generic(String), } -/// A reply to a [`Client::ping`] call. -#[derive(Debug, Clone, uniffi::Record)] -pub struct Pong { - /// Echo of the message that was pinged. - pub message: String, +impl From for GooseError { + fn from(error: anyhow::Error) -> Self { + Self::Generic(error.to_string()) + } } -/// The top-level entry point for the Goose SDK. -/// -/// This is the object that consuming languages instantiate. Today it only knows -/// how to answer a ping; extend it with the real agent API. +impl From for GooseError { + fn from(error: goose_providers::errors::ProviderError) -> Self { + Self::Generic(error.to_string()) + } +} + +impl From for GooseError { + fn from(error: serde_json::Error) -> Self { + Self::Generic(error.to_string()) + } +} + +/// A text message passed to a provider. +#[derive(Debug, Clone, uniffi::Record)] +pub struct ProviderMessage { + pub role: MessageRole, + pub text: String, +} + +/// Supported message roles for provider requests and streamed responses. +#[derive(Debug, Clone, uniffi::Enum)] +pub enum MessageRole { + User, + Assistant, +} + +impl ProviderMessage { + fn to_goose_message(&self) -> Message { + match self.role { + MessageRole::User => Message::user().with_text(&self.text), + MessageRole::Assistant => Message::assistant().with_text(&self.text), + } + } +} + +/// Model selection and optional generation settings for a provider request. +#[derive(Debug, Clone, uniffi::Record)] +pub struct ProviderModelConfig { + pub model_name: String, + #[uniffi(default = None)] + pub context_limit: Option, + #[uniffi(default = None)] + pub temperature: Option, + #[uniffi(default = None)] + pub max_tokens: Option, + #[uniffi(default = false)] + pub toolshim: bool, + #[uniffi(default = None)] + pub toolshim_model: Option, + /// Provider-specific request parameters as a JSON object string. + #[uniffi(default = None)] + pub request_params_json: Option, + #[uniffi(default = None)] + pub reasoning: Option, +} + +impl ProviderModelConfig { + fn to_goose_model_config(&self) -> Result { + let mut config = ModelConfig::new(&self.model_name) + .with_context_limit(self.context_limit.map(|limit| limit as usize)) + .with_temperature(self.temperature) + .with_max_tokens(self.max_tokens) + .with_toolshim(self.toolshim) + .with_toolshim_model(self.toolshim_model.clone()); + + if let Some(request_params_json) = &self.request_params_json { + let request_params = serde_json::from_str(request_params_json)?; + config = config.with_merged_request_params(request_params); + } + + config.reasoning = self.reasoning; + Ok(config) + } +} + +/// One item yielded by a provider stream. +#[derive(Debug, Clone, uniffi::Record)] +pub struct ProviderStreamChunk { + /// The concatenated text content in this message chunk, if one was emitted. + pub text: Option, + /// Full Goose message JSON for callers that need non-text content such as tool requests. + pub message_json: Option, + /// Provider usage JSON when the provider emits usage metadata. + pub usage_json: Option, +} + +/// A declarative Goose provider constructed from provider JSON. #[derive(uniffi::Object)] -pub struct Client {} +pub struct DeclarativeProvider { + provider: Box, + runtime: Arc, +} #[uniffi::export] -impl Client { +impl DeclarativeProvider { + /// Construct a declarative provider using the process environment to resolve + /// configured API key environment variables. #[uniffi::constructor] - pub fn new() -> Arc { - Arc::new(Self {}) + pub fn from_json(json: String) -> Result, GooseError> { + let provider = goose_providers::declarative::from_json(&json, None, EnvKeyResolver {})?; + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| GooseError::Generic(error.to_string()))?; + + Ok(Arc::new(Self { + provider, + runtime: Arc::new(runtime), + })) } - /// Round-trip a message through the SDK. Returns a [`Pong`] echoing the - /// supplied `message`, prefixed with `pong: `. - pub fn ping(&self, message: String) -> Result { - if message.is_empty() { - return Err(GooseError::Generic("message must not be empty".into())); - } - Ok(Pong { - message: format!("pong: {message}"), - }) + pub fn name(&self) -> String { + self.provider.get_name().to_string() + } + + /// Start a streaming completion request. Tools are not yet exposed over the + /// uniffi boundary, so this calls providers with an empty tool list. + pub fn stream( + &self, + model: ProviderModelConfig, + system: String, + messages: Vec, + ) -> Result, GooseError> { + let model = model.to_goose_model_config()?; + let messages = messages + .iter() + .map(ProviderMessage::to_goose_message) + .collect::>(); + let stream = + self.runtime + .block_on(self.provider.stream(&model, &system, &messages, &[]))?; + + Ok(Arc::new(DeclarativeProviderStream { + stream: Mutex::new(stream), + runtime: Arc::clone(&self.runtime), + })) + } +} + +/// A blocking iterator over provider stream chunks. +#[derive(uniffi::Object)] +pub struct DeclarativeProviderStream { + stream: Mutex, + runtime: Arc, +} + +#[uniffi::export] +impl DeclarativeProviderStream { + /// Return the next stream chunk, or `None` when the stream is exhausted. + pub fn next(&self) -> Result, GooseError> { + let mut stream = self + .stream + .lock() + .map_err(|_| GooseError::Generic("provider stream lock poisoned".to_string()))?; + + let Some((message, usage)) = self.runtime.block_on(stream.next()).transpose()? else { + return Ok(None); + }; + + let text = message.as_ref().map(Message::as_concat_text); + let message_json = message.as_ref().map(serde_json::to_string).transpose()?; + let usage_json = usage.as_ref().map(serde_json::to_string).transpose()?; + + Ok(Some(ProviderStreamChunk { + text, + message_json, + usage_json, + })) } } @@ -55,15 +203,29 @@ mod tests { use super::*; #[test] - fn ping_returns_pong() { - let client = Client::new(); - let pong = client.ping("aaif.io".into()).expect("ping should succeed"); - assert_eq!(pong.message, "pong: aaif.io"); + fn model_config_rejects_invalid_request_params_json() { + let config = ProviderModelConfig { + model_name: "test".to_string(), + context_limit: None, + temperature: None, + max_tokens: None, + toolshim: false, + toolshim_model: None, + request_params_json: Some("not json".to_string()), + reasoning: None, + }; + + assert!(config.to_goose_model_config().is_err()); } #[test] - fn empty_ping_errors() { - let client = Client::new(); - assert!(client.ping(String::new()).is_err()); + fn provider_message_converts_user_text() { + let message = ProviderMessage { + role: MessageRole::User, + text: "what is the capital of France?".to_string(), + } + .to_goose_message(); + + assert_eq!(message.as_concat_text(), "what is the capital of France?"); } } diff --git a/crates/goose-sdk/src/lib.rs b/crates/goose-sdk/src/lib.rs index bab0fa2a5..4742ea2de 100644 --- a/crates/goose-sdk/src/lib.rs +++ b/crates/goose-sdk/src/lib.rs @@ -5,17 +5,15 @@ //! that talks to `goose acp` over stdio. //! //! With `--features uniffi` the crate additionally compiles as a -//! `cdylib`/`staticlib` and exposes a small in-process API to Python and Kotlin -//! via [uniffi-rs](https://github.com/mozilla/uniffi-rs). -//! -//! The published uniffi surface is intentionally a single `ping` -> `pong` -//! round-trip. It exists as a working scaffold for adding the real Goose SDK -//! API: replace [`bindings`] with the actual implementation. +//! `cdylib`/`staticlib` and exposes an in-process API to Python and Kotlin via +//! [uniffi-rs](https://github.com/mozilla/uniffi-rs). The current uniffi surface +//! lets callers construct declarative providers from JSON and stream provider +//! completions. pub use goose_sdk_types::{custom_notifications, custom_requests}; #[cfg(feature = "uniffi")] -uniffi::setup_scaffolding!("aaif_goose"); +uniffi::setup_scaffolding!("goose"); #[cfg(feature = "uniffi")] pub mod bindings; diff --git a/crates/goose-sdk/uniffi.toml b/crates/goose-sdk/uniffi.toml index 7c78ef712..aef1f592c 100644 --- a/crates/goose-sdk/uniffi.toml +++ b/crates/goose-sdk/uniffi.toml @@ -1,2 +1,2 @@ [bindings.kotlin] -package_name = "aaif.goose" +package_name = "io.aaif.goose"