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,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 © 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
|