diff --git a/Cargo.lock b/Cargo.lock index e1020de80700..f5eac5ec4ef8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5209,7 +5209,11 @@ version = "1.41.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", diff --git a/crates/goose-sdk/Cargo.toml b/crates/goose-sdk/Cargo.toml index 6bd1e5a34bba..bcaaa381d821 100644 --- a/crates/goose-sdk/Cargo.toml +++ b/crates/goose-sdk/Cargo.toml @@ -19,7 +19,15 @@ 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" } @@ -28,6 +36,11 @@ agent-client-protocol-schema = { workspace = true } uniffi = { version = "0.31", features = ["cli"], optional = true } thiserror = { version = "2", optional = true } +goose-providers = { version = "1.39.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 3dfb839e374c..0334ae4bcc6a 100644 --- a/crates/goose-sdk/README.md +++ b/crates/goose-sdk/README.md @@ -4,13 +4,12 @@ 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`. diff --git a/crates/goose-sdk/examples/deepseek.json b/crates/goose-sdk/examples/deepseek.json new file mode 100644 index 000000000000..1d220744375d --- /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 0f043ee00301..000000000000 --- 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 000000000000..6e23e2f0448d --- /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 000000000000..9b426e6a89a0 --- /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 9503b048080d..000000000000 --- 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 000000000000..0112f0b4bce1 --- /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 4d7acf0151e2..8bdf8bd30618 100644 --- a/crates/goose-sdk/justfile +++ b/crates/goose-sdk/justfile @@ -22,7 +22,7 @@ _generate lang: _build python: (_generate "python") DYLD_LIBRARY_PATH={{lib_dir}} LD_LIBRARY_PATH={{lib_dir}} \ - python3 {{examples_dir}}/ping.py + uv run --script {{examples_dir}}/provider.py kotlin: (_generate "kotlin") @if [ ! -f {{examples_dir}}/jna.jar ]; then \ @@ -30,9 +30,9 @@ 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 + {{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={{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 diff --git a/crates/goose-sdk/src/bindings.rs b/crates/goose-sdk/src/bindings.rs index a601a744c024..b44dd6601c92 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, Mutex}; -use std::sync::Arc; +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. +impl From for GooseError { + fn from(error: anyhow::Error) -> Self { + Self::Generic(error.to_string()) + } +} + +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 Pong { - /// Echo of the message that was pinged. - pub message: String, +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, } -/// 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. +/// 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 bab0fa2a5296..4742ea2de8dd 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 7c78ef712132..aef1f592c9b8 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"