Running the Inbox Agents Concurrently, With a Bound
Fan three email source agents out with Nexo.concurrent: bounded concurrency, ordered results, and a sequential fallback when the async gem is missing.
The workflow from Part 4 runs the three sources one after another. Each source spends almost all of its time waiting on the model to think, so running them in sequence means the total time is roughly the sum of three model conversations. There is no reason for that. The three inboxes are independent; they should run at the same time.
This is the kind of thing that is easy to get wrong by hand. Threads, a thread
pool, collecting results in order, deciding what happens when one of them fails,
and keeping the whole thing from hammering a rate-limited API. Nexo provides a
single call for it: Nexo.concurrent. This post uses it to fan the sources out,
with a bound, and keeps the sequential path as a fallback.
As in the earlier posts, the code here is trimmed to what is worth reading; the whole project is on GitHub at mariochavez/nexo-mail.
The concurrency Nexo actually adds
It is worth being precise about what Nexo.concurrent is for, because it is not
raw parallelism. LLM calls are I/O bound, and under Ruby’s fiber scheduler an
in-flight model request already yields to others. What you actually need for a
fan-out is not more parallelism, it is a bound: a way to run many tasks at once
without launching an unbounded number and tripping a provider rate limit.
Nexo.concurrent gives you exactly that. It runs every task you add inside one
async reactor, capped by a semaphore, coordinated by a barrier, and returns the
results in submission order. The first error is re-raised and the remaining tasks
are stopped. You get bounded fan-out and ordered results without touching a thread
primitive.
Async is opt-in. Nexo runs perfectly well synchronously with no async gem
installed, and only complains when you reach for a fan-out feature. So the gem is a
dependency you add when you want this, not one you carry by default.
Fanning out the sources
Here is the workflow’s source stage, rewritten to run concurrently. sources is
the available hash
Part 4 already
builds, {name => descriptor}, so this replaces that post’s inline
available.map { ... } line with its own method:
def fan_out_sources(sources)
return {} if sources.empty?
pairs =
begin
Nexo.concurrent(max_in_flight: sources.size) do |c|
sources.each_value { |descriptor| c.add { extract_source(descriptor) } }
end
rescue Nexo::MissingDependencyError => e
emit(:async_unavailable, error: e.message)
sources.values.map { |descriptor| extract_source(descriptor) }
end
pairs.compact.to_h
end
Nexo.concurrent takes a block and yields a collector. Each c.add { ... }
registers a task, and the block’s return value is the array of task results in the
order you added them. So extract_source is unchanged from Part 4, still taking
the one descriptor argument; the only difference is that the three calls now
overlap. max_in_flight caps how many run at once. Here it is set to the number of
sources, because three is small. With more sources, or several accounts of the
same kind, you would set it to whatever keeps you under your rate limit rather than
launching all of them.
The rescue is deliberate. If the async gem is not installed, Nexo.concurrent
raises Nexo::MissingDependencyError. Rather than making async a hard requirement,
the workflow catches that, records an event noting it fell back, and runs the
sources sequentially with a plain map. The tool works either way; installing
async just makes the multi-inbox case faster.
extract_source already returns [name, file] on success and nil on failure, so
compact.to_h drops the failed sources and gives back a clean map of what was
produced. The concurrency did not change the result shape.
Configuring it
Two settings in Nexo.configure govern this:
Nexo.configure do |config|
config.concurrency = :async # :threaded (default) or :async
config.max_in_flight = 8 # default bound for Nexo.concurrent
end
concurrency chooses the execution model. Under :async, there is a useful
detail: the :local sandbox offloads its blocking file and shell operations to a
worker thread, so they do not stall the fiber reactor while other tasks are in
flight. Under :threaded, work runs inline with no reactor. max_in_flight is the
default bound when you do not pass one to Nexo.concurrent.
There is also buffer_workflow_events, and a per-run buffer_events: true option
on .run. Normally each emit is persisted as it happens. When many tasks are
emitting events concurrently, buffering collects them and flushes once at the end,
which cuts write pressure. For the terminal tool it does not matter; it matters
when you move this onto a shared database, which comes up in the shipping post.
The same call scales past three inboxes
Three fixed sources is the small case. Nexo.concurrent is the same tool for the
larger one. If you had a list of Gmail accounts to triage, you would fan a whole
workflow run out per account, bounded so you stay polite to the API:
results = Nexo.concurrent(max_in_flight: 5) do |c|
accounts.each do |account|
c.add { MultiInboxTriage.run(account_id: account.id).result }
end
end
Each c.add runs a full workflow, and the results come back in account order. The
bound of five means at most five accounts are being triaged at once, no matter how
long the list is. This is the pattern for turning a single-user tool into a
multi-account one without writing a scheduler.
Where this leaves us
The sources now run concurrently, bounded, with a sequential fallback, and the
result is unchanged. The workflow is doing real orchestration. But I have been
glossing over something the source agents rely on: they write their JSON output
into a workspace, and I have said “the :local sandbox” without explaining what
that is or why writing into it is safe. That is the next post: the sandbox and
permission seams, which are the two things that make read-only mail an enforced
property rather than a hope.
The full source is on GitHub at mariochavez/nexo-mail: this fan-out and the workflow around it, the custom Gmail and HEY tools from Part 3, and the triage skill from Part 2.
What will trip you up
Nexo.concurrent needs the async gem. Without it you get
Nexo::MissingDependencyError. Either add the gem or catch the error and fall back
to a sequential path, as the workflow does. Do not make async a silent hard
requirement of a tool people install.
Wrapping a single agent call in Async {} buys nothing. Async only pays off across
a fan-out. One agent.prompt in a reactor is the same speed as one agent.prompt
without it.
max_in_flight is the rate-limit knob, not a performance dial. Set it to the
number of concurrent requests your provider tolerates, not to the biggest number
your machine can run. Overshooting it is how you get throttled.
Watch the database connection pool under a fiber server. If you later run this under a fiber-based server and each concurrent task touches the database, you can exhaust the connection pool. Raise the pool size and prefer buffered events. This does not come up on a laptop, but it does in production.
Next in the series: Part 6, the sandbox and permission seams.