Skip to content

feat(ruby): run a function spec with a host-built client - #5495

Draft
ryanmazzolini wants to merge 8 commits into
BoundaryML:canaryfrom
ryanmazzolini:feat/ruby-sdk/8-spec-call
Draft

ryanmazzolini wants to merge 8 commits into
BoundaryML:canaryfrom
ryanmazzolini:feat/ruby-sdk/8-spec-call

Conversation

@ryanmazzolini

@ryanmazzolini ryanmazzolini commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Builds on #5033. PRs from a fork can only target canary, so this diff also contains #5033's seven commits. Review only this PR's changes.

Description

Adds FunctionSpec#call and call_async, so Ruby can run a bound spec with a host-built client for one call. This is how the new SDKs replace the legacy ClientRegistry, and Python (spec.call(client=...)) and TypeScript (spec.call({ client })) already expose it. Until now Ruby had to go through the internal Baml::Bridge.call_async.

spec = BamlSdk.parse_order_spec(text)
client = BamlSdk::Vendor::Google::GeminiClient.baml_new(model: "gemini-2.5-flash-lite")
order = spec.call(client: client)   # typed as the function's result

Details

  • client: replaces the function's client for this call only; nil keeps the default. build_request(client:) already worked the same way.
  • Baml::FunctionSpec is now generic in its output type, like Baml::Stream, so the generator emits Baml::FunctionSpec[Out] and Sorbet checks call's result. A spec whose output type Ruby can't express is still generated, with an untyped result.
  • client stays T.untyped, as in Python's wrapper. Typing it needs BAML interface support in the Ruby generator, which doesn't exist yet.
  • The gem README gains a short section on choosing a client at call time.

Testing

Linux x86_64, same commands as #5033: 125 tests passed, 0 skipped. Formatting, strict Clippy, CLI build and parity lint pass.

A new replay-server test runs a spec with its default client, with a host-built override, and with an unreachable override that must fail. The checked-in recordings are streaming, so the replay harness can now serve an inline non-streaming body. The test is Ruby-only and marked SDK_PARITY_LINT(skip), since no shared test runs a spec non-streaming. The typed consumer checks call's Sorbet type.

Not run: the test suite on macOS or Windows, and upstream CI. The macOS service test described in #5033 used the internal call_async path that this replaces.

Developed with AI assistance.

@coderabbitai

coderabbitai Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

@vercel

vercel Bot commented Nov 3, 2026

Copy link
Copy Markdown

@ryanmazzolini is attempting to deploy a commit to the Boundary Team on Vercel.

A member of the Team first needs to authorize it.

…the C bridge

Ruby had a V1 bridge loader but no way to generate an SDK or make calls on
ABI revision 5. This adds the foundation every later Ruby change builds on.

`baml-cli generate` gains a `ruby/sorbet` output type. The generator emits the
whole symbol pool, standard library included, as T::Struct classes, T::Enum
enums and Sorbet-signed functions and methods, each with an `_async` sibling.
Values cover null, booleans, integers, floats, strings, single literals,
lists, string-keyed maps, optionals, classes and enums; declarations that need
anything else are skipped until later changes. Names that collide in Ruby are
escaped with a trailing underscore, in Python's allocation order. Bundled
provider packages are written under baml_sdk/__vendor/, because Tapioca's
default Sorbet config ignores every vendor/ directory. The package registers
its bytecode, embedded metadata, types and parameter descriptors once when it
loads.

The runtime returns a Baml::Call promise for every call; sync methods wait on
it. Results own their engine handles until decoded, abandoned calls are
released, and callback registration is safe against interrupts. One program
runs per process, a forked child using an inherited runtime raises, unhandled
spawn errors end the process as in Python, and the runtime shuts down at exit.
The runtime's Sorbet contract is rbi/baml.rbi.

Under ABI 5 the engine converts nothing: it builds the value the SDK states
and checks it against the declared type. So the runtime encodes each
argument against its declared type in three steps. It reads the Ruby value,
plans the wire value without side effects, choosing union arms and making
exact conversions such as 5 to 5.0 for a float, then commits it, stating
the type of every list, map and class. Depth, node and planning-step
budgets refuse cyclic or pathological values with TypeError, and every
engine reference a refused or abandoned result carries is released.

Also adds the committed Ruby protobuf clients and their CI drift check, Ruby
support in the SDK parity lint, and the shared harness gates (minitest and
Sorbet per fixture).

Tests: adds encoder tests for the read, plan and commit rules; 109 nextest
targets pass and Ruby declares 27 shared SDK tests.

Developed with AI assistance.
…strings

The foundation encodes null, primitives, single literals, classes, enums,
lists, string-keyed maps and optionals. Declarations using any other value
kind were skipped.

This adds bigints, bytes, media values (Baml::Image, Audio, Video and Pdf,
sent as content), general unions, literal unions, enum-variant types, type
aliases and non-string map keys. A Ruby String is sent as bytes where the parameter is declared bytes;
binary Strings also select bytes in untyped slots and string-or-bytes unions. Aliases that form a cycle widen their recursive edges to T.untyped,
because Sorbet cannot express cycles; references outside that cycle stay named. Float parameters and fields
accept Integer, matching Python. Generated code now carries docstrings and
Raises sections.

Under ABI 5 these kinds go through the walker too: bigints keep their kind,
including literals such as 42n; recursive aliases are stated by name with a
cycle guard; and only string map keys can be sent, as the bridge requires.

Tests: ports the shared type_shapes, docstrings_etc and raises tests; 118
nextest targets pass and Ruby declares 132 shared SDK tests.

Developed with AI assistance.
Callables with defaulted arguments were skipped, calls could not be timed out
or cancelled, and errors exposed only a type name and message.

Defaulted parameters become keywords that default to Baml::UNSET, so BAML
applies its own default; nil sends an explicit null. Every generated callable
accepts `_baml: Baml::Options.new(timeout_ms:, cancel:)`. The timeout becomes
an absolute deadline on the engine's clock, and cancel takes a generated
CancelToken. Baml::Error now carries the decoded payload and BAML trace,
engine cancellation raises Baml::CancelledError, a boundary TypeMismatch
raises TypeError, and an exit panic ends the process with its exit code.

Under ABI 5 the cancel token goes through the walker, omitted arguments are
left out before encoding, and JSON nested deeper than the wire allows is
refused with TypeError.

Tests: ports the shared error, optional-argument, invocation, cancellation
and JSON tests, and the HTTP response handle test, which needs Http.fetch's
optional timeout; 118 nextest targets pass and Ruby declares 166 shared SDK
tests.

Developed with AI assistance.
Generic declarations were skipped.

Generic classes are T::Structs with one type_member per BAML type parameter,
and generic callables get type_parameters signatures. The engine infers most
type arguments; `_types: { T: Integer }` binds the rest, checked by name and
count in Ruby because the engine ignores unknown names. Sorbet erases
GenericBox[Integer] at runtime, so a host-built instance that must carry its
arguments uses Baml.bind_types(instance, Integer). Decoded instances keep
their type arguments when passed back.

Under ABI 5 a stated type can't contain type variables, so the runtime
substitutes `_types:` and the receiver's bindings, and solves an unbound
instance's arguments from its field values. An empty container for an
unbound variable raises TypeError asking for `_types:`. Type tokens are now
plain descriptors.

Tests: ports the shared generic call, inference and round-trip tests, skipping
the callable-subscript arity test as Java does; 120 nextest targets pass and
Ruby declares 263 shared SDK tests.

Developed with AI assistance.
@ryanmazzolini
ryanmazzolini force-pushed the feat/ruby-sdk/8-spec-call branch from 57720ea to 6da25a8 Compare November 6, 2026 00:27
BAML functions could not call Ruby code, and BAML closures returned to Ruby
could not be called.

Any object responding to #call is registered as a host value. Each host
invocation runs on its own Ruby thread and completes exactly once: a return
value is encoded with its declared type, a raised Baml::Error sends its BAML
value, and any other exception is kept so it comes back to Ruby as the same
object. Cancellation is cooperative; resources stay alive until the proc
returns. Result capture pins borrowed host values so the engine cannot release
them before decoding. Returned closures decode to Baml::Closure with call and
call_async.

BAML function types are T.any(T.proc..., Baml::Closure). Sorbet's runtime
T.proc check accepts only Proc, so generated methods taking or returning
functions skip runtime checking and keep their static signatures.

Under ABI 5 a callable is sent with its declared function type, and a
callback completes with one of five outcomes: returned, thrown, raised,
contract violation or bridge failure. An exception raised while reading the
callback's result counts as the callback's own.

Tests: ports the shared host-callable tests and the call-control and generic
tests that need callbacks, skipping release on engine GC as Java does; 121
nextest targets pass and Ruby declares 297 shared SDK tests.

Developed with AI assistance.
LLM functions' `@stream` and `@spec` companions were skipped, because their
return types arrive as bare engine handles.

`f_stream` and `f_stream_async` return Baml::Stream[T]: next returns a partial
value or the generated Done, final returns the settled value, and each yields
non-null partials. `f_spec` and `f_spec_async` return Baml::FunctionSpec with
build_request. Both call the BAML methods with the handle as `self`.

Tests: ports the shared llm_functions binding and streaming tests against the
replay server; 122 nextest targets pass and Ruby declares 307 shared SDK
tests, the same as Java.

Developed with AI assistance.
The runtime could only be loaded from the repository checkout.

Adds the pure-Ruby `baml-bridge` gem: the runtime, committed protobuf clients
and the Sorbet RBI, depending on ffi, google-protobuf and sorbet-runtime. The
`baml` name on RubyGems belongs to the legacy BAML v0 client, so this follows
the other new SDKs, which publish as baml-bridge. The bridge library is found
through BAML_BRIDGE_PATH or, when unset, the shared
$BAML_HOME/bridges/<version>/<target>/ cache. It is not downloaded.

Tests: a clean-app check builds the gem, installs it offline outside the
checkout, generates an SDK with baml-cli and calls it through the cache. Where
the pinned Sorbet runs, it also type-checks the app with the installed RBI and
Tapioca's default vendor/ ignore; 123 nextest targets pass.

Developed with AI assistance.
Ruby's FunctionSpec wrapped only build_request, so choosing an LLM client at
call time meant going through the internal Baml::Bridge.call_async. Python
and TypeScript expose this as spec.call(client=...); it is how the new SDKs
replace the legacy ClientRegistry.

FunctionSpec#call and #call_async run the spec, and an optional client:
replaces the function's client for that call only. FunctionSpec is now
generic in its output type, like Baml::Stream, so the generator emits
Baml::FunctionSpec[Out] and Sorbet checks call's result. A spec whose output
type Ruby can't express stays available, with an untyped result. The README
gains a section on choosing a client at call time.

Tests: a replay-server test runs a spec with its default client, with a
host-built override, and with an unreachable override that must fail. The
replay harness can now serve a non-streaming body, since the checked-in
recordings are streaming. The typed consumer checks call's Sorbet type; 123
nextest targets pass.

Developed with AI assistance.
@ryanmazzolini
ryanmazzolini force-pushed the feat/ruby-sdk/8-spec-call branch from 6da25a8 to 6cc1d14 Compare November 9, 2026 15:27

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant