samagotchi 0.92.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -1
  3. data/bin/chi +4 -4
  4. data/docs/architecture.md +4 -4
  5. data/docs/guardrails.md +4 -5
  6. data/docs/internals/engine.md +6 -0
  7. data/docs/sessions.md +1 -1
  8. data/docs/testing.md +2 -1
  9. data/lib/samagotchi/bootstrap/probe.rb +2 -2
  10. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  11. data/lib/samagotchi/child_reports.rb +12 -14
  12. data/lib/samagotchi/client_id.rb +18 -0
  13. data/lib/samagotchi/config/restart_watch.rb +112 -0
  14. data/lib/samagotchi/config.rb +18 -59
  15. data/lib/samagotchi/engine.rb +4 -14
  16. data/lib/samagotchi/guardrails/data_readers.rb +34 -30
  17. data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
  18. data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
  19. data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
  20. data/lib/samagotchi/hook_question.rb +21 -0
  21. data/lib/samagotchi/hook_routes.rb +6 -6
  22. data/lib/samagotchi/hooks/registry.rb +5 -5
  23. data/lib/samagotchi/host_registry.rb +1 -1
  24. data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
  25. data/lib/samagotchi/parent_answers_command.rb +6 -6
  26. data/lib/samagotchi/plugin/context.rb +6 -5
  27. data/lib/samagotchi/plugin/http_transport.rb +14 -3
  28. data/lib/samagotchi/plugin/sessions.rb +2 -3
  29. data/lib/samagotchi/question_desk.rb +12 -9
  30. data/lib/samagotchi/reply_wait/poll.rb +267 -0
  31. data/lib/samagotchi/reply_wait.rb +47 -190
  32. data/lib/samagotchi/send_command/parent_gate.rb +83 -0
  33. data/lib/samagotchi/send_command/wait_target.rb +55 -0
  34. data/lib/samagotchi/send_command.rb +31 -100
  35. data/lib/samagotchi/session.rb +10 -9
  36. data/lib/samagotchi/session_inbox.rb +31 -8
  37. data/lib/samagotchi/session_manager.rb +48 -15
  38. data/lib/samagotchi/session_metrics.rb +22 -3
  39. data/lib/samagotchi/sessions_command.rb +4 -4
  40. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
  41. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  42. data/lib/samagotchi/testing/bundle.rb +1 -1
  43. data/lib/samagotchi/testing/host.rb +2 -8
  44. data/lib/samagotchi/testing/recorder.rb +6 -9
  45. data/lib/samagotchi/tools/delegate.rb +10 -10
  46. data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
  47. data/lib/samagotchi/tools/delegate_relay.rb +31 -16
  48. data/lib/samagotchi/tools/delegate_wait.rb +4 -4
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +46 -63
  51. data/lib/samagotchi/web/public/app.js +122 -556
  52. data/lib/samagotchi/web/public/index.html +1 -1
  53. data/lib/samagotchi/web/public/send_turn.js +173 -0
  54. data/lib/samagotchi/web/public/session_actions.js +203 -0
  55. data/lib/samagotchi/web/public/start_page.js +236 -0
  56. metadata +10 -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
@@ -113,8 +114,8 @@ module Samagotchi
113
114
  # callables, each given the hook's label. +notify+ takes
114
115
  # (text:, level:, hook:, and fallback_for: when the hook gave one: what
115
116
  # the line stands in for, HookRoutes#notify) and shows one line to the
116
- # user; +ask_user+ takes (question:, options:, header:, allow_freeform:, hook:, and
117
- # 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
118
119
  # cancels the running turn (true when it did); +steer+ takes (text:,
119
120
  # hook:) and puts the text into the running turn (Engine#steer, true
120
121
  # when queued); +stop_generation+ takes (reason:, hook:) and cuts the
@@ -392,9 +393,8 @@ module Samagotchi
392
393
  nil
393
394
  }
394
395
  event[:ask_user] ||= lambda { |question:, options:, header: nil, allow_freeform: false, effects: nil|
395
- @runtime&.ask_user&.call(question: question, options: options, header: header,
396
- allow_freeform: allow_freeform, hook: event[:hook],
397
- **(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))
398
398
  }
399
399
  event[:stop_turn] ||= lambda { |reason|
400
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
@@ -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,
@@ -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
@@ -5,6 +5,7 @@ require_relative "../log"
5
5
  require_relative "../paths"
6
6
  require_relative "../session_metrics/cost"
7
7
  require_relative "../guardrails/context"
8
+ require_relative "../hook_question"
8
9
  require_relative "../idle_client"
9
10
  require_relative "service"
10
11
  require_relative "side_question"
@@ -28,8 +29,8 @@ module Samagotchi
28
29
 
29
30
  # The Engine's side of a Context: callables, so the Engine itself is
30
31
  # never handed out. +messages+ returns the conversation, +notify+ takes
31
- # (text, level, label, fallback_for:), +ask_user+ takes (question:, options:, header:,
32
- # allow_freeform:, hook:, and effects: when the plugin gave them) like the hook runtime's, +card+ takes
32
+ # (text, level, label, fallback_for:), +ask_user+ takes a HookQuestion
33
+ # (the plugin's label in it) like the hook runtime's, +card+ takes
33
34
  # Engine#show_card's keywords and returns the id, +ask_model+ takes
34
35
  # (chat messages, timeout:, max_tokens:, cancel_controller:, model:: a
35
36
  # configured model ref, nil for the session's) and returns the answer
@@ -363,13 +364,13 @@ module Samagotchi
363
364
  # @return [Hash, nil] {selected:, freeform:, selected_indices:}, or nil
364
365
  # (no one to ask, cancelled, bad options or effects)
365
366
  def ask_user(question:, options:, header: nil, allow_freeform: false, effects: nil)
366
- tags = effects.nil? ? {} : { effects: effects }
367
367
  if (helper = event_helper(:ask_user))
368
+ tags = effects.nil? ? {} : { effects: effects }
368
369
  return helper.call(question: question, options: options, header: header, allow_freeform: allow_freeform, **tags)
369
370
  end
370
371
 
371
- @host.ask_user.call(question: question, options: options, header: header, allow_freeform: allow_freeform,
372
- hook: @label, **tags)
372
+ @host.ask_user.call(HookQuestion.new(question: question, options: options, header: header,
373
+ allow_freeform: allow_freeform, hook: @label, effects: effects))
373
374
  end
374
375
 
375
376
  # Whether the running turn was cancelled (a long tool should stop).
@@ -12,8 +12,10 @@ module Samagotchi
12
12
  class Http
13
13
  # Plugin::Http's wire: one LLM::HTTP request per call (chi's TLS,
14
14
  # proxies, User-Agent, cancel by closing the socket, and one log line
15
- # without query, headers or body), with chi's retries off. Every error
16
- # but a cancel becomes an HttpError, so a plugin rescues one type.
15
+ # without query, headers or body), with chi's retries off. Every
16
+ # network, TLS, protocol or provider error but a cancel becomes an
17
+ # HttpError, so a plugin rescues one type; anything else is a bug in
18
+ # chi, logged with its backtrace and raised as it is.
17
19
  #
18
20
  # Loaded with the first real request (it pulls in net/http and the
19
21
  # LLM layer). Stateless.
@@ -29,6 +31,12 @@ module Samagotchi
29
31
  Net::ProtocolError => "protocol error", Zlib::Error => "bad compressed body"
30
32
  }.freeze
31
33
 
34
+ # The errors a request's wire may raise: each becomes an HttpError.
35
+ WIRE_ERRORS = [
36
+ *REASONS.keys, *LLM::HTTP::NETWORK_ERRORS, SystemCallError, IOError, OpenSSL::OpenSSLError,
37
+ LLM::ProviderError
38
+ ].uniq.freeze
39
+
32
40
  def initialize(bundle:)
33
41
  @bundle = bundle.to_s
34
42
  end
@@ -47,8 +55,11 @@ module Samagotchi
47
55
  raise HttpCancelled, "#{method} #{where(uri)}: cancelled"
48
56
  rescue HttpError
49
57
  raise
50
- rescue StandardError => e
58
+ rescue *WIRE_ERRORS => e
51
59
  raise HttpError, "#{method} #{where(uri)}: #{reason(e)}"
60
+ rescue StandardError => e
61
+ Log.exception(:plugins, "http_transport_failed", e, bundle: @bundle)
62
+ raise
52
63
  end
53
64
 
54
65
  private
@@ -80,11 +80,10 @@ module Samagotchi
80
80
  sid = resolve(id, state_dir)
81
81
  delivered = SessionManager.deliver_turn(sid, prompt: text.to_s, client_id: ClientId::PLUGIN, state_dir: state_dir,
82
82
  delivery: wire)
83
- case delivered[:status]
83
+ case delivered.status
84
84
  when :accepted then sid
85
85
  when :refused
86
- ack = delivered[:ack] || {}
87
- raise Error, "session #{sid[0, 8]} refused the message (#{ack["error"]}#{": #{ack["detail"]}" if ack["detail"]})"
86
+ raise Error, "session #{sid[0, 8]} refused the message (#{delivered.error}#{": #{delivered.detail}" if delivered.detail})"
88
87
  when :timeout then raise Error, "session #{sid[0, 8]}'s worker did not answer in time; the message was not sent"
89
88
  else raise Error, "the message to session #{sid[0, 8]} could not be written"
90
89
  end
@@ -6,6 +6,7 @@ require "securerandom"
6
6
  require "time"
7
7
 
8
8
  require_relative "client_id"
9
+ require_relative "hook_question"
9
10
  require_relative "log"
10
11
  require_relative "pending_question"
11
12
  require_relative "question_answer"
@@ -404,30 +405,32 @@ module Samagotchi
404
405
  # web), single-select, kind "hook". A --non-interactive run has no one
405
406
  # to ask: nil at once. Anything but an answer (a sync handler's text, no
406
407
  # answer, cancelled) is nil too.
407
- # @param effects [Hash, nil] label => effect for a parent agent's answer
408
- # (Guardrails::ParentPolicy); a label left out is "answer". A label
408
+ # @param asked [HookQuestion] its effects (label => effect for a parent
409
+ # agent's answer, Guardrails::ParentPolicy; a label left out is
410
+ # "answer") become the pending question's per-option tags. A label
409
411
  # the options don't have, or an effect it doesn't know, makes the ask
410
412
  # invalid (nil, warned), like bad options.
411
413
  # @return [Hash, nil] {selected:, freeform:, selected_indices:} (a plain
412
414
  # Hash: the bundle boundary)
413
- def ask_for_hook(question, options, header, allow_freeform, hook, effects: nil)
415
+ def ask_for_hook(asked)
414
416
  return nil if @interface_lookup.call == :non_interactive
415
417
 
416
- opts = Tools::AskUserQuestion.normalize_options(options)
418
+ hook = asked.hook.to_s
419
+ opts = Tools::AskUserQuestion.normalize_options(asked.options)
417
420
  unless opts
418
- Log.warn(:hooks, "ask_user_invalid", echo: "[samagotchi:hooks] #{hook} asked with invalid options (2-8 strings)", hook: hook.to_s)
421
+ Log.warn(:hooks, "ask_user_invalid", echo: "[samagotchi:hooks] #{hook} asked with invalid options (2-8 strings)", hook: hook)
419
422
  return nil
420
423
  end
421
- tags = Guardrails::ParentPolicy.option_tags(opts, effects)
424
+ tags = Guardrails::ParentPolicy.option_tags(opts, asked.effects)
422
425
  if tags == false
423
426
  Log.warn(:hooks, "ask_user_invalid",
424
427
  echo: "[samagotchi:hooks] #{hook} asked with invalid effects (option label => " \
425
- "#{Guardrails::ParentPolicy::EFFECTS.join("|")})", hook: hook.to_s)
428
+ "#{Guardrails::ParentPolicy::EFFECTS.join("|")})", hook: hook)
426
429
  return nil
427
430
  end
428
431
 
429
- fields = { question: question.to_s, options: opts, header: header, multi_select: false,
430
- allow_freeform: !!allow_freeform, effects: tags, kind: "hook", hook: hook.to_s }.compact
432
+ fields = { question: asked.question.to_s, options: opts, header: asked.header, multi_select: false,
433
+ allow_freeform: !!asked.allow_freeform, effects: tags, kind: "hook", hook: hook }.compact
431
434
  answer = open_question(fields)
432
435
  return nil unless answer.is_a?(QuestionAnswer)
433
436