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,53 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Always-on listening + the duplex boundary.
5
+ #
6
+ # Earlier demos had the network ask the human questions. Here nobody asks the
7
+ # human anything — yet a listening Cyborg still hears them. `converse` puts the
8
+ # Cyborg in always-on listen mode: a background reader captures whatever the
9
+ # human types and routes it to peers by @mention (no mention => broadcast). The
10
+ # peers' replies come *back* to the human's channel automatically, because a
11
+ # Cyborg delivers inbound bus traffic to its channel (the output half of the
12
+ # duplex). Neither direction is wired by this demo — both live in the library.
13
+ #
14
+ # The human's "keystrokes" are scripted here (a StringIO) so the demo runs
15
+ # deterministically; in a real session this is your live terminal.
16
+ #
17
+ # ruby examples/05_listening_and_duplex.rb
18
+
19
+ require "stringio"
20
+
21
+ # Prefer the local robot_lab checkout (with the latest fixes: serve/respond_to_tasks)
22
+ # over any installed gem, so the demo runs from a fresh monorepo checkout.
23
+ core_lib = File.expand_path("../../robot_lab/lib", __dir__)
24
+ $LOAD_PATH.unshift(core_lib) if File.directory?(core_lib)
25
+
26
+ require "robot_lab"
27
+ require_relative "../lib/robot_lab/cyborg"
28
+
29
+ Cyborg = RobotLab::Cyborg
30
+ Terminal = RobotLab::Cyborg::Channel::Terminal
31
+
32
+ bus = TypedBus::MessageBus.new
33
+
34
+ # What the human types, unprompted, over time.
35
+ keystrokes = StringIO.new(<<~INPUT)
36
+ @ops what's the current status?
37
+ heads up everyone, deploying at 15:00
38
+ INPUT
39
+
40
+ you = Cyborg.new(name: "you", bus: bus, channel: Terminal.new(input: keystrokes, output: $stdout, name: "network"))
41
+
42
+ # Two key-free robot peers that serve bus tasks (respond_to_tasks — no LLM call),
43
+ # cooperating on the same bus as the human.
44
+ RobotLab.build(name: "ops", bus: bus).respond_to_tasks { |_m| "all systems green" }
45
+ RobotLab.build(name: "deploy", bus: bus).respond_to_tasks { |_m| "roger, holding for go/no-go" }
46
+
47
+ # Start listening: unprompted human input is now routed by @mention; replies come
48
+ # back on the channel on their own.
49
+ you.converse(peers: %w[ops deploy])
50
+
51
+ # Let the background reader consume the scripted input and the replies land.
52
+ sleep 1.0
53
+ you.unlisten
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ class Cyborg
5
+ # A message crossing the human boundary, in either direction.
6
+ #
7
+ # Outbound (network -> human): the Interviewer/Cyborg stamps +content+ and a
8
+ # +kind+ (:question for a prompt awaiting an answer, :message/:notice for
9
+ # everything else) plus, when known, the +sender+ it is on behalf of.
10
+ # Inbound (human -> network): the Channel fills +content+ and, when the
11
+ # transport can correlate a reply to a specific question (a Slack thread, an
12
+ # email In-Reply-To), sets +in_reply_to+ to that question's id. A dumb
13
+ # transport (a bare terminal) leaves +in_reply_to+ nil.
14
+ #
15
+ # @!attribute id [Integer, nil] question id this message carries/answers
16
+ # @!attribute content [String] the human-readable payload
17
+ # @!attribute in_reply_to [Integer, nil] id of the question an inbound answer replies to
18
+ # @!attribute sender [String, nil] who the message is from/for (for display)
19
+ # @!attribute kind [Symbol] :question | :answer | :message | :notice
20
+ # @!attribute at [Time] when the message was created
21
+ ChannelMessage = Data.define(:id, :content, :in_reply_to, :sender, :kind, :at) do
22
+ def initialize(content:, id: nil, in_reply_to: nil, sender: nil, kind: :message, at: nil)
23
+ super(id:, content: content.to_s, in_reply_to:, sender:, kind:, at: at || Time.now)
24
+ end
25
+
26
+ # @return [Boolean] true when this is a prompt the human is expected to answer
27
+ def question? = kind == :question
28
+ end
29
+
30
+ # A Channel is the *means* by which a Cyborg reaches its human: a dumb,
31
+ # bidirectional pipe. It knows how to {#deliver} a message *out* to the human
32
+ # and to surface messages the human sends *in* — nothing more. It has no
33
+ # concept of a "question" or an "answer"; correlating replies to questions is
34
+ # the {Interviewer}'s job (the *process*), not the channel's.
35
+ #
36
+ # The same small API backs a terminal today and Slack, email, SMS, or a web
37
+ # form later — each is just a different Channel. Because the boundary is the
38
+ # injected channel, +$stdin+/+$stdout+ live *only* inside {Terminal}; nothing
39
+ # else in the Cyborg assumes a terminal.
40
+ #
41
+ # A channel that can tie an inbound answer back to the question it answers
42
+ # (Slack threads, email In-Reply-To) reports {#correlates?} true; the
43
+ # Interviewer then lets several questions be outstanding at once. A dumb
44
+ # channel reports false, and the Interviewer serializes questions — one on the
45
+ # wire at a time — so an answer is never mis-attributed.
46
+ #
47
+ # @abstract Subclass and implement {#deliver} and {#receive}.
48
+ class Channel
49
+ # Send a message out to the human (network -> human).
50
+ #
51
+ # @param message [ChannelMessage] the outbound message
52
+ # @return [ChannelMessage] the delivered message
53
+ def deliver(message)
54
+ raise NotImplementedError, "#{self.class} must implement #deliver"
55
+ end
56
+
57
+ # Return the next message from the human (human -> network), waiting up to
58
+ # +timeout+ seconds. Returns nil when nothing arrives in that window — nil
59
+ # is transient ("not yet"), not "closed"; the Interviewer keeps polling.
60
+ # Implementations MUST honor +timeout+ so the consumer can stay responsive
61
+ # to shutdown and question expiry.
62
+ #
63
+ # @param timeout [Numeric, nil] seconds to wait; nil = block indefinitely
64
+ # @return [ChannelMessage, nil]
65
+ def receive(timeout: nil)
66
+ raise NotImplementedError, "#{self.class} must implement #receive"
67
+ end
68
+
69
+ # Whether the transport tags inbound answers with the question they reply to
70
+ # (sets +in_reply_to+). Dumb channels return false and get one-question-at-
71
+ # a-time serialization from the Interviewer.
72
+ #
73
+ # @return [Boolean]
74
+ def correlates? = false
75
+
76
+ # Release any resources. Default: no-op.
77
+ def close; end
78
+
79
+ # Terminal-backed channel: the human is at a keyboard. This is the only
80
+ # place +$stdin+/+$stdout+ are assumed; inject StringIO in tests.
81
+ class Terminal < Channel
82
+ # @param input [IO] stream to read the human's messages from
83
+ # @param output [IO] stream to write messages to the human on
84
+ # @param name [String] label shown when a message has no explicit sender
85
+ def initialize(input: $stdin, output: $stdout, name: "cyborg")
86
+ @input = input
87
+ @output = output
88
+ @name = name
89
+ @write_mutex = Mutex.new
90
+ super()
91
+ end
92
+
93
+ # Print the message. A :question is followed by a "> " prompt; other kinds
94
+ # (inbound peer messages, notices) are not, so unsolicited traffic does not
95
+ # spam the input cursor. Synchronized so concurrent deliveries (a question
96
+ # and an inbound peer message) never interleave on screen.
97
+ def deliver(message)
98
+ @write_mutex.synchronize do
99
+ @output.puts "\n[#{message.sender || @name}] #{message.content}"
100
+ @output.print "> " if message.question?
101
+ @output.flush
102
+ end
103
+ message
104
+ end
105
+
106
+ # Read a line, honoring +timeout+ so the consumer can poll for shutdown and
107
+ # expiry. Uses IO.select on a real IO; a non-selectable stream (StringIO in
108
+ # tests) returns immediately from #gets, which is equally responsive. A nil
109
+ # line means EOF. A bare terminal carries no correlation, so +in_reply_to+
110
+ # stays nil.
111
+ def receive(timeout: nil)
112
+ return nil if timeout && selectable? && !@input.wait_readable(timeout)
113
+ return nil unless (line = @input.gets)
114
+
115
+ ChannelMessage.new(content: line.chomp, kind: :answer)
116
+ rescue IOError
117
+ nil
118
+ end
119
+
120
+ private
121
+
122
+ # Only a real IO can be waited on for readiness; a StringIO (tests) is not,
123
+ # and #gets returns from it immediately anyway.
124
+ def selectable? = @input.is_a?(IO)
125
+ end
126
+
127
+ # Scripted channel: canned human answers for tests, automation, and replay.
128
+ #
129
+ # Each delivered question is paired with the next scripted answer *at
130
+ # delivery time* and stamped with that question's id, so correlation is exact
131
+ # and deterministic (hence {#correlates?} is true). When the script runs dry,
132
+ # delivered questions simply go unanswered — modeling a human who never
133
+ # replies — and the Interviewer's timeout takes over. Every question shown is
134
+ # recorded in {#asked} so tests can assert on what the human saw.
135
+ class Scripted < Channel
136
+ # @return [Array<String>] questions delivered so far, in order
137
+ attr_reader :asked
138
+
139
+ # @param answers [Array<String>, String] answers to return, in order
140
+ def initialize(answers = [])
141
+ @answers = Array(answers).dup
142
+ @asked = []
143
+ @inbox = Thread::Queue.new
144
+ @mutex = Mutex.new
145
+ super()
146
+ end
147
+
148
+ def correlates? = true
149
+
150
+ def deliver(message)
151
+ # A scripted human answers questions; notices and inbound peer messages
152
+ # are shown, not answered (and don't consume a scripted answer).
153
+ return message unless message.question?
154
+
155
+ @mutex.synchronize do
156
+ @asked << message.content
157
+ unless @answers.empty?
158
+ @inbox.push(ChannelMessage.new(content: @answers.shift, in_reply_to: message.id, kind: :answer))
159
+ end
160
+ end
161
+ message
162
+ end
163
+
164
+ def receive(timeout: nil)
165
+ timeout ? @inbox.pop(timeout: timeout) : @inbox.pop
166
+ end
167
+
168
+ def close
169
+ @inbox.close
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ class Cyborg
5
+ # A Conversation turns a listening {Cyborg} into an interactive participant in
6
+ # a multi-peer network. It reads the human's unprompted messages and routes
7
+ # each by @mention:
8
+ #
9
+ # - "@name ..." (one or more mentions, anywhere in the line) fans the whole
10
+ # message out to every mentioned peer.
11
+ # - a line with no mention is a broadcast to every known peer.
12
+ #
13
+ # Replies come *back* to the human's channel automatically — the Cyborg
14
+ # delivers inbound bus traffic to the channel (the output half of the duplex)
15
+ # — so a Conversation only has to handle the human -> network direction.
16
+ #
17
+ # The addressing convention that used to live in example code now lives here,
18
+ # so every channel (terminal, Slack, ...) gets it for free.
19
+ #
20
+ # @example
21
+ # you = RobotLab::Cyborg.new(name: "you", bus: bus)
22
+ # chat = RobotLab::Cyborg::Conversation.new(cyborg: you, peers: %w[analyst scribe]).start
23
+ # # human types "hey @analyst and @scribe: status?" -> both are tasked
24
+ class Conversation
25
+ MENTION = /@(\w+)/ # every @name in a message, in order of first appearance
26
+
27
+ # @return [Array<String>] names this conversation can address
28
+ attr_reader :peers
29
+
30
+ # @param cyborg [Cyborg] the human peer whose channel/bus this drives
31
+ # @param peers [Array<String, Symbol>] known addressable member names
32
+ def initialize(cyborg:, peers: [])
33
+ @cyborg = cyborg
34
+ @peers = peers.map(&:to_s)
35
+ end
36
+
37
+ # Add an addressable peer by name.
38
+ # @return [self]
39
+ def add_peer(name)
40
+ @peers << name.to_s unless @peers.include?(name.to_s)
41
+ self
42
+ end
43
+
44
+ # Begin routing the human's unprompted input by @mention (always-on listen).
45
+ # Idempotent.
46
+ # @return [self]
47
+ def start
48
+ @cyborg.on_human { |message| route(message.content) }
49
+ @cyborg.listen
50
+ self
51
+ end
52
+
53
+ # Stop routing (winds down listening).
54
+ # @return [self]
55
+ def stop
56
+ @cyborg.unlisten
57
+ self
58
+ end
59
+
60
+ # Route one line of human input onto the bus. Unknown mentions are reported
61
+ # back to the human; a line mentioning only unknown peers is not sent.
62
+ #
63
+ # @param line [String]
64
+ # @return [Array<String>] the peers the message was sent to
65
+ def route(line)
66
+ line = line.to_s.strip
67
+ return [] if line.empty?
68
+
69
+ mentioned = line.scan(MENTION).flatten.uniq
70
+ recipients = recipients_for(mentioned)
71
+ return [] if recipients.nil?
72
+
73
+ recipients.each { |name| @cyborg.assign(to: name, task: line) }
74
+ recipients
75
+ end
76
+
77
+ private
78
+
79
+ # Resolve the recipients for a line, or nil when there is nothing to send
80
+ # (after telling the human why).
81
+ def recipients_for(mentioned)
82
+ unknown = mentioned - @peers
83
+ @cyborg.tell("No such peer: #{unknown.map { |n| "@#{n}" }.join(', ')}") unless unknown.empty?
84
+
85
+ recipients = mentioned.empty? ? @peers : (mentioned & @peers)
86
+ return recipients unless recipients.empty?
87
+
88
+ @cyborg.tell("No known peer to address. Known: #{roster}.")
89
+ nil
90
+ end
91
+
92
+ def roster
93
+ @peers.map { |name| "@#{name}" }.join(", ")
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ class Cyborg
5
+ # An Interviewer is the *process* of conducting a human interaction over a
6
+ # {Channel} (the *means*). The channel moves opaque messages; the Interviewer
7
+ # gives them meaning — it tracks which questions are outstanding, decides
8
+ # which inbound message answers which question, and surfaces the rest as
9
+ # unsolicited human initiative.
10
+ #
11
+ # Asking is *always asynchronous*, whatever the channel. A human's answer may
12
+ # be the very next message, may arrive several messages later (after they say
13
+ # other things first), or may never come at all. So {#ask} does not block for
14
+ # an answer — it delivers the question and returns a {Question} you can wait
15
+ # on, with a timeout, whenever you are ready. A background consumer drains the
16
+ # channel and resolves questions as their answers arrive.
17
+ #
18
+ # Correlation:
19
+ # - On a channel that {Channel#correlates?} (Slack threads, email), any number
20
+ # of questions may be outstanding; each inbound answer names the question it
21
+ # replies to via +in_reply_to+.
22
+ # - On a dumb channel (a bare terminal) answers carry no correlation, so the
23
+ # Interviewer serializes: only ONE question is on the wire at a time, and an
24
+ # answer resolves that active question. Extra asks queue and are delivered as
25
+ # the active one resolves or expires. This makes mis-attribution impossible.
26
+ class Interviewer
27
+ # How often the background consumer wakes to poll the channel.
28
+ POLL_INTERVAL = 0.05
29
+
30
+ # @return [StandardError, nil] last error a handler/channel raised in the consumer
31
+ attr_reader :last_error
32
+
33
+ # @param channel [Channel] the injected means of reaching the human
34
+ # @param default_timeout [Numeric, nil] default seconds {#ask_and_wait} waits
35
+ def initialize(channel:, default_timeout: nil)
36
+ @channel = channel
37
+ @default_timeout = default_timeout
38
+ @outstanding = {}
39
+ @pending = [] # registered-but-not-yet-delivered (serialized channels)
40
+ @active_id = nil # the one delivered question awaiting an answer (serialized)
41
+ @counter = 0
42
+ @mutex = Mutex.new
43
+ @on_initiative = nil
44
+ @consumer = nil
45
+ @running = false
46
+ @closing = false
47
+ @listening = false
48
+ @last_error = nil
49
+ end
50
+
51
+ # Ask the human a question, asynchronously.
52
+ #
53
+ # @param content [String] the question text
54
+ # @param choices [Array<String>, nil] optional multiple-choice options
55
+ # @param default [String, nil] value to use when the human answers empty
56
+ # @return [Question] a handle whose {Question#answer} waits for the reply
57
+ def ask(content, choices: nil, default: nil)
58
+ question = register(content, choices, default)
59
+ if @channel.correlates?
60
+ @channel.deliver(question_message(question))
61
+ else
62
+ @mutex.synchronize { @pending << question }
63
+ pump_pending
64
+ end
65
+ ensure_consumer
66
+ question
67
+ end
68
+
69
+ # Ask and block for the answer — the synchronous boundary a pipeline step
70
+ # needs. Returns the human's answer, or the default (nil when none) if no
71
+ # answer arrives within +timeout+.
72
+ #
73
+ # @param content [String] the question text
74
+ # @param timeout [Numeric, nil] seconds to wait; nil = wait indefinitely
75
+ # @return [String, nil]
76
+ def ask_and_wait(content, timeout: @default_timeout, **)
77
+ ask(content, **).answer(timeout: timeout)
78
+ end
79
+
80
+ # Register a handler for inbound messages that answer no outstanding
81
+ # question — the human acting as a peer (raising something unprompted).
82
+ #
83
+ # @yield [ChannelMessage] the unsolicited message
84
+ # @return [self]
85
+ def on_initiative(&block)
86
+ @on_initiative = block
87
+ self
88
+ end
89
+
90
+ # Keep the consumer alive even with no questions outstanding, so unsolicited
91
+ # human input is captured as initiative (human -> network). Idempotent.
92
+ #
93
+ # @return [self]
94
+ def listen
95
+ @mutex.synchronize { @listening = true }
96
+ ensure_consumer
97
+ self
98
+ end
99
+
100
+ # Stop always-on listening. The consumer winds down once nothing is
101
+ # outstanding. Does not close the channel.
102
+ #
103
+ # @return [self]
104
+ def unlisten
105
+ @mutex.synchronize { @listening = false }
106
+ self
107
+ end
108
+
109
+ # Stop the background consumer and close the channel.
110
+ # @return [void]
111
+ def close
112
+ @mutex.synchronize { @closing = true }
113
+ @channel.close
114
+ @consumer&.join(1)
115
+ @consumer = nil
116
+ end
117
+
118
+ # Remove a question from the outstanding set (so a later, unrelated answer
119
+ # cannot claim it) and advance the serialized queue. Called by
120
+ # {Question#answer} on timeout.
121
+ #
122
+ # @param question [Question]
123
+ # @return [void]
124
+ def expire(question)
125
+ @mutex.synchronize { @outstanding.delete(question.id) }
126
+ release_active(question.id)
127
+ end
128
+
129
+ private
130
+
131
+ def register(content, choices, default)
132
+ @mutex.synchronize do
133
+ id = (@counter += 1)
134
+ @outstanding[id] = Question.new(
135
+ id: id, content: content, choices: choices, default: default, interviewer: self
136
+ )
137
+ end
138
+ end
139
+
140
+ def question_message(question)
141
+ ChannelMessage.new(id: question.id, content: render(question), kind: :question)
142
+ end
143
+
144
+ # Deliver the next queued question if nothing is currently on the wire.
145
+ # (Serialized/non-correlating channels only.)
146
+ def pump_pending
147
+ question = @mutex.synchronize do
148
+ next nil if @active_id || @pending.empty?
149
+
150
+ nxt = @pending.shift
151
+ @active_id = nxt.id
152
+ nxt
153
+ end
154
+ @channel.deliver(question_message(question)) if question
155
+ end
156
+
157
+ # If +id+ was the active question, clear it and deliver the next queued one.
158
+ def release_active(id)
159
+ advanced = @mutex.synchronize do
160
+ next false unless @active_id == id
161
+
162
+ @active_id = nil
163
+ true
164
+ end
165
+ pump_pending if advanced
166
+ end
167
+
168
+ # Lazily start the single consumer thread. It runs only while questions are
169
+ # outstanding, so it stops on its own once every ask has been answered or has
170
+ # timed out; the next {#ask} restarts it. (For always-on listening — capturing
171
+ # idle human initiative — a Cyborg keeps a question-free consumer alive; see
172
+ # Cyborg#listen.) Both start and stop flip +@running+ under the mutex, so a
173
+ # restart never races a shutdown.
174
+ def ensure_consumer
175
+ @mutex.synchronize do
176
+ return if @running
177
+
178
+ @running = true
179
+ @consumer = Thread.new { consume }
180
+ end
181
+ end
182
+
183
+ # Drain the channel until there is nothing to serve. A handler or channel
184
+ # error must never kill the consumer (that would wedge every future ask), so
185
+ # errors are caught, recorded, and the loop continues. The +ensure+ resets
186
+ # +@running+ on any exit so {#ensure_consumer} can always restart.
187
+ def consume
188
+ until stop?
189
+ begin
190
+ message = @channel.receive(timeout: POLL_INTERVAL)
191
+ message ? dispatch(message) : sleep(POLL_INTERVAL)
192
+ rescue ClosedQueueError
193
+ break
194
+ rescue StandardError => e
195
+ @last_error = e
196
+ end
197
+ end
198
+ ensure
199
+ @mutex.synchronize { @running = false }
200
+ end
201
+
202
+ # Decide, atomically, whether the consumer should stop: when closing, or when
203
+ # nothing is outstanding and it is not in always-on listen mode.
204
+ def stop?
205
+ @mutex.synchronize do
206
+ next true if @closing
207
+ next false if @listening
208
+
209
+ @outstanding.empty?.tap { |idle| @running = false if idle }
210
+ end
211
+ end
212
+
213
+ # Resolve the question this message answers, or route it as initiative.
214
+ def dispatch(message)
215
+ question = claim(message)
216
+ if question
217
+ question.resolve(interpret(message.content, question))
218
+ release_active(question.id)
219
+ else
220
+ @on_initiative&.call(message)
221
+ end
222
+ end
223
+
224
+ # The outstanding question this message answers: an explicit +in_reply_to+
225
+ # when the channel provides one, otherwise the single active (serialized)
226
+ # question. A correlated id naming no outstanding question, or an answer with
227
+ # no active question, matches nothing (the message becomes initiative).
228
+ def claim(message)
229
+ reply_to = message.in_reply_to
230
+ @mutex.synchronize do
231
+ id = if reply_to && @outstanding.key?(reply_to)
232
+ reply_to
233
+ elsif reply_to.nil? && @active_id && @outstanding.key?(@active_id)
234
+ @active_id
235
+ end
236
+ id ? @outstanding.delete(id) : nil
237
+ end
238
+ end
239
+
240
+ # Turn a raw human reply into an answer: fall back to the default on an empty
241
+ # reply, and map a bare number to its choice. Always returns a String so a
242
+ # genuine empty answer is never confused with a timeout (which is nil).
243
+ def interpret(text, question)
244
+ default = question.default
245
+ text = default if empty_answer?(text) && !default.nil?
246
+ resolve_choice(text.to_s, question.choices)
247
+ end
248
+
249
+ def empty_answer?(text)
250
+ text.nil? || text.empty?
251
+ end
252
+
253
+ def render(question)
254
+ return question.content unless question.choices&.any?
255
+
256
+ numbered = question.choices.each_with_index.map { |choice, i| " #{i + 1}. #{choice}" }
257
+ [question.content, *numbered].join("\n")
258
+ end
259
+
260
+ def resolve_choice(text, choices)
261
+ return text unless choices&.any?
262
+ return text unless text.match?(/\A\d+\z/)
263
+
264
+ index = text.to_i - 1
265
+ index.between?(0, choices.size - 1) ? choices[index] : text
266
+ end
267
+ end
268
+
269
+ # A pending question handed to the human. It carries the question's text and
270
+ # options and acts as a one-shot future for the human's answer.
271
+ class Question
272
+ # @return [Integer] correlation id, unique within an Interviewer
273
+ attr_reader :id
274
+ # @return [String] the question text
275
+ attr_reader :content
276
+ # @return [Array<String>, nil] multiple-choice options, if any
277
+ attr_reader :choices
278
+ # @return [String, nil] value used when the human answers empty / times out
279
+ attr_reader :default
280
+
281
+ def initialize(id:, content:, choices: nil, default: nil, interviewer: nil)
282
+ @id = id
283
+ @content = content
284
+ @choices = choices
285
+ @default = default
286
+ @interviewer = interviewer
287
+ @mailbox = Thread::Queue.new
288
+ end
289
+
290
+ # Deliver the human's (already interpreted) answer. Called by the
291
+ # Interviewer's consumer.
292
+ #
293
+ # @param answer [String]
294
+ # @return [void]
295
+ def resolve(answer)
296
+ @mailbox.push(answer)
297
+ end
298
+
299
+ # Block up to +timeout+ seconds for the human's answer. On timeout, expire
300
+ # the question and return the default (nil when there is none) — this is how
301
+ # "the human may never answer" surfaces to the caller.
302
+ #
303
+ # @param timeout [Numeric, nil] seconds to wait; nil = wait indefinitely
304
+ # @return [String, nil]
305
+ def answer(timeout: nil)
306
+ result = timeout ? @mailbox.pop(timeout: timeout) : @mailbox.pop
307
+ return result unless result.nil?
308
+
309
+ @interviewer&.expire(self)
310
+ @default
311
+ end
312
+ end
313
+ end
314
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ # RobotLab::Cyborg is a class (a human peer worker), so its VERSION and the
5
+ # nested helper classes hang off the class itself rather than a module.
6
+ class Cyborg
7
+ VERSION = "0.2.7"
8
+ end
9
+ end