Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
324 changes: 309 additions & 15 deletions Cargo.lock

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions crates/goose-sdk-types/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
[package]
name = "goose-sdk-types"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
description = "Shared types for the Goose SDK"

[features]
default = []
uniffi = ["dep:uniffi"]

[dependencies]
agent-client-protocol = { workspace = true, features = ["unstable"] }
agent-client-protocol-schema = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
schemars = { workspace = true, features = ["derive"] }
uniffi = { version = "0.29", optional = true }

[package.metadata.cargo-machete]
ignored = ["agent-client-protocol-schema"]
Original file line number Diff line number Diff line change
Expand Up @@ -1283,3 +1283,53 @@ pub struct DictationModelSelectRequest {
pub provider: String,
pub model_id: String,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
#[serde(rename_all = "camelCase")]
pub struct ProviderSpec {
pub name: Option<String>,
pub model: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ExtensionSpec {
Builtin {
name: String,
},
Stdio {
name: String,
cmd: String,
args: Vec<String>,
envs: HashMap<String, String>,
},
StreamableHttp {
name: String,
uri: String,
headers: HashMap<String, String>,
},
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AgentEvent {
AssistantText {
text: String,
},
Thinking {
text: String,
},
ToolRequest {
id: String,
name: String,
arguments: String,
},
ToolResponse {
id: String,
output: String,
is_error: bool,
},
}
8 changes: 8 additions & 0 deletions crates/goose-sdk-types/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//! Shared types for the Goose SDK.

#[cfg(feature = "uniffi")]
uniffi::setup_scaffolding!();

pub mod custom_requests;

pub use custom_requests::{AgentEvent, ExtensionSpec, ProviderSpec};
4 changes: 4 additions & 0 deletions crates/goose-sdk-types/uniffi.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[bindings.kotlin]
package_name = "io.aaif.goose.sdk_types"

[bindings.python]
1 change: 1 addition & 0 deletions crates/goose-sdk/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
generated
36 changes: 34 additions & 2 deletions crates/goose-sdk/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,51 @@ rust-version.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
description = "Rust SDK for talking to Goose over the Agent Client Protocol (ACP)"
description = "Rust SDK for Goose with optional uniffi bindings for Python/Kotlin"

[lib]
name = "goose_sdk"
crate-type = ["cdylib", "staticlib", "rlib"]

[[bin]]
name = "goose-uniffi-bindgen"
path = "src/bin/uniffi-bindgen.rs"
required-features = ["uniffi"]

[features]
default = []
uniffi = [
"dep:uniffi",
"dep:goose",
"dep:tokio",
"dep:futures",
"dep:anyhow",
"dep:thiserror",
"dep:async-trait",
"dep:rmcp",
"goose-sdk-types/uniffi",
]

[dependencies]
goose-sdk-types = { path = "../goose-sdk-types" }
agent-client-protocol = { workspace = true, features = ["unstable"] }
agent-client-protocol-schema = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
schemars = { workspace = true, features = ["derive"] }

goose = { path = "../goose", optional = true }
uniffi = { version = "0.29", features = ["tokio", "cli"], optional = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync"], optional = true }
futures = { workspace = true, optional = true }
anyhow = { workspace = true, optional = true }
thiserror = { version = "2", optional = true }
async-trait = { workspace = true, optional = true }
rmcp = { workspace = true, optional = true }

[dev-dependencies]
tokio = { workspace = true }
tokio-util = { workspace = true, features = ["compat", "rt"] }

[package.metadata.cargo-machete]
# Used to provide extras imports for agent-client-protocol
ignored = ["agent-client-protocol-schema"]
101 changes: 101 additions & 0 deletions crates/goose-sdk/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# goose-sdk

The Goose SDK exposes Goose's agent functionality outside of the main `goose` binary

## 1. ACP client/server (default)

With default features, this crate is a thin Rust library re-exporting the shared types so you can build an Agent Client Protocol client that talks to `goose acp` (or any ACP-compatible Goose server) over stdio.

See `examples/acp_client.rs`:

```bash
cargo run -p goose-sdk --example acp_client -- "What is 2 + 2?"
```

This path has no dependency on the `goose` core crate — it speaks to Goose as an external process via ACP + Goose's custom `_goose/*` JSON-RPC methods.

## 2. uniffi bindings (Python / Kotlin)

With `--features uniffi`, the crate compiles as a `cdylib`/`staticlib` that embeds the `goose` core in-process and exposes an `Agent` object to Python and Kotlin via [uniffi-rs](https://github.com/mozilla/uniffi-rs).

Build the library, generate bindings, and run the example pings:

```bash
just python # generates Python bindings + runs examples/uniffi/ping_aaif.py
just kotlin # generates Kotlin bindings + runs examples/uniffi/PingAaif.kt
```

Generated bindings land in `generated/`. The shared types from `goose-sdk-types` appear as native records in both languages.

## Packaging

Build a distributable artifact for the current platform. Both artifacts bundle the native `libgoose_sdk` for the host platform/arch.

### Python wheel

```bash
just python-wheel
pip install crates/goose-sdk/packaging/python/dist/goose_sdk-*.whl
```

```python
from goose_sdk import Agent, EventSink
from goose_sdk.goose_sdk_types import ProviderSpec, ExtensionSpec, AgentEvent

class Printer(EventSink):
def on_event(self, event):
if isinstance(event, AgentEvent.ASSISTANT_TEXT):
print(event.text, end="", flush=True)
def on_error(self, error): print("error:", error)
def on_done(self): print()

agent = Agent()
agent.configure(
ProviderSpec(name="openai", model="gpt-4o"),
[ExtensionSpec.BUILTIN(name="developer")],
)
agent.reply("ping aaif.io", Printer())
```

### Kotlin/JVM JAR

```bash
just kotlin-jar
# → crates/goose-sdk/packaging/kotlin/dist/goose-sdk-0.1.0-<os>-<arch>.jar
```

Consumers must also have `net.java.dev.jna:jna:5.14.0` and `org.jetbrains.kotlin:kotlin-stdlib` on the classpath. Call `NativeLoader.ensureLoaded()` once before touching any uniffi-generated type — it extracts the bundled native library and points JNA at it.

```kotlin
import io.aaif.goose.sdk.{Agent, EventSink, NativeLoader}
import io.aaif.goose.sdk_types.{AgentEvent, ExtensionSpec, ProviderSpec}

fun main() {
NativeLoader.ensureLoaded()
val agent = Agent()
agent.configure(
ProviderSpec(name = "openai", model = "gpt-4o"),
listOf(ExtensionSpec.Builtin(name = "developer")),
)
agent.reply("ping aaif.io", object : EventSink {
override fun onEvent(event: AgentEvent) {
if (event is AgentEvent.AssistantText) print(event.text)
}
override fun onError(error: String) = System.err.println("error: $error")
override fun onDone() = println()
})
}
```

Provider credentials for both are read from the same global Goose config (env vars, OS keyring, `~/.config/goose/config.yaml`) used by the `goose` CLI.

## When to use which

- **ACP** — use this when you need the full Goose feature surface (sessions, sources, providers, dictation, onboarding, etc.) or process isolation, and you're happy to spawn `goose acp` as a subprocess and speak JSON-RPC over stdio from any language.
- **uniffi** — use this when you want the Goose agent embedded directly inside a Python or Kotlin host process with native types, lower latency, and no subprocess, and the current minimal `Agent` surface (`configure` + `reply`) is enough for your use case.

## Shared types: `goose-sdk-types`

The `goose-sdk-types` crate holds the wire types used by both consumers above — request/response structs for Goose's custom JSON-RPC ACP methods (`AddExtensionRequest`, `GooseToolCallRequest`, provider/session/sources/dictation requests, etc.) and the streaming `AgentEvent`, `ExtensionSpec`, and `ProviderSpec` records.

Keeping these types in a small, dependency-light crate lets the ACP path serialize/deserialize them as JSON-RPC and the uniffi path expose them as native records in Python/Kotlin — from one source of truth.
81 changes: 81 additions & 0 deletions crates/goose-sdk/examples/uniffi/PingAaif.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package examples

import io.aaif.goose.sdk.Agent
import io.aaif.goose.sdk.EventSink
import io.aaif.goose.sdk_types.AgentEvent
import io.aaif.goose.sdk_types.ExtensionSpec
import io.aaif.goose.sdk_types.ProviderSpec

private object Style {
const val DIM = "\u001B[2m"
const val CYAN = "\u001B[36m"
const val GREEN = "\u001B[32m"
const val RED = "\u001B[31m"
const val RESET = "\u001B[0m"
}

private fun String.paint(color: String) = "$color$this${Style.RESET}"

private fun String.preview(maxLines: Int = 3, maxWidth: Int = 100): String =
lineSequence()
.filter { it.isNotBlank() }
.map { it.take(maxWidth) }
.take(maxLines)
.joinToString("\n ")

private class Printer : EventSink {
private var midText = false

override fun onEvent(event: AgentEvent) {
when (event) {
is AgentEvent.AssistantText -> {
print(event.text)
midText = true
}
is AgentEvent.ToolRequest -> {
endTextLine()
val args = event.arguments.replace("\n", " ").take(120)
println("→ ${event.name}".paint(Style.CYAN) + " " + args.paint(Style.DIM))
}
is AgentEvent.ToolResponse -> {
endTextLine()
val color = if (event.isError) Style.RED else Style.GREEN
val marker = if (event.isError) "✗" else "✓"
println(marker.paint(color) + " " + event.output.preview().paint(Style.DIM))
println()
}
is AgentEvent.Thinking -> Unit
}
System.out.flush()
}

override fun onError(error: String) {
System.err.println("\n${"error:".paint(Style.RED)} $error")
}

override fun onDone() = endTextLine()

private fun endTextLine() {
if (midText) {
println()
midText = false
}
}
}

fun main() {
System.err.println("configuring agent…".paint(Style.DIM))

val agent = Agent().apply {
configure(
ProviderSpec(
name = System.getenv("GOOSE_PROVIDER"),
model = System.getenv("GOOSE_MODEL"),
),
listOf(ExtensionSpec.Builtin(name = "developer")),
)
}

System.err.println("> ping aaif.io".paint(Style.DIM) + "\n")
agent.reply("ping aaif.io", Printer())
}
Loading
Loading