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