Workers and Agents
Sol orchestrates swarms of Luna worker agents over ZMQ and JSONL transports. This guide covers connecting workers, the wire protocol, agent modes, and headless operation.
Transport Comparison
| Feature | ZMQ DEALER | JSONL Stdio |
|---|---|---|
| Protocol | msgpack over ZMQ | JSON lines over stdin/stdout |
| Language | Any (ZMQ + msgpack bindings) | Any (stdin/stdout) |
| Process management | External (worker manages itself) | Sol spawns via erlexec |
| Streaming tokens | Yes | Yes |
| Task cancellation | Yes | Yes |
| Child task spawning | Yes | No |
| Reconnection | Automatic (ZMQ) | Process restart |
| Dependencies | libzmq, msgpack | None |
| Best for | Long-running workers, polyglot | Short-lived workers, simple agents |
ZMQ DEALER Workers (Recommended)
Connection Model
Workers connect to Sol's ZMQ ROUTER gateway as DEALER sockets. Communication is asynchronous: workers announce readiness, Sol dispatches tasks, workers stream results back.
Worker (DEALER) <--> Sol ROUTER (port 5555)Multiple workers can connect simultaneously. Sol routes tasks to the first available (ready) worker. If no workers are ready, tasks queue until one becomes available.
Connect and Register
The ZMQ gateway uses a dedicated sharded architecture for high throughput:
- sol_zmq_sender — owns the ROUTER socket, serializes all outbound sends through a dedicated gen_server
- sol_zmq_recv_dispatcher — dedicated gen_server for inbound message routing
- sol_zmq_result_router — routes child task results and manages parent/child cleanup
- sol_zmq_recv_bridge — recv loop with error backoff, extracted from gateway
- ETS indexes —
sol_zmq_tasksandsol_zmq_workerstables for O(1) lookups - Plugin tool executor pool —
simple_one_for_onesupervisor isolates plugin tool execution from the gateway
import zmq
import msgpack
ctx = zmq.Context()
sock = ctx.socket(zmq.DEALER)
sock.setsockopt_string(zmq.IDENTITY, "my-worker-1")
sock.connect("tcp://127.0.0.1:5555")
sock.send_multipart([b"", msgpack.packb({
"type": "ready",
"worker_id": "my-worker-1",
"capabilities": ["python", "streaming"]
}, use_bin_type=True)])Receive and Execute Tasks
poller = zmq.Poller()
poller.register(sock, zmq.POLLIN)
while True:
events = dict(poller.poll(timeout=1000))
if sock not in events:
continue
frames = sock.recv_multipart()
msg = msgpack.unpackb(frames[-1], raw=False)
msg_type = msg.get("type")
if msg_type == "task":
task_id = msg["task_id"]
prompt = msg["prompt"]Stream Results
sock.send_multipart([b"", msgpack.packb({
"type": "token",
"task_id": task_id,
"content": "partial output..."
}, use_bin_type=True)])
sock.send_multipart([b"", msgpack.packb({
"type": "result",
"task_id": task_id,
"status": "ok",
"content": "final output"
}, use_bin_type=True)])
sock.send_multipart([b"", msgpack.packb({
"type": "ready",
"worker_id": "my-worker-1",
"capabilities": ["python", "streaming"]
}, use_bin_type=True)])Handle Cancellation
if msg_type == "cancel":
task_id = msg["task_id"]
sock.send_multipart([b"", msgpack.packb({
"type": "result",
"task_id": task_id,
"status": "cancelled",
"content": ""
}, use_bin_type=True)])Send ready messages every 5 seconds when idle to maintain registration. A full Python reference implementation is at example/python/zmq_worker.py.
See ZMQ Protocol for the complete specification.
JSONL Stdio Workers
Sol spawns an OS process via erlexec and communicates via JSON lines over stdin/stdout. The worker reads JSON objects from stdin (one per line) and writes JSON objects to stdout (one per line, flushed after each).
Message Types
Worker to Sol (stdout)
| Type | Fields | Description |
|---|---|---|
ready | -- | Announce worker is ready |
token | id, content | Streaming partial output |
result | id, status, content | Task completion |
log | level, message | Diagnostic log |
Sol to Worker (stdin)
| Type | Fields | Description |
|---|---|---|
task | id, prompt, opts | Task assignment |
cancel | id | Cancel running task |
Minimal Worker Example
#!/usr/bin/env python3
import sys
import json
def send(msg):
sys.stdout.write(json.dumps(msg) + "\n")
sys.stdout.flush()
send({"type": "ready"})
for line in sys.stdin:
msg = json.loads(line.strip())
if msg.get("type") == "task":
task_id = msg["id"]
prompt = msg["prompt"]
send({"type": "token", "id": task_id, "content": "Working...\n"})
send({"type": "result", "id": task_id, "status": "ok", "content": f"Done: {prompt}"})
elif msg.get("type") == "cancel":
send({"type": "result", "id": msg["id"], "status": "cancelled", "content": ""})Spawn Configuration
Configure in sys.config:
{worker_path, "/path/to/my-worker"},
{max_workers, 16}Or override per-spawn via the HTTP API:
zerg spawn --worker-path /path/to/my-workerThe worker_path is validated against a configurable prefix to prevent arbitrary binary execution. Tasks have a 300-second timeout. Worker stdout is buffered up to 256KB.
Worker Configuration
| Config Key | Default | Description |
|---|---|---|
worker_path | "client/build/luna" | Path to worker binary |
max_workers | 16 | Maximum concurrent workers |
zmq_router_port | 5555 | ZMQ ROUTER socket port |
zmq_pub_port | 5556 | ZMQ PUB event socket port |
zmq_bind_addr | "127.0.0.1" | ZMQ bind address |
zmq_enabled | true | Enable ZMQ gateway |
Agent Modes
Luna workers operate in 6 built-in agent modes, each tuned for a specific task category. Mode selection changes the system prompt, tool permissions, and agent behavior.
| Mode | Purpose | Tool Policy |
|---|---|---|
| Build | Code creation and modification | Read/search allowed, writes require approval |
| Plan | Analysis and planning | Read/search allowed, all writes denied |
| Explore | Codebase exploration | Read/search/lsp only, all writes denied |
| Review | Code review and audit | Read/search/web allowed, writes denied |
| General | General-purpose tasks | All tools allowed, bash/delete require approval |
| Coordinate | Multi-agent orchestration | All tools allowed (orchestrator needs full access) |
Switch modes with Tab in the TUI or the switch_mode tool:
> /mode buildUser-defined modes can be registered via the agent configuration.
Headless Mode
Run Luna without the TUI for scripted or CI/CD workflows:
luajit client/main.lua --print "Analyze this codebase and report issues"
luajit client/main.lua --print "Fix the bug in main.rs" --permission-mode allow-allUse --demo for a self-contained demonstration run. Output is written to stdout in plain text.
Monitoring Workers
zerg zmq-workers
zerg zmq-status
zerg agents
zerg task-status <id>