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,511 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "robot_lab"
4
+
5
+ require_relative "cyborg/version"
6
+ require_relative "cyborg/channel"
7
+
8
+ module RobotLab
9
+ # A Cyborg is a *human* peer worker on a RobotLab::Network.
10
+ #
11
+ # Robots on a network are LLM-backed workers; a Cyborg is a human-backed one.
12
+ # It is a peer at the same level as the robots: it registers as a network task,
13
+ # speaks on the same TypedBus channels, reads and writes the same shared
14
+ # memory, receives tasking (both as a pipeline step and as bus messages), and
15
+ # issues tasking to the other members — humans and robots alike.
16
+ #
17
+ # It reuses RobotLab::Robot::BusMessaging verbatim, so its bus behavior is
18
+ # byte-for-byte identical to a robot's. It deliberately does *not* subclass
19
+ # Robot, because a human needs no LLM, no model, and no API key — the human is
20
+ # the "model," reached across an injectable {Channel} (the *means* — terminal
21
+ # now, Slack/email/web later) by an {Interviewer} (the *process* that conducts
22
+ # the asynchronous ask-and-answer).
23
+ #
24
+ # @example A human peer in a network pipeline
25
+ # dewayne = RobotLab::Cyborg.new(name: "dewayne")
26
+ # network = RobotLab.create_network(name: "review") do
27
+ # task :draft, writer_robot, depends_on: :none
28
+ # task :approve, dewayne, depends_on: [:draft] # the human signs off
29
+ # end
30
+ # network.run(message: "Draft the release notes")
31
+ #
32
+ # @example Peers messaging over a shared bus
33
+ # bus = TypedBus::MessageBus.new
34
+ # analyst = RobotLab.build(name: "analyst", bus: bus)
35
+ # dewayne = RobotLab::Cyborg.new(name: "dewayne", bus: bus)
36
+ # analyst.send_message(to: :dewayne, content: "Approve deploy? (yes/no)")
37
+ # # dewayne's human is prompted; the answer is sent back as a reply
38
+ #
39
+ class Cyborg
40
+ include RobotLab::Robot::BusMessaging
41
+
42
+ # Raised for Cyborg-specific misuse (e.g. issuing a bus task with no bus).
43
+ class Error < StandardError; end
44
+
45
+ # @return [String] the peer's unique name — also its bus channel name
46
+ attr_reader :name
47
+
48
+ # @return [TypedBus::MessageBus, nil] the shared bus, if any
49
+ attr_reader :bus
50
+
51
+ # @return [Hash] outbox of messages this peer has sent, keyed by message key
52
+ attr_reader :outbox
53
+
54
+ # @return [Channel] the injectable means by which this peer reaches its human
55
+ attr_reader :channel
56
+
57
+ # @return [Interviewer] the process conducting this peer's human interaction
58
+ attr_reader :interviewer
59
+
60
+ # @return [RobotLab::Memory] the peer's own (standalone) memory
61
+ attr_reader :memory
62
+
63
+ # Create a human peer.
64
+ #
65
+ # @param name [String] unique name (and bus channel name) for this peer
66
+ # @param bus [TypedBus::MessageBus, nil] shared bus to join immediately
67
+ # @param channel [Channel, nil] means of reaching the human (default: a
68
+ # terminal channel on $stdin/$stdout)
69
+ # @param interviewer [Interviewer, nil] the interaction process (default: a
70
+ # fresh Interviewer over +channel+)
71
+ # @param auto_reply [Boolean] reply to inbound bus tasks automatically
72
+ # @param memory [RobotLab::Memory, nil] standalone memory (default: fresh)
73
+ # @param ask_timeout [Numeric, nil] seconds to wait for the human before
74
+ # giving up on an answer (nil = wait indefinitely)
75
+ def initialize(name:, bus: nil, channel: nil, interviewer: nil,
76
+ auto_reply: true, memory: nil, ask_timeout: nil)
77
+ @name = name.to_s
78
+
79
+ # ivars the BusMessaging mixin expects to find already initialized
80
+ @bus = bus
81
+ @bus_poller = nil
82
+ @private_bus_poller = nil
83
+ @bus_poller_group = :default
84
+ @bus_subscriber_id = nil
85
+ @message_counter = 0
86
+ @outbox = {}
87
+ @bus_mutex = Mutex.new
88
+ @message_handler = method(:handle_incoming)
89
+
90
+ @auto_reply = auto_reply
91
+ @ask_timeout = ask_timeout
92
+ @presence = :online
93
+ @on_task = nil
94
+ @on_human = nil
95
+ @inbox = []
96
+ @inbox_mutex = Mutex.new
97
+ @state_mutex = Mutex.new
98
+ @shared_memory = nil
99
+ @memory = memory || Memory.new
100
+ @channel = channel || Channel::Terminal.new(name: @name)
101
+ @interviewer = interviewer || Interviewer.new(channel: @channel, default_timeout: @ask_timeout)
102
+ @interviewer.on_initiative { |message| handle_human_initiative(message) }
103
+
104
+ setup_bus_channel if @bus
105
+ end
106
+
107
+ # --- Network member interface (peer-level, same as a Robot) --------------
108
+
109
+ # SimpleFlow step interface. The network calls this when the pipeline reaches
110
+ # the human; the human performs the step and the result flows downstream just
111
+ # like any robot's RobotResult.
112
+ #
113
+ # @param result [SimpleFlow::Result] incoming pipeline result
114
+ # @return [SimpleFlow::Result]
115
+ def call(result)
116
+ run_context = extract_run_context(result)
117
+ started = clock
118
+ robot_result = run(run_context[:message], network_memory: run_context[:network_memory])
119
+ robot_result.duration = clock - started
120
+
121
+ result
122
+ .with_context(@name.to_sym, robot_result)
123
+ .continue(robot_result)
124
+ rescue StandardError => e
125
+ error_result = build_result("Error: #{e.class}: #{e.message}")
126
+ result
127
+ .with_context(@name.to_sym, error_result)
128
+ .continue(error_result)
129
+ end
130
+
131
+ # Perform one unit of work by asking the human, and return a RobotResult so
132
+ # the human is interchangeable with a robot everywhere (pipeline steps,
133
+ # Robot#delegate, etc.).
134
+ #
135
+ # @param message [String, nil] the task / prompt for the human
136
+ # @param network_memory [RobotLab::Memory, nil] shared memory when in a network
137
+ # @param memory [RobotLab::Memory, nil] explicit memory override
138
+ # @return [RobotResult]
139
+ def run(message = nil, network_memory: nil, memory: nil, **_kwargs)
140
+ active = memory || network_memory || @memory
141
+ attach_memory(network_memory) if network_memory
142
+
143
+ answer = with_writer(active) { ask(message.to_s) }
144
+ build_result(answer)
145
+ end
146
+
147
+ # --- Human interface ------------------------------------------------------
148
+
149
+ # Ask this peer's human a question and block for the answer. This is the
150
+ # synchronous boundary the network relies on (pipeline steps, bus tasks); the
151
+ # underlying interview is asynchronous. Returns the default (nil when none)
152
+ # if no answer arrives within +timeout+.
153
+ #
154
+ # @param question [String]
155
+ # @param choices [Array<String>, nil]
156
+ # @param default [String, nil]
157
+ # @param timeout [Numeric, nil] seconds to wait (default: this peer's ask_timeout)
158
+ # @param validate [#call, nil] a validator: return a coerced value when the
159
+ # answer is acceptable, or nil to reject and re-ask
160
+ # @param retries [Integer] extra attempts allowed when validation rejects
161
+ # @return [Object, nil] the human's answer (coerced when validated)
162
+ def ask(question, choices: nil, default: nil, timeout: @ask_timeout, validate: nil, retries: 2)
163
+ attempt = 0
164
+ loop do
165
+ answer = @interviewer.ask_and_wait(question, choices: choices, default: default, timeout: timeout)
166
+ return answer if validate.nil? || answer.nil?
167
+
168
+ value = validate.call(answer)
169
+ return value unless value.nil?
170
+
171
+ attempt += 1
172
+ return default if attempt > retries
173
+
174
+ tell(%(Sorry, I couldn't use "#{answer}". Please try again.))
175
+ end
176
+ end
177
+
178
+ # Ask for an integer, re-asking until the human gives a parseable number.
179
+ # @return [Integer, nil]
180
+ def ask_int(question, **)
181
+ ask(question, validate: ->(a) { Integer(a.to_s.strip, exception: false) }, **)
182
+ end
183
+
184
+ # Ask a yes/no question, returning true/false (nil if never answered).
185
+ # @return [Boolean, nil]
186
+ def ask_confirm(question, **)
187
+ ask(question, choices: %w[yes no], validate: method(:parse_bool), **)
188
+ end
189
+
190
+ # Ask this peer's human a question without blocking, returning the pending
191
+ # {Question} so the caller can wait on it later, on its own terms.
192
+ #
193
+ # @param question [String]
194
+ # @param choices [Array<String>, nil]
195
+ # @param default [String, nil]
196
+ # @return [Question]
197
+ def ask_async(question, choices: nil, default: nil)
198
+ @interviewer.ask(question, choices: choices, default: default)
199
+ end
200
+
201
+ # Register a callback fired after the human answers an inbound bus task.
202
+ #
203
+ # @yield [message, answer] the inbound RobotMessage and the human's answer
204
+ # @return [self]
205
+ def on_task(&block)
206
+ @on_task = block
207
+ self
208
+ end
209
+
210
+ # Register a callback fired when the human sends something unprompted — a
211
+ # message over the channel that answers no outstanding question. This is the
212
+ # human-initiates-into-the-network path; a {Conversation} turns these into
213
+ # addressed bus messages, or handle them yourself here.
214
+ #
215
+ # @yield [ChannelMessage] the unsolicited human message
216
+ # @return [self]
217
+ def on_human(&block)
218
+ @on_human = block
219
+ self
220
+ end
221
+
222
+ # Say something to the human over the channel (network -> human), unprompted —
223
+ # the output half of the duplex. Use this to surface notices, or let a
224
+ # {Conversation} route peer replies here automatically.
225
+ #
226
+ # @param text [String]
227
+ # @param kind [Symbol] :notice | :message | :question
228
+ # @return [self]
229
+ def tell(text, kind: :notice)
230
+ @channel.deliver(ChannelMessage.new(content: text.to_s, kind: kind))
231
+ self
232
+ end
233
+
234
+ # Start an interactive {Conversation}: the human addresses peers by @mention
235
+ # (no mention broadcasts to all), replies come back on the channel. Returns
236
+ # the started Conversation.
237
+ #
238
+ # @param peers [Array<String, Symbol>] addressable member names
239
+ # @return [Conversation]
240
+ def converse(peers: [])
241
+ Conversation.new(cyborg: self, peers: peers).start
242
+ end
243
+
244
+ # Start always-on listening: keep reading the channel even when no question is
245
+ # outstanding, so the human can speak to the network unprompted at any time.
246
+ # Their input arrives via {#on_human}. Idempotent.
247
+ #
248
+ # @return [self]
249
+ def listen
250
+ @interviewer.listen
251
+ self
252
+ end
253
+
254
+ # Stop always-on listening.
255
+ # @return [self]
256
+ def unlisten
257
+ @interviewer.unlisten
258
+ self
259
+ end
260
+
261
+ # --- Presence / availability ---------------------------------------------
262
+
263
+ # @return [Symbol] :online, :away, or :offline
264
+ attr_reader :presence
265
+
266
+ # Whether this human peer will take work now. The network can check this
267
+ # before delegating and route around or escalate for an absent human.
268
+ #
269
+ # @return [Boolean]
270
+ def available?
271
+ @state_mutex.synchronize { @presence != :offline }
272
+ end
273
+
274
+ # Mark the human present and taking work.
275
+ # @return [self]
276
+ def online!
277
+ @state_mutex.synchronize { @presence = :online }
278
+ self
279
+ end
280
+
281
+ # Mark the human present but slow to respond (still asked; caller should use a
282
+ # generous timeout).
283
+ # @return [self]
284
+ def away!
285
+ @state_mutex.synchronize { @presence = :away }
286
+ self
287
+ end
288
+
289
+ # Mark the human unavailable. Inbound bus tasks are declined immediately
290
+ # instead of waiting on a human who isn't there.
291
+ # @return [self]
292
+ def offline!
293
+ @state_mutex.synchronize { @presence = :offline }
294
+ self
295
+ end
296
+
297
+ # --- Issuing tasks to other members --------------------------------------
298
+
299
+ # Issue a task to another member over the bus (fire-and-forget; the reply,
300
+ # if any, is correlated into {#outbox}). Alias for the robot idiom
301
+ # +send_message+, named for how a human hands off work.
302
+ #
303
+ # @param to [String, Symbol] target member's name/channel
304
+ # @param task [String, Hash] the task payload
305
+ # @return [RobotMessage] the sent message
306
+ def assign(to:, task:)
307
+ send_message(to: to, content: task)
308
+ end
309
+
310
+ # Delegate a task to another member and get a RobotResult back, synchronously
311
+ # or asynchronously. Works against robots and cyborgs alike, because both
312
+ # respond to +run+.
313
+ #
314
+ # @param to [#run] the member to delegate to (Robot or Cyborg)
315
+ # @param task [String] the task message
316
+ # @param async [Boolean] when true, returns a DelegationFuture immediately
317
+ # @return [RobotResult, DelegationFuture]
318
+ def delegate(to:, task:, async: false, **)
319
+ if async
320
+ future = DelegationFuture.new(robot_name: to.name, delegated_by: @name)
321
+ delegator = @name
322
+ Thread.new do
323
+ result = to.run(task, **)
324
+ result.delegated_by = delegator
325
+ future.resolve!(result)
326
+ rescue StandardError => e
327
+ future.reject!(e)
328
+ end
329
+ future
330
+ else
331
+ result = to.run(task, **)
332
+ result.delegated_by = @name
333
+ result
334
+ end
335
+ end
336
+
337
+ # --- Shared memory --------------------------------------------------------
338
+
339
+ # Write to the active memory (the network's shared memory when in a network,
340
+ # otherwise this peer's own memory). Other members see it immediately.
341
+ #
342
+ # @param key [Object]
343
+ # @param value [Object]
344
+ # @return [Object] value
345
+ def remember(key, value)
346
+ current_memory.set(key, value)
347
+ value
348
+ end
349
+
350
+ # Read from the active memory, optionally blocking until another member
351
+ # writes the key.
352
+ #
353
+ # @param key [Object]
354
+ # @param wait [Boolean, Numeric] false, true, or seconds to wait
355
+ # @return [Object, nil]
356
+ def recall(key, wait: false)
357
+ current_memory.get(key, wait: wait)
358
+ end
359
+
360
+ # Attach this peer to a shared memory — what {#remember}/{#recall} target. A
361
+ # network run attaches automatically; call {#detach_memory} to return to this
362
+ # peer's own standalone memory (so it doesn't keep writing to a finished
363
+ # network's memory).
364
+ #
365
+ # @param mem [RobotLab::Memory]
366
+ # @return [self]
367
+ def attach_memory(mem)
368
+ @state_mutex.synchronize { @shared_memory = mem }
369
+ self
370
+ end
371
+
372
+ # Detach from any shared memory, returning to standalone memory.
373
+ # @return [self]
374
+ def detach_memory
375
+ @state_mutex.synchronize { @shared_memory = nil }
376
+ self
377
+ end
378
+
379
+ # --- Inspection -----------------------------------------------------------
380
+
381
+ # Inbound bus messages this peer has received, oldest first.
382
+ #
383
+ # @return [Array<RobotMessage>]
384
+ def inbox
385
+ @inbox_mutex.synchronize { @inbox.dup }
386
+ end
387
+
388
+ # @return [Hash]
389
+ def to_h
390
+ {
391
+ name: @name,
392
+ kind: :cyborg,
393
+ bus: @bus ? true : nil,
394
+ channel: @channel.class.name
395
+ }.compact
396
+ end
397
+
398
+ private
399
+
400
+ def clock
401
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
402
+ end
403
+
404
+ def current_memory
405
+ @state_mutex.synchronize { @shared_memory } || @memory
406
+ end
407
+
408
+ # Run the block with +memory+'s current writer set to this peer, restoring it
409
+ # afterward. A no-op for memories that don't track a writer.
410
+ def with_writer(memory)
411
+ return yield unless memory.respond_to?(:current_writer=)
412
+
413
+ previous = memory.current_writer if memory.respond_to?(:current_writer)
414
+ memory.current_writer = @name
415
+ yield
416
+ ensure
417
+ memory.current_writer = previous if memory.respond_to?(:current_writer=)
418
+ end
419
+
420
+ # Route an inbound bus delivery (runs on the bus poller drain thread; arity-1
421
+ # handler => the poller auto-acks). A reply is shown to the human as a peer
422
+ # message. A fresh task is surfaced to the human *and answered off this thread*
423
+ # (see {#respond_to_task}) so a slow or absent human never blocks bus intake.
424
+ def handle_incoming(message)
425
+ @inbox_mutex.synchronize { @inbox << message }
426
+ deliver_to_human(message, kind: :message) if message.reply?
427
+ return if message.reply?
428
+
429
+ respond_to_task(message)
430
+ end
431
+
432
+ # Answer an inbound task on its own thread. The poller returns immediately;
433
+ # the human's answer (bounded by ask_timeout) is replied when it arrives. An
434
+ # offline human declines right away rather than leaving the sender hanging.
435
+ def respond_to_task(message)
436
+ unless available?
437
+ send_reply(to: message.from, content: "(#{@name} is unavailable)", in_reply_to: message.key) if @auto_reply && @bus
438
+ return
439
+ end
440
+
441
+ Thread.new do
442
+ answer = ask(task_prompt(message))
443
+ send_reply(to: message.from, content: answer, in_reply_to: message.key) if @auto_reply && @bus && answer
444
+ @on_task&.call(message, answer)
445
+ rescue StandardError => e
446
+ warn "[Cyborg #{@name}] inbound task failed: #{e.class}: #{e.message}"
447
+ end
448
+ end
449
+
450
+ # Show an inbound network message to the human over the channel (the output
451
+ # half of the duplex). Best-effort — a channel error must not break intake.
452
+ def deliver_to_human(message, kind:)
453
+ @channel.deliver(ChannelMessage.new(content: message_body(message), sender: message.from, kind: kind))
454
+ rescue StandardError
455
+ nil
456
+ end
457
+
458
+ # An inbound channel message that answered no outstanding question: the human
459
+ # speaking as a peer, unprompted. Surface it to the on_human handler.
460
+ def handle_human_initiative(message)
461
+ @on_human&.call(message)
462
+ end
463
+
464
+ def task_prompt(message)
465
+ "#{message.from} asks: #{message_body(message)}"
466
+ end
467
+
468
+ def message_body(message)
469
+ content = message.content
470
+ content.is_a?(Hash) ? content.map { |k, v| "#{k}: #{v}" }.join("\n") : content.to_s
471
+ end
472
+
473
+ # Interpret a yes/no answer as a boolean, or nil when it is neither.
474
+ def parse_bool(answer)
475
+ case answer.to_s.strip.downcase
476
+ when "y", "yes", "true", "1" then true
477
+ when "n", "no", "false", "0" then false
478
+ end
479
+ end
480
+
481
+ # Build a RobotResult from the human's text, shaped exactly like a robot's
482
+ # so `result.reply` works and downstream steps can't tell the difference.
483
+ def build_result(text)
484
+ RobotResult.new(
485
+ robot_name: @name,
486
+ output: [TextMessage.new(role: "assistant", content: text.to_s)]
487
+ )
488
+ end
489
+
490
+ # Pull the message and shared memory out of the pipeline result. The first
491
+ # step's value is the run-context Hash; later steps' value is a RobotResult.
492
+ def extract_run_context(result)
493
+ run_params = result.context[:run_params] || {}
494
+ value = result.value
495
+ message = case value
496
+ when Hash then value[:message] || run_params[:message]
497
+ when RobotResult then value.last_text_content
498
+ when String then value
499
+ when NilClass then run_params[:message]
500
+ else value.to_s
501
+ end
502
+
503
+ { message: message, network_memory: run_params[:network_memory] }
504
+ end
505
+ end
506
+ end
507
+
508
+ require_relative "cyborg/interviewer"
509
+ require_relative "cyborg/conversation"
510
+
511
+ RobotLab.register_extension(:cyborg, RobotLab::Cyborg) if RobotLab.respond_to?(:register_extension)
data/mkdocs.yml ADDED
@@ -0,0 +1,118 @@
1
+ site_name: robot_lab-cyborg
2
+ site_description: A human peer worker for the RobotLab LLM agent framework
3
+ site_author: Dewayne VanHoozer
4
+ site_url: https://madbomber.github.io/robot_lab-cyborg
5
+ copyright: Copyright &copy; 2026 Dewayne VanHoozer
6
+
7
+ repo_name: MadBomber/robot_lab-cyborg
8
+ repo_url: https://github.com/MadBomber/robot_lab-cyborg
9
+ edit_uri: edit/main/docs/
10
+ docs_dir: docs
11
+
12
+ theme:
13
+ name: material
14
+
15
+ palette:
16
+ - scheme: default
17
+ primary: indigo
18
+ accent: amber
19
+ toggle:
20
+ icon: material/brightness-7
21
+ name: Switch to dark mode
22
+
23
+ - scheme: slate
24
+ primary: indigo
25
+ accent: amber
26
+ toggle:
27
+ icon: material/brightness-4
28
+ name: Switch to light mode
29
+
30
+ font:
31
+ text: Roboto
32
+ code: Roboto Mono
33
+
34
+ icon:
35
+ repo: fontawesome/brands/github
36
+ logo: material/account-network
37
+
38
+ features:
39
+ - navigation.instant
40
+ - navigation.tracking
41
+ - navigation.tabs
42
+ - navigation.tabs.sticky
43
+ - navigation.path
44
+ - navigation.indexes
45
+ - navigation.top
46
+ - navigation.footer
47
+ - toc.follow
48
+ - search.suggest
49
+ - search.highlight
50
+ - search.share
51
+ - header.autohide
52
+ - content.code.copy
53
+ - content.code.annotate
54
+ - content.tabs.link
55
+ - content.tooltips
56
+ - content.action.edit
57
+ - content.action.view
58
+
59
+ plugins:
60
+ - search:
61
+ separator: '[\s\-,:!=\[\]()"`/]+|\.(?!\d)|&[lg]t;|(?!\b)(?=[A-Z][a-z])'
62
+
63
+ markdown_extensions:
64
+ - abbr
65
+ - admonition
66
+ - attr_list
67
+ - def_list
68
+ - footnotes
69
+ - md_in_html
70
+ - tables
71
+ - toc:
72
+ permalink: true
73
+ title: On this page
74
+ - pymdownx.betterem:
75
+ smart_enable: all
76
+ - pymdownx.caret
77
+ - pymdownx.details
78
+ - pymdownx.emoji:
79
+ emoji_generator: !!python/name:material.extensions.emoji.to_svg
80
+ emoji_index: !!python/name:material.extensions.emoji.twemoji
81
+ - pymdownx.highlight:
82
+ anchor_linenums: true
83
+ line_spans: __span
84
+ pygments_lang_class: true
85
+ - pymdownx.inlinehilite
86
+ - pymdownx.magiclink:
87
+ repo_url_shorthand: true
88
+ user: MadBomber
89
+ repo: robot_lab-cyborg
90
+ normalize_issue_symbols: true
91
+ - pymdownx.mark
92
+ - pymdownx.smartsymbols
93
+ - pymdownx.superfences:
94
+ custom_fences:
95
+ - name: mermaid
96
+ class: mermaid
97
+ format: !!python/name:pymdownx.superfences.fence_code_format
98
+ - pymdownx.tabbed:
99
+ alternate_style: true
100
+ - pymdownx.tasklist:
101
+ custom_checkbox: true
102
+ - pymdownx.tilde
103
+
104
+ extra:
105
+ social:
106
+ - icon: fontawesome/brands/github
107
+ link: https://github.com/MadBomber/robot_lab-cyborg
108
+ name: robot_lab-cyborg on GitHub
109
+ - icon: fontawesome/solid/gem
110
+ link: https://rubygems.org/gems/robot_lab-cyborg
111
+ name: robot_lab-cyborg on RubyGems
112
+
113
+ nav:
114
+ - Home: index.md
115
+ - Getting Started: getting_started.md
116
+ - How It Works: how_it_works.md
117
+ - API Reference: api_reference.md
118
+ - Building Custom Channels: custom_channels.md
@@ -0,0 +1,36 @@
1
+ module RobotLab
2
+ class Cyborg
3
+ VERSION: String
4
+
5
+ attr_reader name: String
6
+ attr_reader bus: untyped
7
+ attr_reader outbox: Hash[untyped, untyped]
8
+ attr_reader interviewer: Cyborg::Interviewer
9
+ attr_reader memory: untyped
10
+
11
+ def initialize: (name: String, ?bus: untyped, ?interviewer: Cyborg::Interviewer?, ?input: untyped, ?output: untyped, ?auto_reply: bool, ?memory: untyped) -> void
12
+ def call: (untyped result) -> untyped
13
+ def run: (?String? message, ?network_memory: untyped, ?memory: untyped, **untyped) -> untyped
14
+ def ask: (String question, ?choices: Array[String]?, ?default: String?) -> String
15
+ def on_task: () { (untyped, String) -> void } -> self
16
+ def assign: (to: (String | Symbol), task: untyped) -> untyped
17
+ def delegate: (to: untyped, task: String, ?async: bool, **untyped) -> untyped
18
+ def remember: (untyped key, untyped value) -> untyped
19
+ def recall: (untyped key, ?wait: untyped) -> untyped
20
+ def inbox: () -> Array[untyped]
21
+ def to_h: () -> Hash[Symbol, untyped]
22
+
23
+ class Interviewer
24
+ def ask: (String question, ?choices: Array[String]?, ?default: String?) -> String
25
+
26
+ class Terminal < Interviewer
27
+ def initialize: (?input: untyped, ?output: untyped, ?name: String) -> void
28
+ end
29
+
30
+ class Scripted < Interviewer
31
+ attr_reader asked: Array[String]
32
+ def initialize: (?(Array[String] | String) answers) -> void
33
+ end
34
+ end
35
+ end
36
+ end