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,301 @@
1
+ # API Reference
2
+
3
+ Every public class and method in `robot_lab-cyborg`, grouped by concern. See [How It Works](how_it_works.md) for the concepts behind each one.
4
+
5
+ ## `RobotLab::Cyborg`
6
+
7
+ ### Constructor
8
+
9
+ #### `new(name:, bus: nil, channel: nil, interviewer: nil, auto_reply: true, memory: nil, ask_timeout: nil)`
10
+
11
+ | Parameter | Type | Default | Description |
12
+ |---|---|---|---|
13
+ | `name` | `String` | *(required)* | unique peer name — also the bus channel name |
14
+ | `bus` | `TypedBus::MessageBus`, `nil` | `nil` | shared bus to join immediately |
15
+ | `channel` | `Channel`, `nil` | `Channel::Terminal.new(name: name)` | means of reaching the human |
16
+ | `interviewer` | `Interviewer`, `nil` | `Interviewer.new(channel:, default_timeout: ask_timeout)` | the interaction process (inject `channel:` instead, in almost all cases) |
17
+ | `auto_reply` | `Boolean` | `true` | reply to inbound bus tasks automatically once the human answers |
18
+ | `memory` | `RobotLab::Memory`, `nil` | a fresh `Memory.new` | standalone memory used outside a network |
19
+ | `ask_timeout` | `Numeric`, `nil` | `nil` (wait indefinitely) | seconds to wait for the human before falling back to a default |
20
+
21
+ ```ruby
22
+ dewayne = RobotLab::Cyborg.new(name: "dewayne")
23
+ dewayne = RobotLab::Cyborg.new(name: "dewayne", bus: bus, ask_timeout: 30)
24
+ ```
25
+
26
+ ### Attributes
27
+
28
+ | Reader | Type | Description |
29
+ |---|---|---|
30
+ | `name` | `String` | peer name / bus channel name |
31
+ | `bus` | `TypedBus::MessageBus`, `nil` | the shared bus, if any |
32
+ | `outbox` | `Hash` | messages this peer has sent, keyed by message key — `{status:, message:, replies:}` |
33
+ | `channel` | `Channel` | the injected means of reaching the human |
34
+ | `interviewer` | `Interviewer` | the process conducting this peer's human interaction |
35
+ | `memory` | `RobotLab::Memory` | this peer's own standalone memory |
36
+ | `presence` | `Symbol` | `:online`, `:away`, or `:offline` |
37
+
38
+ ### Network Member Interface
39
+
40
+ #### `call(result) → SimpleFlow::Result`
41
+
42
+ The `SimpleFlow` step contract — invoked by the network when a pipeline reaches this peer's task. Extracts the message and shared memory from `result`, calls `#run`, and threads the resulting `RobotResult` back into the pipeline. Exceptions are caught and turned into an error-shaped `RobotResult` rather than raised.
43
+
44
+ #### `run(message = nil, network_memory: nil, memory: nil, **) → RobotResult`
45
+
46
+ Asks the human `message` and wraps the answer in a `RobotResult` — the contract that makes a `Cyborg` a valid `delegate`/pipeline target anywhere a `Robot` is. Attaches `network_memory` automatically when given.
47
+
48
+ ### Asking the Human
49
+
50
+ #### `ask(question, choices: nil, default: nil, timeout: @ask_timeout, validate: nil, retries: 2) → Object, nil`
51
+
52
+ Blocks for the human's answer (or `default`/`nil` on timeout). `validate:` is called with the raw answer; return a coerced value to accept it, or `nil` to reject and re-ask (up to `retries` extra attempts, then fall back to `default`).
53
+
54
+ ```ruby
55
+ cyborg.ask("Deploy now?")
56
+ cyborg.ask("Pick one", choices: %w[a b c])
57
+ cyborg.ask("Env?", validate: ->(a) { %w[dev prod].include?(a) ? a : nil })
58
+ ```
59
+
60
+ #### `ask_int(question, **) → Integer, nil`
61
+
62
+ `ask` with a validator that re-asks until the human gives a parseable integer (`Integer(str, exception: false)`).
63
+
64
+ #### `ask_confirm(question, **) → Boolean, nil`
65
+
66
+ `ask` with `choices: %w[yes no]` and a validator mapping `y`/`yes`/`true`/`1` → `true`, `n`/`no`/`false`/`0` → `false` (case-insensitive).
67
+
68
+ #### `ask_async(question, choices: nil, default: nil) → Question`
69
+
70
+ Delivers the question and returns immediately with the pending `Question`, instead of blocking.
71
+
72
+ ### Callbacks
73
+
74
+ #### `on_task(&block) → self`
75
+
76
+ Fires `block.call(message, answer)` after the human answers an inbound bus task.
77
+
78
+ #### `on_human(&block) → self`
79
+
80
+ Fires `block.call(channel_message)` when the human sends something unprompted — a channel message answering no outstanding question.
81
+
82
+ ### Talking to the Human
83
+
84
+ #### `tell(text, kind: :notice) → self`
85
+
86
+ Pushes `text` out to the human over the channel, unprompted — the network → human half of the duplex.
87
+
88
+ #### `converse(peers: []) → Conversation`
89
+
90
+ Starts (`Conversation.new(cyborg: self, peers:).start`) and returns an interactive `Conversation`: the human addresses peers by `@mention`, replies return automatically.
91
+
92
+ #### `listen → self`
93
+
94
+ Keeps the interviewer's consumer alive with no question outstanding, so unprompted human input is captured as initiative. Idempotent.
95
+
96
+ #### `unlisten → self`
97
+
98
+ Stops always-on listening.
99
+
100
+ ### Presence
101
+
102
+ #### `available? → Boolean`
103
+
104
+ `true` unless `presence == :offline`.
105
+
106
+ #### `online! → self`
107
+
108
+ Sets presence to `:online` (default) — taking work now.
109
+
110
+ #### `away! → self`
111
+
112
+ Sets presence to `:away` — still asked, but callers should use a bounded timeout.
113
+
114
+ #### `offline! → self`
115
+
116
+ Sets presence to `:offline` — inbound bus tasks are declined immediately instead of waiting on an absent human.
117
+
118
+ ### Issuing Work to Other Members
119
+
120
+ #### `assign(to:, task:) → RobotMessage`
121
+
122
+ Fire-and-forget bus send — alias for `BusMessaging#send_message`. Correlate any reply later via `outbox[message.key]`.
123
+
124
+ #### `delegate(to:, task:, async: false, **) → RobotResult, DelegationFuture`
125
+
126
+ `to` may be a `Robot` or another `Cyborg` — anything responding to `#run`. Synchronous by default (blocks, returns a `RobotResult` stamped with `delegated_by`); `async: true` spawns a thread and returns a `RobotLab::DelegationFuture` immediately (`future.value` / `future.value(timeout:)` blocks later; raises `DelegationFuture::DelegationTimeout` on expiry).
127
+
128
+ ### Shared Memory
129
+
130
+ #### `remember(key, value) → value`
131
+
132
+ Writes to the active memory (network shared memory when attached, else this peer's own).
133
+
134
+ #### `recall(key, wait: false) → Object, nil`
135
+
136
+ Reads from the active memory. `wait:` may be `false`, `true` (block indefinitely), or a `Numeric` numbers of seconds.
137
+
138
+ #### `attach_memory(mem) → self`
139
+
140
+ Points `remember`/`recall` at `mem` — a network run does this automatically when it starts.
141
+
142
+ #### `detach_memory → self`
143
+
144
+ Returns to this peer's own standalone memory.
145
+
146
+ ### Inspection
147
+
148
+ #### `inbox → Array<RobotMessage>`
149
+
150
+ Inbound bus messages received so far, oldest first (a defensive copy).
151
+
152
+ #### `to_h → Hash`
153
+
154
+ `{name:, kind: :cyborg, bus: true|nil, channel: "Channel::ClassName"}.compact`
155
+
156
+ ### Bus Methods (via `RobotLab::Robot::BusMessaging`)
157
+
158
+ `Cyborg` includes `RobotLab::Robot::BusMessaging` verbatim, gaining `send_message`, `send_reply`, `spawn`, `with_bus`, and `assign_bus_poller`. **Do not call `on_message`, `respond_to_tasks`, or `serve` on a `Cyborg`** — all three replace the inbound message handler wholesale, which disables the built-in ask/auto-reply flow. See [How It Works](how_it_works.md#network-member-and-bus-contracts).
159
+
160
+ ### Errors
161
+
162
+ #### `Cyborg::Error`
163
+
164
+ Raised for `Cyborg`-specific misuse. Not currently raised by any code path in this gem's own methods — reserved for future use and for extensions built on top of `Cyborg`.
165
+
166
+ ---
167
+
168
+ ## `RobotLab::Cyborg::Channel`
169
+
170
+ The abstract base class for the *means* of reaching a human. Subclass and implement `#deliver`/`#receive` to bridge to a new transport — see [Building Custom Channels](custom_channels.md).
171
+
172
+ | Method | Signature | Description |
173
+ |---|---|---|
174
+ | `deliver` | `deliver(message) → ChannelMessage` | send a message out to the human (network → human). Must be implemented by subclasses. |
175
+ | `receive` | `receive(timeout: nil) → ChannelMessage, nil` | return the next message from the human, waiting up to `timeout` seconds; `nil` on timeout (transient — not "closed"). Must be implemented by subclasses. |
176
+ | `correlates?` | `→ Boolean` | whether inbound answers are tagged with the question they reply to. Default `false`. |
177
+ | `close` | `→ void` | release resources. Default no-op. |
178
+
179
+ ### `Channel::Terminal`
180
+
181
+ `Terminal.new(input: $stdin, output: $stdout, name: "cyborg")` — the human at a keyboard; the default channel when none is injected. `deliver` prints `[sender] content`, followed by `> ` for questions. `receive(timeout:)` honors the timeout via `IO#wait_readable` on a real IO (a `StringIO` in tests responds immediately). `correlates?` is `false`.
182
+
183
+ ### `Channel::Scripted`
184
+
185
+ `Scripted.new(answers = [])` — canned answers consumed in order; `correlates?` is `true`. `#asked` (`Array<String>`) records every question shown, in order — useful for asserting what the human saw in tests.
186
+
187
+ ```ruby
188
+ scripted = RobotLab::Cyborg::Channel::Scripted.new(["yes", "ship it"])
189
+ bot = RobotLab::Cyborg.new(name: "dewayne", channel: scripted)
190
+ # ... run something that asks two questions ...
191
+ scripted.asked # => ["first question text", "second question text"]
192
+ ```
193
+
194
+ ---
195
+
196
+ ## `RobotLab::Cyborg::ChannelMessage`
197
+
198
+ ```ruby
199
+ ChannelMessage = Data.define(:id, :content, :in_reply_to, :sender, :kind, :at)
200
+ ```
201
+
202
+ Constructed as `ChannelMessage.new(content:, id: nil, in_reply_to: nil, sender: nil, kind: :message, at: nil)` — `at` defaults to `Time.now`. `#question?` returns `true` when `kind == :question`.
203
+
204
+ ---
205
+
206
+ ## `RobotLab::Cyborg::Interviewer`
207
+
208
+ The process that conducts a human interaction over an injected `Channel`. See [How It Works](how_it_works.md#asking-asynchronously) for the full asking/correlation/timeout model.
209
+
210
+ #### `new(channel:, default_timeout: nil)`
211
+
212
+ #### `ask(content, choices: nil, default: nil) → Question`
213
+
214
+ Delivers the question (immediately on a correlating channel, or queued behind a serialized one) and returns a pending `Question` without blocking.
215
+
216
+ #### `ask_and_wait(content, timeout: @default_timeout, **) → String, nil`
217
+
218
+ `ask(**).answer(timeout:)` — the synchronous form.
219
+
220
+ #### `on_initiative(&block) → self`
221
+
222
+ Registers the handler for inbound messages that answer no outstanding question.
223
+
224
+ #### `listen → self` / `unlisten → self`
225
+
226
+ Keep the consumer alive with nothing outstanding (or let it wind down again).
227
+
228
+ #### `close → void`
229
+
230
+ Stops the consumer and closes the channel.
231
+
232
+ #### `expire(question) → void`
233
+
234
+ Removes `question` from the outstanding set and advances a serialized queue. Called by `Question#answer` on timeout — not normally called directly.
235
+
236
+ #### `last_error → StandardError, nil`
237
+
238
+ The last error a handler or channel raised inside the consumer loop (caught, not re-raised, so the consumer keeps running).
239
+
240
+ ---
241
+
242
+ ## `RobotLab::Cyborg::Question`
243
+
244
+ A pending question handed to the human; a one-shot future for its answer.
245
+
246
+ | Reader | Type | Description |
247
+ |---|---|---|
248
+ | `id` | `Integer` | correlation id, unique within an `Interviewer` |
249
+ | `content` | `String` | the question text |
250
+ | `choices` | `Array<String>`, `nil` | multiple-choice options, if any |
251
+ | `default` | `String`, `nil` | value used on an empty answer or a timeout |
252
+
253
+ #### `answer(timeout: nil) → String, nil`
254
+
255
+ Blocks up to `timeout` seconds for the human's (already-interpreted) answer. On timeout, expires the question via its `Interviewer` and returns `default` (`nil` if none).
256
+
257
+ #### `resolve(answer) → void`
258
+
259
+ Delivers the answer — called by the `Interviewer`'s consumer, not normally called directly.
260
+
261
+ ---
262
+
263
+ ## `RobotLab::Cyborg::Conversation`
264
+
265
+ Turns a listening `Cyborg` into a multi-peer, `@mention`-addressed chat participant. See [How It Works](how_it_works.md#conversation-multi-peer-chat-by-mention).
266
+
267
+ #### `new(cyborg:, peers: [])`
268
+
269
+ #### `peers → Array<String>`
270
+
271
+ The addressable roster.
272
+
273
+ #### `add_peer(name) → self`
274
+
275
+ Adds an addressable peer.
276
+
277
+ #### `start → self`
278
+
279
+ Registers `on_human` routing and calls `cyborg.listen`. Idempotent.
280
+
281
+ #### `stop → self`
282
+
283
+ Calls `cyborg.unlisten`.
284
+
285
+ #### `route(line) → Array<String>`
286
+
287
+ Routes one line of human input by `@mention` (fan-out to every mentioned peer; no mention → broadcast to all known peers) and returns the names it was actually sent to. Unknown mentions are reported back to the human and excluded; a line with no resolvable recipient sends nothing.
288
+
289
+ ---
290
+
291
+ ## Related Core Classes (from `robot_lab`, not this gem)
292
+
293
+ Used throughout this gem's public API but defined in core `robot_lab` — see that gem's docs for full detail:
294
+
295
+ | Class | Relevance here |
296
+ |---|---|
297
+ | `RobotLab::RobotResult` | what `Cyborg#run`/`#call` return; `#reply` is the human's answer text |
298
+ | `RobotLab::DelegationFuture` | returned by `delegate(async: true)`; `DelegationFuture::DelegationTimeout` on a `value(timeout:)` expiry |
299
+ | `RobotLab::Robot::BusMessaging` | mixed into `Cyborg` for bus send/receive; see the caveat above about `on_message`/`respond_to_tasks`/`serve` |
300
+ | `RobotLab::Memory` | `remember`/`recall`'s backing store; `#current_writer=` is what `with_writer` toggles around a memory write |
301
+ | `RobotLab::RobotMessage` | the bus message type `assign`/`send_message` produce and `#inbox` holds |
@@ -0,0 +1,123 @@
1
+ # Building Custom Channels
2
+
3
+ `Channel::Terminal` and `Channel::Scripted` are the only channels this gem ships, but the whole point of the `Channel` abstraction is that a Slack, email, SMS, or web-form transport is *just another subclass* — nothing in `Cyborg`, `Interviewer`, or `Conversation` assumes a terminal. This page covers what a custom channel needs to get right.
4
+
5
+ ## The Contract
6
+
7
+ Subclass `RobotLab::Cyborg::Channel` and implement two methods:
8
+
9
+ ```ruby
10
+ class MyChannel < RobotLab::Cyborg::Channel
11
+ def deliver(message) # ChannelMessage -> ChannelMessage
12
+ # send `message.content` out to the human, over whatever transport this is.
13
+ # Return the message (or a copy of it) once sent.
14
+ end
15
+
16
+ def receive(timeout: nil) # -> ChannelMessage, nil
17
+ # Wait up to `timeout` seconds for the human's next message.
18
+ # Return nil if nothing arrives in that window — nil means "not yet",
19
+ # not "closed"; the Interviewer will call #receive again.
20
+ end
21
+ end
22
+ ```
23
+
24
+ Override two more only if they apply:
25
+
26
+ ```ruby
27
+ def correlates? = true # default: false — see "Correlation" below
28
+ def close # default: no-op — release sockets, files, etc. here
29
+ end
30
+ ```
31
+
32
+ ## `#receive` Must Honor `timeout:`
33
+
34
+ This is the one requirement that's easy to get wrong. The `Interviewer`'s background consumer calls `channel.receive(timeout: 0.05)` in a tight loop (`Interviewer::POLL_INTERVAL`), and relies on that call actually returning within roughly that window — every 50ms — so it can notice `Question` timeouts, respond to `#close`, and stay responsive. A `#receive` that blocks indefinitely regardless of `timeout:` will make questions never expire and `#close` hang.
35
+
36
+ For a polling-style transport (an HTTP API you poll, a database table), implement this literally: poll, and if nothing new has arrived within `timeout` seconds, return `nil`. For a push-style transport (a webhook, a queue with blocking pop), buffer inbound messages into a `Thread::Queue` from your webhook handler and let `receive` do `@inbox.pop(timeout: timeout)` — exactly what `Channel::Scripted` and the `Autobot` example below do.
37
+
38
+ ## Correlation
39
+
40
+ Whether `correlates?` should be `true` or `false` depends on whether your transport can tie an inbound answer back to the specific question it answers:
41
+
42
+ - **Slack threads** — reply in the same thread as the question; the thread's parent message id becomes `in_reply_to`. `correlates? = true`.
43
+ - **Email** — the `In-Reply-To` header names the message id being answered. `correlates? = true`.
44
+ - **A bare terminal, or an SMS number with no threading** — there's no way to tell which question an inbound text answers except "whichever one is outstanding." `correlates? = false` — the `Interviewer` will serialize (one question on the wire at a time) so an answer is never mis-attributed. See [How It Works — Correlation vs. Serialization](how_it_works.md#correlation-vs-serialization).
45
+
46
+ Get this wrong in the `true` direction (claiming correlation your transport doesn't actually have) and answers can resolve the wrong question when more than one is outstanding. Getting it wrong in the `false` direction just costs you unnecessary serialization — safe, but limits concurrent questions on a channel that could actually support them.
47
+
48
+ ## A Worked Example: an Automated Peer
49
+
50
+ Every peer on the bus — human or otherwise — is reached through a `Channel`, which means a scripted or programmatic "human" is just a `Channel` whose deliver/receive are backed by a block instead of a person. This is exactly how `examples/02_terminal_mentions.rb` builds key-free canned peers that still go through the real `Cyborg`/`Interviewer` machinery:
51
+
52
+ ```ruby
53
+ class Autobot < RobotLab::Cyborg::Channel
54
+ def initialize(&reply)
55
+ @reply = reply
56
+ @inbox = Thread::Queue.new
57
+ super()
58
+ end
59
+
60
+ def correlates? = true
61
+ def deliver(message) = message.question? ? @inbox.push(reply_to(message)) : message
62
+ def receive(timeout: nil) = timeout ? @inbox.pop(timeout: timeout) : @inbox.pop
63
+ def close = @inbox.close
64
+
65
+ private
66
+
67
+ def reply_to(message)
68
+ RobotLab::Cyborg::ChannelMessage.new(content: @reply.call(message.content), in_reply_to: message.id, kind: :answer)
69
+ end
70
+ end
71
+
72
+ analyst = RobotLab::Cyborg.new(name: "analyst", bus: bus,
73
+ channel: Autobot.new { |_question| "error rate is 0.2% over the last hour." })
74
+ ```
75
+
76
+ Notice what `Autobot` does *not* need to know about: bus wiring, reply correlation logic beyond stamping `in_reply_to`, or anything about questions vs. answers as concepts — that's the `Interviewer`'s job. It only has to answer "how do I move a `ChannelMessage` across this boundary."
77
+
78
+ ## Sketch: a Slack Channel
79
+
80
+ A real Slack channel is push-style (events arrive via the Events API or Socket Mode) and does correlate (thread replies):
81
+
82
+ ```ruby
83
+ class SlackChannel < RobotLab::Cyborg::Channel
84
+ def initialize(slack_client:, channel_id:)
85
+ @slack = slack_client
86
+ @channel_id = channel_id
87
+ @inbox = Thread::Queue.new
88
+ super()
89
+ end
90
+
91
+ def correlates? = true
92
+
93
+ def deliver(message)
94
+ response = @slack.chat_postMessage(channel: @channel_id, text: message.content,
95
+ thread_ts: message.in_reply_to)
96
+ message # or a copy carrying response["ts"] if you need the posted id later
97
+ end
98
+
99
+ def receive(timeout: nil)
100
+ timeout ? @inbox.pop(timeout: timeout) : @inbox.pop
101
+ end
102
+
103
+ def close
104
+ @inbox.close
105
+ end
106
+
107
+ # Called from your Slack event handler / webhook controller, on whatever
108
+ # thread that framework hands events to you on:
109
+ def handle_slack_event(event)
110
+ @inbox.push(RobotLab::Cyborg::ChannelMessage.new(
111
+ content: event["text"],
112
+ in_reply_to: event["thread_ts"], # nil for a top-level message
113
+ sender: event["user"]
114
+ ))
115
+ end
116
+ end
117
+ ```
118
+
119
+ The pattern generalizes directly to email (`In-Reply-To`/`Message-Id` headers instead of Slack thread timestamps), a web form (poll a table of submitted answers, or push from a controller action the same way), or an SMS/queue transport.
120
+
121
+ ## Testing a Custom Channel
122
+
123
+ Give it the same treatment `test/robot_lab/test_interviewer.rb`'s `Probe` channel gets: a hand-driven channel whose `receive` you control from the test, so you can push messages (correlated or not) in whatever order you want and assert on what the `Interviewer`/`Cyborg` did with them, without depending on real network/IO timing.
@@ -0,0 +1,156 @@
1
+ # Getting Started
2
+
3
+ ## Prerequisites
4
+
5
+ - Ruby 3.2+ (per the gemspec's `required_ruby_version`)
6
+ - `robot_lab` `~> 0.2`, `>= 0.2.6` — `robot_lab-cyborg` needs the `respond_to_tasks`/`serve` and shared bus-mutex additions to `RobotLab::Robot::BusMessaging` that landed at that version, and `RobotLab::Memory#current_writer=`.
7
+ - `robot_lab` must be `require`d before `robot_lab/cyborg` (`Cyborg` includes `RobotLab::Robot::BusMessaging` and calls `RobotLab.register_extension`, both of which must already be defined).
8
+
9
+ ## Installation
10
+
11
+ Add to your `Gemfile`:
12
+
13
+ ```ruby
14
+ gem "robot_lab"
15
+ gem "robot_lab-cyborg"
16
+ ```
17
+
18
+ Then:
19
+
20
+ ```sh
21
+ bundle install
22
+ ```
23
+
24
+ Or install directly:
25
+
26
+ ```sh
27
+ gem install robot_lab-cyborg
28
+ ```
29
+
30
+ ## Creating a Human Peer
31
+
32
+ ```ruby
33
+ require "robot_lab"
34
+ require "robot_lab/cyborg"
35
+
36
+ # By default, talks to a terminal on $stdin/$stdout.
37
+ dewayne = RobotLab::Cyborg.new(name: "dewayne")
38
+ ```
39
+
40
+ `Cyborg.new` accepts:
41
+
42
+ | Keyword | Default | Purpose |
43
+ |---|---|---|
44
+ | `name:` | *(required)* | unique peer name — also the bus channel name |
45
+ | `bus:` | `nil` | a `TypedBus::MessageBus` to join immediately |
46
+ | `channel:` | a `Channel::Terminal` on `$stdin`/`$stdout` | the means of reaching the human |
47
+ | `interviewer:` | a fresh `Interviewer` over `channel:` | the process conducting the ask (rarely overridden directly — inject `channel:` instead) |
48
+ | `auto_reply:` | `true` | reply to inbound bus tasks automatically once the human answers |
49
+ | `memory:` | a fresh `RobotLab::Memory` | this peer's own (standalone) memory, used outside a network |
50
+ | `ask_timeout:` | `nil` (wait indefinitely) | seconds to wait for the human before falling back to a default |
51
+
52
+ ## A Human as a Pipeline Step
53
+
54
+ The human is interchangeable with a robot anywhere a network member is expected — it satisfies the same `call`/`run` contract a `Robot` does:
55
+
56
+ ```ruby
57
+ writer = RobotLab.build(name: "writer", template: :writer)
58
+
59
+ network = RobotLab.create_network(name: "release") do
60
+ task :draft, writer, depends_on: :none
61
+ task :approve, dewayne, depends_on: [:draft] # the human signs off
62
+ end
63
+
64
+ result = network.run(message: "Draft the release notes")
65
+ result.value.reply # => the human's answer, same shape as a robot's RobotResult#reply
66
+ ```
67
+
68
+ When the pipeline reaches the human's task, the network calls `dewayne.call(pipeline_result)`, which extracts the incoming message, calls `dewayne.ask(message)`, and wraps the answer in a `RobotResult` so downstream steps can't tell a human answered instead of a robot.
69
+
70
+ ## Peers Messaging Over a Shared Bus
71
+
72
+ Robots and cyborgs talk to each other by name over one shared bus — the same `TypedBus::MessageBus` a `Robot` would join via `RobotLab.build(bus:)`:
73
+
74
+ ```ruby
75
+ bus = TypedBus::MessageBus.new
76
+ analyst = RobotLab.build(name: "analyst", bus: bus)
77
+ dewayne = RobotLab::Cyborg.new(name: "dewayne", bus: bus)
78
+
79
+ # The robot asks the human a question; the human's answer comes back as a reply.
80
+ analyst.send_message(to: :dewayne, content: "Approve the deploy? (yes/no)")
81
+
82
+ # The human issues work to the robot, too.
83
+ dewayne.assign(to: :analyst, task: "Summarize today's error budget.")
84
+ ```
85
+
86
+ `send_message`/`send_reply`/`on_message` all come from `RobotLab::Robot::BusMessaging`, which `Cyborg` includes — see [How It Works](how_it_works.md#network-member-and-bus-contracts) for the one important caveat about overriding `on_message` on a `Cyborg`.
87
+
88
+ ## Shared Memory
89
+
90
+ ```ruby
91
+ dewayne.remember(:decision, "ship it") # visible to every member
92
+ dewayne.recall(:sentiment, wait: 30) # block until a robot writes it
93
+ ```
94
+
95
+ Inside a network run, `remember`/`recall` target the network's shared memory automatically (`attach_memory` is called for you); outside a network, they fall back to the peer's own standalone `memory:`.
96
+
97
+ ## A Minimal, Key-Free Example
98
+
99
+ Inject a `Channel::Scripted` to run the human side end-to-end with no live terminal and no API keys:
100
+
101
+ ```ruby
102
+ require "robot_lab"
103
+ require "robot_lab/cyborg"
104
+
105
+ Scripted = RobotLab::Cyborg::Channel::Scripted
106
+
107
+ approver = RobotLab::Cyborg.new(name: "approver", channel: Scripted.new(["approved: ship it"]))
108
+
109
+ network = RobotLab.create_network(name: "release") do
110
+ task :approve, approver, depends_on: :none
111
+ end
112
+
113
+ result = network.run(message: "Approve the v1.0 release notes?")
114
+ puts result.value.reply # => "approved: ship it"
115
+ puts approver.channel.asked.first # => "Approve the v1.0 release notes?"
116
+ ```
117
+
118
+ ## Bundled Examples
119
+
120
+ The gem ships five runnable demos, one feature area each — see the [examples/](https://github.com/MadBomber/robot_lab-cyborg/tree/main/examples) directory:
121
+
122
+ | Example | Demonstrates | Needs a live human? | Needs an LLM? |
123
+ |---|---|---|---|
124
+ | `01_human_in_the_network.rb` | Pipeline step, bus messaging between two humans, shared memory | No (scripted) | No |
125
+ | `02_terminal_mentions.rb` | Live `@mention` addressing via `Conversation`, a real Ollama robot cooperating via `#serve` | Yes | Optional (canned peers work without it) |
126
+ | `03_robot_interviews_cyborg.rb` | The interview run the *other* way — an LLM robot interviews the human via `delegate`, typed intake (`ask_confirm`/`ask_int`) | Yes | Yes (Ollama) |
127
+ | `04_presence_and_availability.rb` | `online!`/`away!`/`offline!`, an offline human declining immediately, bounded-timeout escalation | No (scripted) | No |
128
+ | `05_listening_and_duplex.rb` | Always-on `listen`: the human speaks unprompted, replies come back on the channel | No (scripted) | No |
129
+
130
+ ```sh
131
+ ruby examples/01_human_in_the_network.rb
132
+ ruby examples/04_presence_and_availability.rb
133
+ ruby examples/05_listening_and_duplex.rb
134
+
135
+ # Examples 2 and 3 use a real robot on Ollama (ollama pull qwen3.6; override with
136
+ # OLLAMA_MODEL / OLLAMA_API_BASE). In example 2 the canned peers still work
137
+ # without it — only @assistant needs Ollama.
138
+ ruby examples/02_terminal_mentions.rb # then type: @analyst and @scribe: status?
139
+ ruby examples/03_robot_interviews_cyborg.rb # the robot asks you the questions
140
+ ```
141
+
142
+ ## Development
143
+
144
+ ```sh
145
+ bin/setup # install dependencies
146
+ bundle exec rake test # run tests
147
+ bundle exec rake quality # tests + RuboCop + Flog + Flay
148
+ bin/console # IRB with the gem loaded
149
+ ```
150
+
151
+ ## Key Constraints
152
+
153
+ - `robot_lab` must be `require`d before `robot_lab/cyborg` — see Prerequisites above.
154
+ - `$stdin`/`$stdout` are only ever touched inside `Channel::Terminal`; every other part of the gem is transport-agnostic.
155
+ - A human step holds a thread while it waits for an answer (`ask`/`ask_and_wait` block the calling thread; `ask_async` does not — see [How It Works](how_it_works.md#asking-asynchronously)). There is currently no way to persist a pending question across a process restart — see the [Durable Human Steps](how_it_works.md#roadmap-durable-human-steps) roadmap note.
156
+ - Calling `on_message`, `respond_to_tasks`, or `serve` (all inherited from `BusMessaging`) on a `Cyborg` replaces its built-in inbound-task handling — see [How It Works](how_it_works.md#network-member-and-bus-contracts).