diff --git a/Cargo.lock b/Cargo.lock index c22c0c45ee..fe946b9801 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5142,7 +5142,11 @@ version = "1.40.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 6bd1e5a34b..bcaaa381d8 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/examples/uniffi/provider.py b/crates/goose-sdk/examples/uniffi/provider.py new file mode 100644 index 0000000000..cc95504866 --- /dev/null +++ b/crates/goose-sdk/examples/uniffi/provider.py @@ -0,0 +1,52 @@ +"""Goose SDK demo: build a declarative provider and stream a completion.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parent.parent / "generated")) + +from aaif_goose import ( # noqa: E402 + DeclarativeProvider, + MessageRole, + ProviderMessage, + ProviderModelConfig, +) + + +def main() -> None: + provider_json = (HERE.parent.parent.parent / "goose-providers" / "examples" / "deepseek.json").read_text() + provider = DeclarativeProvider.from_json(provider_json) + model = ProviderModelConfig( + model_name="deepseek-v4-flash", + context_limit=None, + temperature=None, + max_tokens=None, + toolshim=False, + toolshim_model=None, + request_params_json=None, + reasoning=None, + ) + messages = [ProviderMessage(role=MessageRole.USER, text="what is the capital of France?")] + stream = provider.stream( + model, + "", + "You are a knowledgable geography expert", + messages, + ) + + print(f"{provider.name()}:") + 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/src/bindings.rs b/crates/goose-sdk/src/bindings.rs index a601a744c0..f966489206 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,178 @@ 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, + pub context_limit: Option, + pub temperature: Option, + pub max_tokens: Option, + pub toolshim: bool, + pub toolshim_model: Option, + /// Provider-specific request parameters as a JSON object string. + pub request_params_json: Option, + 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, + session_id: String, + 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, + &session_id, + &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 +201,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 bab0fa2a52..754eabe37f 100644 --- a/crates/goose-sdk/src/lib.rs +++ b/crates/goose-sdk/src/lib.rs @@ -5,12 +5,10 @@ //! 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};