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.
- checksums.yaml +7 -0
- data/.envrc +1 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/.loki +10 -0
- data/.rubocop.yml +1 -0
- data/CHANGELOG.md +94 -0
- data/LICENSE.txt +21 -0
- data/README.md +138 -0
- data/Rakefile +132 -0
- data/docs/api_reference.md +301 -0
- data/docs/custom_channels.md +123 -0
- data/docs/getting_started.md +156 -0
- data/docs/how_it_works.md +211 -0
- data/docs/index.md +57 -0
- data/examples/01_human_in_the_network.rb +61 -0
- data/examples/02_terminal_mentions.rb +95 -0
- data/examples/03_robot_interviews_cyborg.rb +125 -0
- data/examples/04_presence_and_availability.rb +70 -0
- data/examples/05_listening_and_duplex.rb +53 -0
- data/lib/robot_lab/cyborg/channel.rb +174 -0
- data/lib/robot_lab/cyborg/conversation.rb +97 -0
- data/lib/robot_lab/cyborg/interviewer.rb +314 -0
- data/lib/robot_lab/cyborg/version.rb +9 -0
- data/lib/robot_lab/cyborg.rb +511 -0
- data/mkdocs.yml +118 -0
- data/sig/robot_lab/cyborg.rbs +36 -0
- metadata +90 -0
|
@@ -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).
|