samagotchi 0.84.0 → 0.86.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 +80 -1
- data/docs/architecture.md +44 -6
- data/docs/bundle-authoring.md +125 -0
- data/docs/cli.md +54 -4
- data/docs/configuration.md +8 -5
- data/docs/guardrails.md +63 -35
- data/docs/hooks.md +20 -4
- data/docs/internals/anthropic.md +2 -2
- data/docs/internals/bundles.md +11 -2
- data/docs/internals/context-telemetry.md +19 -1
- data/docs/internals/engine.md +4 -3
- data/docs/memory.md +1 -1
- data/docs/plugins.md +59 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +8 -6
- data/docs/sub-agent.md +35 -24
- data/docs/testing.md +7 -2
- data/lib/samagotchi/answer_command.rb +21 -25
- data/lib/samagotchi/bridge/client_event.rb +50 -0
- data/lib/samagotchi/bridge/sse_writer.rb +6 -2
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -0
- data/lib/samagotchi/bridge.rb +76 -12
- data/lib/samagotchi/bridge_client.rb +14 -2
- data/lib/samagotchi/bundle_command/check.rb +348 -0
- data/lib/samagotchi/bundle_command/scaffold.rb +135 -0
- data/lib/samagotchi/bundle_command/test_run.rb +89 -0
- data/lib/samagotchi/bundle_command.rb +125 -2
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -2
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/ruby/manifest.yml +11 -0
- data/lib/samagotchi/bundles/ruby/plugin.rb +74 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/client_id.rb +1 -1
- data/lib/samagotchi/config.rb +195 -115
- data/lib/samagotchi/context_status.rb +1 -1
- data/lib/samagotchi/continue_offer.rb +2 -1
- data/lib/samagotchi/engine.rb +5 -5
- data/lib/samagotchi/guardrail_wiring.rb +4 -0
- data/lib/samagotchi/guardrails/approval.rb +12 -2
- data/lib/samagotchi/guardrails/load_failures.rb +17 -3
- data/lib/samagotchi/guardrails/parent_approvals.rb +14 -70
- data/lib/samagotchi/guardrails/parent_policy.rb +258 -0
- data/lib/samagotchi/guardrails/verdict.rb +6 -3
- data/lib/samagotchi/hook_routes.rb +5 -3
- data/lib/samagotchi/hooks/bundle_loader.rb +3 -1
- data/lib/samagotchi/hooks/registry.rb +13 -6
- data/lib/samagotchi/hooks.rb +5 -0
- data/lib/samagotchi/kernel_loop.rb +10 -3
- data/lib/samagotchi/llm/anthropic_assembly.rb +1 -1
- data/lib/samagotchi/llm/chat_loop.rb +5 -1
- data/lib/samagotchi/log_subscriber.rb +1 -0
- data/lib/samagotchi/memory_bundle/shas.rb +127 -0
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/parent_report.rb +92 -16
- data/lib/samagotchi/pending_question.rb +8 -4
- data/lib/samagotchi/plugin/api.rb +16 -0
- data/lib/samagotchi/plugin/context.rb +22 -12
- data/lib/samagotchi/plugin/loader.rb +5 -3
- data/lib/samagotchi/question_desk.rb +37 -24
- data/lib/samagotchi/queued_command.rb +5 -4
- data/lib/samagotchi/relay.rb +5 -1
- data/lib/samagotchi/relay_verifier.rb +4 -5
- data/lib/samagotchi/reply_wait.rb +22 -6
- data/lib/samagotchi/send_command.rb +34 -8
- data/lib/samagotchi/session_commands.rb +21 -7
- data/lib/samagotchi/session_inbox.rb +68 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +102 -12
- data/lib/samagotchi/terminal_ui/thinking_line.rb +5 -9
- data/lib/samagotchi/terminal_ui.rb +15 -9
- data/lib/samagotchi/testing/boundary.rb +0 -3
- data/lib/samagotchi/testing/bundle.rb +7 -0
- data/lib/samagotchi/testing/host.rb +6 -4
- data/lib/samagotchi/testing/install.rb +119 -0
- data/lib/samagotchi/testing/recorder.rb +13 -4
- data/lib/samagotchi/testing.rb +1 -0
- data/lib/samagotchi/tools/builtins.rb +2 -1
- data/lib/samagotchi/tools/delegate_wait.rb +6 -1
- data/lib/samagotchi/tools/task_runtime.rb +5 -3
- data/lib/samagotchi/turn_checkpoint.rb +64 -2
- data/lib/samagotchi/turn_flow.rb +10 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +76 -10
- data/lib/samagotchi/web/public/app.js +17 -0
- data/lib/samagotchi/web/public/bubbles.js +9 -0
- data/lib/samagotchi/web/public/chunk_router.js +8 -19
- data/lib/samagotchi/web/public/commands.js +1 -0
- data/lib/samagotchi/web/public/ctx.js +12 -26
- data/lib/samagotchi/web/public/data.js +11 -0
- data/lib/samagotchi/web/public/index.html +9 -0
- data/lib/samagotchi/web/public/stage_view.js +6 -0
- data/lib/samagotchi/web/public/stream_handlers.js +4 -1
- data/lib/samagotchi/web/public/turn_events.js +4 -0
- data/lib/samagotchi/web/public/turn_html.js +9 -1
- data/lib/samagotchi/web/public/unqueue.js +56 -0
- data/lib/samagotchi/web/server.rb +4 -0
- data/lib/samagotchi/worker.rb +19 -5
- data/lib/samagotchi/worker_inbound.rb +4 -4
- metadata +12 -2
- data/lib/samagotchi/guardrails/parent_continue.rb +0 -43
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module MemoryBundle
|
|
8
|
+
# A bundle manifest's sha256 lines, recomputed from the files they name:
|
|
9
|
+
# `rake bundles:sha` for the shipped bundles and `chi bundle check --fix`
|
|
10
|
+
# for anyone's. Plain Ruby (no chi requires), so the release tools load
|
|
11
|
+
# it on their own.
|
|
12
|
+
module Shas
|
|
13
|
+
class Error < StandardError; end
|
|
14
|
+
|
|
15
|
+
# A sha line that doesn't match its file: +old+ is the hex the line
|
|
16
|
+
# holds ("" for a placeholder `sha256: sha256:`), nil when the entry
|
|
17
|
+
# has no sha line at all (a plugin:, hooks: or scripts: entry the
|
|
18
|
+
# installer then takes unchecked; .refresh can't write one).
|
|
19
|
+
Stale = Data.define(:file, :old, :new)
|
|
20
|
+
|
|
21
|
+
# What may follow a sha value on its line: a comment, then the line end
|
|
22
|
+
# (\r\n kept as it was).
|
|
23
|
+
TAIL = /(?:[ \t]+#[^\r\n]*)?[ \t]*\r?\n?\z/
|
|
24
|
+
# A sha value with what follows it, for the rewrite.
|
|
25
|
+
VALUE = /sha256:(\h*)((?:[ \t]+#[^\r\n]*)?)[ \t]*(\r?\n?)\z/
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# The manifest text with every sha256 line recomputed from the file it
|
|
30
|
+
# names, the layout otherwise untouched. The shapes:
|
|
31
|
+
# files: a.md: sha256:<hex> (dir/a.md)
|
|
32
|
+
# a.md:\n sha256: sha256:<hex> (dir/a.md, with description:)
|
|
33
|
+
# hooks: h.rb:\n sha256: sha256:<hex> (dir/hooks/h.rb)
|
|
34
|
+
# plugin: file: p.rb\n sha256: sha256:<hex> (dir/p.rb)
|
|
35
|
+
# scripts: s.rb: sha256:<hex> (dir/scripts/s.rb)
|
|
36
|
+
# Returns [text, [[relative file, old sha, new sha], ...]] for the lines
|
|
37
|
+
# that changed. +text+ defaults to dir/manifest.yml. A named file that's
|
|
38
|
+
# missing raises, and so does a files: entry none of those shapes
|
|
39
|
+
# matched (a quoted sha, a flow mapping), which would otherwise be
|
|
40
|
+
# skipped without a word.
|
|
41
|
+
def refresh(dir, text = File.read(File.join(dir, "manifest.yml")))
|
|
42
|
+
text, changes, = scan(dir, text)
|
|
43
|
+
[text, changes]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The stale sha lines of the bundle at +dir+, then the plugin:, hooks:
|
|
47
|
+
# and scripts: entries with no sha line, as [Stale].
|
|
48
|
+
def stale(dir, text = File.read(File.join(dir, "manifest.yml")))
|
|
49
|
+
_, changes, unlined = scan(dir, text)
|
|
50
|
+
changes.map { |file, old, new| Stale.new(file, old, new) } +
|
|
51
|
+
unlined.map { |file| Stale.new(file, nil, digest(dir, file)) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def scan(dir, text)
|
|
55
|
+
parsed = YAML.safe_load(parseable(text)) || {}
|
|
56
|
+
plugin_file = parsed.dig("plugin", "file")
|
|
57
|
+
top = sub = nil
|
|
58
|
+
changes = []
|
|
59
|
+
seen = []
|
|
60
|
+
lines = text.lines.map do |line|
|
|
61
|
+
if line.match?(/\A[^\s#]/)
|
|
62
|
+
top = line[/\A([^\s#][^:]*):/, 1]
|
|
63
|
+
sub = nil
|
|
64
|
+
end
|
|
65
|
+
sub = line[/\A ([^\s#][^:]*):/, 1] if line.match?(/\A [^\s#]/)
|
|
66
|
+
file, prefix = sha_line(line, top, sub, plugin_file)
|
|
67
|
+
next line unless file
|
|
68
|
+
|
|
69
|
+
seen << file
|
|
70
|
+
_, old, comment, eol = line.match(VALUE).to_a
|
|
71
|
+
new = digest(dir, file)
|
|
72
|
+
changes << [file, old, new] unless old == new
|
|
73
|
+
"#{prefix}sha256:#{new}#{comment}#{eol}"
|
|
74
|
+
end
|
|
75
|
+
unread = keys(parsed["files"]) - seen
|
|
76
|
+
unless unread.empty?
|
|
77
|
+
raise Error, "#{File.basename(dir)}/manifest.yml: no sha line to refresh for files: #{unread.join(", ")} " \
|
|
78
|
+
"(write `name: sha256:<hex>`, or a mapping with an unquoted `sha256: sha256:<hex>` line)"
|
|
79
|
+
end
|
|
80
|
+
[lines.join, changes, coded(parsed, plugin_file) - seen]
|
|
81
|
+
end
|
|
82
|
+
private_class_method :scan
|
|
83
|
+
|
|
84
|
+
# +text+ with each placeholder `sha256: sha256:` given a hex digit: a
|
|
85
|
+
# value that ends in a colon isn't YAML. Manifest readers that should
|
|
86
|
+
# take a placeholder (`chi bundle check`) parse this text.
|
|
87
|
+
def parseable(text) = text.gsub(/^(\s*(?:["']?[^"':\s][^"':]*["']?: )?)sha256:(?=[ \t]*(?:#[^\r\n]*)?\r?$)/, "\\1sha256:0")
|
|
88
|
+
|
|
89
|
+
# The code files the manifest names (plugin, hooks, scripts), as paths
|
|
90
|
+
# relative to the bundle dir.
|
|
91
|
+
def coded(parsed, plugin_file)
|
|
92
|
+
(plugin_file.is_a?(String) ? [plugin_file] : []) +
|
|
93
|
+
keys(parsed["hooks"]).map { |h| File.join("hooks", h) } +
|
|
94
|
+
keys(parsed["scripts"]).map { |s| File.join("scripts", s) }
|
|
95
|
+
end
|
|
96
|
+
private_class_method :coded
|
|
97
|
+
|
|
98
|
+
def keys(section) = section.is_a?(Hash) ? section.keys.map(&:to_s) : []
|
|
99
|
+
private_class_method :keys
|
|
100
|
+
|
|
101
|
+
def digest(dir, file)
|
|
102
|
+
path = File.join(dir, file)
|
|
103
|
+
raise Error, "#{File.basename(dir)}/manifest.yml names #{file}, which isn't there" unless File.file?(path)
|
|
104
|
+
|
|
105
|
+
Digest::SHA256.hexdigest(File.binread(path))
|
|
106
|
+
end
|
|
107
|
+
private_class_method :digest
|
|
108
|
+
|
|
109
|
+
# [file relative to the bundle dir, the line up to its sha256: value] for a
|
|
110
|
+
# sha line, nil for any other line.
|
|
111
|
+
def sha_line(line, top, sub, plugin_file)
|
|
112
|
+
case top
|
|
113
|
+
when "files"
|
|
114
|
+
m = line.match(/\A( sha256: )sha256:\h*#{TAIL}/) and return sub && [sub.delete("\"'"), m[1]]
|
|
115
|
+
m = line.match(/\A( ["']?([^"':]+)["']?: )sha256:\h*#{TAIL}/) and [m[2], m[1]]
|
|
116
|
+
when "hooks"
|
|
117
|
+
m = line.match(/\A( sha256: )sha256:\h*#{TAIL}/) and sub and [File.join("hooks", sub.delete("\"'")), m[1]]
|
|
118
|
+
when "plugin"
|
|
119
|
+
m = line.match(/\A( sha256: )sha256:\h*#{TAIL}/) and plugin_file and [plugin_file, m[1]]
|
|
120
|
+
when "scripts"
|
|
121
|
+
m = line.match(/\A( ["']?([^"':]+)["']?: )sha256:\h*#{TAIL}/) and [File.join("scripts", m[2]), m[1]]
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
private_class_method :sha_line
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -5,6 +5,7 @@ require "json"
|
|
|
5
5
|
|
|
6
6
|
require_relative "cli/exit"
|
|
7
7
|
require_relative "pending_question"
|
|
8
|
+
require_relative "guardrails/parent_policy"
|
|
8
9
|
|
|
9
10
|
module Samagotchi
|
|
10
11
|
# How a wait's end (ReplyWait::Result) reads to whoever runs chi as a
|
|
@@ -34,8 +35,9 @@ module Samagotchi
|
|
|
34
35
|
# @param result [ReplyWait::Result]
|
|
35
36
|
# @return [String] answered, question, failed, canceled, limit (the
|
|
36
37
|
# turn ran out of iterations, nobody asked), not_continued (a Stop
|
|
37
|
-
# answered the step-limit question),
|
|
38
|
-
#
|
|
38
|
+
# answered the step-limit question), removed (the queued message was
|
|
39
|
+
# taken back before it ran), no_answer, error, worker_gone, stopped
|
|
40
|
+
# or running
|
|
39
41
|
def status(result)
|
|
40
42
|
case result.status
|
|
41
43
|
when :done then "answered"
|
|
@@ -46,6 +48,7 @@ module Samagotchi
|
|
|
46
48
|
when "canceled" then "canceled"
|
|
47
49
|
when "exhausted" then "limit"
|
|
48
50
|
when "not_continued" then "not_continued"
|
|
51
|
+
when "removed" then "removed"
|
|
49
52
|
else "no_answer"
|
|
50
53
|
end
|
|
51
54
|
when :error then "error"
|
|
@@ -142,38 +145,86 @@ module Samagotchi
|
|
|
142
145
|
# parent sees it.
|
|
143
146
|
# kind is "question" for the model's own (the desk stores none),
|
|
144
147
|
# "hook", "approval" or "continue" (the step-limit question, with the
|
|
145
|
-
# turn's limit).
|
|
148
|
+
# turn's limit). allowed_options: the options a parent may pick, by
|
|
149
|
+
# this process's guardrails.parent_answers (Guardrails::ParentPolicy),
|
|
150
|
+
# when that is not all of them.
|
|
146
151
|
# @return [Hash]
|
|
147
152
|
def question(pending)
|
|
148
|
-
pending
|
|
153
|
+
pending = PendingQuestion.from_file(pending) || PendingQuestion.new
|
|
149
154
|
approval = pending.approval.is_a?(Hash) ? symbolize(pending.approval) : nil
|
|
155
|
+
options = Array(pending.options).map(&:to_s)
|
|
156
|
+
allowed = allowed_options(pending)
|
|
150
157
|
{
|
|
151
158
|
id: pending.id.to_s,
|
|
152
159
|
kind: (pending.kind || "question").to_s,
|
|
153
160
|
header: blank(pending.header),
|
|
154
161
|
text: pending.question.to_s,
|
|
155
|
-
options:
|
|
162
|
+
options: options,
|
|
156
163
|
multi_select: !!pending.multi_select,
|
|
157
164
|
allow_freeform: !!pending.allow_freeform,
|
|
165
|
+
allowed_options: (allowed unless allowed == options),
|
|
158
166
|
limit: pending.limit,
|
|
159
167
|
approval: approval&.slice(*APPROVAL_KEYS)
|
|
160
168
|
}.compact
|
|
161
169
|
end
|
|
162
170
|
|
|
163
171
|
# The command that answers +question+; N is the parent's to fill in.
|
|
164
|
-
# An approval's is a deny:
|
|
165
|
-
#
|
|
166
|
-
# turn
|
|
172
|
+
# An approval's is a deny: a wider allow is the user's (#question_text
|
|
173
|
+
# lists an Allow once a parent may give). A step-limit question's
|
|
174
|
+
# continues the turn, or stops it where a parent may not continue (its
|
|
175
|
+
# other form is in #question_text).
|
|
167
176
|
def answer_with(session_id, question)
|
|
168
177
|
return "chi answer #{session_id} --question #{question[:id]} --option Deny --text WHY" if approval?(question)
|
|
178
|
+
return stop_command(session_id, question) if continue?(question) && !may_continue?(question)
|
|
169
179
|
return "chi answer #{session_id} --question #{question[:id]} --option Continue" if continue?(question)
|
|
170
180
|
|
|
181
|
+
# The one option a parent may pick (cost-cap's Stop under hard).
|
|
182
|
+
only = Array(question[:allowed_options])
|
|
183
|
+
return "chi answer #{session_id} --question #{question[:id]} --option #{option_arg(only.first)}" if only.size == 1
|
|
184
|
+
|
|
171
185
|
"chi answer #{session_id} --question #{question[:id]} --option N"
|
|
172
186
|
end
|
|
173
187
|
|
|
174
|
-
# What a parent does with an approval.
|
|
188
|
+
# What a parent does with an approval it may not allow.
|
|
175
189
|
DENY_AND_TELL = "deny it, and tell your user"
|
|
176
190
|
|
|
191
|
+
# The labels a parent may pick on +pending+ (a PendingQuestion, or a
|
|
192
|
+
# Hash with its fields) by this process's strategy.
|
|
193
|
+
# @return [Array<String>]
|
|
194
|
+
def allowed_options(pending)
|
|
195
|
+
pending = PendingQuestion.from_file(pending) || PendingQuestion.new
|
|
196
|
+
Guardrails::ParentPolicy.allowed_options(pending, strategy: Guardrails::ParentPolicy.current)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# 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)
|
|
202
|
+
deny = Guardrails::Approval::DENY
|
|
203
|
+
question.fetch(:allowed_options, question[:options]).reject { |label| label == deny }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# What a parent does with an approval: deny it and tell its user, or
|
|
207
|
+
# give the Allow once its strategy lets it.
|
|
208
|
+
def approval_advice(question)
|
|
209
|
+
allows = allowed_allows(question)
|
|
210
|
+
return DENY_AND_TELL if allows.empty?
|
|
211
|
+
|
|
212
|
+
"#{allows.join(" or ")} is yours to give (#{Guardrails::ParentPolicy.current.label}); " \
|
|
213
|
+
"anything wider is up to your user: #{DENY_AND_TELL}"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# 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)
|
|
219
|
+
question.fetch(:allowed_options, question[:options]).include?(Guardrails::ParentPolicy::CONTINUE)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def stop_command(session_id, question)
|
|
223
|
+
"chi answer #{session_id} --question #{question[:id]} --option Stop --text WHY"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def option_arg(label) = label.match?(/\A[\w.-]+\z/) ? label : label.inspect
|
|
227
|
+
|
|
177
228
|
# The other way: the user answers it in chi (the web's bell and badge
|
|
178
229
|
# say it waits), and a wait with no message waits past it.
|
|
179
230
|
def leave_open(session_id)
|
|
@@ -207,7 +258,10 @@ module Samagotchi
|
|
|
207
258
|
when :waiting_for_answer
|
|
208
259
|
q = question(result.question)
|
|
209
260
|
if approval?(q)
|
|
210
|
-
"waiting for an approval: #{first_line(q[:text])}; #{
|
|
261
|
+
"waiting for an approval: #{first_line(q[:text])}; #{approval_advice(q)}"
|
|
262
|
+
elsif continue?(q) && !may_continue?(q)
|
|
263
|
+
"waiting at the step limit: #{first_line(q[:text])}; a parent may only stop it here " \
|
|
264
|
+
"(#{Guardrails::ParentPolicy.current.label}): #{stop_command(session_id, q)}, or tell your user it waits"
|
|
211
265
|
elsif continue?(q)
|
|
212
266
|
"waiting at the step limit: #{first_line(q[:text])}; " \
|
|
213
267
|
"answer Continue or Stop: chi answer #{session_id} --question #{q[:id]} --option Continue"
|
|
@@ -236,16 +290,29 @@ module Samagotchi
|
|
|
236
290
|
lines.concat(text_lines.map { |line| " #{line}" })
|
|
237
291
|
q[:options].each_with_index { |option, i| lines << " #{i + 1}. #{option}" }
|
|
238
292
|
if approval?(q)
|
|
239
|
-
|
|
293
|
+
allows = allowed_allows(q)
|
|
294
|
+
if allows.empty?
|
|
295
|
+
lines << " allowing it is up to your user: #{DENY_AND_TELL}"
|
|
296
|
+
else
|
|
297
|
+
lines << " #{approval_advice(q)}"
|
|
298
|
+
allows.each { |label| lines << " allow: chi answer #{session_id} --question #{q[:id]} --option #{option_arg(label)}" }
|
|
299
|
+
end
|
|
240
300
|
lines << " deny: #{answer_with(session_id, q)}"
|
|
241
301
|
lines << " #{leave_open(session_id)}"
|
|
242
302
|
elsif continue?(q)
|
|
243
|
-
lines <<
|
|
244
|
-
|
|
303
|
+
lines << if may_continue?(q)
|
|
304
|
+
" continue: #{answer_with(session_id, q)}"
|
|
305
|
+
else
|
|
306
|
+
" continuing it is up to your user (#{Guardrails::ParentPolicy.current.label}): stop it, or tell your user it waits"
|
|
307
|
+
end
|
|
308
|
+
lines << " stop: #{stop_command(session_id, q)} (the text is optional; the model reads it)"
|
|
245
309
|
lines << " #{message_drops(session_id)}"
|
|
246
310
|
else
|
|
247
311
|
lines << " more than one allowed: repeat --option" if q[:multi_select]
|
|
248
312
|
lines << " free text allowed: --text" if q[:allow_freeform]
|
|
313
|
+
if q[:allowed_options]
|
|
314
|
+
lines << " a parent may pick #{q[:allowed_options].map { |o| option_arg(o) }.join(", ")}; the others are up to your user"
|
|
315
|
+
end
|
|
249
316
|
lines << " answer: #{answer_with(session_id, q)}"
|
|
250
317
|
lines << " or open it: chi --attach #{session_id} or the web"
|
|
251
318
|
end
|
|
@@ -255,12 +322,21 @@ module Samagotchi
|
|
|
255
322
|
def no_reply_line(result, session_id = nil)
|
|
256
323
|
case result.outcome
|
|
257
324
|
when "failed" then failed_line(result)
|
|
258
|
-
when "canceled"
|
|
325
|
+
when "canceled"
|
|
326
|
+
# A turn its worker died in, settled by the next (TurnCheckpoint.settle).
|
|
327
|
+
result.cancel_reason == "worker_stopped" ? "the turn was canceled: its worker exited mid-turn" : "the turn was canceled"
|
|
259
328
|
when "completed" then "the turn ended with no visible answer"
|
|
260
329
|
when "not_continued" then "the turn was not continued (Stop); its work so far stays"
|
|
330
|
+
when "removed" then "the queued message was removed before it ran; no turn ran for it"
|
|
261
331
|
when "exhausted"
|
|
262
|
-
"the turn ran out of iterations#{" (#{result.limit} steps)" if result.limit} before it answered
|
|
263
|
-
|
|
332
|
+
ran_out = "the turn ran out of iterations#{" (#{result.limit} steps)" if result.limit} before it answered"
|
|
333
|
+
strategy = Guardrails::ParentPolicy.current
|
|
334
|
+
if strategy.allow?(Guardrails::ParentPolicy::SPEND)
|
|
335
|
+
"#{ran_out}; chi send #{session_id} -m '/continue yes' continues it, a message drops it"
|
|
336
|
+
else
|
|
337
|
+
# No /continue yes hint: a parent may not continue it here.
|
|
338
|
+
"#{ran_out}; continuing it is up to your user (#{strategy.label}), a message drops it"
|
|
339
|
+
end
|
|
264
340
|
else "the turn ended without a reply (canceled, failed or empty)"
|
|
265
341
|
end
|
|
266
342
|
end
|
|
@@ -7,8 +7,12 @@ module Samagotchi
|
|
|
7
7
|
# step-limit question (kind "continue").
|
|
8
8
|
#
|
|
9
9
|
# id: the question's uuid; question, options, header, multi_select,
|
|
10
|
-
# allow_freeform: what the UIs show;
|
|
11
|
-
#
|
|
10
|
+
# allow_freeform: what the UIs show; effects: each option's effect for
|
|
11
|
+
# a parent agent's answer, in option order (Guardrails::ParentPolicy;
|
|
12
|
+
# nil: untagged); user_only: only the user may pick an option that isn't
|
|
13
|
+
# a deny or a plain answer (an approval on chi's own config); kind: nil
|
|
14
|
+
# for the model's own, "hook", "approval" or "continue"; hook: the asking
|
|
15
|
+
# hook's name;
|
|
12
16
|
# approval: the approval's facts (Guardrails::Approval.payload); relay: a
|
|
13
17
|
# relayed approval's facts (Relay.card); limit, context: the step-limit
|
|
14
18
|
# question's turn limit and offer context (ContinueOffer); status:
|
|
@@ -20,8 +24,8 @@ module Samagotchi
|
|
|
20
24
|
# Crosses the process as a Hash: #to_h for an event, a Bridge snapshot or
|
|
21
25
|
# a UI (symbol keys), #to_file for session.json (string keys); both in
|
|
22
26
|
# member order with unset fields left out. .from_file reads either back.
|
|
23
|
-
PendingQuestion = Data.define(:id, :question, :options, :header, :multi_select, :allow_freeform, :
|
|
24
|
-
:approval, :relay, :limit, :context, :status, :created_at, :relayed_to) do
|
|
27
|
+
PendingQuestion = Data.define(:id, :question, :options, :header, :multi_select, :allow_freeform, :effects, :user_only,
|
|
28
|
+
:kind, :hook, :approval, :relay, :limit, :context, :status, :created_at, :relayed_to) do
|
|
25
29
|
def initialize(**fields)
|
|
26
30
|
super(**members.to_h { |m| [m, nil] }, **fields)
|
|
27
31
|
end
|
|
@@ -18,6 +18,22 @@ module Samagotchi
|
|
|
18
18
|
ANNOTATION_LABEL_MAX = 40
|
|
19
19
|
ANNOTATIONS_MAX = 5
|
|
20
20
|
|
|
21
|
+
# The first chi release with each call a plugin may make (from git:
|
|
22
|
+
# the first release tag holding it), for `chi bundle check`'s
|
|
23
|
+
# requires-chi-floor: a bundle whose requires_chi admits an older chi.
|
|
24
|
+
# A call not listed gets no floor check: it came before the first tag
|
|
25
|
+
# (v0.2.0), or isn't listed yet. Keys have no "?" (API_CALL drops it).
|
|
26
|
+
SINCE = {
|
|
27
|
+
"ctx.steer" => "0.3.0", "ctx.stop_turn" => "0.3.0", "ctx.stop_generation" => "0.6.0",
|
|
28
|
+
"Samagotchi::AtomicFile" => "0.8.0", "ctx.model" => "0.17.0", "ctx.model_key" => "0.17.0",
|
|
29
|
+
"ctx.context" => "0.32.0", "ctx.delegate" => "0.32.0", "ctx.scratch" => "0.32.0",
|
|
30
|
+
"fallback_for:" => "0.35.0", "ctx.fork" => "0.45.0", "ctx.frontend" => "0.49.0",
|
|
31
|
+
"ctx.prompt_files" => "0.60.0", "chi.annotation" => "0.66.0", "chi.toolkit" => "0.66.0",
|
|
32
|
+
"ctx.use_toolkit" => "0.66.0", "ctx.toolkit" => "0.66.0", "ctx.declared_tools" => "0.66.0",
|
|
33
|
+
"chi.tool_source" => "0.67.0", "chi.peer_source" => "0.67.0", "ctx.http" => "0.80.0",
|
|
34
|
+
"ctx.cost" => "0.83.0"
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
21
37
|
# @param bundle [String]
|
|
22
38
|
# @param label [String] "<file> (bundle <name>)": what hooks and
|
|
23
39
|
# failures are named by
|
|
@@ -29,7 +29,7 @@ module Samagotchi
|
|
|
29
29
|
# The Engine's side of a Context: callables, so the Engine itself is
|
|
30
30
|
# never handed out. +messages+ returns the conversation, +notify+ takes
|
|
31
31
|
# (text, level, label, fallback_for:), +ask_user+ takes (question:, options:, header:,
|
|
32
|
-
# allow_freeform:, hook:) like the hook runtime's, +card+ takes
|
|
32
|
+
# allow_freeform:, hook:, and effects: when the plugin gave them) like the hook runtime's, +card+ takes
|
|
33
33
|
# Engine#show_card's keywords and returns the id, +ask_model+ takes
|
|
34
34
|
# (chat messages, timeout:, max_tokens:, cancel_controller:, model:: a
|
|
35
35
|
# configured model ref, nil for the session's) and returns the answer
|
|
@@ -111,8 +111,9 @@ module Samagotchi
|
|
|
111
111
|
cached = @session_settings
|
|
112
112
|
return cached.last if cached && cached.first == id
|
|
113
113
|
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
values = session_values(id) if id
|
|
115
|
+
merged = id ? overlay(@settings, values || {}) : @settings
|
|
116
|
+
@session_settings = [id, merged].freeze unless id && values.nil?
|
|
116
117
|
merged
|
|
117
118
|
end
|
|
118
119
|
|
|
@@ -354,15 +355,21 @@ module Samagotchi
|
|
|
354
355
|
|
|
355
356
|
# A single-select question through the question flow. Inside a
|
|
356
357
|
# stream hook's handler (generation_progress) it asks no one (nil).
|
|
358
|
+
# @param effects [Hash{String => Symbol}, nil] what an option does, by
|
|
359
|
+
# its label, for a parent agent's answer (Guardrails::ParentPolicy):
|
|
360
|
+
# :spend (more time or money: a Continue), :permit (one call),
|
|
361
|
+
# :permit_wide, :deny (a Stop); a label left out is a plain :answer.
|
|
362
|
+
# Tag every option that spends or permits.
|
|
357
363
|
# @return [Hash, nil] {selected:, freeform:, selected_indices:}, or nil
|
|
358
|
-
# (no one to ask, cancelled, bad options)
|
|
359
|
-
def ask_user(question:, options:, header: nil, allow_freeform: false)
|
|
364
|
+
# (no one to ask, cancelled, bad options or effects)
|
|
365
|
+
def ask_user(question:, options:, header: nil, allow_freeform: false, effects: nil)
|
|
366
|
+
tags = effects.nil? ? {} : { effects: effects }
|
|
360
367
|
if (helper = event_helper(:ask_user))
|
|
361
|
-
return helper.call(question: question, options: options, header: header, allow_freeform: allow_freeform)
|
|
368
|
+
return helper.call(question: question, options: options, header: header, allow_freeform: allow_freeform, **tags)
|
|
362
369
|
end
|
|
363
370
|
|
|
364
371
|
@host.ask_user.call(question: question, options: options, header: header, allow_freeform: allow_freeform,
|
|
365
|
-
hook: @label)
|
|
372
|
+
hook: @label, **tags)
|
|
366
373
|
end
|
|
367
374
|
|
|
368
375
|
# Whether the running turn was cancelled (a long tool should stop).
|
|
@@ -435,14 +442,17 @@ module Samagotchi
|
|
|
435
442
|
nil
|
|
436
443
|
end
|
|
437
444
|
|
|
438
|
-
# The session's own values for this bundle ({} without any
|
|
439
|
-
# the Host
|
|
440
|
-
def session_values
|
|
445
|
+
# The session's own values for this bundle ({} without any; nil when
|
|
446
|
+
# the Host couldn't tell them, so #settings can retry).
|
|
447
|
+
def session_values(id)
|
|
441
448
|
values = @host.setup&.call&.bundle_settings
|
|
442
449
|
values.is_a?(Hash) ? values.fetch(@bundle, {}) : {}
|
|
443
450
|
rescue StandardError => e
|
|
444
|
-
@
|
|
445
|
-
|
|
451
|
+
unless (@session_settings_unread ||= {}).key?(id)
|
|
452
|
+
@session_settings_unread[id] = true
|
|
453
|
+
@log.warn("session_settings_unread", error: "#{e.class}: #{e.message}")
|
|
454
|
+
end
|
|
455
|
+
nil
|
|
446
456
|
end
|
|
447
457
|
|
|
448
458
|
def overlay(config, values)
|
|
@@ -4,6 +4,7 @@ require_relative "api"
|
|
|
4
4
|
require_relative "context"
|
|
5
5
|
require_relative "../hooks"
|
|
6
6
|
require_relative "../log"
|
|
7
|
+
require_relative "../guardrails/load_failures"
|
|
7
8
|
require_relative "../version"
|
|
8
9
|
require_relative "../memory_bundle/manifest"
|
|
9
10
|
require_relative "../memory_bundle/provenance"
|
|
@@ -71,7 +72,8 @@ module Samagotchi
|
|
|
71
72
|
rescue Exception => e # rubocop:disable Lint/RescueException -- a plugin's SyntaxError or exit must not stop chi
|
|
72
73
|
raise if e.is_a?(SignalException) # Ctrl-C and kill signals are the user's
|
|
73
74
|
|
|
74
|
-
failed(bundle_name, basename, "#{e.class}: #{e.message}", failures
|
|
75
|
+
failed(bundle_name, basename, "#{e.class}: #{e.message}", failures,
|
|
76
|
+
location: file && Guardrails::LoadFailures.location(e, File.dirname(file)))
|
|
75
77
|
end
|
|
76
78
|
|
|
77
79
|
# Build +file+'s plugin with +settings+ and commit what its #register
|
|
@@ -136,10 +138,10 @@ module Samagotchi
|
|
|
136
138
|
end
|
|
137
139
|
end
|
|
138
140
|
|
|
139
|
-
def failed(bundle_name, basename, reason, failures)
|
|
141
|
+
def failed(bundle_name, basename, reason, failures, location: nil)
|
|
140
142
|
Log.warn(TAG, "plugin_not_loaded", echo: "[samagotchi:plugins] bundle '#{bundle_name}' plugin '#{basename}' not loaded: #{reason}",
|
|
141
143
|
bundle: bundle_name, file: basename)
|
|
142
|
-
failures&.add("plugin #{basename} (bundle #{bundle_name})", reason, required: false)
|
|
144
|
+
failures&.add("plugin #{basename} (bundle #{bundle_name})", reason, required: false, location: location)
|
|
143
145
|
false
|
|
144
146
|
end
|
|
145
147
|
end
|
|
@@ -10,8 +10,7 @@ require_relative "log"
|
|
|
10
10
|
require_relative "pending_question"
|
|
11
11
|
require_relative "question_answer"
|
|
12
12
|
require_relative "tools/ask_user_question"
|
|
13
|
-
require_relative "guardrails/
|
|
14
|
-
require_relative "guardrails/parent_continue"
|
|
13
|
+
require_relative "guardrails/parent_policy"
|
|
15
14
|
|
|
16
15
|
module Samagotchi
|
|
17
16
|
# An Engine's question flow: the one open question (ask_user_question, a
|
|
@@ -36,18 +35,25 @@ module Samagotchi
|
|
|
36
35
|
class NotPending < ArgumentError; end
|
|
37
36
|
|
|
38
37
|
# Raised by #answer when a parent agent's answer (chi answer's client
|
|
39
|
-
# id)
|
|
40
|
-
#
|
|
41
|
-
# continues a turn turn.parent_continue keeps it from
|
|
42
|
-
# (Guardrails::ParentContinue). The question stays open; transports map
|
|
38
|
+
# id) picks an option this worker's strategy keeps parents from
|
|
39
|
+
# (Guardrails::ParentPolicy). The question stays open; transports map
|
|
43
40
|
# it to 403.
|
|
44
41
|
class Refused < StandardError
|
|
45
|
-
# @return [Symbol] :
|
|
42
|
+
# @return [Symbol] ParentPolicy.refusal's: :user_only, :permit_wide,
|
|
43
|
+
# :permit or :spend
|
|
46
44
|
attr_reader :reason
|
|
47
45
|
|
|
48
|
-
def initialize(reason)
|
|
46
|
+
def initialize(reason, pending:, strategy:)
|
|
49
47
|
@reason = reason
|
|
50
|
-
|
|
48
|
+
@pending = pending
|
|
49
|
+
@strategy = strategy
|
|
50
|
+
super("a parent may not pick that option here (#{reason})")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The line for the parent (ParentPolicy.message).
|
|
54
|
+
# @param typed [Boolean] typed at a ? prompt (the REPL)
|
|
55
|
+
def detail(typed: false)
|
|
56
|
+
Guardrails::ParentPolicy.message(reason, pending: @pending, strategy: @strategy, typed: typed)
|
|
51
57
|
end
|
|
52
58
|
end
|
|
53
59
|
|
|
@@ -290,7 +296,7 @@ module Samagotchi
|
|
|
290
296
|
# @param freeform [String, nil]
|
|
291
297
|
# @param client_id [String, nil] who answers
|
|
292
298
|
# @param parent_agent [Boolean, nil] the answer is a parent agent's, so
|
|
293
|
-
# it is held to
|
|
299
|
+
# it is held to Guardrails::ParentPolicy and doesn't
|
|
294
300
|
# bring the session back to the lists; nil: chi answer's client id says so
|
|
295
301
|
# @return [QuestionAnswer] the answer; a parent agent's carries by: "parent_agent"
|
|
296
302
|
# @raise [NotPending, ArgumentError, Refused]
|
|
@@ -300,8 +306,7 @@ module Samagotchi
|
|
|
300
306
|
fm = nil if fm.empty?
|
|
301
307
|
parent_agent = client_id.to_s == ClientId::CLI_ANSWER if parent_agent.nil?
|
|
302
308
|
# Read before the lock (config may touch the disk); this worker's own.
|
|
303
|
-
|
|
304
|
-
parent_continue = Guardrails::ParentContinue.allowed? if parent_agent
|
|
309
|
+
strategy = Guardrails::ParentPolicy.current if parent_agent
|
|
305
310
|
standing = nil
|
|
306
311
|
answer = @lock.synchronize do
|
|
307
312
|
pending = @pending
|
|
@@ -313,10 +318,7 @@ module Samagotchi
|
|
|
313
318
|
raise NotPending, "question already answered" if @answer
|
|
314
319
|
raise NotPending, "question #{pending.status}" unless pending.status.to_s == "pending"
|
|
315
320
|
|
|
316
|
-
answer = validated_answer(pending, sel, fm, parent_agent: parent_agent,
|
|
317
|
-
if parent_agent && (reason = Guardrails::ParentContinue.refusal(pending, sel, allowed: parent_continue))
|
|
318
|
-
raise Refused, reason
|
|
319
|
-
end
|
|
321
|
+
answer = validated_answer(pending, sel, fm, parent_agent: parent_agent, strategy: strategy)
|
|
320
322
|
|
|
321
323
|
if @standing
|
|
322
324
|
# No waiter: cleared here, so a second answer finds none.
|
|
@@ -401,9 +403,13 @@ module Samagotchi
|
|
|
401
403
|
# web), single-select, kind "hook". A --non-interactive run has no one
|
|
402
404
|
# to ask: nil at once. Anything but an answer (a sync handler's text, no
|
|
403
405
|
# answer, cancelled) is nil too.
|
|
406
|
+
# @param effects [Hash, nil] label => effect for a parent agent's answer
|
|
407
|
+
# (Guardrails::ParentPolicy); a label left out is "answer". A label
|
|
408
|
+
# the options don't have, or an effect it doesn't know, makes the ask
|
|
409
|
+
# invalid (nil, warned), like bad options.
|
|
404
410
|
# @return [Hash, nil] {selected:, freeform:, selected_indices:} (a plain
|
|
405
411
|
# Hash: the bundle boundary)
|
|
406
|
-
def ask_for_hook(question, options, header, allow_freeform, hook)
|
|
412
|
+
def ask_for_hook(question, options, header, allow_freeform, hook, effects: nil)
|
|
407
413
|
return nil if @interface_lookup.call == :non_interactive
|
|
408
414
|
|
|
409
415
|
opts = Tools::AskUserQuestion.normalize_options(options)
|
|
@@ -411,9 +417,16 @@ module Samagotchi
|
|
|
411
417
|
Log.warn(:hooks, "ask_user_invalid", echo: "[samagotchi:hooks] #{hook} asked with invalid options (2-8 strings)", hook: hook.to_s)
|
|
412
418
|
return nil
|
|
413
419
|
end
|
|
420
|
+
tags = Guardrails::ParentPolicy.option_tags(opts, effects)
|
|
421
|
+
if tags == false
|
|
422
|
+
Log.warn(:hooks, "ask_user_invalid",
|
|
423
|
+
echo: "[samagotchi:hooks] #{hook} asked with invalid effects (option label => " \
|
|
424
|
+
"#{Guardrails::ParentPolicy::EFFECTS.join("|")})", hook: hook.to_s)
|
|
425
|
+
return nil
|
|
426
|
+
end
|
|
414
427
|
|
|
415
428
|
fields = { question: question.to_s, options: opts, header: header, multi_select: false,
|
|
416
|
-
allow_freeform: !!allow_freeform, kind: "hook", hook: hook.to_s }.compact
|
|
429
|
+
allow_freeform: !!allow_freeform, effects: tags, kind: "hook", hook: hook.to_s }.compact
|
|
417
430
|
answer = open_question(fields)
|
|
418
431
|
return nil unless answer.is_a?(QuestionAnswer)
|
|
419
432
|
|
|
@@ -493,11 +506,11 @@ module Samagotchi
|
|
|
493
506
|
|
|
494
507
|
# An answer to +pending+, checked: the selections are its options (value
|
|
495
508
|
# == label in v1), one for a single-select, one or a text when required,
|
|
496
|
-
# and a parent agent's within
|
|
509
|
+
# and a parent agent's within its strategy (ParentPolicy). Under the
|
|
497
510
|
# question lock: checked against the question pending now.
|
|
498
511
|
# @return [QuestionAnswer]
|
|
499
512
|
# @raise [ArgumentError, Refused]
|
|
500
|
-
def validated_answer(pending, sel, fm, parent_agent:,
|
|
513
|
+
def validated_answer(pending, sel, fm, parent_agent:, strategy:)
|
|
501
514
|
opts = Array(pending.options)
|
|
502
515
|
invalid = sel.reject { |v| opts.include?(v) }
|
|
503
516
|
raise ArgumentError, "invalid selection: #{invalid.join(", ")} (valid: #{opts.join(", ")})" unless invalid.empty?
|
|
@@ -505,10 +518,10 @@ module Samagotchi
|
|
|
505
518
|
raise ArgumentError, "selection required" if pending.multi_select == false && sel.empty? && fm.nil?
|
|
506
519
|
|
|
507
520
|
indices = sel.map { |v| opts.index(v) }
|
|
508
|
-
#
|
|
509
|
-
if
|
|
510
|
-
reason = Guardrails::
|
|
511
|
-
raise Refused,
|
|
521
|
+
# By index, as the effects (and Approval.settle) read it.
|
|
522
|
+
if strategy
|
|
523
|
+
reason = Guardrails::ParentPolicy.refusal(pending, indices, strategy: strategy)
|
|
524
|
+
raise Refused.new(reason, pending: pending, strategy: strategy) if reason
|
|
512
525
|
end
|
|
513
526
|
QuestionAnswer.new(id: pending.id.to_s, selected: sel, freeform: fm, selected_indices: indices.compact,
|
|
514
527
|
by: parent_agent ? "parent_agent" : nil)
|
|
@@ -9,10 +9,11 @@ module Samagotchi
|
|
|
9
9
|
# question's answer), whose line the UIs leave out. The worker adds
|
|
10
10
|
# where it waits (Worker#on_command): +mid_turn+ (the policy it got:
|
|
11
11
|
# :queue, :refuse, :loop), +after_seq+ (the event count then, so a
|
|
12
|
-
# turn's end knows it came before)
|
|
13
|
-
#
|
|
14
|
-
QueuedCommand = Data.define(:command_id, :client_id, :line, :card, :mid_turn, :after_seq, :after_file) do
|
|
15
|
-
def initialize(command_id:, line:, client_id: nil, card: false, mid_turn: nil, after_seq: nil, after_file: nil
|
|
12
|
+
# turn's end knows it came before), +after_file+ (the input file it runs
|
|
13
|
+
# after, a basename) and +enqueued_id+ (a file-delivered command's sender).
|
|
14
|
+
QueuedCommand = Data.define(:command_id, :client_id, :line, :card, :mid_turn, :after_seq, :after_file, :enqueued_id) do
|
|
15
|
+
def initialize(command_id:, line:, client_id: nil, card: false, mid_turn: nil, after_seq: nil, after_file: nil,
|
|
16
|
+
enqueued_id: nil) = super
|
|
16
17
|
|
|
17
18
|
# A new command with a fresh id.
|
|
18
19
|
def self.mint(line, client_id: nil, card: false)
|
data/lib/samagotchi/relay.rb
CHANGED
|
@@ -6,7 +6,9 @@ module Samagotchi
|
|
|
6
6
|
# A delegate's approval reopened as the parent session's own (the
|
|
7
7
|
# approval relay): the same kind "approval" and the same approval facts,
|
|
8
8
|
# so every UI shows it as it shows the parent's own approvals, the diff
|
|
9
|
-
# included, and Guardrails::
|
|
9
|
+
# included, and Guardrails::ParentPolicy judges it the same way: the
|
|
10
|
+
# child's effects and user_only come along (an older child's question
|
|
11
|
+
# has none; the policy derives them from the approval facts). The
|
|
10
12
|
# question text names the delegate and its task over the child's own
|
|
11
13
|
# text; `relay:` says which child and which of its questions it answers.
|
|
12
14
|
# A relay of a relay (a grandchild's) grows relay.chain.
|
|
@@ -37,6 +39,8 @@ module Samagotchi
|
|
|
37
39
|
header: header(who, more),
|
|
38
40
|
multi_select: false,
|
|
39
41
|
allow_freeform: pending.allow_freeform.nil? || !!pending.allow_freeform,
|
|
42
|
+
# Same order: only the session option's label changes.
|
|
43
|
+
**{ effects: pending.effects, user_only: pending.user_only }.compact,
|
|
40
44
|
kind: Guardrails::Approval::KIND,
|
|
41
45
|
approval: facts,
|
|
42
46
|
relay: { id: relay_id, child_id: child.id, child_short: short, child_question_id: pending.id.to_s,
|