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.
Files changed (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/docs/architecture.md +44 -6
  4. data/docs/bundle-authoring.md +125 -0
  5. data/docs/cli.md +54 -4
  6. data/docs/configuration.md +8 -5
  7. data/docs/guardrails.md +63 -35
  8. data/docs/hooks.md +20 -4
  9. data/docs/internals/anthropic.md +2 -2
  10. data/docs/internals/bundles.md +11 -2
  11. data/docs/internals/context-telemetry.md +19 -1
  12. data/docs/internals/engine.md +4 -3
  13. data/docs/memory.md +1 -1
  14. data/docs/plugins.md +59 -2
  15. data/docs/releasing.md +1 -1
  16. data/docs/sessions.md +8 -6
  17. data/docs/sub-agent.md +35 -24
  18. data/docs/testing.md +7 -2
  19. data/lib/samagotchi/answer_command.rb +21 -25
  20. data/lib/samagotchi/bridge/client_event.rb +50 -0
  21. data/lib/samagotchi/bridge/sse_writer.rb +6 -2
  22. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -0
  23. data/lib/samagotchi/bridge.rb +76 -12
  24. data/lib/samagotchi/bridge_client.rb +14 -2
  25. data/lib/samagotchi/bundle_command/check.rb +348 -0
  26. data/lib/samagotchi/bundle_command/scaffold.rb +135 -0
  27. data/lib/samagotchi/bundle_command/test_run.rb +89 -0
  28. data/lib/samagotchi/bundle_command.rb +125 -2
  29. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
  31. data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
  32. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -2
  34. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  35. data/lib/samagotchi/bundles/ruby/manifest.yml +11 -0
  36. data/lib/samagotchi/bundles/ruby/plugin.rb +74 -0
  37. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  38. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  39. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  40. data/lib/samagotchi/client_id.rb +1 -1
  41. data/lib/samagotchi/config.rb +195 -115
  42. data/lib/samagotchi/context_status.rb +1 -1
  43. data/lib/samagotchi/continue_offer.rb +2 -1
  44. data/lib/samagotchi/engine.rb +5 -5
  45. data/lib/samagotchi/guardrail_wiring.rb +4 -0
  46. data/lib/samagotchi/guardrails/approval.rb +12 -2
  47. data/lib/samagotchi/guardrails/load_failures.rb +17 -3
  48. data/lib/samagotchi/guardrails/parent_approvals.rb +14 -70
  49. data/lib/samagotchi/guardrails/parent_policy.rb +258 -0
  50. data/lib/samagotchi/guardrails/verdict.rb +6 -3
  51. data/lib/samagotchi/hook_routes.rb +5 -3
  52. data/lib/samagotchi/hooks/bundle_loader.rb +3 -1
  53. data/lib/samagotchi/hooks/registry.rb +13 -6
  54. data/lib/samagotchi/hooks.rb +5 -0
  55. data/lib/samagotchi/kernel_loop.rb +10 -3
  56. data/lib/samagotchi/llm/anthropic_assembly.rb +1 -1
  57. data/lib/samagotchi/llm/chat_loop.rb +5 -1
  58. data/lib/samagotchi/log_subscriber.rb +1 -0
  59. data/lib/samagotchi/memory_bundle/shas.rb +127 -0
  60. data/lib/samagotchi/memory_bundle.rb +1 -0
  61. data/lib/samagotchi/parent_report.rb +92 -16
  62. data/lib/samagotchi/pending_question.rb +8 -4
  63. data/lib/samagotchi/plugin/api.rb +16 -0
  64. data/lib/samagotchi/plugin/context.rb +22 -12
  65. data/lib/samagotchi/plugin/loader.rb +5 -3
  66. data/lib/samagotchi/question_desk.rb +37 -24
  67. data/lib/samagotchi/queued_command.rb +5 -4
  68. data/lib/samagotchi/relay.rb +5 -1
  69. data/lib/samagotchi/relay_verifier.rb +4 -5
  70. data/lib/samagotchi/reply_wait.rb +22 -6
  71. data/lib/samagotchi/send_command.rb +34 -8
  72. data/lib/samagotchi/session_commands.rb +21 -7
  73. data/lib/samagotchi/session_inbox.rb +68 -0
  74. data/lib/samagotchi/terminal_ui/attach_launcher.rb +1 -1
  75. data/lib/samagotchi/terminal_ui/attached_loop.rb +102 -12
  76. data/lib/samagotchi/terminal_ui/thinking_line.rb +5 -9
  77. data/lib/samagotchi/terminal_ui.rb +15 -9
  78. data/lib/samagotchi/testing/boundary.rb +0 -3
  79. data/lib/samagotchi/testing/bundle.rb +7 -0
  80. data/lib/samagotchi/testing/host.rb +6 -4
  81. data/lib/samagotchi/testing/install.rb +119 -0
  82. data/lib/samagotchi/testing/recorder.rb +13 -4
  83. data/lib/samagotchi/testing.rb +1 -0
  84. data/lib/samagotchi/tools/builtins.rb +2 -1
  85. data/lib/samagotchi/tools/delegate_wait.rb +6 -1
  86. data/lib/samagotchi/tools/task_runtime.rb +5 -3
  87. data/lib/samagotchi/turn_checkpoint.rb +64 -2
  88. data/lib/samagotchi/turn_flow.rb +10 -0
  89. data/lib/samagotchi/version.rb +1 -1
  90. data/lib/samagotchi/web/app.rb +76 -10
  91. data/lib/samagotchi/web/public/app.js +17 -0
  92. data/lib/samagotchi/web/public/bubbles.js +9 -0
  93. data/lib/samagotchi/web/public/chunk_router.js +8 -19
  94. data/lib/samagotchi/web/public/commands.js +1 -0
  95. data/lib/samagotchi/web/public/ctx.js +12 -26
  96. data/lib/samagotchi/web/public/data.js +11 -0
  97. data/lib/samagotchi/web/public/index.html +9 -0
  98. data/lib/samagotchi/web/public/stage_view.js +6 -0
  99. data/lib/samagotchi/web/public/stream_handlers.js +4 -1
  100. data/lib/samagotchi/web/public/turn_events.js +4 -0
  101. data/lib/samagotchi/web/public/turn_html.js +9 -1
  102. data/lib/samagotchi/web/public/unqueue.js +56 -0
  103. data/lib/samagotchi/web/server.rb +4 -0
  104. data/lib/samagotchi/worker.rb +19 -5
  105. data/lib/samagotchi/worker_inbound.rb +4 -4
  106. metadata +12 -2
  107. 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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "memory_bundle/manifest"
4
+ require_relative "memory_bundle/shas"
4
5
  require_relative "memory_bundle/source"
5
6
  require_relative "memory_bundle/provenance"
6
7
  require_relative "memory_bundle/placeholder"
@@ -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), no_answer, error, worker_gone,
38
- # stopped or running
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 ||= PendingQuestion.new
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: Array(pending.options).map(&:to_s),
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: allowing it is the user's
165
- # (Guardrails::ParentApprovals). A step-limit question's continues the
166
- # turn (its Stop form is in #question_text).
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])}; #{DENY_AND_TELL}"
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
- lines << " allowing it is up to your user: #{DENY_AND_TELL}"
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 << " continue: #{answer_with(session_id, q)}"
244
- lines << " stop: chi answer #{session_id} --question #{q[:id]} --option Stop --text WHY (the text is optional; the model reads it)"
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" then "the turn was 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
- "chi send #{session_id} -m '/continue yes' continues it, a message drops it"
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; kind: nil for the model's own,
11
- # "hook", "approval" or "continue"; hook: the asking hook's name;
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, :kind, :hook,
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
- merged = id ? overlay(@settings, session_values) : @settings
115
- @session_settings = [id, merged].freeze
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, or when
439
- # the Host can't tell them).
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
- @log.warn("session_settings_unread", error: "#{e.class}: #{e.message}")
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/parent_approvals"
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) allows an approval further than this worker's
40
- # guardrails.parent_approvals lets it (Guardrails::ParentApprovals), or
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] :off, :once_only, :protected or :stop_only
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
- super(reason == :stop_only ? "a parent may not continue this turn" : "a parent may not allow this approval (#{reason})")
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 guardrails.parent_approvals on an approval and doesn't
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
- parent_setting = Guardrails::ParentApprovals.setting if parent_agent
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, parent_setting: parent_setting)
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 guardrails.parent_approvals. Under the
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:, parent_setting:)
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
- # The one whose answer settles the approval (Approval.settle, by index).
509
- if parent_setting
510
- reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: parent_setting)
511
- raise Refused, reason if reason
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) and +after_file+ (the input file it
13
- # runs after, a basename).
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) = super
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)
@@ -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::ParentApprovals judges it the same way. The
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,