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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +88 -1
- data/README.md +5 -2
- data/bin/chi +8 -8
- data/docs/architecture.md +20 -16
- data/docs/bundle-authoring.md +2 -1
- data/docs/cli.md +5 -5
- data/docs/configuration.md +3 -1
- data/docs/context.md +5 -3
- data/docs/guardrails.md +4 -5
- data/docs/hooks.md +3 -3
- data/docs/internals/background-tasks.md +2 -2
- data/docs/internals/bundles.md +4 -4
- data/docs/internals/engine.md +27 -8
- data/docs/internals/tool-guardrails.md +10 -2
- data/docs/memory.md +6 -0
- data/docs/plugins.md +30 -13
- data/docs/releasing.md +3 -0
- data/docs/sessions.md +4 -4
- data/docs/testing.md +2 -1
- data/lib/samagotchi/bootstrap/probe.rb +2 -2
- data/lib/samagotchi/bridge.rb +8 -9
- data/lib/samagotchi/bundle_command/check.rb +43 -12
- data/lib/samagotchi/bundle_command/scaffold.rb +12 -0
- data/lib/samagotchi/bundle_command/test_run.rb +12 -0
- data/lib/samagotchi/bundle_command/try/command.rb +164 -0
- data/lib/samagotchi/bundle_command/try/render.rb +80 -0
- data/lib/samagotchi/bundle_command/try/run.rb +35 -17
- data/lib/samagotchi/bundle_command/try/sandbox.rb +113 -34
- data/lib/samagotchi/bundle_command/try/trace.rb +20 -26
- data/lib/samagotchi/bundle_command.rb +11 -213
- data/lib/samagotchi/bundle_session_settings.rb +82 -45
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -5
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +57 -12
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_env.rb +50 -0
- 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 +26 -26
- 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 +7 -5
- data/lib/samagotchi/host_registry.rb +1 -1
- data/lib/samagotchi/kernel_loop.rb +14 -3
- data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
- data/lib/samagotchi/llm/chat_loop.rb +3 -1
- data/lib/samagotchi/llm/errors.rb +4 -4
- data/lib/samagotchi/memory_bundle/builder.rb +3 -2
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +7 -2
- data/lib/samagotchi/memory_bundle/installer.rb +1 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +42 -8
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
- data/lib/samagotchi/parent_answers_command.rb +6 -6
- data/lib/samagotchi/parent_report.rb +12 -0
- 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 +51 -190
- data/lib/samagotchi/sandbox_layout.rb +66 -0
- 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 +34 -103
- data/lib/samagotchi/session.rb +10 -9
- data/lib/samagotchi/session_inbox.rb +31 -8
- data/lib/samagotchi/session_manager.rb +60 -35
- data/lib/samagotchi/session_metrics.rb +22 -3
- data/lib/samagotchi/sessions_command.rb +4 -4
- data/lib/samagotchi/task_watch.rb +64 -21
- 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 +33 -20
- data/lib/samagotchi/testing/host.rb +2 -8
- data/lib/samagotchi/testing/install.rb +26 -17
- data/lib/samagotchi/testing/recorder.rb +6 -9
- data/lib/samagotchi/testing/sandbox.rb +45 -37
- data/lib/samagotchi/testing/waiting.rb +5 -7
- data/lib/samagotchi/tool_response.rb +28 -6
- 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/tools/task_runtime.rb +2 -2
- data/lib/samagotchi/turn_checkpoint.rb +23 -10
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +46 -63
- data/lib/samagotchi/web/public/app.js +130 -554
- data/lib/samagotchi/web/public/cards_place.js +4 -1
- data/lib/samagotchi/web/public/images.js +6 -0
- data/lib/samagotchi/web/public/index.html +1 -1
- data/lib/samagotchi/web/public/model_pick.js +10 -0
- 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
- data/lib/samagotchi/web/public/toasts.js +17 -9
- data/lib/samagotchi/web/public/unqueue.js +20 -4
- data/lib/samagotchi/worker.rb +77 -185
- data/lib/samagotchi/worker_idle_exit.rb +20 -5
- data/lib/samagotchi/worker_inbound.rb +5 -5
- data/lib/samagotchi/worker_task_wakes.rb +160 -0
- data/lib/samagotchi/worker_wakes.rb +0 -3
- 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
|
|
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
|
|
@@ -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 (
|
|
115
|
-
#
|
|
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
|
-
|
|
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)
|
|
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 =
|
|
675
|
-
|
|
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
|
|
551
|
-
# built by then (the prompt plus completed
|
|
552
|
-
# streamed reply is not kept), so the caller can
|
|
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, :
|
|
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: [],
|
|
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"] =
|
|
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
|
-
#
|
|
175
|
-
#
|
|
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?(
|
|
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
|
-
|
|
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
|