class

Arcana::Toolset

Inherits Reference < Object

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.

  1. 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

  2. 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

    run is start + client.connect. Over a Client, start alone only installs the message handler: the service has no directory listing until the Client connects, and a Toolset still unconnected connect_grace after start warns 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

new(bus : Bus, directory : Directory, address : String, name : String, description : String, tags : Array(String) = [] of String, max_concurrent : Int32 | Nil = Dispatcher::DEFAULT_MAX_CONCURRENT)

In-process constructor: registers a listing on directory and reads envelopes from a Bus mailbox.

Source
new(client : Client, name : String | Nil = nil, description : String | Nil = nil, max_concurrent : Int32 | Nil = Dispatcher::DEFAULT_MAX_CONCURRENT)

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.

Source

Instance methods

address
Source
connect_grace

How long a Client-transport Toolset may stay unconnected after start before it warns that it is invisible on the bus.

Source
connect_grace=(connect_grace : Time::Span)

How long a Client-transport Toolset may stay unconnected after start before it warns that it is invisible on the bus.

Source
exclusive(key : String | Symbol = :default, &)

Run the block while no handler of this toolset runs one under the same key (see HandlerContext#exclusive); returns its value.

Source
lane(name : String, max_concurrent : Int32) : Nil

Cap a lane: requests in it run at most max_concurrent at once.

Source
manifest

Return the tool manifest.

Source
max_concurrent

The most requests that run at once (nil: no cap).

Source
run

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.

Source
start

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.

Source
stop

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.

Source
tool(name : String, description : String, input_schema : JSON::Any | Nil = nil, lane : String | Proc(JSON::Any, String | Nil) | Nil = nil, &handler : JSON::Any, HandlerContext -> JSON::Any)

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.

Source

Nested types