samagotchi 0.91.0 → 0.93.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -1
  3. data/README.md +5 -2
  4. data/bin/chi +8 -8
  5. data/docs/architecture.md +20 -16
  6. data/docs/bundle-authoring.md +2 -1
  7. data/docs/cli.md +5 -5
  8. data/docs/configuration.md +3 -1
  9. data/docs/context.md +5 -3
  10. data/docs/guardrails.md +4 -5
  11. data/docs/hooks.md +3 -3
  12. data/docs/internals/background-tasks.md +2 -2
  13. data/docs/internals/bundles.md +4 -4
  14. data/docs/internals/engine.md +27 -8
  15. data/docs/internals/tool-guardrails.md +10 -2
  16. data/docs/memory.md +6 -0
  17. data/docs/plugins.md +30 -13
  18. data/docs/releasing.md +3 -0
  19. data/docs/sessions.md +4 -4
  20. data/docs/testing.md +2 -1
  21. data/lib/samagotchi/bootstrap/probe.rb +2 -2
  22. data/lib/samagotchi/bridge.rb +8 -9
  23. data/lib/samagotchi/bundle_command/check.rb +43 -12
  24. data/lib/samagotchi/bundle_command/scaffold.rb +12 -0
  25. data/lib/samagotchi/bundle_command/test_run.rb +12 -0
  26. data/lib/samagotchi/bundle_command/try/command.rb +164 -0
  27. data/lib/samagotchi/bundle_command/try/render.rb +80 -0
  28. data/lib/samagotchi/bundle_command/try/run.rb +35 -17
  29. data/lib/samagotchi/bundle_command/try/sandbox.rb +113 -34
  30. data/lib/samagotchi/bundle_command/try/trace.rb +20 -26
  31. data/lib/samagotchi/bundle_command.rb +11 -213
  32. data/lib/samagotchi/bundle_session_settings.rb +82 -45
  33. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  34. data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -5
  35. data/lib/samagotchi/bundles/cost-cap/plugin.rb +57 -12
  36. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  37. data/lib/samagotchi/child_env.rb +50 -0
  38. data/lib/samagotchi/child_reports.rb +12 -14
  39. data/lib/samagotchi/client_id.rb +18 -0
  40. data/lib/samagotchi/config/restart_watch.rb +112 -0
  41. data/lib/samagotchi/config.rb +18 -59
  42. data/lib/samagotchi/engine.rb +26 -26
  43. data/lib/samagotchi/guardrails/data_readers.rb +34 -30
  44. data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
  45. data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
  46. data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
  47. data/lib/samagotchi/hook_question.rb +21 -0
  48. data/lib/samagotchi/hook_routes.rb +6 -6
  49. data/lib/samagotchi/hooks/registry.rb +7 -5
  50. data/lib/samagotchi/host_registry.rb +1 -1
  51. data/lib/samagotchi/kernel_loop.rb +14 -3
  52. data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
  53. data/lib/samagotchi/llm/chat_loop.rb +3 -1
  54. data/lib/samagotchi/llm/errors.rb +4 -4
  55. data/lib/samagotchi/memory_bundle/builder.rb +3 -2
  56. data/lib/samagotchi/memory_bundle/installed_bundle.rb +7 -2
  57. data/lib/samagotchi/memory_bundle/installer.rb +1 -0
  58. data/lib/samagotchi/memory_bundle/manifest.rb +42 -8
  59. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  60. data/lib/samagotchi/parent_answers_command.rb +6 -6
  61. data/lib/samagotchi/parent_report.rb +12 -0
  62. data/lib/samagotchi/plugin/context.rb +6 -5
  63. data/lib/samagotchi/plugin/http_transport.rb +14 -3
  64. data/lib/samagotchi/plugin/sessions.rb +2 -3
  65. data/lib/samagotchi/question_desk.rb +12 -9
  66. data/lib/samagotchi/reply_wait/poll.rb +267 -0
  67. data/lib/samagotchi/reply_wait.rb +51 -190
  68. data/lib/samagotchi/sandbox_layout.rb +66 -0
  69. data/lib/samagotchi/send_command/parent_gate.rb +83 -0
  70. data/lib/samagotchi/send_command/wait_target.rb +55 -0
  71. data/lib/samagotchi/send_command.rb +34 -103
  72. data/lib/samagotchi/session.rb +10 -9
  73. data/lib/samagotchi/session_inbox.rb +31 -8
  74. data/lib/samagotchi/session_manager.rb +60 -35
  75. data/lib/samagotchi/session_metrics.rb +22 -3
  76. data/lib/samagotchi/sessions_command.rb +4 -4
  77. data/lib/samagotchi/task_watch.rb +64 -21
  78. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
  79. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  80. data/lib/samagotchi/testing/bundle.rb +33 -20
  81. data/lib/samagotchi/testing/host.rb +2 -8
  82. data/lib/samagotchi/testing/install.rb +26 -17
  83. data/lib/samagotchi/testing/recorder.rb +6 -9
  84. data/lib/samagotchi/testing/sandbox.rb +45 -37
  85. data/lib/samagotchi/testing/waiting.rb +5 -7
  86. data/lib/samagotchi/tool_response.rb +28 -6
  87. data/lib/samagotchi/tools/delegate.rb +10 -10
  88. data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
  89. data/lib/samagotchi/tools/delegate_relay.rb +31 -16
  90. data/lib/samagotchi/tools/delegate_wait.rb +4 -4
  91. data/lib/samagotchi/tools/task_runtime.rb +2 -2
  92. data/lib/samagotchi/turn_checkpoint.rb +23 -10
  93. data/lib/samagotchi/version.rb +1 -1
  94. data/lib/samagotchi/web/app.rb +46 -63
  95. data/lib/samagotchi/web/public/app.js +130 -554
  96. data/lib/samagotchi/web/public/cards_place.js +4 -1
  97. data/lib/samagotchi/web/public/images.js +6 -0
  98. data/lib/samagotchi/web/public/index.html +1 -1
  99. data/lib/samagotchi/web/public/model_pick.js +10 -0
  100. data/lib/samagotchi/web/public/send_turn.js +173 -0
  101. data/lib/samagotchi/web/public/session_actions.js +203 -0
  102. data/lib/samagotchi/web/public/start_page.js +236 -0
  103. data/lib/samagotchi/web/public/toasts.js +17 -9
  104. data/lib/samagotchi/web/public/unqueue.js +20 -4
  105. data/lib/samagotchi/worker.rb +77 -185
  106. data/lib/samagotchi/worker_idle_exit.rb +20 -5
  107. data/lib/samagotchi/worker_inbound.rb +5 -5
  108. data/lib/samagotchi/worker_task_wakes.rb +160 -0
  109. data/lib/samagotchi/worker_wakes.rb +0 -3
  110. metadata +15 -1
@@ -32,17 +32,30 @@ module Samagotchi
32
32
 
33
33
  # Where command_word's word is in +words+ (nil when there is none).
34
34
  def command_index(words)
35
- i = 0
35
+ each_argument_index(words).find { |i| !assignment?(words[i]) }
36
+ end
37
+
38
+ # Each index of +words+ from +from+ on that is no redirection and no
39
+ # spaced redirection's target (the "/tmp/x" of "> /tmp/x"); an
40
+ # Enumerator without a block.
41
+ def each_argument_index(words, from = 0)
42
+ return enum_for(:each_argument_index, words, from) unless block_given?
43
+
44
+ i = from
36
45
  while i < words.size
37
46
  word = words[i]
38
47
  if word.match?(REDIRECT)
39
- i += 1 if word.match?(/\A\d*[<>]{1,2}\z/)
40
- elsif !word.match?(/\A[A-Za-z_]\w*=/)
41
- return i
48
+ i += word.match?(/\A\d*[<>]{1,2}\z/) ? 2 : 1
49
+ next
42
50
  end
51
+ yield i
43
52
  i += 1
44
53
  end
45
- nil
54
+ end
55
+
56
+ # Whether a word is a NAME=value assignment.
57
+ def assignment?(word)
58
+ word.match?(/\A[A-Za-z_]\w*=/)
46
59
  end
47
60
 
48
61
  # Whether a cat or tee command sends its output to a file (a file
@@ -62,8 +75,21 @@ module Samagotchi
62
75
  # ">& -". A >& with any other target does write one: bash reads
63
76
  # ">& file" and ">&file" as "&>file".
64
77
  def stdout_file?(words)
78
+ redirects_to_file?(words, /\A1?(>>?)(&?)(.*)\z/m)
79
+ end
80
+
81
+ # Whether one of +words+ sends any fd's output to a file (3>run.sh,
82
+ # 2>>log, {fd}>run.sh, 3<>run.sh as well as stdout_file?'s forms),
83
+ # /dev/null and fd dups left out as there.
84
+ def output_file?(words)
85
+ redirects_to_file?(words, /\A(?:\d*|\{\w+\})(>>?|<>)(&?)(.*)\z/m)
86
+ end
87
+
88
+ # stdout_file? and output_file?: a word matching +pattern+ (its
89
+ # captures: the operator, an &, the target) that writes a file.
90
+ def redirects_to_file?(words, pattern)
65
91
  words.each_with_index do |word, i|
66
- match = word.match(/\A1?(>>?)(&?)(.*)\z/m)
92
+ match = word.match(pattern)
67
93
  next unless match
68
94
 
69
95
  amp = match[2]
@@ -84,18 +110,7 @@ module Samagotchi
84
110
  verb = command_index(words)
85
111
  return false unless verb
86
112
 
87
- i = verb + 1
88
- while i < words.size
89
- word = words[i]
90
- if word.match?(REDIRECT)
91
- i += word.match?(/\A\d*[<>]{1,2}\z/) ? 2 : 1
92
- next
93
- end
94
- return true if filename_word?(word)
95
-
96
- i += 1
97
- end
98
- false
113
+ each_argument_index(words, verb + 1).any? { |i| filename_word?(words[i]) }
99
114
  end
100
115
 
101
116
  # Whether a tee argument names a file: not an option, a heredoc's tag,
@@ -139,18 +154,7 @@ module Samagotchi
139
154
  # their spaced targets), the <<TAG words and the heredoc bodies left
140
155
  # out.
141
156
  def arguments(words)
142
- args = []
143
- i = 0
144
- while i < words.size
145
- word = words[i]
146
- if word.match?(REDIRECT)
147
- i += word.match?(/\A\d*[<>]{1,2}\z/) ? 2 : 1
148
- next
149
- end
150
- args << word unless word.match?(/\A[A-Za-z_]\w*=/) || heredoc_word?(word)
151
- i += 1
152
- end
153
- args
157
+ each_argument_index(words).map { |i| words[i] }.reject { |word| assignment?(word) || heredoc_word?(word) }
154
158
  end
155
159
 
156
160
  # Whether a word is a heredoc's <<TAG or one of its bodies.
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Guardrails
5
+ # The parent-answers level words and their one reading (#parse_level),
6
+ # apart from the rest of ParentPolicy so Session (its parent_answers
7
+ # setter) reads them without loading the guardrails.
8
+ module ParentPolicy
9
+ HARD = "hard"
10
+ STANDARD = "standard"
11
+ SOFT = "soft"
12
+ # Strictest first: a session's own level may only move left of
13
+ # config.yml's.
14
+ ORDER = [HARD, STANDARD, SOFT].freeze
15
+ # A session value that means "none of its own" (config's).
16
+ UNSET = "default"
17
+
18
+ # A level word as written anywhere (a session file, /parent-answers,
19
+ # --parent-answers), stripped and case-blind.
20
+ # @param word [String, Symbol, nil]
21
+ # @return [String, Symbol] the level, :unset (nil, "" or "default":
22
+ # config's) or :invalid
23
+ def self.parse_level(word)
24
+ text = word.to_s.strip.downcase
25
+ return :unset if text.empty? || text == UNSET
26
+
27
+ ORDER.include?(text) ? text : :invalid
28
+ end
29
+ end
30
+ end
31
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative "../config"
4
4
  require_relative "../pending_question"
5
5
  require_relative "approval"
6
+ require_relative "parent_levels"
6
7
  require_relative "parent_approvals"
7
8
 
8
9
  module Samagotchi
@@ -49,9 +50,8 @@ module Samagotchi
49
50
  KEY = "guardrails.parent_answers"
50
51
  # A session's own value (Session#parent_answers): it may only tighten KEY.
51
52
  SESSION_KEY = "parent_answers"
52
- HARD = "hard"
53
- STANDARD = "standard"
54
- SOFT = "soft"
53
+ # The level words (HARD, STANDARD, SOFT, ORDER, UNSET) and
54
+ # #parse_level: parent_levels.rb.
55
55
 
56
56
  # A strategy: its name, the effects a parent may pick under it, and
57
57
  # where it comes from: :config (config.yml's) or :session (a
@@ -83,12 +83,6 @@ module Samagotchi
83
83
  # @return [Strategy]
84
84
  def strategy(name) = STRATEGIES.fetch(name.to_s, STRATEGIES[HARD])
85
85
 
86
- # Strictest first: a session's own level may only move left of
87
- # config.yml's.
88
- ORDER = [HARD, STANDARD, SOFT].freeze
89
- # A session value that means "none of its own" (config's).
90
- UNSET = "default"
91
-
92
86
  # The stricter of two strategies, by name; a tie keeps +a+.
93
87
  # @return [Strategy]
94
88
  def stricter(a, b) = ORDER.index(b.name) < ORDER.index(a.name) ? b : a
@@ -99,18 +93,16 @@ module Samagotchi
99
93
  # is read on every parent answer).
100
94
  # @return [String, nil]
101
95
  def session_level(value)
102
- word = value.to_s.strip.downcase
103
- return nil if word.empty? || word == UNSET
104
-
105
- STRATEGIES.key?(word) ? word : HARD
96
+ case (level = parse_level(value))
97
+ when :unset then nil
98
+ when :invalid then HARD
99
+ else level
100
+ end
106
101
  end
107
102
 
108
103
  # Whether a session's own value is set but not a level (a hand-edited
109
104
  # file: read as hard); the worker says so once when it loads.
110
- def unknown_level?(value)
111
- word = value.to_s.strip.downcase
112
- !word.empty? && word != UNSET && !STRATEGIES.key?(word)
113
- end
105
+ def unknown_level?(value) = parse_level(value) == :invalid
114
106
 
115
107
  # What a parent may answer on a session whose own value is
116
108
  # +session_value+: config.yml's (+config+) unless the session's is
@@ -150,9 +142,6 @@ module Samagotchi
150
142
  level.nil? ? ORDER.size : ORDER.index(level)
151
143
  end
152
144
 
153
- # @return [Array<String>] the effects +strategy+ lets a parent pick
154
- def allowed_effects(strategy) = strategy.allowed
155
-
156
145
  # Each option's effect, in option order. Tagged questions carry them;
157
146
  # an untagged one (asked by an older worker, a relayed card built
158
147
  # from an older child's question) gets them derived as before:
@@ -95,6 +95,7 @@ module Samagotchi
95
95
 
96
96
  bodies = lexer.heredoc_spans.map { |doc| doc[:span] }
97
97
  cuts = []
98
+ redirected = false
98
99
  each_command(tokens) do |indices|
99
100
  words = indices.map { |i| tokens[i][1] }
100
101
  indices.each_with_index do |i, position|
@@ -103,12 +104,13 @@ module Samagotchi
103
104
 
104
105
  span = lexer.spans[i]
105
106
  cut = if bodies.include?(span)
106
- body_cut(text, span, words) if value == HEREDOC
107
+ body_cut(text, span, words, redirected) if value == HEREDOC
107
108
  elsif value == SUBST
108
109
  substitution_cut(text, tokens, i, span, words, position)
109
110
  end
110
111
  cuts << cut if cut
111
112
  end
113
+ redirected ||= exec_redirect?(tokens, indices, words)
112
114
  end
113
115
  cuts.sort_by { |span, _| -span.begin }.each_with_object(+text) do |(span, replacement), out|
114
116
  out[span] = replacement
@@ -134,25 +136,44 @@ module Samagotchi
134
136
 
135
137
  # [span, what it becomes] for a data heredoc's body at +span+ (its
136
138
  # command's +words+), or nil when the body stays in the code text: a
137
- # cat or tee writing a file may be run later (D1).
138
- def self.body_cut(text, span, words)
139
- return nil unless cut_body?(words)
139
+ # cat or tee writing a file may be run later (D1), and so may one
140
+ # printing after an earlier exec sent the shell's output to a file
141
+ # (+redirected+).
142
+ def self.body_cut(text, span, words, redirected)
143
+ return nil unless cut_body?(words, redirected)
140
144
 
141
145
  [span, terminator_line(text[span])]
142
146
  end
143
147
  private_class_method :body_cut
144
148
 
145
149
  # Whether a body a data reader reads leaves the code text: a cat or tee
146
- # sending it to a file keeps it (D1); a body any other command reads as
150
+ # sending it to a file keeps it (D1), as does any cat or tee once an
151
+ # exec redirected the shell's output (+redirected+: its stdout, or the
152
+ # fd a >&3 names, may be a file); a body any other command reads as
147
153
  # data is one the lexer's data_sink? approved.
148
- def self.cut_body?(words)
154
+ def self.cut_body?(words, redirected)
149
155
  verb = DataReaders.command_word(words)
150
156
  return true unless DataReaders::SINKS.include?(File.basename(verb.to_s))
151
157
 
152
- !DataReaders.writes_file?(words)
158
+ !redirected && !DataReaders.writes_file?(words)
153
159
  end
154
160
  private_class_method :cut_body?
155
161
 
162
+ # Whether the simple command at +indices+ may be an exec that redirects
163
+ # the shell's own output to a file (exec >run.sh, exec 3>run.sh,
164
+ # exec {fd}>run.sh, exec 3<>run.sh): what a later cat or tee prints may
165
+ # land there. Any exec word counts, not only the command word ({ exec
166
+ # …; }, then exec …, command exec …, a function's body), and so does an
167
+ # exec backgrounded (exec &>run.sh, which the lexer reads as "exec &"
168
+ # and ">run.sh"): the conservative reading.
169
+ def self.exec_redirect?(tokens, indices, words)
170
+ return false unless words.include?("exec")
171
+
172
+ following = tokens[indices.last + 1]
173
+ DataReaders.output_file?(words) || following == [:op, "&"]
174
+ end
175
+ private_class_method :exec_redirect?
176
+
156
177
  # [span, what it becomes] for the body of a "$(cat <<'EOF' … EOF)" given
157
178
  # as a message (-m, --body, …) of a data reader (D2), or nil: eval,
158
179
  # bash -c, a command piped on and a body that runs stay.
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # A hook's or a plugin's question (event[:ask_user], ctx.ask_user) as one
5
+ # value on its way to the question flow: built at the plugin-facing edge
6
+ # (Hooks::Registry's event helper, Plugin::Context#ask_user), passed
7
+ # through the hook runtime and the plugin host (Hooks::Runtime#ask_user,
8
+ # Plugin::Host#ask_user), Engine#hook_ask_user and HookRoutes#ask_user to
9
+ # QuestionDesk#ask_for_hook. The bundle test kit records it as asked
10
+ # (Testing::Recorder#questions).
11
+ #
12
+ # question, options, header, allow_freeform: as the hook gave them
13
+ # (QuestionDesk checks the options); hook: the asking hook's or plugin's
14
+ # label; effects: what an option does for a parent agent's answer, by its
15
+ # label (label => effect, as the hook gave it; nil for none). QuestionDesk
16
+ # turns it into the pending question's per-option tags
17
+ # (Guardrails::ParentPolicy.option_tags, PendingQuestion#effects).
18
+ HookQuestion = Data.define(:question, :options, :header, :allow_freeform, :hook, :effects) do
19
+ def initialize(effects: nil, **) = super
20
+ end
21
+ end
@@ -31,14 +31,13 @@ module Samagotchi
31
31
  end
32
32
 
33
33
  # The things a hook can do beyond reading its event. Each gets the
34
- # hook's label (event[:hook]) from the registry.
34
+ # hook's label (event[:hook]) from the registry; ask_user gets it inside
35
+ # the HookQuestion.
35
36
  # @return [Hooks::Runtime]
36
37
  def runtime
37
38
  Hooks::Runtime.new(
38
39
  notify: ->(text:, level:, hook:, fallback_for: nil) { notify(text, level, hook, fallback_for: fallback_for) },
39
- ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:, effects: nil|
40
- ask_user(question, options, header, allow_freeform, hook, effects: effects)
41
- },
40
+ ask_user: ->(question) { ask_user(question) },
42
41
  stop_turn: ->(reason:, hook:) { stop_turn(reason, hook) },
43
42
  steer: ->(text:, hook:) { @steer.call(text, source(hook)) },
44
43
  stop_generation: ->(reason:, hook:) { stop_generation(reason, hook) }
@@ -87,9 +86,10 @@ module Samagotchi
87
86
  # web), single-select, kind "hook". A --non-interactive run has no one
88
87
  # to ask: nil at once. Anything but an answer (a sync handler's text, no
89
88
  # answer, cancelled) is nil too.
89
+ # @param question [HookQuestion]
90
90
  # @return [Hash, nil] {selected:, freeform:, selected_indices:}
91
- def ask_user(question, options, header, allow_freeform, hook, effects: nil)
92
- @question_desk.call.ask_for_hook(question, options, header, allow_freeform, hook, effects: effects)
91
+ def ask_user(question)
92
+ @question_desk.call.ask_for_hook(question)
93
93
  end
94
94
 
95
95
  # Cancel the running turn (reason :hook), after a notice that says why.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "monitor"
4
+ require_relative "../hook_question"
4
5
  require_relative "../log"
5
6
 
6
7
  module Samagotchi
@@ -93,6 +94,8 @@ module Samagotchi
93
94
  lambda do |payload|
94
95
  yield(payload)
95
96
  rescue Exception => e # rubocop:disable Lint/RescueException -- :deny fails closed on anything
97
+ # Ctrl-C and kill signals are the user's, whatever the policy.
98
+ raise if e.is_a?(SignalException)
96
99
  raise unless policy == :deny || e.is_a?(StandardError) || e.is_a?(ScriptError)
97
100
 
98
101
  Hooks.record_failure(label, event, e)
@@ -111,8 +114,8 @@ module Samagotchi
111
114
  # callables, each given the hook's label. +notify+ takes
112
115
  # (text:, level:, hook:, and fallback_for: when the hook gave one: what
113
116
  # the line stands in for, HookRoutes#notify) and shows one line to the
114
- # user; +ask_user+ takes (question:, options:, header:, allow_freeform:, hook:, and
115
- # effects: when the hook gave them) and returns the answer hash or nil; +stop_turn+ takes (reason:, hook:) and
117
+ # user; +ask_user+ takes a HookQuestion (the hook's label in it) and
118
+ # returns the answer hash or nil; +stop_turn+ takes (reason:, hook:) and
116
119
  # cancels the running turn (true when it did); +steer+ takes (text:,
117
120
  # hook:) and puts the text into the running turn (Engine#steer, true
118
121
  # when queued); +stop_generation+ takes (reason:, hook:) and cuts the
@@ -390,9 +393,8 @@ module Samagotchi
390
393
  nil
391
394
  }
392
395
  event[:ask_user] ||= lambda { |question:, options:, header: nil, allow_freeform: false, effects: nil|
393
- @runtime&.ask_user&.call(question: question, options: options, header: header,
394
- allow_freeform: allow_freeform, hook: event[:hook],
395
- **(effects.nil? ? {} : { effects: effects }))
396
+ @runtime&.ask_user&.call(HookQuestion.new(question: question, options: options, header: header,
397
+ allow_freeform: allow_freeform, hook: event[:hook], effects: effects))
396
398
  }
397
399
  event[:stop_turn] ||= lambda { |reason|
398
400
  next false if TURN_OVER_EVENTS.include?(event[:type])
@@ -68,7 +68,7 @@ module Samagotchi
68
68
  def api_base_url
69
69
  return openai_base_url unless api&.to_sym == :anthropic
70
70
 
71
- (url || root_url).chomp("/").delete_suffix("/v1")
71
+ LLM::AnthropicMessages.api_root(url || root_url)
72
72
  end
73
73
 
74
74
  # The header the host's key goes in: auth: when set, else the api's
@@ -243,7 +243,9 @@ module Samagotchi
243
243
  streamed_thinking: turn.streamed_thinking)
244
244
  end
245
245
  finish(turn)
246
- rescue StandardError => e
246
+ rescue StandardError, Interrupt => e
247
+ # A failure, or a Ctrl-C, carries the conversation so far: the
248
+ # Engine keeps the turn's finished iterations.
247
249
  LLM::FailedTurn.attach(e, turn && duplicate_conversation(turn.conversation))
248
250
  raise
249
251
  end
@@ -671,8 +673,17 @@ module Samagotchi
671
673
  def dispatch_calls(turn, calls)
672
674
  turn.qwen_attempts = 0
673
675
  turn.qwen_partial = nil
674
- runs = ToolResponse.run_batch(tool_runner, calls, iteration: turn.iteration, emit: turn.emit,
675
- on_stream_event: turn.on_stream_event, cap: turn.cap)
676
+ runs = []
677
+ begin
678
+ ToolResponse.run_batch(tool_runner, calls, iteration: turn.iteration, emit: turn.emit,
679
+ on_stream_event: turn.on_stream_event, cap: turn.cap) { |run, _| runs << run }
680
+ rescue Interrupt
681
+ # A Ctrl-C mid-batch: the entry still answers every call (the
682
+ # finished runs, then run_batch's interrupted ones), for #run to
683
+ # hand on with the conversation.
684
+ turn.conversation << ToolResponse.joined(runs, ids: ToolIds.next_ids(turn.conversation, runs.size))
685
+ raise
686
+ end
676
687
  turn.tool_activity.concat(ToolResponse.activities(runs))
677
688
  # One entry for the batch: the capped outputs joined (ToolResponse.joined),
678
689
  # an id per run (ToolIds).
@@ -87,6 +87,11 @@ module Samagotchi
87
87
 
88
88
  attr_reader :base_url, :host_name, :api_key_env, :models_ttl, :first_token_timeout, :dialect
89
89
 
90
+ # The Messages API root a base URL names: without a trailing / and a
91
+ # trailing /v1 (as ANTHROPIC_BASE_URL), so requests never go to
92
+ # /v1/v1. HostEntry#api_base_url and the bootstrap probe both use it.
93
+ def self.api_root(url) = url.to_s.chomp("/").delete_suffix("/v1")
94
+
90
95
  # @param entry [HostRegistry::HostEntry]
91
96
  def self.for(entry, **)
92
97
  new(base_url: entry.api_base_url, host_name: entry.name, api_key_env: entry.api_key_env, auth: entry.auth,
@@ -90,7 +90,9 @@ module Samagotchi
90
90
  conversation = Array(messages).map(&:dup)
91
91
  Run.new(self, conversation, on_stream_event, cancel_controller, model_name, pending_input, start: reasoning_since)
92
92
  .call(max_iterations: max_iterations || 1, cap: KernelLoop.resolve_output_char_cap(max_tool_output_chars))
93
- rescue StandardError => e
93
+ rescue StandardError, Interrupt => e
94
+ # A failure, or a Ctrl-C (its calls answered: ToolResponse.run_batch),
95
+ # carries the conversation so far for the Engine to keep.
94
96
  FailedTurn.attach(e, conversation && plain(conversation))
95
97
  raise
96
98
  end
@@ -547,10 +547,10 @@ module Samagotchi
547
547
  end
548
548
  end
549
549
 
550
- # Marks an error that ended a turn with the conversation the loop had
551
- # built by then (the prompt plus completed tool iterations; a half
552
- # streamed reply is not kept), so the caller can keep it, as a cancel's
553
- # salvage does.
550
+ # Marks an error that ended a turn (or the Interrupt of a Ctrl-C) with
551
+ # the conversation the loop had built by then (the prompt plus completed
552
+ # tool iterations; a half streamed reply is not kept), so the caller can
553
+ # keep it, as a cancel's salvage does.
554
554
  module FailedTurn
555
555
  # +kept_steps+: set by the Engine when the turn's work stays
556
556
  # (Engine#failed_messages): the tool results it got to; nil when it
@@ -202,7 +202,7 @@ module Samagotchi
202
202
  parts.scripts.each_value { |src| FileUtils.cp(src, File.join(staging, "scripts", File.basename(src))) }
203
203
  end
204
204
 
205
- # manifest.yml for the staged bundle: requires_chi, needs, session_settings and the
205
+ # manifest.yml for the staged bundle: requires_chi, needs, session_settings (typed) and the
206
206
  # file descriptions come from the installed bundle's record, the
207
207
  # trust_level from the CLI else the record.
208
208
  def write_manifest(staging, name, version, files_map, parts)
@@ -224,7 +224,8 @@ module Samagotchi
224
224
  scripts: parts.scripts.transform_values { |src| "sha256:#{Digest::SHA256.hexdigest(File.binread(src))}" },
225
225
  context_providers: parts.context_providers,
226
226
  file_descriptions: file_descriptions(record, files_map),
227
- session_settings: record&.session_settings || []
227
+ session_settings: record&.session_settings || [],
228
+ session_setting_types: record&.session_setting_types || {}
228
229
  )
229
230
  end
230
231
 
@@ -22,15 +22,19 @@ module Samagotchi
22
22
  # includes a profile's recorded members, nil for a plain bundle
23
23
  # session_settings
24
24
  # the keys a session may set (chi --set), [] when none
25
+ # session_setting_types
26
+ # {key => type} for the typed ones (Manifest::SESSION_SETTING_TYPES), {} when none
25
27
  # dev a dev install (`chi bundle install --dev`): its source is
26
28
  # the folder it links to and its files carry Provenance::DEV_SHA
27
29
  # error why manifest.json couldn't be read (the rest empty), else nil
28
30
  class InstalledBundle < Data.define(:name, :version, :scope, :source, :installed_at, :trust_level, :source_commit,
29
31
  :requires_chi, :files, :hooks, :guardrails, :scripts, :plugin, :needs,
30
- :includes, :context_providers, :session_settings, :dev, :error)
32
+ :includes, :context_providers, :session_settings, :session_setting_types, :dev,
33
+ :error)
31
34
  OPTIONAL = { version: nil, scope: nil, source: nil, installed_at: nil, trust_level: nil, source_commit: nil,
32
35
  requires_chi: nil, files: {}, hooks: {}, guardrails: {}, scripts: {}, plugin: nil, needs: nil,
33
- includes: nil, context_providers: nil, session_settings: [], dev: false, error: nil }.freeze
36
+ includes: nil, context_providers: nil, session_settings: [], session_setting_types: {}, dev: false,
37
+ error: nil }.freeze
34
38
 
35
39
  def initialize(name:, **fields)
36
40
  super(name: name, **OPTIONAL, **fields)
@@ -63,6 +67,7 @@ module Samagotchi
63
67
  plugin: PluginRef.parse(h["plugin"]), needs: h["needs"], context_providers: h["context_providers"],
64
68
  includes: h["includes"].is_a?(Array) ? h["includes"].map(&:to_s) : nil,
65
69
  session_settings: h["session_settings"].is_a?(Array) ? h["session_settings"].map(&:to_s) : [],
70
+ session_setting_types: mapping(h["session_setting_types"]) { |type| type.to_s },
66
71
  dev: h["dev"] == true)
67
72
  end
68
73
 
@@ -440,6 +440,7 @@ module Samagotchi
440
440
  context_providers: @manifest.context_providers,
441
441
  file_descriptions: @manifest.file_descriptions,
442
442
  session_settings: @manifest.session_settings,
443
+ session_setting_types: @manifest.session_setting_types,
443
444
  dev: @dev
444
445
  )
445
446
  end
@@ -43,7 +43,9 @@ module Samagotchi
43
43
  # every_seconds: 300 # optional
44
44
  # session_settings: [cap] # optional — the settings a session may set
45
45
  # # (chi --set cost-cap.cap=3), read by the
46
- # # plugin from ctx.settings at call time
46
+ # # plugin from ctx.settings at call time;
47
+ # # or {cap: number}: each with the type
48
+ # # --set checks (SESSION_SETTING_TYPES)
47
49
  # includes: [a, b] # optional — a profile (meta bundle): the
48
50
  # # shipped bundles it installs; its dir holds
49
51
  # # only manifest.yml (MemoryBundle::Profile)
@@ -63,7 +65,7 @@ module Samagotchi
63
65
  # the description a mapping gave, for the files that have one; hooks:
64
66
  # name → BundleHook.
65
67
  attr_reader :name, :version, :scope, :description, :files, :file_descriptions, :hooks, :trust_level, :plugin, :requires_chi, :needs, :includes,
66
- :scripts, :context_providers, :session_settings
68
+ :scripts, :context_providers, :session_settings, :session_setting_types
67
69
 
68
70
  def initialize(path:)
69
71
  @path = path
@@ -88,6 +90,7 @@ module Samagotchi
88
90
  @context_providers = parse_context_providers(raw["context_providers"])
89
91
  @includes = parse_includes(raw["includes"])
90
92
  @session_settings = self.class.parse_session_settings(raw["session_settings"])
93
+ @session_setting_types = self.class.parse_session_setting_types(raw["session_settings"])
91
94
  end
92
95
 
93
96
  # A profile: it installs the bundles in includes instead of files.
@@ -141,7 +144,7 @@ module Samagotchi
141
144
  # +file_descriptions+ (name → text) writes those files as mappings.
142
145
  def self.write(dir:, name:, version:, files:, scope: nil, description: "", hooks: nil, trust_level: nil,
143
146
  plugin: nil, requires_chi: nil, needs: nil, scripts: {}, context_providers: [], file_descriptions: {},
144
- session_settings: [])
147
+ session_settings: [], session_setting_types: {})
145
148
  FileUtils.mkdir_p(dir)
146
149
  manifest = {
147
150
  "name" => name,
@@ -164,26 +167,57 @@ module Samagotchi
164
167
  manifest["needs"] = needs.map { |n| n.transform_keys(&:to_s).compact } unless needs.empty?
165
168
  manifest["scripts"] = scripts.transform_keys(&:to_s) unless scripts.empty?
166
169
  manifest["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
167
- manifest["session_settings"] = Array(session_settings).map(&:to_s) unless Array(session_settings).empty?
170
+ manifest["session_settings"] = session_settings_entry(session_settings, session_setting_types) unless Array(session_settings).empty?
168
171
  File.write(File.join(dir, "manifest.yml"), YAML.dump(manifest))
169
172
  end
170
173
 
171
174
  # A setting key a session may set (BundleSessionSettings::KEY).
172
175
  SESSION_SETTING = /\A[A-Za-z0-9_][A-Za-z0-9_-]*\z/
173
176
 
174
- # session_settings: → the keys, [] when absent; anything but a list of
175
- # plain key names is a ValidationError.
177
+ # The types a session setting may be given (session_settings: {key:
178
+ # type}), checked by BundleSessionSettings.check!: number (or off),
179
+ # boolean, string; any (or a list's key) takes every scalar.
180
+ SESSION_SETTING_TYPES = %w[number boolean string any].freeze
181
+
182
+ # session_settings: → the keys, [] when absent: a list of plain key
183
+ # names, or a mapping of them to types; anything else is a
184
+ # ValidationError.
176
185
  def self.parse_session_settings(raw)
177
186
  return [] if raw.nil?
178
187
 
179
- keys = raw.is_a?(Array) ? raw : nil
188
+ keys = raw.is_a?(Hash) && raw.values.all?(String) ? raw.keys : raw
189
+ keys = nil unless keys.is_a?(Array)
180
190
  unless keys&.all? { |key| (key.is_a?(String) || key.is_a?(Symbol)) && key.to_s.match?(SESSION_SETTING) }
181
- raise ValidationError, "session_settings: a list of setting names (#{raw.inspect})"
191
+ raise ValidationError, "session_settings: a list of setting names, or a mapping of them to types (#{raw.inspect})"
182
192
  end
183
193
 
184
194
  keys.map(&:to_s).uniq
185
195
  end
186
196
 
197
+ # session_settings: → {key => type} for the keys a mapping types
198
+ # (any left out), {} for a list or none.
199
+ # @raise [ValidationError] a type that isn't one of SESSION_SETTING_TYPES
200
+ def self.parse_session_setting_types(raw)
201
+ return {} unless raw.is_a?(Hash)
202
+
203
+ raw.each_with_object({}) do |(key, type), types|
204
+ unless SESSION_SETTING_TYPES.include?(type)
205
+ raise ValidationError, "session_settings: #{key}'s type is #{SESSION_SETTING_TYPES[0..-2].join(", ")} or " \
206
+ "#{SESSION_SETTING_TYPES.last}, not #{type.inspect}"
207
+ end
208
+ types[key.to_s] = type unless type == "any"
209
+ end
210
+ end
211
+
212
+ # session_settings: as written back: a list without types, else a
213
+ # mapping (an untyped key as any).
214
+ def self.session_settings_entry(keys, types)
215
+ keys = Array(keys).map(&:to_s)
216
+ return keys if types.nil? || types.empty?
217
+
218
+ keys.to_h { |key| [key, types[key] || "any"] }
219
+ end
220
+
187
221
  # needs: → [{command:, why:, hint:}] (why/hint a String or nil), [] when
188
222
  # absent. Takes the manifest's string-keyed YAML or the symbol-keyed
189
223
  # form provenance stores. Duplicates merge (the first one's why/hint
@@ -154,7 +154,7 @@ module Samagotchi
154
154
  # source, recorded as DEV_SHA (no sha256 check) with "dev": true
155
155
  def write(files:, scope:, version:, source_path:, hooks: {}, trust_level: nil, source_commit: nil, hooks_files: {},
156
156
  guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil, conflicts: [], includes: nil,
157
- scripts_files: {}, context_providers: [], file_descriptions: {}, session_settings: [], dev: false)
157
+ scripts_files: {}, context_providers: [], file_descriptions: {}, session_settings: [], session_setting_types: {}, dev: false)
158
158
  FileUtils.mkdir_p(@bundle_dir)
159
159
  bases_dir = File.join(@bundle_dir, "bases")
160
160
  FileUtils.mkdir_p(bases_dir)
@@ -255,6 +255,7 @@ module Samagotchi
255
255
  end
256
256
  manifest_data["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
257
257
  manifest_data["session_settings"] = Array(session_settings).map(&:to_s) unless Array(session_settings).empty?
258
+ manifest_data["session_setting_types"] = session_setting_types.to_h { |k, v| [k.to_s, v.to_s] } unless session_setting_types.to_h.empty?
258
259
 
259
260
  write_manifest(manifest_data)
260
261
  end
@@ -25,10 +25,11 @@ module Samagotchi
25
25
  return [:show] if words.empty?
26
26
  return [:usage] if words.size > 1
27
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]
28
+ case (level = Guardrails::ParentPolicy.parse_level(words.first))
29
+ when :unset then [:set, nil]
30
+ when :invalid then [:usage]
31
+ else [:set, level]
32
+ end
32
33
  end
33
34
 
34
35
  # Whether the command line +line+ loosens a session whose own value is
@@ -72,8 +73,7 @@ module Samagotchi
72
73
  effective = Guardrails::ParentPolicy.for_session(session)
73
74
  own = session.parent_answers || "none"
74
75
  # 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)
76
+ own = "#{own} (unreadable value, read as hard)" if Guardrails::ParentPolicy.unknown_level?(session.parent_answers)
77
77
  "parent answers: #{effective.name} (this session: #{own}; config.yml #{config.label})"
78
78
  end
79
79
  end