samagotchi 0.86.0 → 0.88.0
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 +4 -4
- data/CHANGELOG.md +32 -1
- data/bin/chi +39 -2
- data/docs/architecture.md +5 -3
- data/docs/cli.md +18 -5
- data/docs/configuration.md +1 -1
- data/docs/guardrails.md +53 -3
- data/docs/hooks.md +1 -1
- data/docs/plugins.md +1 -1
- data/docs/sessions.md +19 -7
- data/docs/sub-agent.md +15 -3
- data/lib/samagotchi/answer_command.rb +6 -6
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -3
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +100 -7
- data/lib/samagotchi/cli/parent_wait.rb +3 -1
- data/lib/samagotchi/guardrails/parent_policy.rb +94 -5
- data/lib/samagotchi/parent_answers_command.rb +80 -0
- data/lib/samagotchi/parent_report.rb +53 -34
- data/lib/samagotchi/question_desk.rb +3 -2
- data/lib/samagotchi/relay.rb +34 -2
- data/lib/samagotchi/relay_desk.rb +13 -0
- data/lib/samagotchi/send_command.rb +75 -10
- data/lib/samagotchi/session.rb +26 -2
- data/lib/samagotchi/session_commands.rb +35 -9
- data/lib/samagotchi/session_setup.rb +10 -8
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -5
- data/lib/samagotchi/terminal_ui/question_prompt.rb +2 -1
- data/lib/samagotchi/terminal_ui.rb +12 -6
- data/lib/samagotchi/tools/delegate.rb +4 -1
- data/lib/samagotchi/tools/delegate_relay.rb +69 -15
- data/lib/samagotchi/tools/delegate_wait.rb +14 -8
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/notify.js +6 -2
- data/lib/samagotchi/web/public/question_card.js +2 -0
- data/lib/samagotchi/web/public/question_cards.js +25 -18
- data/lib/samagotchi/worker.rb +60 -10
- metadata +2 -1
|
@@ -130,7 +130,13 @@ module Samagotchi
|
|
|
130
130
|
# (DelegateWait.finish, its report variant), the cursor it moves the child to,
|
|
131
131
|
# and the cursor it was read from (a commit after the model moved it
|
|
132
132
|
# itself, with delegate_result, leaves it alone).
|
|
133
|
-
|
|
133
|
+
# +relay+: the child's question when it goes to the parent's user
|
|
134
|
+
# (Tools::DelegateRelay.relay?): #resolve relays it on the turn thread
|
|
135
|
+
# and gives the report its outcome line; +text+ is the question's
|
|
136
|
+
# report until then (what a parent with no relay gets).
|
|
137
|
+
Report = Data.define(:child_id, :text, :before, :after, :rings, :claim_id, :relay) do
|
|
138
|
+
def initialize(relay: nil, **) = super
|
|
139
|
+
|
|
134
140
|
def line(mark: nil) = Steer::Line.new(text: text, source: ChildReports::SOURCE, mark: mark)
|
|
135
141
|
def origin = { client_id: "#{ClientId::CHILD_PREFIX}#{child_id[0, 8]}" }
|
|
136
142
|
end
|
|
@@ -146,14 +152,19 @@ module Samagotchi
|
|
|
146
152
|
Tools::DelegateCursors.clear_report_claims(@session_id, state_dir: @state_dir)
|
|
147
153
|
# child id → the Report this turn took for it (rings merged).
|
|
148
154
|
@taken = {}
|
|
155
|
+
# child id → the question id whose relay card a Stop closed (#hold_back).
|
|
156
|
+
@held = {}
|
|
149
157
|
end
|
|
150
158
|
|
|
159
|
+
# The user's next input: questions held back after a Stop are offered again.
|
|
160
|
+
def release_holds = @held.clear
|
|
161
|
+
|
|
151
162
|
# Whether a ring this turn hasn't taken waits (the idle loop's check).
|
|
152
163
|
def waiting?
|
|
153
164
|
return false if ChildRing.mode == "off"
|
|
154
165
|
|
|
155
166
|
taken = taken_rings
|
|
156
|
-
ring_files.any? { |file| !taken.include?(file) }
|
|
167
|
+
ring_files.any? { |file| !taken.include?(file) && !held?(file) }
|
|
157
168
|
end
|
|
158
169
|
|
|
159
170
|
# The reports for rings this turn hasn't taken yet, recorded as taken.
|
|
@@ -164,7 +175,7 @@ module Samagotchi
|
|
|
164
175
|
return [] if ChildRing.mode == "off"
|
|
165
176
|
|
|
166
177
|
taken = taken_rings
|
|
167
|
-
fresh = ring_files.reject { |file| taken.include?(file) }
|
|
178
|
+
fresh = ring_files.reject { |file| taken.include?(file) || held?(file) }
|
|
168
179
|
by_child = fresh.group_by { |file| SessionInbox.read_ring(file)&.dig(:child_id) }
|
|
169
180
|
by_child.filter_map do |child_id, rings|
|
|
170
181
|
report = child_id && report_for(child_id, rings)
|
|
@@ -207,8 +218,79 @@ module Samagotchi
|
|
|
207
218
|
@taken = {}
|
|
208
219
|
end
|
|
209
220
|
|
|
221
|
+
# A report whose question goes to the parent's user, settled: relayed
|
|
222
|
+
# through +relay+ (DelegateRelay.call: blocks until the card is
|
|
223
|
+
# answered, closed or the turn stopped) and given the outcome line as
|
|
224
|
+
# its text. Runs on the turn thread, before the boundary merges it.
|
|
225
|
+
# The child's question answered since the ring was taken opens no card;
|
|
226
|
+
# nor does one already answered on a relay (RelayDesk#settled?).
|
|
227
|
+
# @param relay [#open_question, #relay_desk, nil] Engine#relay_peer;
|
|
228
|
+
# nil: the report's question text, as without P4's relay
|
|
229
|
+
# @return [Report, nil] nil when the parent's turn was stopped with the
|
|
230
|
+
# card open: the report is released (its ring and the cursor stay), so
|
|
231
|
+
# the next turn offers it again
|
|
232
|
+
def resolve(report, relay:)
|
|
233
|
+
return report unless report.relay
|
|
234
|
+
return report.with(relay: nil) unless relay
|
|
235
|
+
|
|
236
|
+
question = report.relay
|
|
237
|
+
what = question.header.to_s.empty? ? question.hook.to_s : question.header.to_s
|
|
238
|
+
line = if Session.load(report.child_id, state_dir: @state_dir).pending_question&.id.to_s != question.id.to_s
|
|
239
|
+
Tools::DelegateRelay.line(what, "answered on the child", hook: true)
|
|
240
|
+
elsif relay.relay_desk.settled?(report.child_id, question.id)
|
|
241
|
+
Tools::DelegateRelay.line(what, "answered on a relay already; it waits for the user on the child", hook: true)
|
|
242
|
+
else
|
|
243
|
+
outcome = Tools::DelegateRelay.call(report.child_id, question, relay: relay, state_dir: @state_dir)
|
|
244
|
+
return hold_back(report) if outcome.stopped
|
|
245
|
+
|
|
246
|
+
outcome.line
|
|
247
|
+
end
|
|
248
|
+
resolved = report.with(relay: nil, text: relayed_text(report.child_id, line))
|
|
249
|
+
@taken[report.child_id] = resolved if @taken[report.child_id]&.claim_id == report.claim_id
|
|
250
|
+
resolved
|
|
251
|
+
rescue ArgumentError => e
|
|
252
|
+
# The child is gone (or a bug): its question's report, as without a relay.
|
|
253
|
+
Log.info(:worker, "delegate_relay_unresolved", child: report.child_id[0, 8], error: e.message)
|
|
254
|
+
report.with(relay: nil)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# A relay report the parent's turn stopped on, or one behind it in the
|
|
258
|
+
# same take: released (its ring and the cursor stay) and held back, so
|
|
259
|
+
# no wake turn offers that question again until the user's next input
|
|
260
|
+
# (#release_holds); other children's rings go on.
|
|
261
|
+
# @return [nil]
|
|
262
|
+
def hold_back(report)
|
|
263
|
+
@held[report.child_id] = report.relay.id.to_s
|
|
264
|
+
release_report(report)
|
|
265
|
+
end
|
|
266
|
+
|
|
210
267
|
private
|
|
211
268
|
|
|
269
|
+
# A ring of a child whose held-back question still waits.
|
|
270
|
+
def held?(file)
|
|
271
|
+
child_id = SessionInbox.read_ring(file)&.dig(:child_id)
|
|
272
|
+
question_id = @held[child_id] or return false
|
|
273
|
+
Session.load(child_id, state_dir: @state_dir).pending_question&.id.to_s == question_id
|
|
274
|
+
rescue ArgumentError
|
|
275
|
+
false
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# A relayed question's report: the outcome line, then the next reply's
|
|
279
|
+
# promise (as a report's question text ends).
|
|
280
|
+
def relayed_text(child_id, line)
|
|
281
|
+
Tools::DelegateWait.result(child_id, "question", "#{line}\n#{Tools::DelegateWait::REPORT_NEXT_REPLY}")
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# One taken report back: its cursor claim goes, its rings stay untaken.
|
|
285
|
+
# @return [nil]
|
|
286
|
+
def release_report(report)
|
|
287
|
+
@taken.delete(report.child_id)
|
|
288
|
+
Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
|
|
289
|
+
cursor.report_claim&.fetch("id", nil) == report.claim_id ? cursor.with(report_claim: nil) : cursor
|
|
290
|
+
end
|
|
291
|
+
nil
|
|
292
|
+
end
|
|
293
|
+
|
|
212
294
|
def taken_rings
|
|
213
295
|
@taken.values.flat_map(&:rings)
|
|
214
296
|
end
|
|
@@ -260,22 +342,33 @@ module Samagotchi
|
|
|
260
342
|
# a later wait on it would repeat its reply.
|
|
261
343
|
after = after.with(report_claim: claim)
|
|
262
344
|
text = Tools::DelegateWait.finish(wait, child_id, timeout: 0, report: true)
|
|
263
|
-
Report.new(child_id: child_id, text: text, before: before, after: after, rings: rings, claim_id: claim_id
|
|
345
|
+
Report.new(child_id: child_id, text: text, before: before, after: after, rings: rings, claim_id: claim_id,
|
|
346
|
+
relay: relay?(wait) ? wait.question : nil)
|
|
264
347
|
rescue ArgumentError
|
|
265
348
|
nil # the child is gone
|
|
266
349
|
end
|
|
267
350
|
|
|
268
351
|
# A reply, a turn that left none, a crash, or a question the parent's
|
|
269
|
-
# model decides (its own ask_user_question, the step-limit continue
|
|
270
|
-
#
|
|
352
|
+
# model decides (its own ask_user_question, the step-limit continue, a
|
|
353
|
+
# hook's question). Approvals are the user's (the relay, while the
|
|
354
|
+
# parent waits; else on the child's card).
|
|
271
355
|
def reportable?(wait)
|
|
272
356
|
case wait.status
|
|
273
357
|
when :done, :no_reply, :error then true
|
|
274
|
-
when :waiting_for_answer then
|
|
358
|
+
when :waiting_for_answer then REPORTED_KINDS.include?(wait.question&.kind&.to_s)
|
|
275
359
|
else false
|
|
276
360
|
end
|
|
277
361
|
end
|
|
278
362
|
|
|
363
|
+
REPORTED_KINDS = [nil, "", "continue", Tools::DelegateRelay::HOOK_KIND].freeze
|
|
364
|
+
|
|
365
|
+
# Whether the child's question goes to this parent's user rather than
|
|
366
|
+
# its model (Tools::DelegateRelay.relay?, under the child's strategy).
|
|
367
|
+
def relay?(wait)
|
|
368
|
+
wait.status == :waiting_for_answer && wait.question.kind.to_s == Tools::DelegateRelay::HOOK_KIND &&
|
|
369
|
+
Tools::DelegateRelay.relay?(wait.question, strategy: Guardrails::ParentPolicy.for_session(wait.session))
|
|
370
|
+
end
|
|
371
|
+
|
|
279
372
|
def delete_rings(rings)
|
|
280
373
|
rings.each { |file| FileUtils.rm_f(file) }
|
|
281
374
|
end
|
|
@@ -76,8 +76,10 @@ module Samagotchi
|
|
|
76
76
|
result.turn_id = report_turn_id if report_turn_id && !report_turn_id.to_s.empty?
|
|
77
77
|
result.text = result.text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
|
|
78
78
|
@reported = true
|
|
79
|
+
# What a parent may pick there: config.yml tightened by the session's own.
|
|
80
|
+
strategy = ParentReport.strategy_for(id, state_dir: @state_dir)
|
|
79
81
|
ParentReport.report(result, session_id: id, stdout: @stdout, stderr: @stderr, command: command_name,
|
|
80
|
-
json: @json, timeout: timeout, extra: report_extra)
|
|
82
|
+
json: @json, timeout: timeout, extra: report_extra, strategy: strategy)
|
|
81
83
|
rescue Interrupt
|
|
82
84
|
if @json
|
|
83
85
|
@stdout.puts(ParentReport.json_line(ReplyWait::Result.new(status: :canceled, turn_id: report_turn_id), session_id: id,
|
|
@@ -17,7 +17,9 @@ module Samagotchi
|
|
|
17
17
|
# but eager parent, not a security boundary.
|
|
18
18
|
#
|
|
19
19
|
# Strategies (guardrails.parent_answers, config.yml only): hard = answer
|
|
20
|
-
# and deny; standard = + spend; soft (the default) = + permit.
|
|
20
|
+
# and deny; standard = + spend; soft (the default) = + permit. A session
|
|
21
|
+
# may carry its own, stricter one (Session#parent_answers): config.yml
|
|
22
|
+
# is the ceiling (#effective).
|
|
21
23
|
#
|
|
22
24
|
# Effects: answer (an untagged option), deny, spend (more time and
|
|
23
25
|
# tokens: Continue), permit (one call: Allow once), permit_wide (more
|
|
@@ -45,16 +47,23 @@ module Samagotchi
|
|
|
45
47
|
CONTINUE_QUESTION = PendingQuestion.new(kind: CONTINUE_KIND, options: [CONTINUE, STOP], effects: [SPEND, DENY])
|
|
46
48
|
|
|
47
49
|
KEY = "guardrails.parent_answers"
|
|
50
|
+
# A session's own value (Session#parent_answers): it may only tighten KEY.
|
|
51
|
+
SESSION_KEY = "parent_answers"
|
|
48
52
|
HARD = "hard"
|
|
49
53
|
STANDARD = "standard"
|
|
50
54
|
SOFT = "soft"
|
|
51
55
|
|
|
52
|
-
# A strategy: its name
|
|
53
|
-
|
|
56
|
+
# A strategy: its name, the effects a parent may pick under it, and
|
|
57
|
+
# where it comes from: :config (config.yml's) or :session (a
|
|
58
|
+
# session's own, stricter one: #effective).
|
|
59
|
+
Strategy = Data.define(:name, :allowed, :origin) do
|
|
60
|
+
def initialize(name:, allowed:, origin: :config) = super
|
|
61
|
+
|
|
54
62
|
def allow?(effect) = allowed.include?(effect.to_s)
|
|
55
63
|
|
|
56
|
-
# How a refusal names the setting: "guardrails.parent_answers: hard"
|
|
57
|
-
|
|
64
|
+
# How a refusal names the setting: "guardrails.parent_answers: hard",
|
|
65
|
+
# or "parent_answers: hard (this session)" when the session's binds.
|
|
66
|
+
def label = origin == :session ? "#{SESSION_KEY}: #{name} (this session)" : "#{KEY}: #{name}"
|
|
58
67
|
end
|
|
59
68
|
|
|
60
69
|
STRATEGIES = {
|
|
@@ -74,6 +83,73 @@ module Samagotchi
|
|
|
74
83
|
# @return [Strategy]
|
|
75
84
|
def strategy(name) = STRATEGIES.fetch(name.to_s, STRATEGIES[HARD])
|
|
76
85
|
|
|
86
|
+
# Strictest first: a session's own level may only move left of
|
|
87
|
+
# config.yml's.
|
|
88
|
+
ORDER = [HARD, STANDARD, SOFT].freeze
|
|
89
|
+
# A session value that means "none of its own" (config's).
|
|
90
|
+
UNSET = "default"
|
|
91
|
+
|
|
92
|
+
# The stricter of two strategies, by name; a tie keeps +a+.
|
|
93
|
+
# @return [Strategy]
|
|
94
|
+
def stricter(a, b) = ORDER.index(b.name) < ORDER.index(a.name) ? b : a
|
|
95
|
+
|
|
96
|
+
# A session's own value as a level: nil when it has none (nil, "",
|
|
97
|
+
# "default"), the level, or hard for anything else (a hand-edited
|
|
98
|
+
# file: the strictest, as config's invalid value; not logged, as this
|
|
99
|
+
# is read on every parent answer).
|
|
100
|
+
# @return [String, nil]
|
|
101
|
+
def session_level(value)
|
|
102
|
+
word = value.to_s.strip.downcase
|
|
103
|
+
return nil if word.empty? || word == UNSET
|
|
104
|
+
|
|
105
|
+
STRATEGIES.key?(word) ? word : HARD
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Whether a session's own value is set but not a level (a hand-edited
|
|
109
|
+
# file: read as hard); the worker says so once when it loads.
|
|
110
|
+
def unknown_level?(value)
|
|
111
|
+
word = value.to_s.strip.downcase
|
|
112
|
+
!word.empty? && word != UNSET && !STRATEGIES.key?(word)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# What a parent may answer on a session whose own value is
|
|
116
|
+
# +session_value+: config.yml's (+config+) unless the session's is
|
|
117
|
+
# stricter. Config's own object when it binds, so its label stays.
|
|
118
|
+
# @return [Strategy]
|
|
119
|
+
def effective(session_value, config: current)
|
|
120
|
+
level = session_level(session_value)
|
|
121
|
+
return config if level.nil?
|
|
122
|
+
|
|
123
|
+
own = STRATEGIES[level]
|
|
124
|
+
stricter(config, own).equal?(config) ? config : own.with(origin: :session)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Why a session's own +value+ doesn't apply: "soft is looser than
|
|
128
|
+
# config.yml's guardrails.parent_answers: standard; standard applies";
|
|
129
|
+
# nil when it applies (or is none).
|
|
130
|
+
# @return [String, nil]
|
|
131
|
+
def capped_note(value, config: current)
|
|
132
|
+
level = session_level(value)
|
|
133
|
+
return nil if level.nil? || effective(level, config: config).name == level
|
|
134
|
+
|
|
135
|
+
"#{level} is looser than config.yml's #{config.label}; #{config.name} applies"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# #effective with +session+'s own value (a Session; nil: config's).
|
|
139
|
+
# @return [Strategy]
|
|
140
|
+
def for_session(session) = effective(session&.parent_answers)
|
|
141
|
+
|
|
142
|
+
# Whether setting a session's value +from+ → +to+ loosens it: unset
|
|
143
|
+
# (config's) is the loosest, an invalid value hard. Only the user
|
|
144
|
+
# loosens (/parent-answers in a UI).
|
|
145
|
+
def looser?(from, to) = session_rank(to) > session_rank(from)
|
|
146
|
+
|
|
147
|
+
# hard 0, standard 1, soft 2, unset 3.
|
|
148
|
+
def session_rank(value)
|
|
149
|
+
level = session_level(value)
|
|
150
|
+
level.nil? ? ORDER.size : ORDER.index(level)
|
|
151
|
+
end
|
|
152
|
+
|
|
77
153
|
# @return [Array<String>] the effects +strategy+ lets a parent pick
|
|
78
154
|
def allowed_effects(strategy) = strategy.allowed
|
|
79
155
|
|
|
@@ -174,6 +250,19 @@ module Samagotchi
|
|
|
174
250
|
end
|
|
175
251
|
end
|
|
176
252
|
|
|
253
|
+
# Whether a parent may usefully answer +pending+ under +strategy+:
|
|
254
|
+
# some option it may pick is not a deny (free text alone is a deny
|
|
255
|
+
# with a reason). A delegate's question it may is reported to the
|
|
256
|
+
# parent's model; one it may not goes to the parent's user
|
|
257
|
+
# (Tools::DelegateRelay.relay?). Who may answer what stays #refusal's.
|
|
258
|
+
def parent_decides?(pending, strategy:)
|
|
259
|
+
deny = deny_labels(pending)
|
|
260
|
+
allowed_options(pending, strategy: strategy).any? { |label| !deny.include?(label) }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# The deny-tagged options' labels.
|
|
264
|
+
def deny_labels(pending) = Array(pending.options).zip(effects_of(pending)).filter_map { |label, tag| label.to_s if tag == DENY }
|
|
265
|
+
|
|
177
266
|
# The question's deny-tagged option's label, or nil.
|
|
178
267
|
def deny_label(pending)
|
|
179
268
|
index = effects_of(pending).index(DENY)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "guardrails/parent_policy"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# /parent-answers, the session's own guardrails.parent_answers
|
|
7
|
+
# (Session#parent_answers): with no argument, the level that applies, the
|
|
8
|
+
# session's own and config.yml's; `/parent-answers LEVEL` (hard, standard,
|
|
9
|
+
# soft) or `default` (none of its own) sets it, saved at once, for the next
|
|
10
|
+
# answer a parent gives. It may only tighten config.yml's
|
|
11
|
+
# (Guardrails::ParentPolicy.effective). Tightening goes from anyone;
|
|
12
|
+
# loosening (toward soft, or default) only from a user at a UI: the REPL,
|
|
13
|
+
# an attached TUI, the web (+user_ui+). A parent agent's line (chi send,
|
|
14
|
+
# a piped or marked attach, a delegate's follow-up) is refused.
|
|
15
|
+
class ParentAnswersCommand
|
|
16
|
+
NAME = "/parent-answers"
|
|
17
|
+
USAGE = "usage: #{NAME} [hard|standard|soft|default]".freeze
|
|
18
|
+
REFUSED = "loosening parent_answers is the user's: #{NAME} in chi --attach, the REPL or the web " \
|
|
19
|
+
"(not chi send or a parent agent)".freeze
|
|
20
|
+
|
|
21
|
+
# What +args+ (the text after the name) asks for.
|
|
22
|
+
# @return [Array] [:show], [:set, level or nil (default)] or [:usage]
|
|
23
|
+
def self.parse(args)
|
|
24
|
+
words = args.to_s.split
|
|
25
|
+
return [:show] if words.empty?
|
|
26
|
+
return [:usage] if words.size > 1
|
|
27
|
+
|
|
28
|
+
word = words.first.downcase
|
|
29
|
+
return [:set, nil] if word == Guardrails::ParentPolicy::UNSET
|
|
30
|
+
|
|
31
|
+
Guardrails::ParentPolicy::ORDER.include?(word) ? [:set, word] : [:usage]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Whether the command line +line+ loosens a session whose own value is
|
|
35
|
+
# +current+ (chi send's early refusal, before it sends the line).
|
|
36
|
+
def self.loosens?(line, current)
|
|
37
|
+
kind, value = parse(line.to_s.strip.delete_prefix(NAME))
|
|
38
|
+
kind == :set && Guardrails::ParentPolicy.looser?(current, value)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param engine [Engine] #session
|
|
42
|
+
# @param save [#call] saves the session after a change
|
|
43
|
+
def initialize(engine:, save:)
|
|
44
|
+
@engine = engine
|
|
45
|
+
@save = save
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param args [String] what follows /parent-answers
|
|
49
|
+
# @param user_ui [Boolean] a user at a UI typed it: it may loosen
|
|
50
|
+
# @return [Array(String, Boolean, Symbol)] the reply, whether the
|
|
51
|
+
# session's value changed, and :ok or :error
|
|
52
|
+
def run(args, user_ui:)
|
|
53
|
+
session = @engine.session or return ["no session yet", false, :error]
|
|
54
|
+
kind, value = self.class.parse(args)
|
|
55
|
+
return [USAGE, false, :error] if kind == :usage
|
|
56
|
+
return [listing(session), false, :ok] if kind == :show
|
|
57
|
+
return ["#{listing(session)}\n(no change)", false, :ok] if value == session.parent_answers
|
|
58
|
+
return [REFUSED, false, :error] if Guardrails::ParentPolicy.looser?(session.parent_answers, value) && !user_ui
|
|
59
|
+
|
|
60
|
+
session.parent_answers = value
|
|
61
|
+
@save.call(session)
|
|
62
|
+
capped = Guardrails::ParentPolicy.capped_note(value)
|
|
63
|
+
[[listing(session), ("(#{capped})" if capped)].compact.join("\n"), true, :ok]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# "parent answers: standard (this session: standard; config.yml
|
|
69
|
+
# guardrails.parent_answers: soft)"
|
|
70
|
+
def listing(session)
|
|
71
|
+
config = Guardrails::ParentPolicy.current
|
|
72
|
+
effective = Guardrails::ParentPolicy.for_session(session)
|
|
73
|
+
own = session.parent_answers || "none"
|
|
74
|
+
# A hand-edited value that isn't a level: fail-closed, said here.
|
|
75
|
+
own = "#{own} (unreadable value, read as hard)" if session.parent_answers &&
|
|
76
|
+
!Guardrails::ParentPolicy::ORDER.include?(session.parent_answers)
|
|
77
|
+
"parent answers: #{effective.name} (this session: #{own}; config.yml #{config.label})"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -5,6 +5,7 @@ require "json"
|
|
|
5
5
|
|
|
6
6
|
require_relative "cli/exit"
|
|
7
7
|
require_relative "pending_question"
|
|
8
|
+
require_relative "session"
|
|
8
9
|
require_relative "guardrails/parent_policy"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
@@ -77,18 +78,20 @@ module Samagotchi
|
|
|
77
78
|
# @param extra [Hash] more keys for the object (chi answer's
|
|
78
79
|
# answered_here: false)
|
|
79
80
|
# @return [Hash] the --format json object
|
|
80
|
-
|
|
81
|
+
# @param strategy [Guardrails::ParentPolicy::Strategy, nil] what a parent
|
|
82
|
+
# may pick on the waited-for session (#strategy_for); nil: config.yml's
|
|
83
|
+
def json(result, session_id:, timeout: nil, extra: {}, strategy: nil)
|
|
81
84
|
report = { status: status(result), session_id: session_id }
|
|
82
85
|
report[:turn_id] = result.turn_id unless result.turn_id.to_s.empty?
|
|
83
86
|
case report[:status]
|
|
84
87
|
when "answered" then report[:text] = result.text.to_s
|
|
85
88
|
when "question"
|
|
86
|
-
question = question(result.question)
|
|
89
|
+
question = question(result.question, strategy: strategy)
|
|
87
90
|
report[:question] = question
|
|
88
91
|
report[:answer_with] = answer_with(session_id, question)
|
|
89
92
|
else
|
|
90
93
|
report.merge!(stop_facts(result))
|
|
91
|
-
report[:detail] = detail(result, session_id: session_id, timeout: timeout)
|
|
94
|
+
report[:detail] = detail(result, session_id: session_id, timeout: timeout, strategy: strategy)
|
|
92
95
|
end
|
|
93
96
|
report.merge(extra)
|
|
94
97
|
end
|
|
@@ -106,8 +109,8 @@ module Samagotchi
|
|
|
106
109
|
end
|
|
107
110
|
|
|
108
111
|
# @return [String] the JSON object as one line
|
|
109
|
-
def json_line(result, session_id:, timeout: nil, extra: {})
|
|
110
|
-
JSON.generate(json(result, session_id: session_id, timeout: timeout, extra: extra))
|
|
112
|
+
def json_line(result, session_id:, timeout: nil, extra: {}, strategy: nil)
|
|
113
|
+
JSON.generate(json(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy))
|
|
111
114
|
end
|
|
112
115
|
|
|
113
116
|
# Print a wait's end the way the commands do: the JSON object on
|
|
@@ -116,17 +119,17 @@ module Samagotchi
|
|
|
116
119
|
# @param command [String] "chi send", "chi answer"
|
|
117
120
|
# @param extra [Hash] more keys for the JSON object (#json)
|
|
118
121
|
# @return [Integer] the exit status
|
|
119
|
-
def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil, extra: {})
|
|
122
|
+
def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil, extra: {}, strategy: nil)
|
|
120
123
|
if json
|
|
121
|
-
stdout.puts(json_line(result, session_id: session_id, timeout: timeout, extra: extra))
|
|
124
|
+
stdout.puts(json_line(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy))
|
|
122
125
|
elsif result.status == :done
|
|
123
126
|
stdout.puts(result.text)
|
|
124
127
|
else
|
|
125
128
|
stdout.flush
|
|
126
129
|
text = if result.status == :waiting_for_answer
|
|
127
|
-
question_text(result.question, session_id: session_id)
|
|
130
|
+
question_text(result.question, session_id: session_id, strategy: strategy)
|
|
128
131
|
else
|
|
129
|
-
"#{detail(result, session_id: session_id, timeout: timeout, turn_id: result.turn_id)}\n"
|
|
132
|
+
"#{detail(result, session_id: session_id, timeout: timeout, turn_id: result.turn_id, strategy: strategy)}\n"
|
|
130
133
|
end
|
|
131
134
|
stderr.print("#{command}: #{text}")
|
|
132
135
|
end
|
|
@@ -146,14 +149,14 @@ module Samagotchi
|
|
|
146
149
|
# kind is "question" for the model's own (the desk stores none),
|
|
147
150
|
# "hook", "approval" or "continue" (the step-limit question, with the
|
|
148
151
|
# turn's limit). allowed_options: the options a parent may pick, by
|
|
149
|
-
# this process's guardrails.parent_answers
|
|
150
|
-
# when that is not all of them.
|
|
152
|
+
# +strategy+ (nil: this process's guardrails.parent_answers;
|
|
153
|
+
# Guardrails::ParentPolicy), when that is not all of them.
|
|
151
154
|
# @return [Hash]
|
|
152
|
-
def question(pending)
|
|
155
|
+
def question(pending, strategy: nil)
|
|
153
156
|
pending = PendingQuestion.from_file(pending) || PendingQuestion.new
|
|
154
157
|
approval = pending.approval.is_a?(Hash) ? symbolize(pending.approval) : nil
|
|
155
158
|
options = Array(pending.options).map(&:to_s)
|
|
156
|
-
allowed = allowed_options(pending)
|
|
159
|
+
allowed = allowed_options(pending, strategy: strategy)
|
|
157
160
|
{
|
|
158
161
|
id: pending.id.to_s,
|
|
159
162
|
kind: (pending.kind || "question").to_s,
|
|
@@ -189,33 +192,49 @@ module Samagotchi
|
|
|
189
192
|
DENY_AND_TELL = "deny it, and tell your user"
|
|
190
193
|
|
|
191
194
|
# The labels a parent may pick on +pending+ (a PendingQuestion, or a
|
|
192
|
-
# Hash with its fields) by this process's
|
|
195
|
+
# Hash with its fields) by +strategy+ (nil: this process's).
|
|
193
196
|
# @return [Array<String>]
|
|
194
|
-
def allowed_options(pending)
|
|
197
|
+
def allowed_options(pending, strategy: nil)
|
|
195
198
|
pending = PendingQuestion.from_file(pending) || PendingQuestion.new
|
|
196
|
-
Guardrails::ParentPolicy.allowed_options(pending, strategy:
|
|
199
|
+
Guardrails::ParentPolicy.allowed_options(pending, strategy: policy(strategy))
|
|
197
200
|
end
|
|
198
201
|
|
|
202
|
+
# What a parent may pick on the session +session_id+: config.yml's
|
|
203
|
+
# level tightened by the session file's own parent_answers
|
|
204
|
+
# (Guardrails::ParentPolicy.for_session); config.yml's when there is no
|
|
205
|
+
# such session, hard (the strictest) when its file can't be read.
|
|
206
|
+
# @return [Guardrails::ParentPolicy::Strategy]
|
|
207
|
+
def strategy_for(session_id, state_dir:)
|
|
208
|
+
return Guardrails::ParentPolicy.current unless Session.exist?(session_id, state_dir: state_dir)
|
|
209
|
+
|
|
210
|
+
Guardrails::ParentPolicy.for_session(Session.load(session_id, state_dir: state_dir))
|
|
211
|
+
rescue ArgumentError, KeyError, TypeError, NoMethodError, SystemCallError, JSON::ParserError
|
|
212
|
+
Guardrails::ParentPolicy.strategy(Guardrails::ParentPolicy::HARD)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# +strategy+, or this process's config.yml's.
|
|
216
|
+
def policy(strategy) = strategy || Guardrails::ParentPolicy.current
|
|
217
|
+
|
|
199
218
|
# An approval's options a parent may allow (Allow once under soft).
|
|
200
|
-
def allowed_allows(question)
|
|
201
|
-
question = question(question) unless question.is_a?(Hash) && question.key?(:text)
|
|
219
|
+
def allowed_allows(question, strategy: nil)
|
|
220
|
+
question = question(question, strategy: strategy) unless question.is_a?(Hash) && question.key?(:text)
|
|
202
221
|
deny = Guardrails::Approval::DENY
|
|
203
222
|
question.fetch(:allowed_options, question[:options]).reject { |label| label == deny }
|
|
204
223
|
end
|
|
205
224
|
|
|
206
225
|
# What a parent does with an approval: deny it and tell its user, or
|
|
207
226
|
# give the Allow once its strategy lets it.
|
|
208
|
-
def approval_advice(question)
|
|
209
|
-
allows = allowed_allows(question)
|
|
227
|
+
def approval_advice(question, strategy: nil)
|
|
228
|
+
allows = allowed_allows(question, strategy: strategy)
|
|
210
229
|
return DENY_AND_TELL if allows.empty?
|
|
211
230
|
|
|
212
|
-
"#{allows.join(" or ")} is yours to give (#{
|
|
231
|
+
"#{allows.join(" or ")} is yours to give (#{policy(strategy).label}); " \
|
|
213
232
|
"anything wider is up to your user: #{DENY_AND_TELL}"
|
|
214
233
|
end
|
|
215
234
|
|
|
216
235
|
# Whether a parent may answer Continue to a step-limit question.
|
|
217
|
-
def may_continue?(question)
|
|
218
|
-
question = question(question) unless question.is_a?(Hash) && question.key?(:text)
|
|
236
|
+
def may_continue?(question, strategy: nil)
|
|
237
|
+
question = question(question, strategy: strategy) unless question.is_a?(Hash) && question.key?(:text)
|
|
219
238
|
question.fetch(:allowed_options, question[:options]).include?(Guardrails::ParentPolicy::CONTINUE)
|
|
220
239
|
end
|
|
221
240
|
|
|
@@ -252,23 +271,23 @@ module Samagotchi
|
|
|
252
271
|
end
|
|
253
272
|
|
|
254
273
|
# What a wait that ended without an answer says, one line.
|
|
255
|
-
def detail(result, session_id:, timeout: nil, turn_id: nil)
|
|
274
|
+
def detail(result, session_id:, timeout: nil, turn_id: nil, strategy: nil)
|
|
256
275
|
attach = "chi --attach #{session_id}"
|
|
257
276
|
case result.status
|
|
258
277
|
when :waiting_for_answer
|
|
259
|
-
q = question(result.question)
|
|
278
|
+
q = question(result.question, strategy: strategy)
|
|
260
279
|
if approval?(q)
|
|
261
|
-
"waiting for an approval: #{first_line(q[:text])}; #{approval_advice(q)}"
|
|
280
|
+
"waiting for an approval: #{first_line(q[:text])}; #{approval_advice(q, strategy: strategy)}"
|
|
262
281
|
elsif continue?(q) && !may_continue?(q)
|
|
263
282
|
"waiting at the step limit: #{first_line(q[:text])}; a parent may only stop it here " \
|
|
264
|
-
"(#{
|
|
283
|
+
"(#{policy(strategy).label}): #{stop_command(session_id, q)}, or tell your user it waits"
|
|
265
284
|
elsif continue?(q)
|
|
266
285
|
"waiting at the step limit: #{first_line(q[:text])}; " \
|
|
267
286
|
"answer Continue or Stop: chi answer #{session_id} --question #{q[:id]} --option Continue"
|
|
268
287
|
else
|
|
269
288
|
"waiting for an answer: #{first_line(q[:text])}; open it: #{attach} or the web"
|
|
270
289
|
end
|
|
271
|
-
when :no_reply then "#{no_reply_line(result, session_id)}; #{attach} shows it"
|
|
290
|
+
when :no_reply then "#{no_reply_line(result, session_id, strategy: strategy)}; #{attach} shows it"
|
|
272
291
|
when :error then "the worker failed: #{result.text}; #{attach} shows what happened"
|
|
273
292
|
when :worker_gone then "the worker is gone; #{attach} shows what happened"
|
|
274
293
|
when :stopped then "the session was stopped (chi sessions stop)"
|
|
@@ -282,8 +301,8 @@ module Samagotchi
|
|
|
282
301
|
# The whole question for stderr: header, text, numbered options, what
|
|
283
302
|
# else it takes, and the commands that answer it.
|
|
284
303
|
# @return [String] lines, newline-terminated
|
|
285
|
-
def question_text(pending, session_id:)
|
|
286
|
-
q = question(pending)
|
|
304
|
+
def question_text(pending, session_id:, strategy: nil)
|
|
305
|
+
q = question(pending, strategy: strategy)
|
|
287
306
|
lines = ["waiting for an answer (#{q[:kind]}): #{q[:header] || first_line(q[:text])}"]
|
|
288
307
|
text_lines = q[:text].strip.lines.map(&:rstrip)
|
|
289
308
|
text_lines = text_lines.drop(1) unless q[:header]
|
|
@@ -294,7 +313,7 @@ module Samagotchi
|
|
|
294
313
|
if allows.empty?
|
|
295
314
|
lines << " allowing it is up to your user: #{DENY_AND_TELL}"
|
|
296
315
|
else
|
|
297
|
-
lines << " #{approval_advice(q)}"
|
|
316
|
+
lines << " #{approval_advice(q, strategy: strategy)}"
|
|
298
317
|
allows.each { |label| lines << " allow: chi answer #{session_id} --question #{q[:id]} --option #{option_arg(label)}" }
|
|
299
318
|
end
|
|
300
319
|
lines << " deny: #{answer_with(session_id, q)}"
|
|
@@ -303,7 +322,7 @@ module Samagotchi
|
|
|
303
322
|
lines << if may_continue?(q)
|
|
304
323
|
" continue: #{answer_with(session_id, q)}"
|
|
305
324
|
else
|
|
306
|
-
" continuing it is up to your user (#{
|
|
325
|
+
" continuing it is up to your user (#{policy(strategy).label}): stop it, or tell your user it waits"
|
|
307
326
|
end
|
|
308
327
|
lines << " stop: #{stop_command(session_id, q)} (the text is optional; the model reads it)"
|
|
309
328
|
lines << " #{message_drops(session_id)}"
|
|
@@ -319,7 +338,7 @@ module Samagotchi
|
|
|
319
338
|
lines.map { |line| "#{line}\n" }.join
|
|
320
339
|
end
|
|
321
340
|
|
|
322
|
-
def no_reply_line(result, session_id = nil)
|
|
341
|
+
def no_reply_line(result, session_id = nil, strategy: nil)
|
|
323
342
|
case result.outcome
|
|
324
343
|
when "failed" then failed_line(result)
|
|
325
344
|
when "canceled"
|
|
@@ -330,7 +349,7 @@ module Samagotchi
|
|
|
330
349
|
when "removed" then "the queued message was removed before it ran; no turn ran for it"
|
|
331
350
|
when "exhausted"
|
|
332
351
|
ran_out = "the turn ran out of iterations#{" (#{result.limit} steps)" if result.limit} before it answered"
|
|
333
|
-
strategy =
|
|
352
|
+
strategy = policy(strategy)
|
|
334
353
|
if strategy.allow?(Guardrails::ParentPolicy::SPEND)
|
|
335
354
|
"#{ran_out}; chi send #{session_id} -m '/continue yes' continues it, a message drops it"
|
|
336
355
|
else
|
|
@@ -305,8 +305,9 @@ module Samagotchi
|
|
|
305
305
|
fm = freeform.to_s.strip
|
|
306
306
|
fm = nil if fm.empty?
|
|
307
307
|
parent_agent = client_id.to_s == ClientId::CLI_ANSWER if parent_agent.nil?
|
|
308
|
-
# Read before the lock (config may touch the disk); this worker's own
|
|
309
|
-
|
|
308
|
+
# Read before the lock (config may touch the disk); this worker's own
|
|
309
|
+
# config.yml, tightened by its session's own parent_answers.
|
|
310
|
+
strategy = Guardrails::ParentPolicy.for_session(@session_lookup.call) if parent_agent
|
|
310
311
|
standing = nil
|
|
311
312
|
answer = @lock.synchronize do
|
|
312
313
|
pending = @pending
|