robot_lab-cyborg 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,211 @@
1
+ # How It Works
2
+
3
+ ## The Means and the Process: Channel and Interviewer
4
+
5
+ Reaching the human is deliberately split into two small, separately-testable concerns:
6
+
7
+ - **`Channel`** is the *means* — a dumb, bidirectional pipe. It knows how to `deliver` a message **out** to the human and how to `receive` a message the human sends **in**. It has no concept of a "question" or an "answer" — just messages crossing a boundary.
8
+ - **`Interviewer`** is the *process* — it conducts the interaction over whatever channel is injected. It tracks which questions are outstanding, decides which inbound message answers which question, and surfaces everything else as unsolicited human *initiative*.
9
+
10
+ This split is why `$stdin`/`$stdout` live **only** inside `Channel::Terminal` — nothing else in the gem assumes a terminal. Swapping in a Slack, email, or web-form channel changes nothing about how `Interviewer`, `Cyborg`, or `Conversation` work. See [Building Custom Channels](custom_channels.md) to write one.
11
+
12
+ ### `ChannelMessage`
13
+
14
+ The one data shape that crosses the boundary in both directions:
15
+
16
+ ```ruby
17
+ ChannelMessage = Data.define(:id, :content, :in_reply_to, :sender, :kind, :at)
18
+ ```
19
+
20
+ | Field | Meaning |
21
+ |---|---|
22
+ | `id` | the question id this message carries (outbound) or answers (inbound, when the channel can correlate) |
23
+ | `content` | the human-readable text |
24
+ | `in_reply_to` | set by the channel when it can tie an inbound answer to a specific question (a Slack thread, an email `In-Reply-To`) |
25
+ | `sender` | who the message is from/for, for display |
26
+ | `kind` | `:question` \| `:answer` \| `:message` \| `:notice` |
27
+ | `at` | `Time.now` unless given |
28
+
29
+ `kind == :question` (checked via the `question?` predicate) is what tells `Channel::Terminal` to print a `> ` prompt after the line; other kinds (inbound peer messages, notices) print without one, so unsolicited traffic doesn't spam the input cursor.
30
+
31
+ ### Built-in Channels
32
+
33
+ **`Channel::Terminal`** — the human at a keyboard, and the default when no `channel:` is given.
34
+
35
+ - `input:`/`output:` are injectable (default `$stdin`/`$stdout`); inject `StringIO` in tests.
36
+ - `deliver` is synchronized with a `Mutex` so a question and an inbound peer message never interleave on screen.
37
+ - `receive(timeout:)` uses `IO.wait_readable` on a real IO so timeouts, shutdown, and question expiry all work; a non-selectable stream (a `StringIO` in tests) returns immediately from `#gets`, which is equally responsive.
38
+ - A `nil` line from `#gets` (EOF) is treated as "nothing arrived" and propagates as `nil`.
39
+ - `correlates?` is `false` — a bare terminal carries no correlation, so `in_reply_to` always stays `nil` on what it produces.
40
+
41
+ **`Channel::Scripted`** — canned answers for tests, automation, and replay.
42
+
43
+ - Constructed with an array (or single value) of answers, consumed in order.
44
+ - `correlates?` is `true` — each delivered question is paired with the *next* scripted answer **at delivery time** and stamped with that question's id, so correlation is exact and deterministic.
45
+ - Every question shown is recorded in `#asked`, so tests can assert on what the human saw: `cyborg.channel.asked`.
46
+ - When the script runs dry, a delivered question simply goes unanswered — modeling a human who never replies — and the `Interviewer`'s timeout (if any) takes over. Supply enough answers, or an `ask_timeout`, for any code path that asks more questions than you scripted.
47
+
48
+ ## Asking Asynchronously
49
+
50
+ Asking is **always** asynchronous, whatever the channel — a human's answer may be the very next message, may arrive several messages later (after they say other things first), or may never come at all. So `Interviewer#ask` never blocks: it delivers the question and returns a `Question`, a one-shot future, immediately.
51
+
52
+ ```ruby
53
+ question = interviewer.ask("Deploy now?") # returns instantly
54
+ # ... do other things ...
55
+ answer = question.answer(timeout: 30) # blocks here, not before
56
+ ```
57
+
58
+ `Interviewer#ask_and_wait` (and `Cyborg#ask`) combine both steps into one call for the common synchronous case — this is the boundary a pipeline step or a bus task handler actually needs: block *this* thread, but don't block the channel's consumer thread.
59
+
60
+ `Cyborg#ask_async` exposes the non-blocking primitive directly, returning the pending `Question` so the caller decides when (and whether) to wait — the primitive a durable/suspendable human step would need to persist (see [Roadmap: Durable Human Steps](#roadmap-durable-human-steps)).
61
+
62
+ ### The Background Consumer
63
+
64
+ `Interviewer` lazily starts a single background thread (`ensure_consumer`) the first time a question is outstanding, or when `#listen` is called. It polls `channel.receive(timeout: 0.05)` in a loop and, for each inbound message, either resolves the question it answers or calls the `on_initiative` handler.
65
+
66
+ The consumer is deliberately hard to kill:
67
+
68
+ - **A handler or channel error never wedges it.** Any `StandardError` raised while dispatching a message (in a channel read, in `on_initiative`, in interpreting an answer) is caught, recorded in `Interviewer#last_error`, and the loop keeps running. Before this, a throwing handler used to kill the consumer thread and hang every future `ask` — this was fixed as part of a design review (see `CHANGELOG.md`, item *A1*).
69
+ - **It stops itself once idle**, unless `#listen` has been called: no questions outstanding and not in always-on mode → the loop exits and `@running` resets, so the next `#ask` (or `#listen`) restarts it cleanly. Both the start and stop paths flip `@running` under the same mutex, so a restart can never race a shutdown.
70
+ - **`#close`** stops it for good and closes the channel — call this when a `Cyborg`/`Interviewer` is done and its channel resources (a socket, a file handle) need releasing.
71
+
72
+ ### Correlation vs. Serialization
73
+
74
+ Whether more than one question can be outstanding at once depends entirely on the channel:
75
+
76
+ - A channel that **can** tie an inbound answer back to its question (`correlates? == true` — Slack threads, email `In-Reply-To`, `Channel::Scripted`) lets any number of questions be outstanding concurrently. Each is delivered immediately; `Interviewer#dispatch` resolves whichever one an inbound message's `in_reply_to` names.
77
+ - A channel that **cannot** (`correlates? == false` — a bare terminal) gets **serialized**: only one question is ever "on the wire" at a time (`@active_id`). Additional `ask` calls queue in `@pending` and are delivered one at a time as the active question resolves or expires (`pump_pending`/`release_active`). An inbound message with no `in_reply_to` resolves whichever question is currently active. This makes mis-attribution — a fast second answer accidentally resolving the first question — impossible.
78
+
79
+ An inbound message that names no outstanding question (an unknown/expired id, or an answer arriving when nothing is active) matches nothing and is routed to `on_initiative` instead — the human speaking as a peer, unprompted.
80
+
81
+ ### Interpreting an Answer
82
+
83
+ Before resolving a `Question`, the `Interviewer` runs the raw text through `interpret`:
84
+
85
+ - An empty answer falls back to the question's `default`, when one was given.
86
+ - If the question had `choices:`, a bare number is mapped to the corresponding choice (1-indexed) — out-of-range numbers, and any non-numeric text, pass through unchanged as free text.
87
+ - The result is always coerced to a `String`, so a genuine empty answer (`""`) is never confused with a timeout (`nil`).
88
+
89
+ ### Timeouts and Expiry
90
+
91
+ `Question#answer(timeout:)` blocks on an internal `Thread::Queue` (the mailbox `Interviewer#dispatch` pushes into). If nothing arrives within `timeout`:
92
+
93
+ 1. The question calls back into `Interviewer#expire(self)`, which removes it from the outstanding set (so a later, unrelated answer can never claim it) and, on a serialized channel, advances the queue to the next pending question.
94
+ 2. `#answer` returns the question's `default` (`nil` if none was given).
95
+
96
+ `nil` from `#answer` is therefore unambiguous: it means "no answer, no default" — never "the human answered with an empty string."
97
+
98
+ ## Cyborg: The Human Peer
99
+
100
+ ### Network Member and Bus Contracts
101
+
102
+ `Cyborg` satisfies the same two contracts a `Robot` does, so a network (or another peer) never has to know which one it's talking to:
103
+
104
+ - **The `SimpleFlow` step contract** — `#call(result)`. The network invokes this when a pipeline reaches the human's task; `Cyborg` extracts the incoming message and shared memory from the pipeline `Result` (`extract_run_context`), calls its own `#run`, and threads a `RobotResult` back into the pipeline the same way a robot's step would (`result.with_context(name, robot_result).continue(robot_result)`). Any exception is caught and turned into an error-shaped `RobotResult` rather than raising through the pipeline.
105
+ - **The `#run` contract** — `run(message = nil, network_memory: nil, memory: nil, **)`. This is what makes a `Cyborg` a valid `delegate`/`spawn` target anywhere a `Robot` is: it asks the human the given message (via `#ask`) and wraps the answer in a `RobotResult`, so `result.reply` works identically regardless of which kind of peer produced it.
106
+
107
+ `Cyborg` also **includes `RobotLab::Robot::BusMessaging` verbatim** — the same module `Robot` uses for `send_message`/`send_reply`/`on_message`/`spawn`/bus-poller wiring. This is why a robot and a human are wire-compatible peers on the same `TypedBus`: there is only one implementation of "how a member talks on the bus," shared by both.
108
+
109
+ **Caveat:** `Cyborg#initialize` wires its own inbound handler — `@message_handler = method(:handle_incoming)` — to get its default human-in-the-loop behavior (surface the task, ask, auto-reply). `BusMessaging#on_message`, `#respond_to_tasks`, and `#serve` all *replace* `@message_handler` wholesale. Calling any of them on a `Cyborg` overwrites `handle_incoming` and disables the built-in ask/auto-reply flow — those three methods are meant for `Robot`s opting *into* task-serving behavior a `Cyborg` already has by default. If you need custom bus-message handling on a `Cyborg`, build it around `#on_task`/`#on_human` instead (see below), not `#on_message`.
110
+
111
+ ### The Inbound Task Lifecycle
112
+
113
+ `handle_incoming(message)` — the private method wired as `@message_handler` — is what runs on the bus poller's drain thread for every message addressed to this peer:
114
+
115
+ 1. The message is recorded in `#inbox` (thread-safe, for later inspection).
116
+ 2. If it's a **reply** to something this peer sent earlier, it's shown to the human via the channel (`kind: :message`) and handling stops there — replies don't get re-asked as tasks.
117
+ 3. Otherwise it's a **fresh task**, handled by `respond_to_task`.
118
+
119
+ `respond_to_task` never blocks the poller:
120
+
121
+ - If the peer is `!available?` (offline), it declines immediately — a reply saying `"(name is unavailable)"` is sent back at once (if `auto_reply` and a bus are configured) rather than waiting on a human who won't answer.
122
+ - Otherwise, it spawns a **new thread** that asks the human (`ask(task_prompt(message))`), sends the answer back as a reply once it arrives (again, if `auto_reply` and a bus are configured, and only if there is an answer), and invokes the `#on_task` callback with the original message and the answer. Any error in that thread is caught and logged with `warn`, not raised — a slow or crashing human-answer thread can never take down the bus poller.
123
+
124
+ This is the fix for a real deadlock: before it, an inbound task ran synchronously on the poller thread, so a slow (or absent) human blocked delivery of every other message to every other peer sharing that poller group.
125
+
126
+ ### Duplex: Talking Back to the Human
127
+
128
+ Two directions, both automatic in the common case:
129
+
130
+ - **Network → human**: any inbound bus message or reply is shown on the channel via `deliver_to_human`, best-effort (a channel error here is swallowed — a broken display must never break message intake). Call `Cyborg#tell(text, kind: :notice)` yourself to push an unprompted line (a status update, a warning) the same way.
131
+ - **Human → network**: an inbound channel message that answers no outstanding question is *initiative* — the human speaking unprompted. `Interviewer#on_initiative` routes it to `Cyborg#handle_human_initiative`, which in turn calls the `#on_human` callback you register. `Conversation` (below) is the built-in way to turn that initiative into addressed bus tasks; you can also register `#on_human` directly for custom routing.
132
+
133
+ ### Always-On Listening
134
+
135
+ By default, the `Interviewer`'s consumer thread only runs while a question is outstanding — there's nothing to read otherwise. `Cyborg#listen` (and `Conversation#start`, which calls it) keeps the consumer alive with *no* question pending, so the human can address the network unprompted at any time; their input arrives via `#on_human`. `#unlisten` (and `Conversation#stop`) turn this back off; the consumer then winds down once nothing is outstanding, same as normal.
136
+
137
+ ### Presence and Availability
138
+
139
+ ```ruby
140
+ cyborg.online! # taking work now (default)
141
+ cyborg.away! # present but slow — still asked, use a generous timeout
142
+ cyborg.offline! # not taking work — inbound tasks declined immediately
143
+ cyborg.available? # => false only when :offline
144
+ ```
145
+
146
+ Presence is plain in-memory state (`@presence`, guarded by a mutex) — it doesn't itself change *how* a question is asked, only what `respond_to_task` does with an inbound bus task before asking at all. A network or dispatcher can check `available?` before routing work to a specific human, to pick another peer or escalate instead of tasking someone who will just decline (or, if `:away`, take a long time).
147
+
148
+ ### Typed and Validated Answers
149
+
150
+ ```ruby
151
+ cyborg.ask("Deploy?") # raw text (or the default)
152
+ cyborg.ask_int("How many replicas?") # re-asks until a parseable Integer
153
+ cyborg.ask_confirm("Proceed?") # => true / false / nil
154
+ cyborg.ask("Pick one", choices: %w[a b c]) # numbered choices; a bare digit maps to one
155
+ cyborg.ask("Env?", validate: ->(a) { %w[dev prod].include?(a) ? a : nil }, retries: 2)
156
+ ```
157
+
158
+ `#ask`'s `validate:` contract: return the (optionally coerced) value when the answer is acceptable, or `nil` to reject it. On rejection, `Cyborg` tells the human why (`"Sorry, I couldn't use ..."`) and re-asks, up to `retries` extra attempts, after which it falls back to `default`. `ask_int`/`ask_confirm` are just `ask` with a pre-built `validate:`.
159
+
160
+ ### Delegation
161
+
162
+ ```ruby
163
+ result = cyborg.delegate(to: some_robot_or_cyborg, task: "Summarize this") # blocks, returns RobotResult
164
+ future = cyborg.delegate(to: some_robot_or_cyborg, task: "Summarize this", async: true) # returns a DelegationFuture
165
+ future.value # blocks here instead; raises DelegationFuture::DelegationTimeout if value(timeout:) expires
166
+ ```
167
+
168
+ `delegate` works against **any** target that responds to `#run` — a `Robot` or another `Cyborg` — because both satisfy the same `#run` contract. The synchronous form calls `to.run(task, **)` directly and stamps `delegated_by` on the result. The async form spawns a `Thread`, resolves or rejects a `RobotLab::DelegationFuture` from it, and returns the future immediately — so a human (or a robot) can fan a task out to several peers in parallel and collect results later.
169
+
170
+ `assign(to:, task:)` is the fire-and-forget bus counterpart — an alias for `BusMessaging#send_message`, named for how a human hands off work; correlate its reply later via `cyborg.outbox[message.key]`.
171
+
172
+ ### Shared Memory
173
+
174
+ ```ruby
175
+ cyborg.remember(:decision, "ship it") # writes to the active memory
176
+ cyborg.recall(:sentiment, wait: 30) # reads from it, optionally blocking for up to 30s
177
+ cyborg.attach_memory(some_network_memory) # explicit target (a network run does this for you)
178
+ cyborg.detach_memory # back to this peer's own standalone memory
179
+ ```
180
+
181
+ "The active memory" is the network's shared memory while attached, and this peer's own standalone `memory:` otherwise (`current_memory`, mutex-guarded). `#run` calls `attach_memory` automatically whenever a `network_memory:` is passed in — which is how a network run wires it up without you doing so by hand. Detaching explicitly matters when a peer might otherwise keep writing to a finished network's memory after that run has ended.
182
+
183
+ `remember`/`recall` additionally set/restore the memory's `current_writer` around the call (`with_writer`, a no-op for memory implementations that don't track one) — a no-op for memories that don't track a writer, and otherwise how downstream consumers know a given memory write came from this human rather than a robot.
184
+
185
+ ## Conversation: Multi-Peer Chat by @mention
186
+
187
+ `Conversation` turns a listening `Cyborg` into an interactive participant among several named peers:
188
+
189
+ ```ruby
190
+ you = RobotLab::Cyborg.new(name: "you", bus: bus)
191
+ chat = you.converse(peers: %w[analyst scribe])
192
+ # human types: "hey @analyst and @scribe: status?" -> both are tasked
193
+ # human types: "status?" -> broadcasts to both (no mention = everyone)
194
+ ```
195
+
196
+ `Cyborg#converse(peers:)` is sugar for `Conversation.new(cyborg: self, peers: peers).start`. `#start` registers an `#on_human` handler that routes each unprompted line and calls `#listen`; `#stop` calls `#unlisten`.
197
+
198
+ Routing rules (`Conversation#route`):
199
+
200
+ - Every `@name` mention in the line (via the `MENTION = /@(\w+)/` pattern, in order of first appearance, deduplicated) addresses that peer.
201
+ - A line with **no** mention broadcasts to **every** known peer.
202
+ - A line whose mentions are *all* unknown peers is **not sent** — `Conversation` tells the human which name(s) weren't recognized, and (if there were no other, valid mentions) which peers *are* known.
203
+ - A line mixing known and unknown mentions still sends to the known ones, after reporting the unknown ones.
204
+
205
+ Replies come back automatically — `Cyborg`'s duplex delivers inbound bus traffic to the channel regardless of how the outbound task was sent, so `Conversation` only has to handle the human → network direction; it never polls for or renders replies itself.
206
+
207
+ `add_peer(name)` grows the addressable roster after construction (e.g. as new robots join a running session).
208
+
209
+ ## Roadmap: Durable Human Steps
210
+
211
+ A human step currently holds a thread while it waits (`ask`/`ask_and_wait` block; the underlying `Question#answer` sits on a blocking `Thread::Queue#pop`). `ask_async` returns the pending `Question` without blocking — the primitive a durable integration would need to persist across a process restart. The intended path, per the gem's `CHANGELOG.md` and `README.md`, is to store a pending decision through **`robot_lab-durable`** (and `robot_lab-to`'s `DecisionManager`) so a human decision survives a restart without pinning a thread for the duration. Per-peer cryptographic identity/attribution (signed events), building on the existing per-message `sender`/`from`, is a complementary but separate direction. Neither is implemented in this gem as of this writing — treat this section as intent, not a shipped feature.
data/docs/index.md ADDED
@@ -0,0 +1,57 @@
1
+ # robot_lab-cyborg
2
+
3
+ A [RobotLab](https://github.com/MadBomber/robot_lab) extension gem that puts a **human** into the network as a peer worker.
4
+
5
+ Robots on a RobotLab network are LLM-backed workers. A **`Cyborg`** is a *human*-backed worker that sits at exactly the same level as the robots: it registers as a network task, speaks on the same [TypedBus](https://github.com/MadBomber/typed_bus) 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.
6
+
7
+ ```ruby
8
+ require "robot_lab"
9
+ require "robot_lab/cyborg"
10
+
11
+ dewayne = RobotLab::Cyborg.new(name: "dewayne")
12
+
13
+ network = RobotLab.create_network(name: "release") do
14
+ task :draft, writer_robot, depends_on: :none
15
+ task :approve, dewayne, depends_on: [:draft] # the human signs off
16
+ end
17
+
18
+ network.run(message: "Draft the release notes")
19
+ ```
20
+
21
+ 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 LLM, no model, and no API key. The human *is* the "model," reached across an injectable **`Channel`** (the *means* — terminal today, Slack/email/web later) by an **`Interviewer`** (the *process* that conducts the asynchronous ask-and-answer).
22
+
23
+ ## Navigation
24
+
25
+ - [Getting Started](getting_started.md) — installation, creating a Cyborg, pipeline steps, bus messaging, running the bundled examples
26
+ - [How It Works](how_it_works.md) — the Channel/Interviewer split, correlation vs. serialization, the consumer thread, the duplex channel, presence, memory, the network-member contract, delegation
27
+ - [API Reference](api_reference.md) — every public class and method, grouped by concern
28
+ - [Building Custom Channels](custom_channels.md) — bridging a Cyborg to Slack, email, a web form, or any other transport
29
+
30
+ ## At a Glance
31
+
32
+ | | |
33
+ |---|---|
34
+ | **Core class** | `RobotLab::Cyborg` — a human peer worker |
35
+ | **Bus behavior** | Identical to `Robot`'s — reuses `RobotLab::Robot::BusMessaging` |
36
+ | **Reaches the human via** | An injectable `Channel` (`Terminal` by default, `Scripted` for tests, or your own) |
37
+ | **Conducts the interaction via** | `Interviewer` — async ask/answer, question correlation, serialization on dumb channels |
38
+ | **Network roles** | Pipeline step (`task :name, cyborg`), bus peer (`assign`/`delegate`), shared-memory participant (`remember`/`recall`) |
39
+ | **Presence** | `online!` / `away!` / `offline!` / `available?` |
40
+ | **Multi-peer chat** | `Conversation` — `@mention` addressing, no-mention broadcast |
41
+ | **Typed answers** | `ask`, `ask_int`, `ask_confirm`, `ask(validate:, retries:)` |
42
+ | **Dependency** | `robot_lab ~> 0.2, >= 0.2.6` |
43
+
44
+ ## Why a Cyborg, Not Just a Human-Shaped Robot?
45
+
46
+ A `Robot` is built around an LLM chat: model, provider, tokens, a `ruby_llm` chat object. None of that applies to a human. Rather than force a human through `Robot`'s LLM-shaped constructor and stub out everything that doesn't apply, `Cyborg` is a separate, smaller class that:
47
+
48
+ - Includes only the one piece of `Robot` it genuinely needs and shares byte-for-byte — `BusMessaging` (send/receive over `TypedBus`, spawn, bus poller wiring).
49
+ - Replaces "call the model" with "ask a human," via an injectable `Channel` + `Interviewer` pair instead of a `ruby_llm` chat.
50
+ - Still satisfies the same *network member* contract (`call`/`run`) so it is a drop-in peer anywhere a `Robot` is expected — a pipeline `task`, a `delegate` target, a `spawn`-style bus peer.
51
+
52
+ ## Links
53
+
54
+ - [RobotLab Core](https://github.com/MadBomber/robot_lab)
55
+ - [RubyGems](https://rubygems.org/gems/robot_lab-cyborg)
56
+ - [GitHub](https://github.com/MadBomber/robot_lab-cyborg)
57
+ - [Changelog](https://github.com/MadBomber/robot_lab-cyborg/blob/main/CHANGELOG.md)
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Human-in-the-network demo.
5
+ #
6
+ # A Cyborg is a human peer worker. It reaches its human across an injectable
7
+ # Channel (the *means*: terminal now, Slack/email/web later). This example
8
+ # injects a Scripted channel so it runs end-to-end without a live human — omit
9
+ # `channel:` to get the default terminal channel and answer the prompts yourself.
10
+ #
11
+ # ruby examples/01_human_in_the_network.rb
12
+
13
+ # Prefer the local robot_lab checkout (with the latest fixes) over any installed gem.
14
+ core_lib = File.expand_path("../../robot_lab/lib", __dir__)
15
+ $LOAD_PATH.unshift(core_lib) if File.directory?(core_lib)
16
+
17
+ require "robot_lab"
18
+ require_relative "../lib/robot_lab/cyborg"
19
+
20
+ Scripted = RobotLab::Cyborg::Channel::Scripted
21
+
22
+ # --- 1. A human as a pipeline step ------------------------------------------
23
+ # The human is interchangeable with a robot: it registers as a network task and
24
+ # its answer flows downstream like any RobotResult.
25
+
26
+ approver = RobotLab::Cyborg.new(name: "approver", channel: Scripted.new(["approved: ship it"]))
27
+
28
+ network = RobotLab.create_network(name: "release") do
29
+ task :approve, approver, depends_on: :none
30
+ end
31
+
32
+ result = network.run(message: "Approve the v1.0 release notes?")
33
+ puts "Pipeline result : #{result.value.reply}"
34
+ puts "Human was asked : #{approver.channel.asked.first}"
35
+
36
+ # --- 2. Two peers messaging over a shared bus -------------------------------
37
+ # A robot would normally be on this bus too; here both peers are humans to keep
38
+ # the example key-free. The message plumbing is identical either way.
39
+
40
+ bus = TypedBus::MessageBus.new
41
+ lead = RobotLab::Cyborg.new(name: "lead", bus: bus, channel: Scripted.new([]))
42
+ # oncall just needs to exist on the bus to receive the task and reply; it is
43
+ # addressed by name (:oncall), so no local reference is kept.
44
+ RobotLab::Cyborg.new(name: "oncall", bus: bus, channel: Scripted.new(["yes, go ahead"]))
45
+
46
+ msg = lead.assign(to: :oncall, task: "Safe to deploy right now?")
47
+
48
+ # Wait for the async round-trip (task out, human answer, reply back).
49
+ 50.times do
50
+ break if lead.outbox[msg.key][:status] == :replied
51
+
52
+ sleep 0.02
53
+ end
54
+
55
+ reply = lead.outbox[msg.key][:replies].first
56
+ puts "\nlead asked oncall : Safe to deploy right now?"
57
+ puts "oncall replied : #{reply&.content}"
58
+
59
+ # --- 3. Shared memory --------------------------------------------------------
60
+ lead.remember(:decision, "deploy at 15:00")
61
+ puts "\nShared memory :decision => #{lead.recall(:decision)}"
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Addressing peers by @mention, driven by the library Conversation.
5
+ #
6
+ # A live human (a Cyborg on the terminal channel) shares a bus with several
7
+ # peers. Address them by mentioning them — "@name" — anywhere in a message, as
8
+ # many as you like; the whole message is fanned out to everyone you mention. A
9
+ # message with NO mention broadcasts to every peer.
10
+ #
11
+ # The addressing/fan-out/broadcast logic lives in RobotLab::Cyborg::Conversation
12
+ # now, not here. Replies come back to your terminal on their own: a Cyborg
13
+ # delivers inbound bus traffic to its channel (the output half of the duplex),
14
+ # so this demo never has to poll or render replies.
15
+ #
16
+ # @assistant is a real LLM robot (Ollama) that cooperates via #serve — the
17
+ # one-call symmetric responder. @analyst and @scribe are key-free canned peers.
18
+ # Set OLLAMA_API_BASE/OLLAMA_MODEL, or run without Ollama and just use the canned
19
+ # peers (@assistant will simply not answer).
20
+ #
21
+ # ruby examples/02_terminal_mentions.rb
22
+ # # then type: @analyst and @scribe: status?
23
+ # # @assistant one-line haiku about deploys
24
+ # # @quit
25
+
26
+ require "logger"
27
+
28
+ # Prefer the local robot_lab checkout (with serve/respond_to_tasks) over any
29
+ # installed gem, so the demo runs from a fresh monorepo checkout.
30
+ core_lib = File.expand_path("../../robot_lab/lib", __dir__)
31
+ $LOAD_PATH.unshift(core_lib) if File.directory?(core_lib)
32
+
33
+ require "robot_lab"
34
+ require_relative "../lib/robot_lab/cyborg"
35
+
36
+ Cyborg = RobotLab::Cyborg
37
+ Channel = RobotLab::Cyborg::Channel
38
+
39
+ RubyLLM.configure do |c|
40
+ c.ollama_api_base = ENV.fetch("OLLAMA_API_BASE", "http://localhost:11434/v1")
41
+ c.logger = Logger.new(File::NULL)
42
+ end
43
+ RobotLab.configure { |c| c.logger = Logger.new(File::NULL) }
44
+
45
+ # A canned automated peer: a Channel whose "human" is a block.
46
+ class Autobot < Channel
47
+ def initialize(&reply)
48
+ @reply = reply
49
+ @inbox = Thread::Queue.new
50
+ super()
51
+ end
52
+
53
+ def correlates? = true
54
+ def deliver(message) = message.question? ? @inbox.push(reply_to(message)) : message
55
+ def receive(timeout: nil) = timeout ? @inbox.pop(timeout: timeout) : @inbox.pop
56
+ def close = @inbox.close
57
+
58
+ private
59
+
60
+ def reply_to(message)
61
+ Cyborg::ChannelMessage.new(content: @reply.call(message.content), in_reply_to: message.id, kind: :answer)
62
+ end
63
+ end
64
+
65
+ bus = TypedBus::MessageBus.new
66
+
67
+ # The live human. Its terminal labels inbound lines by their sender.
68
+ you = Cyborg.new(name: "you", bus: bus, channel: Channel::Terminal.new(name: "network"))
69
+
70
+ # A real LLM robot that serves bus tasks — the symmetric counterpart to how the
71
+ # Cyborg answers its human. One call, no hand-wired on_message.
72
+ RobotLab.build(name: "assistant", bus: bus, provider: "ollama",
73
+ model: ENV.fetch("OLLAMA_MODEL", "qwen3.6"),
74
+ system_prompt: "You are a concise teammate. Answer in 1-2 sentences.").serve
75
+
76
+ # Two key-free canned peers.
77
+ Cyborg.new(name: "analyst", bus: bus, channel: Autobot.new { |_q| "error rate is 0.2% over the last hour." })
78
+ Cyborg.new(name: "scribe", bus: bus, channel: Autobot.new { |_q| "noted — logged to the record." })
79
+
80
+ chat = Cyborg::Conversation.new(cyborg: you, peers: %w[assistant analyst scribe])
81
+
82
+ you.tell("Mention peers with @name (one or more, anywhere); no mention broadcasts to all. " \
83
+ "Known: @assistant, @analyst, @scribe. (@quit to exit)")
84
+
85
+ # The human drives the read loop; replies arrive on their own via the channel.
86
+ loop do
87
+ message = you.channel.receive
88
+ break if message.nil?
89
+
90
+ line = message.content.strip
91
+ next if line.empty?
92
+ break if line == "@quit"
93
+
94
+ chat.route(line)
95
+ end
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # A robot interviews a cyborg (the Interviewer, the other way round).
5
+ #
6
+ # Examples 1 and 2 had the human drive. Here the *robot* drives: an LLM-backed
7
+ # RobotLab robot (ProfileBot, on a local Ollama model) conducts an interview to
8
+ # learn as much as the human is willing to share, then classifies them across
9
+ # several categories. Each question the robot invents is delegated to the Cyborg,
10
+ # whose Interviewer conducts it on the terminal and hands the answer back — so
11
+ # the robot never touches $stdin/$stdout; the Cyborg's Interviewer is the whole
12
+ # bridge between the robot's intent and the person at the keyboard.
13
+ #
14
+ # You can answer each question, type "skip" to pass on one, or "done" to end the
15
+ # interview early. The robot builds the profile from whatever you chose to share.
16
+ #
17
+ # Requires a running Ollama with the model pulled: ollama pull qwen3.6
18
+ # Override with OLLAMA_MODEL / OLLAMA_API_BASE if yours differ.
19
+ #
20
+ # ruby examples/03_robot_interviews_cyborg.rb
21
+
22
+ require "logger"
23
+ # Prefer the local robot_lab checkout (with the latest fixes) over any installed gem.
24
+ core_lib = File.expand_path("../../robot_lab/lib", __dir__)
25
+ $LOAD_PATH.unshift(core_lib) if File.directory?(core_lib)
26
+
27
+ require "robot_lab"
28
+ require_relative "../lib/robot_lab/cyborg"
29
+
30
+ Cyborg = RobotLab::Cyborg
31
+ Channel = RobotLab::Cyborg::Channel
32
+
33
+ OLLAMA_API_BASE = ENV.fetch("OLLAMA_API_BASE", "http://localhost:11434/v1")
34
+ OLLAMA_MODEL = ENV.fetch("OLLAMA_MODEL", "qwen3.6")
35
+ MAX_QUESTIONS = Integer(ENV.fetch("MAX_QUESTIONS", "5"))
36
+
37
+ ENDING_WORDS = %w[done stop quit exit].freeze
38
+ SKIPPING_WORDS = %w[skip pass].freeze
39
+
40
+ RubyLLM.configure do |c|
41
+ c.ollama_api_base = OLLAMA_API_BASE
42
+ c.logger = Logger.new(File::NULL)
43
+ end
44
+ RobotLab.configure { |c| c.logger = Logger.new(File::NULL) }
45
+
46
+ # The interviewer robot. Its system prompt fixes the categories it is building
47
+ # toward and keeps it to one question at a time so each turn maps cleanly onto a
48
+ # single Interviewer ask.
49
+ robot = RobotLab.build(
50
+ name: "ProfileBot",
51
+ provider: "ollama",
52
+ model: OLLAMA_MODEL,
53
+ system_prompt: <<~PROMPT
54
+ You are ProfileBot, a warm, concise interviewer. Your goal is to learn as much
55
+ as the person is willing to share so you can later classify them across these
56
+ categories: technical proficiency, professional role, communication style,
57
+ interests, and decision-making style.
58
+
59
+ Rules:
60
+ - Ask exactly ONE short, friendly question per turn.
61
+ - Output only the question itself — no preamble, numbering, or commentary.
62
+ - Build on what they have already told you; do not repeat a topic.
63
+ - Do not classify or summarize until you are explicitly asked to.
64
+ PROMPT
65
+ )
66
+
67
+ # The human peer. Its terminal shows each question under a "[ProfileBot]" label,
68
+ # since in this demo everything the human hears comes from the robot.
69
+ you = Cyborg.new(name: "you", channel: Channel::Terminal.new(name: "ProfileBot"), ask_timeout: 300)
70
+
71
+ # Ask the human one question by delegating it to the Cyborg. The Cyborg's
72
+ # Interviewer delivers it over the terminal channel and returns the answer.
73
+ def interview_turn(robot, human, question)
74
+ robot.delegate(to: human, task: question).reply.to_s.strip
75
+ end
76
+
77
+ you.tell("Hi! I'd like to ask you a few questions.")
78
+
79
+ # A short *typed* intake first — the Cyborg validates and re-asks on bad input.
80
+ unless you.ask_confirm("Ready to begin?")
81
+ you.tell("No problem — maybe another time.")
82
+ exit
83
+ end
84
+ years = you.ask_int("Roughly how many years have you worked in your field?")
85
+ you.tell("Thanks. Answer freely from here, say \"skip\" to pass, or \"done\" to finish.")
86
+
87
+ # Seed the robot with the structured intake so it shows up in the profile.
88
+ robot.run(%(Context: they have about #{years || "an unstated number of"} years of experience. Acknowledge in one word.))
89
+
90
+ question = robot.run("Ask your first question.").reply.to_s.strip
91
+ answers_given = 0
92
+
93
+ until answers_given >= MAX_QUESTIONS
94
+ answer = interview_turn(robot, you, question)
95
+ break if ENDING_WORDS.include?(answer.downcase)
96
+
97
+ answers_given += 1
98
+ feedback = if answer.empty? || SKIPPING_WORDS.include?(answer.downcase)
99
+ "They preferred to skip that. Ask a different, lighter question."
100
+ elsif answers_given >= MAX_QUESTIONS
101
+ %(They answered: "#{answer}". That was the final question — reply with just "Thanks!")
102
+ else
103
+ %(They answered: "#{answer}". Ask your next question.)
104
+ end
105
+ # Feeding the answer back both records it in the robot's memory and produces
106
+ # the next question (the last one is discarded once the quota is reached).
107
+ question = robot.run(feedback).reply.to_s.strip
108
+ end
109
+
110
+ puts "\n …ProfileBot is building your profile…"
111
+ profile = robot.run(<<~PROMPT).reply.to_s.strip
112
+ The interview is complete. Using only what they told you, build their profile.
113
+ For each category give your classification and a one-line reason grounded in
114
+ their answers; write "insufficient information" where they did not reveal enough.
115
+
116
+ Technical proficiency:
117
+ Professional role:
118
+ Communication style:
119
+ Interests:
120
+ Decision-making style:
121
+
122
+ End with a one-sentence overall summary.
123
+ PROMPT
124
+
125
+ you.tell("Here is the profile I built from what you shared:\n\n#{profile}")
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Presence & availability: routing work to a human who is actually there.
5
+ #
6
+ # A human peer publishes a presence — :online, :away, or :offline — that the
7
+ # network can use to route around or escalate for someone who isn't available,
8
+ # instead of blocking on a person who will never answer.
9
+ #
10
+ # :online takes work now
11
+ # :away still asked, but the caller should use a bounded timeout
12
+ # :offline declines inbound bus tasks immediately
13
+ #
14
+ # Key-free: the "humans" here are scripted, so the demo runs deterministically.
15
+ #
16
+ # ruby examples/04_presence_and_availability.rb
17
+
18
+ # Prefer the local robot_lab checkout (with the latest fixes) over any installed gem.
19
+ core_lib = File.expand_path("../../robot_lab/lib", __dir__)
20
+ $LOAD_PATH.unshift(core_lib) if File.directory?(core_lib)
21
+
22
+ require "robot_lab"
23
+ require_relative "../lib/robot_lab/cyborg"
24
+
25
+ Cyborg = RobotLab::Cyborg
26
+ Scripted = RobotLab::Cyborg::Channel::Scripted
27
+
28
+ bus = TypedBus::MessageBus.new
29
+
30
+ dispatcher = Cyborg.new(name: "dispatcher", bus: bus, channel: Scripted.new)
31
+ alice = Cyborg.new(name: "alice", bus: bus, channel: Scripted.new(["Approved — ship it.", "Signed off."])) # online
32
+ bob = Cyborg.new(name: "bob", bus: bus, channel: Scripted.new(["(bob should never be asked)"])) # offline
33
+ sam = Cyborg.new(name: "sam", bus: bus, channel: Scripted.new, ask_timeout: 0.4) # away, never answers
34
+
35
+ bob.offline!
36
+ sam.away!
37
+
38
+ # Wait briefly for a reply to a sent task; return its text or nil.
39
+ def await_reply(peer, key, timeout: 3)
40
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
41
+ loop do
42
+ entry = peer.outbox[key]
43
+ return entry[:replies].first&.content if entry && entry[:status] == :replied
44
+ return nil if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
45
+
46
+ sleep 0.02
47
+ end
48
+ end
49
+
50
+ def ask_over_bus(dispatcher, name, task)
51
+ message = dispatcher.assign(to: name, task: task)
52
+ await_reply(dispatcher, message.key)
53
+ end
54
+
55
+ puts "Presence of each peer:"
56
+ [alice, bob, sam].each { |p| puts " #{p.name.ljust(6)} #{p.presence} (available? #{p.available?})" }
57
+
58
+ puts "\n1) Route to the first *available* peer among [bob, alice]:"
59
+ approver = [bob, alice].find(&:available?)
60
+ puts " picked @#{approver.name} (bob is offline) -> asked over the bus"
61
+ puts " @#{approver.name} says: #{ask_over_bus(dispatcher, approver.name, 'Approve the deploy?')}"
62
+
63
+ puts "\n2) Ask @bob anyway (offline) — declined immediately, no hanging:"
64
+ puts " @bob replies: #{ask_over_bus(dispatcher, 'bob', 'Approve the deploy?')}"
65
+
66
+ puts "\n3) Ask @sam (away) with a 0.4s bound — times out, so escalate:"
67
+ if ask_over_bus(dispatcher, "sam", "Quick sign-off?").nil?
68
+ puts " @sam did not answer in time -> escalating to @alice"
69
+ puts " @alice says: #{ask_over_bus(dispatcher, 'alice', 'Quick sign-off?')}"
70
+ end