The project is in a healthy, maintained state
Adds a human peer worker to RobotLab networks: same bus and memory, receiving and issuing tasks alongside robots.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

~> 0.3.0
 Project Readme

robot_lab-cyborg

A RobotLab extension gem that puts a human into the network as a peer worker.

Robots on a RobotLab network are LLM-backed workers. A Cyborg is a human-backed worker that sits at the same level as the robots: it registers as a network task, speaks on the same TypedBus channels, reads and writes the same shared memory, receives tasking (as a pipeline step and as bus messages), and issues tasking to the other members — humans and robots alike.

A Cyborg reuses RobotLab::Robot::BusMessaging verbatim, so its bus behavior is byte-for-byte identical to a robot's. It deliberately does not subclass Robot: a human needs no model and no API key. The human is the "model," reached across an injectable Channel (the means — terminal now, Slack/email/web later) by an Interviewer (the process that conducts the ask).

Installation

Add to your Gemfile:

gem "robot_lab"
gem "robot_lab-cyborg"

Usage

require "robot_lab"
require "robot_lab/cyborg"

# A human peer. By default it talks to a terminal; inject a Channel to
# reach the human over a web form, a queue, a chat app, or a test.
dewayne = RobotLab::Cyborg.new(name: "dewayne")

A human as a pipeline step

The human is interchangeable with a robot anywhere a member is expected:

writer = RobotLab.build(name: "writer", template: :writer)

network = RobotLab.create_network(name: "release") do
  task :draft,   writer,  depends_on: :none
  task :approve, dewayne, depends_on: [:draft]   # the human signs off
end

network.run(message: "Draft the release notes")

Peers messaging over a shared bus

Robots and cyborgs talk to each other by name over one shared bus:

bus     = TypedBus::MessageBus.new
analyst = RobotLab.build(name: "analyst", bus: bus)
dewayne = RobotLab::Cyborg.new(name: "dewayne", bus: bus)

# The robot asks the human a question; the human's answer comes back as a reply.
analyst.send_message(to: :dewayne, content: "Approve the deploy? (yes/no)")

# The human issues work to the robot, too.
dewayne.assign(to: :analyst, task: "Summarize today's error budget.")

Shared memory

dewayne.remember(:decision, "ship it")   # visible to every member
dewayne.recall(:sentiment, wait: 30)     # block until a robot writes it

Channels and the Interviewer

Reaching the human is split into two concerns:

  • A Channel is the means — a dumb bidirectional pipe with two jobs: deliver a message out to the human, and surface messages the human sends in. It knows nothing about questions or answers. This is the injection point; $stdin/$stdout live only inside Channel::Terminal.
  • An Interviewer is the process — it conducts the interaction over whatever channel is injected. Asking is always asynchronous: the human's answer may be the next inbound message, a later one, or never. So ask delivers the question and returns a Question you can wait on with a timeout; a background consumer matches answers to questions (by the channel's correlation id when it has one, else oldest-first) and routes anything unsolicited as human initiative.

Built-in channels:

  • Channel::Terminal — the human at a keyboard (the default); IO is injectable.
  • Channel::Scripted — canned answers in order, with exact correlation (tests, automation, replay).
  • Channel — the abstract base; subclass it (implement deliver/receive) to bridge to Slack, email, a web UI, or a task queue.

A channel that can tie an answer back to its question (Slack threads, email) reports correlates? == true, and the Interviewer lets several questions be outstanding at once. A dumb channel (a bare terminal) reports false, and the Interviewer serializes — one question on the wire at a time — so an answer is never mis-attributed.

scripted = RobotLab::Cyborg::Channel::Scripted.new(["yes", "ship it"])
bot      = RobotLab::Cyborg.new(name: "dewayne", channel: scripted)

# Give a slow or absent human a bounded wait:
oncall   = RobotLab::Cyborg.new(name: "oncall", ask_timeout: 30)   # nil answer if no reply

Cooperating in a network

  • Symmetric bus membership. A Cyborg answers inbound bus tasks out of the box; a Robot opts in with one call — robot.serve (run each task through the model and reply) or robot.respond_to_tasks { |m| ... }. Both are first-class responders.
  • Duplex. Inbound messages/replies are shown to the human on their channel automatically; cyborg.tell("...") pushes a line out yourself.
  • Addressing. cyborg.converse(peers: %w[analyst scribe]) starts a Conversation: the human addresses peers by @mention anywhere in a message (fan-out to all mentioned; no mention broadcasts to everyone), and replies come back on the channel.
  • Listening. converse/listen keep reading the channel with no question pending, so the human can speak to the network unprompted (delivered via on_human).
  • Typed answers. ask_int, ask_confirm, or ask(validate:, retries:) re-ask on bad input and return coerced values.
  • Presence. online! / away! / offline! / available? — an offline human declines inbound tasks immediately, so the network can route around or escalate.
you = RobotLab::Cyborg.new(name: "you", bus: bus)
you.converse(peers: %w[analyst scribe])         # @mention to address, no mention = broadcast
ready = you.ask_confirm("Deploy now?")          # => true / false
you.away!                                        # still asked, but use a bounded timeout

Durable human steps (roadmap)

A human step currently holds a thread while it waits. ask_async returns the pending Question without blocking — the primitive a durable integration would persist. The intended path is to store a pending decision through robot_lab-durable (and robot_lab-to's DecisionManager) so a human decision survives a process restart and doesn't pin a thread. Per-peer cryptographic identity/attribution (signed events) is the complementary trust direction, on top of the existing per-message sender/from.

Examples

Runnable demos in examples/ — one feature area each:

  • 01_human_in_the_network.rb — a human as a pipeline step, peers messaging over a bus, shared memory (network → human). Key-free.
  • 02_terminal_mentions.rb — a live human addresses peers by @mention via the library Conversation (fan-out, and no-mention broadcast); replies return on their own through the duplex. Includes a real LLM robot (@assistant, LM Studio via the :lms provider) cooperating via serve, plus key-free canned peers.
  • 03_robot_interviews_cyborg.rb — the Interviewer the other way round: an LLM robot (LM Studio via the :lms provider) interviews the human via delegate, starting with a typed intake (ask_confirm/ask_int, which re-ask on bad input), then builds a categorized profile.
  • 04_presence_and_availability.rb — routing to a peer who's actually there: online/away/offline, an offline human declining immediately, and a bounded-timeout escalation. Key-free.
  • 05_listening_and_duplex.rb — always-on listen: the human speaks to the network unprompted and replies come back on the channel — both directions handled by the library. Key-free.
ruby examples/01_human_in_the_network.rb
ruby examples/04_presence_and_availability.rb
ruby examples/05_listening_and_duplex.rb

# Examples 2 and 3 use a real robot on LM Studio (lms server start &&
# lms get openai/gpt-oss-20b; override with LMS_MODEL / LMS_API_BASE). In
# example 2 the canned peers still work without it — only @assistant needs it.
ruby examples/02_terminal_mentions.rb        # then type: @analyst and @scribe: status?
ruby examples/03_robot_interviews_cyborg.rb  # the robot asks you the questions

Development

After checking out the repo, run bin/setup to install dependencies. Then run rake test to run the tests, or rake quality to run the full gate (tests, RuboCop, Flog, Flay). bin/console gives an interactive prompt.

License

The gem is available as open source under the terms of the MIT License.