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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -1
- data/bin/chi +4 -4
- data/docs/architecture.md +4 -4
- data/docs/guardrails.md +4 -5
- data/docs/internals/engine.md +6 -0
- data/docs/sessions.md +1 -1
- data/docs/testing.md +2 -1
- data/lib/samagotchi/bootstrap/probe.rb +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +12 -14
- data/lib/samagotchi/client_id.rb +18 -0
- data/lib/samagotchi/config/restart_watch.rb +112 -0
- data/lib/samagotchi/config.rb +18 -59
- data/lib/samagotchi/engine.rb +4 -14
- data/lib/samagotchi/guardrails/data_readers.rb +34 -30
- data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
- data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
- data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
- data/lib/samagotchi/hook_question.rb +21 -0
- data/lib/samagotchi/hook_routes.rb +6 -6
- data/lib/samagotchi/hooks/registry.rb +5 -5
- data/lib/samagotchi/host_registry.rb +1 -1
- data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
- data/lib/samagotchi/parent_answers_command.rb +6 -6
- data/lib/samagotchi/plugin/context.rb +6 -5
- data/lib/samagotchi/plugin/http_transport.rb +14 -3
- data/lib/samagotchi/plugin/sessions.rb +2 -3
- data/lib/samagotchi/question_desk.rb +12 -9
- data/lib/samagotchi/reply_wait/poll.rb +267 -0
- data/lib/samagotchi/reply_wait.rb +47 -190
- data/lib/samagotchi/send_command/parent_gate.rb +83 -0
- data/lib/samagotchi/send_command/wait_target.rb +55 -0
- data/lib/samagotchi/send_command.rb +31 -100
- data/lib/samagotchi/session.rb +10 -9
- data/lib/samagotchi/session_inbox.rb +31 -8
- data/lib/samagotchi/session_manager.rb +48 -15
- data/lib/samagotchi/session_metrics.rb +22 -3
- data/lib/samagotchi/sessions_command.rb +4 -4
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
- data/lib/samagotchi/testing/bundle.rb +1 -1
- data/lib/samagotchi/testing/host.rb +2 -8
- data/lib/samagotchi/testing/recorder.rb +6 -9
- data/lib/samagotchi/tools/delegate.rb +10 -10
- data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
- data/lib/samagotchi/tools/delegate_relay.rb +31 -16
- data/lib/samagotchi/tools/delegate_wait.rb +4 -4
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +46 -63
- data/lib/samagotchi/web/public/app.js +122 -556
- data/lib/samagotchi/web/public/index.html +1 -1
- data/lib/samagotchi/web/public/send_turn.js +173 -0
- data/lib/samagotchi/web/public/session_actions.js +203 -0
- data/lib/samagotchi/web/public/start_page.js +236 -0
- 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
|
|
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 +=
|
|
40
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
53
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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)
|
|
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:
|
|
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
|
|
92
|
-
@question_desk.call.ask_for_hook(question
|
|
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 (
|
|
117
|
-
#
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
32
|
-
#
|
|
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,
|
|
372
|
-
|
|
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
|
|
16
|
-
#
|
|
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
|
|
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
|
|
83
|
+
case delivered.status
|
|
84
84
|
when :accepted then sid
|
|
85
85
|
when :refused
|
|
86
|
-
|
|
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
|
|
408
|
-
#
|
|
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(
|
|
415
|
+
def ask_for_hook(asked)
|
|
414
416
|
return nil if @interface_lookup.call == :non_interactive
|
|
415
417
|
|
|
416
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|