Repository navigation
feat(ruby): run a function spec with a host-built client - #5495
Draft
ryanmazzolini wants to merge 8 commits into
Draft
ryanmazzolini wants to merge 8 commits into
ryanmazzolini wants to merge 8 commits into
Conversation
Contributor
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
|
|
@ryanmazzolini is attempting to deploy a commit to the Boundary Team on Vercel. A member of the Team first needs to authorize it. |
This was referenced Nov 3, 2026
ryanmazzolini
force-pushed
the
feat/ruby-sdk/8-spec-call
branch
4 times, most recently
from
November 5, 2026 21:50
30a1a14 to
57720ea
Compare
…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
force-pushed
the
feat/ruby-sdk/8-spec-call
branch
from
November 6, 2026 00:27
57720ea to
6da25a8
Compare
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
force-pushed
the
feat/ruby-sdk/8-spec-call
branch
from
November 9, 2026 15:27
6da25a8 to
6cc1d14
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Adds
FunctionSpec#callandcall_async, so Ruby can run a bound spec with a host-built client for one call. This is how the new SDKs replace the legacyClientRegistry, and Python (spec.call(client=...)) and TypeScript (spec.call({ client })) already expose it. Until now Ruby had to go through the internalBaml::Bridge.call_async.Details
client:replaces the function's client for this call only;nilkeeps the default.build_request(client:)already worked the same way.Baml::FunctionSpecis now generic in its output type, likeBaml::Stream, so the generator emitsBaml::FunctionSpec[Out]and Sorbet checkscall's result. A spec whose output type Ruby can't express is still generated, with an untyped result.clientstaysT.untyped, as in Python's wrapper. Typing it needs BAML interface support in the Ruby generator, which doesn't exist yet.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 checkscall'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_asyncpath that this replaces.Developed with AI assistance.