samagotchi 0.85.0 → 0.87.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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -1
  3. data/bin/chi +39 -2
  4. data/docs/architecture.md +36 -5
  5. data/docs/bundle-authoring.md +125 -0
  6. data/docs/cli.md +51 -3
  7. data/docs/configuration.md +6 -5
  8. data/docs/guardrails.md +112 -39
  9. data/docs/hooks.md +19 -3
  10. data/docs/internals/anthropic.md +2 -2
  11. data/docs/internals/bundles.md +11 -2
  12. data/docs/internals/context-telemetry.md +19 -1
  13. data/docs/internals/engine.md +1 -1
  14. data/docs/memory.md +1 -1
  15. data/docs/plugins.md +59 -2
  16. data/docs/releasing.md +1 -1
  17. data/docs/sessions.md +19 -7
  18. data/docs/sub-agent.md +39 -25
  19. data/docs/testing.md +7 -2
  20. data/lib/samagotchi/answer_command.rb +21 -25
  21. data/lib/samagotchi/bridge/client_event.rb +50 -0
  22. data/lib/samagotchi/bridge/sse_writer.rb +6 -2
  23. data/lib/samagotchi/bridge.rb +22 -11
  24. data/lib/samagotchi/bridge_client.rb +2 -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/known-names/hooks/known_names.rb +3 -3
  36. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  37. data/lib/samagotchi/bundles/ruby/manifest.yml +11 -0
  38. data/lib/samagotchi/bundles/ruby/plugin.rb +74 -0
  39. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  40. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  41. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  42. data/lib/samagotchi/cli/parent_wait.rb +3 -1
  43. data/lib/samagotchi/client_id.rb +1 -1
  44. data/lib/samagotchi/config.rb +195 -115
  45. data/lib/samagotchi/context_status.rb +1 -1
  46. data/lib/samagotchi/continue_offer.rb +2 -1
  47. data/lib/samagotchi/engine.rb +4 -4
  48. data/lib/samagotchi/guardrail_wiring.rb +4 -0
  49. data/lib/samagotchi/guardrails/approval.rb +12 -2
  50. data/lib/samagotchi/guardrails/load_failures.rb +17 -3
  51. data/lib/samagotchi/guardrails/parent_approvals.rb +14 -70
  52. data/lib/samagotchi/guardrails/parent_policy.rb +327 -0
  53. data/lib/samagotchi/guardrails/verdict.rb +6 -3
  54. data/lib/samagotchi/hook_routes.rb +5 -3
  55. data/lib/samagotchi/hooks/bundle_loader.rb +3 -1
  56. data/lib/samagotchi/hooks/registry.rb +13 -6
  57. data/lib/samagotchi/hooks.rb +5 -0
  58. data/lib/samagotchi/kernel_loop.rb +5 -2
  59. data/lib/samagotchi/llm/anthropic_assembly.rb +1 -1
  60. data/lib/samagotchi/memory_bundle/shas.rb +127 -0
  61. data/lib/samagotchi/memory_bundle.rb +1 -0
  62. data/lib/samagotchi/parent_answers_command.rb +80 -0
  63. data/lib/samagotchi/parent_report.rb +122 -30
  64. data/lib/samagotchi/pending_question.rb +8 -4
  65. data/lib/samagotchi/plugin/api.rb +16 -0
  66. data/lib/samagotchi/plugin/context.rb +11 -5
  67. data/lib/samagotchi/plugin/loader.rb +5 -3
  68. data/lib/samagotchi/question_desk.rb +39 -25
  69. data/lib/samagotchi/queued_command.rb +5 -4
  70. data/lib/samagotchi/relay.rb +5 -1
  71. data/lib/samagotchi/relay_verifier.rb +4 -5
  72. data/lib/samagotchi/reply_wait.rb +14 -5
  73. data/lib/samagotchi/send_command.rb +103 -12
  74. data/lib/samagotchi/session.rb +26 -2
  75. data/lib/samagotchi/session_commands.rb +45 -8
  76. data/lib/samagotchi/session_setup.rb +10 -8
  77. data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -8
  78. data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -12
  79. data/lib/samagotchi/terminal_ui/thinking_line.rb +5 -9
  80. data/lib/samagotchi/terminal_ui.rb +20 -11
  81. data/lib/samagotchi/testing/boundary.rb +0 -3
  82. data/lib/samagotchi/testing/bundle.rb +7 -0
  83. data/lib/samagotchi/testing/host.rb +6 -4
  84. data/lib/samagotchi/testing/install.rb +119 -0
  85. data/lib/samagotchi/testing/recorder.rb +13 -4
  86. data/lib/samagotchi/testing.rb +1 -0
  87. data/lib/samagotchi/tools/builtins.rb +2 -1
  88. data/lib/samagotchi/tools/delegate.rb +4 -1
  89. data/lib/samagotchi/tools/delegate_wait.rb +16 -7
  90. data/lib/samagotchi/tools/task_runtime.rb +5 -3
  91. data/lib/samagotchi/turn_checkpoint.rb +64 -2
  92. data/lib/samagotchi/turn_flow.rb +10 -0
  93. data/lib/samagotchi/version.rb +1 -1
  94. data/lib/samagotchi/web/app.rb +48 -10
  95. data/lib/samagotchi/web/public/chunk_router.js +8 -19
  96. data/lib/samagotchi/web/public/ctx.js +12 -26
  97. data/lib/samagotchi/web/public/stream_handlers.js +1 -1
  98. data/lib/samagotchi/web/server.rb +4 -0
  99. data/lib/samagotchi/worker.rb +27 -5
  100. data/lib/samagotchi/worker_inbound.rb +4 -4
  101. metadata +12 -2
  102. data/lib/samagotchi/guardrails/parent_continue.rb +0 -43
@@ -525,8 +525,11 @@ module Samagotchi
525
525
  end
526
526
 
527
527
  # The payload a :generation_chunk event carries: llama.cpp's last chunk
528
- # echoes the whole rendered prompt, which nothing reads and every SSE
529
- # client would get (the kernel's own reads keep the full payload).
528
+ # echoes the whole rendered prompt, which nothing reads. SSE clients get
529
+ # no payload at all (Bridge::ClientEvent), but the event is still written
530
+ # to the debug log and handed to in-process observers (the ring, which
531
+ # `?raw=1` replays, SessionMetrics, hooks), so the prompt stays out of
532
+ # it (the kernel's own reads keep the full payload).
530
533
  def event_payload(payload)
531
534
  payload.is_a?(Hash) ? payload.except("prompt") : payload
532
535
  end
@@ -102,7 +102,7 @@ module Samagotchi
102
102
 
103
103
  # +event+ as on_delta passes it on: one that carries usage gets the
104
104
  # usage so far in the OpenAI shape as its `usage` (SessionMetrics,
105
- # ContextStatus and ctx.js read that).
105
+ # ContextStatus and the Bridge's chunk `counts` read that).
106
106
  def with_usage(event)
107
107
  return event unless %w[message_start message_delta].include?(event["type"]) && (payload = usage_payload)
108
108
 
@@ -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"
@@ -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,8 @@ require "json"
5
5
 
6
6
  require_relative "cli/exit"
7
7
  require_relative "pending_question"
8
+ require_relative "session"
9
+ require_relative "guardrails/parent_policy"
8
10
 
9
11
  module Samagotchi
10
12
  # How a wait's end (ReplyWait::Result) reads to whoever runs chi as a
@@ -76,18 +78,20 @@ module Samagotchi
76
78
  # @param extra [Hash] more keys for the object (chi answer's
77
79
  # answered_here: false)
78
80
  # @return [Hash] the --format json object
79
- def json(result, session_id:, timeout: nil, extra: {})
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)
80
84
  report = { status: status(result), session_id: session_id }
81
85
  report[:turn_id] = result.turn_id unless result.turn_id.to_s.empty?
82
86
  case report[:status]
83
87
  when "answered" then report[:text] = result.text.to_s
84
88
  when "question"
85
- question = question(result.question)
89
+ question = question(result.question, strategy: strategy)
86
90
  report[:question] = question
87
91
  report[:answer_with] = answer_with(session_id, question)
88
92
  else
89
93
  report.merge!(stop_facts(result))
90
- report[:detail] = detail(result, session_id: session_id, timeout: timeout)
94
+ report[:detail] = detail(result, session_id: session_id, timeout: timeout, strategy: strategy)
91
95
  end
92
96
  report.merge(extra)
93
97
  end
@@ -105,8 +109,8 @@ module Samagotchi
105
109
  end
106
110
 
107
111
  # @return [String] the JSON object as one line
108
- def json_line(result, session_id:, timeout: nil, extra: {})
109
- 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))
110
114
  end
111
115
 
112
116
  # Print a wait's end the way the commands do: the JSON object on
@@ -115,17 +119,17 @@ module Samagotchi
115
119
  # @param command [String] "chi send", "chi answer"
116
120
  # @param extra [Hash] more keys for the JSON object (#json)
117
121
  # @return [Integer] the exit status
118
- 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)
119
123
  if json
120
- 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))
121
125
  elsif result.status == :done
122
126
  stdout.puts(result.text)
123
127
  else
124
128
  stdout.flush
125
129
  text = if result.status == :waiting_for_answer
126
- question_text(result.question, session_id: session_id)
130
+ question_text(result.question, session_id: session_id, strategy: strategy)
127
131
  else
128
- "#{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"
129
133
  end
130
134
  stderr.print("#{command}: #{text}")
131
135
  end
@@ -144,38 +148,102 @@ module Samagotchi
144
148
  # parent sees it.
145
149
  # kind is "question" for the model's own (the desk stores none),
146
150
  # "hook", "approval" or "continue" (the step-limit question, with the
147
- # turn's limit).
151
+ # turn's limit). allowed_options: the options a parent may pick, by
152
+ # +strategy+ (nil: this process's guardrails.parent_answers;
153
+ # Guardrails::ParentPolicy), when that is not all of them.
148
154
  # @return [Hash]
149
- def question(pending)
150
- pending ||= PendingQuestion.new
155
+ def question(pending, strategy: nil)
156
+ pending = PendingQuestion.from_file(pending) || PendingQuestion.new
151
157
  approval = pending.approval.is_a?(Hash) ? symbolize(pending.approval) : nil
158
+ options = Array(pending.options).map(&:to_s)
159
+ allowed = allowed_options(pending, strategy: strategy)
152
160
  {
153
161
  id: pending.id.to_s,
154
162
  kind: (pending.kind || "question").to_s,
155
163
  header: blank(pending.header),
156
164
  text: pending.question.to_s,
157
- options: Array(pending.options).map(&:to_s),
165
+ options: options,
158
166
  multi_select: !!pending.multi_select,
159
167
  allow_freeform: !!pending.allow_freeform,
168
+ allowed_options: (allowed unless allowed == options),
160
169
  limit: pending.limit,
161
170
  approval: approval&.slice(*APPROVAL_KEYS)
162
171
  }.compact
163
172
  end
164
173
 
165
174
  # The command that answers +question+; N is the parent's to fill in.
166
- # An approval's is a deny: allowing it is the user's
167
- # (Guardrails::ParentApprovals). A step-limit question's continues the
168
- # turn (its Stop form is in #question_text).
175
+ # An approval's is a deny: a wider allow is the user's (#question_text
176
+ # lists an Allow once a parent may give). A step-limit question's
177
+ # continues the turn, or stops it where a parent may not continue (its
178
+ # other form is in #question_text).
169
179
  def answer_with(session_id, question)
170
180
  return "chi answer #{session_id} --question #{question[:id]} --option Deny --text WHY" if approval?(question)
181
+ return stop_command(session_id, question) if continue?(question) && !may_continue?(question)
171
182
  return "chi answer #{session_id} --question #{question[:id]} --option Continue" if continue?(question)
172
183
 
184
+ # The one option a parent may pick (cost-cap's Stop under hard).
185
+ only = Array(question[:allowed_options])
186
+ return "chi answer #{session_id} --question #{question[:id]} --option #{option_arg(only.first)}" if only.size == 1
187
+
173
188
  "chi answer #{session_id} --question #{question[:id]} --option N"
174
189
  end
175
190
 
176
- # What a parent does with an approval.
191
+ # What a parent does with an approval it may not allow.
177
192
  DENY_AND_TELL = "deny it, and tell your user"
178
193
 
194
+ # The labels a parent may pick on +pending+ (a PendingQuestion, or a
195
+ # Hash with its fields) by +strategy+ (nil: this process's).
196
+ # @return [Array<String>]
197
+ def allowed_options(pending, strategy: nil)
198
+ pending = PendingQuestion.from_file(pending) || PendingQuestion.new
199
+ Guardrails::ParentPolicy.allowed_options(pending, strategy: policy(strategy))
200
+ end
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
+
218
+ # An approval's options a parent may allow (Allow once under soft).
219
+ def allowed_allows(question, strategy: nil)
220
+ question = question(question, strategy: strategy) unless question.is_a?(Hash) && question.key?(:text)
221
+ deny = Guardrails::Approval::DENY
222
+ question.fetch(:allowed_options, question[:options]).reject { |label| label == deny }
223
+ end
224
+
225
+ # What a parent does with an approval: deny it and tell its user, or
226
+ # give the Allow once its strategy lets it.
227
+ def approval_advice(question, strategy: nil)
228
+ allows = allowed_allows(question, strategy: strategy)
229
+ return DENY_AND_TELL if allows.empty?
230
+
231
+ "#{allows.join(" or ")} is yours to give (#{policy(strategy).label}); " \
232
+ "anything wider is up to your user: #{DENY_AND_TELL}"
233
+ end
234
+
235
+ # Whether a parent may answer Continue to a step-limit question.
236
+ def may_continue?(question, strategy: nil)
237
+ question = question(question, strategy: strategy) unless question.is_a?(Hash) && question.key?(:text)
238
+ question.fetch(:allowed_options, question[:options]).include?(Guardrails::ParentPolicy::CONTINUE)
239
+ end
240
+
241
+ def stop_command(session_id, question)
242
+ "chi answer #{session_id} --question #{question[:id]} --option Stop --text WHY"
243
+ end
244
+
245
+ def option_arg(label) = label.match?(/\A[\w.-]+\z/) ? label : label.inspect
246
+
179
247
  # The other way: the user answers it in chi (the web's bell and badge
180
248
  # say it waits), and a wait with no message waits past it.
181
249
  def leave_open(session_id)
@@ -203,20 +271,23 @@ module Samagotchi
203
271
  end
204
272
 
205
273
  # What a wait that ended without an answer says, one line.
206
- def detail(result, session_id:, timeout: nil, turn_id: nil)
274
+ def detail(result, session_id:, timeout: nil, turn_id: nil, strategy: nil)
207
275
  attach = "chi --attach #{session_id}"
208
276
  case result.status
209
277
  when :waiting_for_answer
210
- q = question(result.question)
278
+ q = question(result.question, strategy: strategy)
211
279
  if approval?(q)
212
- "waiting for an approval: #{first_line(q[:text])}; #{DENY_AND_TELL}"
280
+ "waiting for an approval: #{first_line(q[:text])}; #{approval_advice(q, strategy: strategy)}"
281
+ elsif continue?(q) && !may_continue?(q)
282
+ "waiting at the step limit: #{first_line(q[:text])}; a parent may only stop it here " \
283
+ "(#{policy(strategy).label}): #{stop_command(session_id, q)}, or tell your user it waits"
213
284
  elsif continue?(q)
214
285
  "waiting at the step limit: #{first_line(q[:text])}; " \
215
286
  "answer Continue or Stop: chi answer #{session_id} --question #{q[:id]} --option Continue"
216
287
  else
217
288
  "waiting for an answer: #{first_line(q[:text])}; open it: #{attach} or the web"
218
289
  end
219
- 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"
220
291
  when :error then "the worker failed: #{result.text}; #{attach} shows what happened"
221
292
  when :worker_gone then "the worker is gone; #{attach} shows what happened"
222
293
  when :stopped then "the session was stopped (chi sessions stop)"
@@ -230,40 +301,61 @@ module Samagotchi
230
301
  # The whole question for stderr: header, text, numbered options, what
231
302
  # else it takes, and the commands that answer it.
232
303
  # @return [String] lines, newline-terminated
233
- def question_text(pending, session_id:)
234
- q = question(pending)
304
+ def question_text(pending, session_id:, strategy: nil)
305
+ q = question(pending, strategy: strategy)
235
306
  lines = ["waiting for an answer (#{q[:kind]}): #{q[:header] || first_line(q[:text])}"]
236
307
  text_lines = q[:text].strip.lines.map(&:rstrip)
237
308
  text_lines = text_lines.drop(1) unless q[:header]
238
309
  lines.concat(text_lines.map { |line| " #{line}" })
239
310
  q[:options].each_with_index { |option, i| lines << " #{i + 1}. #{option}" }
240
311
  if approval?(q)
241
- lines << " allowing it is up to your user: #{DENY_AND_TELL}"
312
+ allows = allowed_allows(q)
313
+ if allows.empty?
314
+ lines << " allowing it is up to your user: #{DENY_AND_TELL}"
315
+ else
316
+ lines << " #{approval_advice(q, strategy: strategy)}"
317
+ allows.each { |label| lines << " allow: chi answer #{session_id} --question #{q[:id]} --option #{option_arg(label)}" }
318
+ end
242
319
  lines << " deny: #{answer_with(session_id, q)}"
243
320
  lines << " #{leave_open(session_id)}"
244
321
  elsif continue?(q)
245
- lines << " continue: #{answer_with(session_id, q)}"
246
- lines << " stop: chi answer #{session_id} --question #{q[:id]} --option Stop --text WHY (the text is optional; the model reads it)"
322
+ lines << if may_continue?(q)
323
+ " continue: #{answer_with(session_id, q)}"
324
+ else
325
+ " continuing it is up to your user (#{policy(strategy).label}): stop it, or tell your user it waits"
326
+ end
327
+ lines << " stop: #{stop_command(session_id, q)} (the text is optional; the model reads it)"
247
328
  lines << " #{message_drops(session_id)}"
248
329
  else
249
330
  lines << " more than one allowed: repeat --option" if q[:multi_select]
250
331
  lines << " free text allowed: --text" if q[:allow_freeform]
332
+ if q[:allowed_options]
333
+ lines << " a parent may pick #{q[:allowed_options].map { |o| option_arg(o) }.join(", ")}; the others are up to your user"
334
+ end
251
335
  lines << " answer: #{answer_with(session_id, q)}"
252
336
  lines << " or open it: chi --attach #{session_id} or the web"
253
337
  end
254
338
  lines.map { |line| "#{line}\n" }.join
255
339
  end
256
340
 
257
- def no_reply_line(result, session_id = nil)
341
+ def no_reply_line(result, session_id = nil, strategy: nil)
258
342
  case result.outcome
259
343
  when "failed" then failed_line(result)
260
- when "canceled" then "the turn was canceled"
344
+ when "canceled"
345
+ # A turn its worker died in, settled by the next (TurnCheckpoint.settle).
346
+ result.cancel_reason == "worker_stopped" ? "the turn was canceled: its worker exited mid-turn" : "the turn was canceled"
261
347
  when "completed" then "the turn ended with no visible answer"
262
348
  when "not_continued" then "the turn was not continued (Stop); its work so far stays"
263
349
  when "removed" then "the queued message was removed before it ran; no turn ran for it"
264
350
  when "exhausted"
265
- "the turn ran out of iterations#{" (#{result.limit} steps)" if result.limit} before it answered; " \
266
- "chi send #{session_id} -m '/continue yes' continues it, a message drops it"
351
+ ran_out = "the turn ran out of iterations#{" (#{result.limit} steps)" if result.limit} before it answered"
352
+ strategy = policy(strategy)
353
+ if strategy.allow?(Guardrails::ParentPolicy::SPEND)
354
+ "#{ran_out}; chi send #{session_id} -m '/continue yes' continues it, a message drops it"
355
+ else
356
+ # No /continue yes hint: a parent may not continue it here.
357
+ "#{ran_out}; continuing it is up to your user (#{strategy.label}), a message drops it"
358
+ end
267
359
  else "the turn ended without a reply (canceled, failed or empty)"
268
360
  end
269
361
  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
@@ -355,15 +355,21 @@ module Samagotchi
355
355
 
356
356
  # A single-select question through the question flow. Inside a
357
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.
358
363
  # @return [Hash, nil] {selected:, freeform:, selected_indices:}, or nil
359
- # (no one to ask, cancelled, bad options)
360
- 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 }
361
367
  if (helper = event_helper(:ask_user))
362
- 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)
363
369
  end
364
370
 
365
371
  @host.ask_user.call(question: question, options: options, header: header, allow_freeform: allow_freeform,
366
- hook: @label)
372
+ hook: @label, **tags)
367
373
  end
368
374
 
369
375
  # Whether the running turn was cancelled (a long tool should stop).
@@ -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