Arcana::Toolset
A multi-tool bus provider. One directory listing, many callable tools
dispatched by the tool field in the payload. Auto-registers a
help tool that returns the tool manifest so callers can discover
what the provider offers.
Use when you have more than one operation to expose under a single
address. For a single-purpose service (echo, markdown, one chat
endpoint), use Arcana::Service directly.
Two transports.
-
In-process (Bus + Directory) — for tools registered inside the arcana daemon itself:
ts = Arcana::Toolset.new( bus: bus, directory: dir, address: "markdown", name: "Markdown", description: "Converts markdown to HTML/ANSI", ) ts.tool("to_html", ...) { |data| ... } ts.start
-
Over a WebSocket Client — for a separate process (your Kemal app, mj, etc.) exposing tools on the daemon's bus:
client = Arcana::Client.new( url: "ws://localhost:19118/bus", address: "mj", name: "Minanime", description: "Image generation studio", kind: Arcana::Directory::Kind::Service, tags: ["image"], ) ts = Arcana::Toolset.new(client: client) ts.tool("pixelize", "Pixel-art stylize", input_schema: ...) do |data| JSON::Any.new({"image_base64" => ...}) end ts.run # joins the bus, then blocks running the WebSocket loop
runisstart+client.connect. Over a Client,startalone only installs the message handler: the service has no directory listing until the Client connects, and a Toolset still unconnectedconnect_graceafterstartwarns on STDERR. To serve several Toolsets from one process, run all but the last in fibers:spawn { camera.run } voice.run
Same tool-registration API, same manifest shape, same dispatch. The transport is the only thing that differs.
Concurrency. Requests run concurrently, each in its own fiber, at
most max_concurrent at once (default 8; nil for no cap). Set it to
what the backend your handlers call can take: max_concurrent: 3 for
a provider that asks for 2–4 concurrent requests. The listing reports
busy while at least one request is in flight. See Dispatcher.
Lanes, for a toolset in front of several backends. One cap for the whole toolset lets requests queued for a slow backend crowd out ones another backend could serve. Give each backend a lane with its own cap, and say which lane a request takes, fixed per tool or worked out from its data:
ts.lane("runware", max_concurrent: 3) ts.lane("google", max_concurrent: 6) ts.tool("shoot", "Take a picture", lane: ->(data : JSON::Any) { backend_for(data) }) { |data| ... }
A request waits for its lane before it takes one of the toolset's
slots, so waiting on a busy lane blocks no one else; max_concurrent
stays the overall backstop. The lane function runs before the
handler: keep it quick, and return nil for no lane.
Shared state. Handlers run at the same time, and even though
fibers share one thread, any IO can pause one partway through while
another runs. State they share (an append-only log, a counter read
and written around a call, a cached client) belongs inside
ctx.exclusive, which runs one block at a time per key and leaves the
rest of the handler concurrent:
ts.tool("shoot", "Take a picture") do |data, ctx| image = camera.shoot(data) ctx.exclusive(:ledger) { ledger << cost } image end
Code outside handlers can share the same locks with Toolset#exclusive.
Callers (from any transport): deliver to:"mj" payload:{"tool":"help"} → {"tools":[{"name":"pixelize","description":"...","inputSchema":{...}}, ...]}
deliver to:"mj" payload:{"tool":"pixelize","prompt":"..."} → (result)
Constructors
In-process constructor: registers a listing on directory and
reads envelopes from a Bus mailbox.
Client-transport constructor: wraps a Client (already
configured with address/name/kind/tags). Envelopes flow over the
Client's WebSocket. The daemon's Directory is populated by the
Client's join frame — this constructor does not register a
listing itself.
name: and description: are optional overrides for the tools
manifest header; if omitted, the client's own name/description
are used.
Instance methods
How long a Client-transport Toolset may stay unconnected after
start before it warns that it is invisible on the bus.
How long a Client-transport Toolset may stay unconnected after
start before it warns that it is invisible on the bus.
Run the block while no handler of this toolset runs one under the
same key (see HandlerContext#exclusive); returns its value.
Cap a lane: requests in it run at most max_concurrent at once.
Start and serve until the connection closes. On Client transport
this is start followed by Client#connect, which sends the join
frame (creating the directory listing) and blocks running the
WebSocket loop. On Bus transport the listing already exists, so
run starts and then blocks forever.
Start listening for envelopes. On Bus transport, spawns a fiber
reading from the mailbox. On Client transport, registers an
on_message handler — the caller still owns Client#connect (which
blocks running the WebSocket loop), or can call run instead.
Also unions the user-provided tags with the registered tool names
so arcana_directory tag:"chat" finds every entity offering a
chat tool without the user having to double-declare.
Stop the toolset. In Bus mode, unregisters and drops the read loop. In Client mode, this just flips a flag — the caller closes the Client separately.
Register a tool. Add all tools before calling start. The handler
takes the request's data and, optionally, a HandlerContext. lane
names the lane its requests take, or works it out from their data.