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
|
@@ -18,11 +18,18 @@ module Samagotchi
|
|
|
18
18
|
class TaskWatch
|
|
19
19
|
# A watched task: +limit_sec+, the limit an execute handed it over at
|
|
20
20
|
# (nil for task_create's); +note_only+, the user stopped the turn that
|
|
21
|
-
# started or waited on it (M5): its finish is a plain note, no wake
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
# started or waited on it (M5): its finish is a plain note, no wake;
|
|
22
|
+
# +failures+, how often its note couldn't be written (#put_back) or the
|
|
23
|
+
# look for it failed (#look_failed).
|
|
24
|
+
Entry = Data.define(:id, :limit_sec, :note_only, :failures) do
|
|
25
|
+
def initialize(id:, limit_sec: nil, note_only: false, failures: 0) = super
|
|
24
26
|
end
|
|
25
27
|
|
|
28
|
+
# A task whose note or look failed this often still gets tried at each
|
|
29
|
+
# look, but no longer holds the worker up (#holding): a failure that
|
|
30
|
+
# never passes must not keep it alive for good.
|
|
31
|
+
MAX_FAILURES = 3
|
|
32
|
+
|
|
26
33
|
# The cancel reasons that are the user's own Stop (Bridge::CANCEL_REASONS
|
|
27
34
|
# less session_stopped, and the TUI's ctrl_c).
|
|
28
35
|
USER_CANCEL_REASONS = %i[manual user ctrl_c].freeze
|
|
@@ -36,11 +43,18 @@ module Samagotchi
|
|
|
36
43
|
MODE_KEY = "session.task_wakes"
|
|
37
44
|
MODES = %w[wake queue off].freeze
|
|
38
45
|
|
|
39
|
-
# session.task_wakes: wake (default), queue or off; anything else is
|
|
46
|
+
# session.task_wakes: wake (default), queue or off; anything else is
|
|
47
|
+
# wake, and so is a config that can't be read (logged once per error
|
|
48
|
+
# class: it is read at every idle pass).
|
|
40
49
|
def self.mode
|
|
41
50
|
value = Config.get(MODE_KEY).to_s
|
|
42
51
|
MODES.include?(value) ? value : "wake"
|
|
43
|
-
rescue StandardError
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
@mode_errors ||= {}
|
|
54
|
+
unless @mode_errors[e.class]
|
|
55
|
+
@mode_errors[e.class] = true
|
|
56
|
+
Log.warn(:worker, "task_wakes_unreadable", error: e.class.name, msg: e.message)
|
|
57
|
+
end
|
|
44
58
|
"wake"
|
|
45
59
|
end
|
|
46
60
|
|
|
@@ -94,7 +108,9 @@ module Samagotchi
|
|
|
94
108
|
|
|
95
109
|
def ids = @mutex.synchronize { @entries.keys }
|
|
96
110
|
|
|
97
|
-
|
|
111
|
+
# The watched tasks, oldest first (a copy).
|
|
112
|
+
# @return [Array<Entry>]
|
|
113
|
+
def entries = @mutex.synchronize { @entries.values }
|
|
98
114
|
|
|
99
115
|
def any? = @mutex.synchronize { @entries.any? }
|
|
100
116
|
|
|
@@ -107,10 +123,10 @@ module Samagotchi
|
|
|
107
123
|
# running task (TaskRuntime.get_record); nothing with an empty watch.
|
|
108
124
|
# @return [Array<Array(Entry, Tools::TaskRecord)>]
|
|
109
125
|
def take_finished(keep: nil)
|
|
110
|
-
|
|
111
|
-
return [] if
|
|
126
|
+
watched = entries
|
|
127
|
+
return [] if watched.empty?
|
|
112
128
|
|
|
113
|
-
|
|
129
|
+
watched.filter_map do |entry|
|
|
114
130
|
# Safe for any llm_context strategy: stale and forget stubs are drawn
|
|
115
131
|
# in the view sent to the model (LLMContextView, the entry's edits),
|
|
116
132
|
# never written over the saved tool_response text; only a !rollback
|
|
@@ -118,7 +134,10 @@ module Samagotchi
|
|
|
118
134
|
next drop(entry, log: "not_in_conversation") if keep && !keep.include?(entry.id)
|
|
119
135
|
|
|
120
136
|
record, error = Tools::TaskRuntime.get_record(entry.id)
|
|
121
|
-
|
|
137
|
+
if error || record.nil?
|
|
138
|
+
# Gone (its task dir deleted) or unreadable: no note can be written.
|
|
139
|
+
next drop(entry, log: "record_unreadable", level: :info, error: error.to_s.lines.first&.strip)
|
|
140
|
+
end
|
|
122
141
|
next nil if record.running?
|
|
123
142
|
# Removed by a look meanwhile (a task_wait on an anytime command's thread).
|
|
124
143
|
next nil unless drop(entry, kept: true)
|
|
@@ -129,13 +148,25 @@ module Samagotchi
|
|
|
129
148
|
end
|
|
130
149
|
|
|
131
150
|
# +entries+ taken by #take_finished whose note couldn't be written:
|
|
132
|
-
# watched again (an id watched meanwhile keeps its
|
|
151
|
+
# watched again, the failure counted (an id watched meanwhile keeps its
|
|
152
|
+
# own entry).
|
|
133
153
|
def put_back(entries)
|
|
134
|
-
@mutex.synchronize
|
|
154
|
+
@mutex.synchronize do
|
|
155
|
+
entries.each { |entry| @entries[entry.id] ||= entry.with(failures: entry.failures + 1) }
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The look for finished tasks failed before it could tell which ended
|
|
160
|
+
# (the conversation's task ids couldn't be read): every watched task
|
|
161
|
+
# counts a failure.
|
|
162
|
+
def look_failed
|
|
163
|
+
@mutex.synchronize { @entries.transform_values! { |entry| entry.with(failures: entry.failures + 1) } }
|
|
135
164
|
end
|
|
136
165
|
|
|
137
166
|
# Whether a watched task has finished (#take_finished would take it):
|
|
138
|
-
# Engine#next_turn_waiting asks at a turn's end. Takes nothing.
|
|
167
|
+
# Engine#next_turn_waiting asks at a turn's end. Takes nothing. A record
|
|
168
|
+
# that can't be read counts as not finished here (and not running in
|
|
169
|
+
# #running): #take_finished drops it at the next look, logged.
|
|
139
170
|
def finished?
|
|
140
171
|
ids.any? do |id|
|
|
141
172
|
record, = Tools::TaskRuntime.get_record(id)
|
|
@@ -143,23 +174,35 @@ module Samagotchi
|
|
|
143
174
|
end
|
|
144
175
|
end
|
|
145
176
|
|
|
146
|
-
# The watched tasks
|
|
147
|
-
#
|
|
177
|
+
# The watched tasks a finish would wake for, running or finished and
|
|
178
|
+
# not yet taken (#take_finished takes them at the idle loop's next
|
|
179
|
+
# look): they hold the worker's idle exit (WorkerIdleExit's
|
|
180
|
+
# watched_tasks). Not a note-only one (no wake would come for it), nor
|
|
181
|
+
# one whose note or look failed MAX_FAILURES times. No `ps`: in memory.
|
|
182
|
+
# @return [Array<Entry>]
|
|
183
|
+
def holding = entries.reject { |entry| entry.note_only || entry.failures >= MAX_FAILURES }
|
|
184
|
+
|
|
185
|
+
# The #holding tasks still running ({id:, command:}), for /exit
|
|
186
|
+
# (WorkerIdleExit's watched_running): one finished meanwhile is noted
|
|
187
|
+
# as the worker leaves. One `ps` per task.
|
|
148
188
|
# @return [Array<Hash>]
|
|
149
189
|
def running
|
|
150
|
-
|
|
151
|
-
record, = Tools::TaskRuntime.get_record(id)
|
|
152
|
-
{ id: id, command: record.command.to_s } if record&.running?
|
|
190
|
+
holding.filter_map do |entry|
|
|
191
|
+
record, = Tools::TaskRuntime.get_record(entry.id)
|
|
192
|
+
{ id: entry.id, command: record.command.to_s } if record&.running?
|
|
153
193
|
end
|
|
154
194
|
end
|
|
155
195
|
|
|
156
196
|
private
|
|
157
197
|
|
|
158
198
|
# Removes +entry+; nil (a filter_map's skip), or with +kept+ whether it
|
|
159
|
-
# was there. +log+: why a task leaves without a note (debug
|
|
160
|
-
|
|
199
|
+
# was there. +log+: why a task leaves without a note, at +level+ (debug
|
|
200
|
+
# by default), with +error+ when there is one.
|
|
201
|
+
def drop(entry, kept: false, log: nil, level: :debug, error: nil)
|
|
161
202
|
removed = !remove(entry.id).nil?
|
|
162
|
-
|
|
203
|
+
if log && removed
|
|
204
|
+
Log.public_send(level, :worker, "task_watch_dropped", **{ task_id: entry.id, reason: log, error: error }.compact)
|
|
205
|
+
end
|
|
163
206
|
kept ? removed : nil
|
|
164
207
|
end
|
|
165
208
|
|
|
@@ -1639,9 +1639,9 @@ module Samagotchi
|
|
|
1639
1639
|
end
|
|
1640
1640
|
|
|
1641
1641
|
def reminder_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::SYSTEM_PREFIX)
|
|
1642
|
-
def report_origin?(origin) = origin[:client_id]
|
|
1643
|
-
def context_origin?(origin) = origin[:client_id]
|
|
1644
|
-
def task_origin?(origin) = origin[:client_id]
|
|
1642
|
+
def report_origin?(origin) = ClientId.origin(origin[:client_id]) == "delegate_report"
|
|
1643
|
+
def context_origin?(origin) = ClientId.origin(origin[:client_id]) == "context"
|
|
1644
|
+
def task_origin?(origin) = ClientId.origin(origin[:client_id]) == "task"
|
|
1645
1645
|
# A turn with no prompt: "(continuing)"; a context wake's or a task
|
|
1646
1646
|
# finish wake's label alone.
|
|
1647
1647
|
def continuing_text(origin) = context_origin?(origin) || task_origin?(origin) ? nil : "(continuing)"
|
|
@@ -215,11 +215,14 @@ module Samagotchi
|
|
|
215
215
|
return fallback unless client_id
|
|
216
216
|
|
|
217
217
|
id = client_id.to_s
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
218
|
+
case ClientId.origin(id)
|
|
219
|
+
when "context"
|
|
220
|
+
name = id.delete_prefix(ClientId::CONTEXT_PREFIX)
|
|
221
|
+
return "context #{name} changed" unless name.empty?
|
|
222
|
+
when "task"
|
|
223
|
+
task = id.delete_prefix(ClientId::TASK_PREFIX)
|
|
224
|
+
return "task #{Tools::TaskRecord.short_id(task)} finished" unless task.empty?
|
|
225
|
+
end
|
|
223
226
|
|
|
224
227
|
prefix = id[/\A[^:]*:/]
|
|
225
228
|
CLIENT_LABELS[id] || (prefix && CLIENT_LABELS[prefix]) || AUTOMATIC_LABEL
|
|
@@ -11,6 +11,7 @@ require_relative "../peer_sources"
|
|
|
11
11
|
require_relative "../toolkit"
|
|
12
12
|
require_relative "../guardrails/gate"
|
|
13
13
|
require_relative "../memory_bundle/manifest"
|
|
14
|
+
require_relative "../memory_bundle/source"
|
|
14
15
|
require_relative "../session_setup"
|
|
15
16
|
require_relative "sandbox"
|
|
16
17
|
require_relative "recorder"
|
|
@@ -69,7 +70,7 @@ module Samagotchi
|
|
|
69
70
|
# raises HookFailed after the call (chi would log it and go on);
|
|
70
71
|
# #lenient collects them in #errors instead.
|
|
71
72
|
class Bundle
|
|
72
|
-
SHIPPED_DIR =
|
|
73
|
+
SHIPPED_DIR = MemoryBundle::SourceNormalizer::SHIPPED_DIR
|
|
73
74
|
|
|
74
75
|
class << self
|
|
75
76
|
# @param dir [String] the bundle's folder (its manifest.yml)
|
|
@@ -88,6 +89,20 @@ module Samagotchi
|
|
|
88
89
|
|
|
89
90
|
# A bundle chi ships (lib/samagotchi/bundles/<name>), as #load.
|
|
90
91
|
def shipped(name, **) = load(File.join(SHIPPED_DIR, name.to_s), **)
|
|
92
|
+
|
|
93
|
+
# Registries as an Engine's start: chi's built-in commands and
|
|
94
|
+
# tools, empty services, toolkits, tool index and peer sources;
|
|
95
|
+
# +fields+ set the rest (hooks:, context_for:, init: ...) or replace
|
|
96
|
+
# these. Bundle#build_registries and Install's load both start here.
|
|
97
|
+
# @api private
|
|
98
|
+
# @return [Plugin::Registries]
|
|
99
|
+
def registries(**fields)
|
|
100
|
+
Plugin::Registries.new(
|
|
101
|
+
commands: SessionCommands.register_builtins(Commands::Registry.new), tools: Tools::Builtins.registry,
|
|
102
|
+
services: Plugin::Services.new, stage_tools: ->(*) {}, toolkits: [], tool_index: Tools::ToolIndex.new,
|
|
103
|
+
peer_sources: PeerSources.new, **fields
|
|
104
|
+
)
|
|
105
|
+
end
|
|
91
106
|
end
|
|
92
107
|
|
|
93
108
|
# @return [String] the bundle's name (manifest.yml)
|
|
@@ -132,6 +147,13 @@ module Samagotchi
|
|
|
132
147
|
own = session_settings.nil? || session_settings.empty? ? {} : { setup: setup_with(session_settings, host[:setup]) }
|
|
133
148
|
@own_sandbox = sandbox.nil? && Sandbox.current.nil?
|
|
134
149
|
@sandbox = sandbox || Sandbox.current || Sandbox.enter
|
|
150
|
+
build(host, own)
|
|
151
|
+
rescue Exception # rubocop:disable Lint/RescueException -- leave the sandbox it entered, whatever failed
|
|
152
|
+
@sandbox.leave if @own_sandbox && @sandbox
|
|
153
|
+
raise
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def build(host, own)
|
|
135
157
|
@recorder = Recorder.new(cwd: @sandbox.work_dir, **host, **own)
|
|
136
158
|
@sessions = Sessions.new
|
|
137
159
|
@http = FakeHttp.new
|
|
@@ -143,6 +165,7 @@ module Samagotchi
|
|
|
143
165
|
@contexts = {}
|
|
144
166
|
@registries = build_registries
|
|
145
167
|
end
|
|
168
|
+
private :build
|
|
146
169
|
|
|
147
170
|
# @api private (Bundle.load)
|
|
148
171
|
def load!
|
|
@@ -316,7 +339,7 @@ module Samagotchi
|
|
|
316
339
|
def model_answers = @recorder.model_answers
|
|
317
340
|
|
|
318
341
|
# Queue ask_user answers ({selected:, freeform:, selected_indices:},
|
|
319
|
-
# or a callable given the
|
|
342
|
+
# or a callable given the HookQuestion).
|
|
320
343
|
def answer(*answers)
|
|
321
344
|
@recorder.answers.concat(answers)
|
|
322
345
|
self
|
|
@@ -349,28 +372,22 @@ module Samagotchi
|
|
|
349
372
|
private
|
|
350
373
|
|
|
351
374
|
def build_registries
|
|
352
|
-
builtin_tools = Tools::Builtins.registry
|
|
353
375
|
toolkits = []
|
|
354
376
|
host = Host.build(@recorder, toolkits: -> { Toolkit::BUILTIN_KITS.keys + toolkits.map(&:name) },
|
|
355
377
|
tool_names: -> { @registries.tools.names })
|
|
356
|
-
|
|
357
|
-
commands: SessionCommands.register_builtins(Commands::Registry.new),
|
|
358
|
-
tools: builtin_tools,
|
|
378
|
+
Bundle.registries(
|
|
359
379
|
hooks: Hooks::Registry.new.tap { |registry| registry.runtime = Runtime.build(@recorder) },
|
|
360
380
|
context_for: lambda { |bundle, settings, label|
|
|
361
381
|
@contexts[label] = Plugin::Context.new(bundle: bundle, label: label, settings: settings, host: host,
|
|
362
382
|
env: ENV, sessions: @sessions, http_transport: @http)
|
|
363
383
|
},
|
|
364
384
|
tools_changed: -> { @tools_changes += 1 },
|
|
365
|
-
services: Plugin::Services.new,
|
|
366
385
|
stage_tools: ->(_bundle, specs, context) { @staged_tools << StagedTools.new(specs: specs, context: context) },
|
|
367
386
|
init: lambda { |_bundle, label, plugin_label, provides_tools:, quiet:, timeout:, failed: nil, &block|
|
|
368
387
|
@inits << Init.new(label: label, plugin_label: plugin_label, provides_tools: provides_tools, quiet: quiet,
|
|
369
388
|
timeout: timeout, failed: failed, block: block)
|
|
370
389
|
},
|
|
371
|
-
toolkits: toolkits
|
|
372
|
-
tool_index: Tools::ToolIndex.new,
|
|
373
|
-
peer_sources: PeerSources.new
|
|
390
|
+
toolkits: toolkits
|
|
374
391
|
)
|
|
375
392
|
end
|
|
376
393
|
|
|
@@ -388,21 +405,17 @@ module Samagotchi
|
|
|
388
405
|
|
|
389
406
|
# +setup+ (the recorder's, nil for none) with +session_settings+ as
|
|
390
407
|
# this bundle's own values, checked against the manifest's
|
|
391
|
-
# session_settings: as chi --set checks them.
|
|
392
|
-
# @raise [ArgumentError] a key the manifest doesn't list
|
|
408
|
+
# session_settings: (keys and types) as chi --set checks them.
|
|
409
|
+
# @raise [ArgumentError] a key the manifest doesn't list, or a value
|
|
410
|
+
# of another type than it gives the key
|
|
393
411
|
def setup_with(session_settings, setup)
|
|
394
412
|
values = { @name => stringify(session_settings) }
|
|
395
|
-
BundleSessionSettings.check!(values, bundles: { @name => @manifest.session_settings },
|
|
413
|
+
BundleSessionSettings.check!(values, bundles: { @name => @manifest.session_settings },
|
|
414
|
+
types: { @name => @manifest.session_setting_types }, flag: "session_settings")
|
|
396
415
|
(setup || SessionSetup.new).with(bundle_settings: values)
|
|
397
416
|
end
|
|
398
417
|
|
|
399
|
-
def stringify(value)
|
|
400
|
-
case value
|
|
401
|
-
when Hash then value.to_h { |key, item| [key.to_s, stringify(item)] }
|
|
402
|
-
when Array then value.map { |item| stringify(item) }
|
|
403
|
-
else value
|
|
404
|
-
end
|
|
405
|
-
end
|
|
418
|
+
def stringify(value) = Sandbox.stringify(value)
|
|
406
419
|
end
|
|
407
420
|
end
|
|
408
421
|
end
|
|
@@ -27,10 +27,7 @@ module Samagotchi
|
|
|
27
27
|
messages: -> { recorder[:messages] },
|
|
28
28
|
messages_partial: -> { recorder[:messages_partial] },
|
|
29
29
|
notify: ->(text, level, label, fallback_for: nil) { recorder.notify(text, level, label, fallback_for) },
|
|
30
|
-
ask_user:
|
|
31
|
-
recorder.ask_user(question: question, options: options, header: header, allow_freeform: allow_freeform, hook: hook,
|
|
32
|
-
effects: effects)
|
|
33
|
-
},
|
|
30
|
+
ask_user: ->(question) { recorder.ask_user(question) },
|
|
34
31
|
cancelled: -> { recorder[:cancelled] },
|
|
35
32
|
card: ->(**card) { recorder.card(**card) },
|
|
36
33
|
ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil|
|
|
@@ -89,10 +86,7 @@ module Samagotchi
|
|
|
89
86
|
def build(recorder)
|
|
90
87
|
Hooks::Runtime.new(
|
|
91
88
|
notify: ->(text:, level:, hook:, fallback_for: nil) { recorder.notify(text, level, hook, fallback_for) },
|
|
92
|
-
ask_user:
|
|
93
|
-
recorder.ask_user(question: question, options: options, header: header, allow_freeform: allow_freeform, hook: hook,
|
|
94
|
-
effects: effects)
|
|
95
|
-
},
|
|
89
|
+
ask_user: ->(question) { recorder.ask_user(question) },
|
|
96
90
|
stop_turn: ->(reason:, hook:) { recorder.stop(:turn, reason, hook) },
|
|
97
91
|
steer: ->(text:, hook:) { recorder.steer(text, hook) },
|
|
98
92
|
stop_generation: ->(reason:, hook:) { recorder.stop(:generation, reason, hook) }
|
|
@@ -19,16 +19,24 @@ module Samagotchi
|
|
|
19
19
|
# failed names the bundle's source files, not the installed copies.
|
|
20
20
|
#
|
|
21
21
|
# result = Samagotchi::Testing::Install.run("path/to/bundle", settings: { "every" => 3 })
|
|
22
|
-
# result.warnings, result.plugin_failures, result.hook_failures, result.registered
|
|
22
|
+
# result.warnings, result.plugin_failures, result.hook_failures, result.registered.commands
|
|
23
23
|
module Install
|
|
24
24
|
# warnings: the Installer's; plugin_failures / hook_failures:
|
|
25
25
|
# [Guardrails::LoadFailures::Failure] of this bundle, each location
|
|
26
26
|
# in the source dir; registered: what it registered (#registered).
|
|
27
27
|
Result = Data.define(:name, :warnings, :plugin_failures, :hook_failures, :registered) do
|
|
28
28
|
def failures = plugin_failures + hook_failures
|
|
29
|
-
def ok? = failures.empty?
|
|
30
29
|
end
|
|
31
30
|
|
|
31
|
+
# What a bundle registered (#registered): commands, tools (names),
|
|
32
|
+
# hooks per event, inits (labels), toolkits, tool sources and peer
|
|
33
|
+
# sources (names).
|
|
34
|
+
Registered = Data.define(:commands, :tools, :hooks, :inits, :toolkits, :tool_sources, :peer_sources)
|
|
35
|
+
# A command: its mid_turn policy and its selection-bar labels (chi.annotation).
|
|
36
|
+
Command = Data.define(:name, :mid_turn, :labels)
|
|
37
|
+
# A hook on an event: the bundle, the hook (its file, or the plugin's) and its priority.
|
|
38
|
+
Hook = Data.define(:bundle, :hook, :priority)
|
|
39
|
+
|
|
32
40
|
module_function
|
|
33
41
|
|
|
34
42
|
# Install the bundle at +dir+ into +sandbox+ (default Sandbox.current,
|
|
@@ -57,15 +65,12 @@ module Samagotchi
|
|
|
57
65
|
hook_failures = Guardrails::LoadFailures.new
|
|
58
66
|
plugin_failures = Guardrails::LoadFailures.new
|
|
59
67
|
extension = ExtensionLoad.new(hook_failures: hook_failures)
|
|
60
|
-
registries =
|
|
61
|
-
|
|
62
|
-
tools: Tools::Builtins.registry, hooks: extension.hooks,
|
|
68
|
+
registries = Bundle.registries(
|
|
69
|
+
hooks: extension.hooks,
|
|
63
70
|
context_for: lambda { |bundle, bundle_settings, label|
|
|
64
71
|
Plugin::Context.new(bundle: bundle, label: label, settings: bundle_settings, host: host)
|
|
65
72
|
},
|
|
66
|
-
|
|
67
|
-
init: ->(_bundle, label, _plugin_label, **, &_block) { inits << { label: label } },
|
|
68
|
-
toolkits: [], tool_index: Tools::ToolIndex.new, peer_sources: PeerSources.new
|
|
73
|
+
init: ->(_bundle, label, _plugin_label, **, &_block) { inits << { label: label } }
|
|
69
74
|
)
|
|
70
75
|
extension.load_plugins(registries, failures: plugin_failures)
|
|
71
76
|
installed = File.join(MemoryBundle::Provenance.bundles_dir, name)
|
|
@@ -79,26 +84,30 @@ module Samagotchi
|
|
|
79
84
|
private_class_method :load_installed
|
|
80
85
|
|
|
81
86
|
# What bundle +name+ registered, from the registries it loaded into:
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
# the bundle registered for), inits, toolkits, tool sources and
|
|
85
|
-
# peer sources.
|
|
87
|
+
# hooks per event are Hooks::EVENTS, then any other event the bundle
|
|
88
|
+
# registered for.
|
|
86
89
|
# @param inits [Array<Hash>] {label:} of each chi.init
|
|
87
|
-
# @return [
|
|
90
|
+
# @return [Registered]
|
|
88
91
|
def registered(registries, name, inits)
|
|
89
|
-
|
|
90
|
-
commands: registries.commands.entries.select { |e| e.source == name }.map
|
|
92
|
+
Registered.new(
|
|
93
|
+
commands: registries.commands.entries.select { |e| e.source == name }.map do |e|
|
|
94
|
+
Command.new(name: e.name, mid_turn: e.mid_turn, labels: e.annotations.map(&:label))
|
|
95
|
+
end,
|
|
91
96
|
tools: registries.tools.by_source(name).map(&:name),
|
|
92
97
|
hooks: hooks_of(registries.hooks, name),
|
|
93
98
|
inits: inits.map { |init| init[:label] },
|
|
94
99
|
toolkits: Array(registries.toolkits).map(&:name),
|
|
95
100
|
tool_sources: registries.tool_index.sources.map(&:label),
|
|
96
101
|
peer_sources: registries.peer_sources.names
|
|
97
|
-
|
|
102
|
+
)
|
|
98
103
|
end
|
|
99
104
|
|
|
100
105
|
def hooks_of(hooks, name)
|
|
101
|
-
own =
|
|
106
|
+
own = lambda do |event|
|
|
107
|
+
hooks.bundle_hook_names(event).filter_map do |bundle, hook, priority|
|
|
108
|
+
Hook.new(bundle: bundle, hook: hook, priority: priority) if bundle == name
|
|
109
|
+
end
|
|
110
|
+
end
|
|
102
111
|
others = (hooks.bundle_events - Hooks::EVENTS).to_h { |event| [event, own.call(event)] }.reject { |_, list| list.empty? }
|
|
103
112
|
Hooks::EVENTS.to_h { |event| [event, own.call(event)] }.merge(others)
|
|
104
113
|
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "../plugin/card"
|
|
4
4
|
require_relative "../plugin/context"
|
|
5
5
|
require_relative "../guardrails/parent_policy"
|
|
6
|
+
require_relative "../hook_question"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
module Testing
|
|
@@ -13,11 +14,6 @@ module Samagotchi
|
|
|
13
14
|
Steer = Data.define(:text, :hook)
|
|
14
15
|
# A stop_turn or stop_generation (+kind+ :turn or :generation).
|
|
15
16
|
Stop = Data.define(:kind, :reason, :hook)
|
|
16
|
-
# An ask_user question; effects: its options' tags as the bundle gave
|
|
17
|
-
# them (label => effect), nil for none.
|
|
18
|
-
Question = Data.define(:question, :options, :header, :allow_freeform, :hook, :effects) do
|
|
19
|
-
def initialize(effects: nil, **) = super
|
|
20
|
-
end
|
|
21
17
|
# An ask_model request: the request Context built (SideQuestion), its
|
|
22
18
|
# limits, cancel controller and the model: it named (nil: the session's;
|
|
23
19
|
# the kit doesn't check it against any hosts), and +source+, the asking
|
|
@@ -107,11 +103,12 @@ module Samagotchi
|
|
|
107
103
|
# Effects chi would refuse (a label the options don't have, an
|
|
108
104
|
# unknown effect: Guardrails::ParentPolicy.option_tags) ask no one, as
|
|
109
105
|
# in chi: nil, nothing recorded, no answer taken.
|
|
110
|
-
|
|
111
|
-
|
|
106
|
+
# @param question [HookQuestion] recorded as asked (#questions; its
|
|
107
|
+
# effects as the bundle gave them: label => effect, nil for none)
|
|
108
|
+
def ask_user(question)
|
|
109
|
+
return nil if Guardrails::ParentPolicy.option_tags(question.options, question.effects) == false
|
|
112
110
|
|
|
113
|
-
record(@questions,
|
|
114
|
-
allow_freeform: allow_freeform, hook: hook, effects: effects))
|
|
111
|
+
record(@questions, question)
|
|
115
112
|
answer = @mutex.synchronize { @answers.shift }
|
|
116
113
|
answer.respond_to?(:call) ? answer.call(@questions.last) : answer
|
|
117
114
|
end
|
|
@@ -5,6 +5,7 @@ require "tmpdir"
|
|
|
5
5
|
require "yaml"
|
|
6
6
|
require_relative "../log"
|
|
7
7
|
require_relative "../paths"
|
|
8
|
+
require_relative "../sandbox_layout"
|
|
8
9
|
require_relative "fake_executable"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
@@ -38,7 +39,16 @@ module Samagotchi
|
|
|
38
39
|
sandbox&.leave
|
|
39
40
|
end
|
|
40
41
|
|
|
42
|
+
# +value+ with every Hash key a String, as config.yml and a
|
|
43
|
+
# handler's args read (Bundle uses it too).
|
|
41
44
|
# @api private
|
|
45
|
+
def stringify(value)
|
|
46
|
+
case value
|
|
47
|
+
when Hash then value.to_h { |key, item| [key.to_s, stringify(item)] }
|
|
48
|
+
when Array then value.map { |item| stringify(item) }
|
|
49
|
+
else value
|
|
50
|
+
end
|
|
51
|
+
end
|
|
42
52
|
end
|
|
43
53
|
|
|
44
54
|
# @return [String] the temp folder holding everything
|
|
@@ -55,31 +65,16 @@ module Samagotchi
|
|
|
55
65
|
def enter
|
|
56
66
|
raise ArgumentError, "the sandbox was entered already" if @root
|
|
57
67
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
ENV
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
"XDG_DATA_HOME" => File.join(@root, "data"),
|
|
69
|
-
"XDG_CACHE_HOME" => File.join(@root, "cache"),
|
|
70
|
-
"CLAUDE_CONFIG_DIR" => File.join(@root, "home", ".claude"),
|
|
71
|
-
"CODEX_HOME" => File.join(@root, "home", ".codex"),
|
|
72
|
-
"GIT_CONFIG_GLOBAL" => File.join(@root, "gitconfig"),
|
|
73
|
-
"GIT_CONFIG_NOSYSTEM" => "1",
|
|
74
|
-
"SAMAGOTCHI_KEYCHAIN" => "off",
|
|
75
|
-
"SAMAGOTCHI_ENV" => "test",
|
|
76
|
-
"SAMAGOTCHI_RECAP_ENABLED" => "false",
|
|
77
|
-
"SAMAGOTCHI_CACHE_WARMUP" => "off",
|
|
78
|
-
"PATH" => [bin_dir, ENV.fetch("PATH", nil)].compact.join(File::PATH_SEPARATOR)
|
|
79
|
-
)
|
|
80
|
-
# The log resolves its file from the (now temp) state home.
|
|
81
|
-
Log.reset!
|
|
82
|
-
Sandbox.current = self
|
|
68
|
+
layout = SandboxLayout.create("chi-bundle-test-", extra: %w[data cache home bin])
|
|
69
|
+
begin
|
|
70
|
+
@root = layout.root
|
|
71
|
+
@saved = ENV.to_h
|
|
72
|
+
@outer = Sandbox.current
|
|
73
|
+
point_env(layout)
|
|
74
|
+
rescue Exception # rubocop:disable Lint/RescueException -- put ENV back, whatever failed
|
|
75
|
+
leave
|
|
76
|
+
raise
|
|
77
|
+
end
|
|
83
78
|
self
|
|
84
79
|
end
|
|
85
80
|
|
|
@@ -87,7 +82,7 @@ module Samagotchi
|
|
|
87
82
|
def leave
|
|
88
83
|
return unless @root
|
|
89
84
|
|
|
90
|
-
ENV.replace(@saved)
|
|
85
|
+
ENV.replace(@saved) if @saved
|
|
91
86
|
Log.reset!
|
|
92
87
|
FileUtils.rm_rf(@root)
|
|
93
88
|
Sandbox.current = @outer
|
|
@@ -97,10 +92,7 @@ module Samagotchi
|
|
|
97
92
|
# Write config.yml (what Config.get reads): a Hash or YAML text.
|
|
98
93
|
# @return [String] its path
|
|
99
94
|
def config(data)
|
|
100
|
-
|
|
101
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
102
|
-
File.write(path, data.is_a?(String) ? data : YAML.dump(stringify(data)))
|
|
103
|
-
path
|
|
95
|
+
SandboxLayout.new(@root).write_config(data.is_a?(String) ? data : stringify(data), config_home: Paths.config_home)
|
|
104
96
|
end
|
|
105
97
|
|
|
106
98
|
# @return [String] $XDG_STATE_HOME/samagotchi (ctx.data_dir's parent)
|
|
@@ -145,6 +137,28 @@ module Samagotchi
|
|
|
145
137
|
|
|
146
138
|
private
|
|
147
139
|
|
|
140
|
+
# ENV at +layout+'s folders, none of the SAMAGOTCHI_* settings but KEPT.
|
|
141
|
+
def point_env(layout)
|
|
142
|
+
ENV.each_key.select { |key| key.start_with?("SAMAGOTCHI_") && !KEPT.include?(key) }.each { |key| ENV.delete(key) }
|
|
143
|
+
File.write(File.join(@root, "gitconfig"), "")
|
|
144
|
+
ENV.update(
|
|
145
|
+
**layout.env,
|
|
146
|
+
"HOME" => File.join(@root, "home"),
|
|
147
|
+
"XDG_DATA_HOME" => File.join(@root, "data"),
|
|
148
|
+
"XDG_CACHE_HOME" => File.join(@root, "cache"),
|
|
149
|
+
"CLAUDE_CONFIG_DIR" => File.join(@root, "home", ".claude"),
|
|
150
|
+
"CODEX_HOME" => File.join(@root, "home", ".codex"),
|
|
151
|
+
"GIT_CONFIG_GLOBAL" => File.join(@root, "gitconfig"),
|
|
152
|
+
"GIT_CONFIG_NOSYSTEM" => "1",
|
|
153
|
+
"SAMAGOTCHI_KEYCHAIN" => "off",
|
|
154
|
+
"SAMAGOTCHI_ENV" => "test",
|
|
155
|
+
"PATH" => [bin_dir, ENV.fetch("PATH", nil)].compact.join(File::PATH_SEPARATOR)
|
|
156
|
+
)
|
|
157
|
+
# The log resolves its file from the (now temp) state home.
|
|
158
|
+
Log.reset!
|
|
159
|
+
Sandbox.current = self
|
|
160
|
+
end
|
|
161
|
+
|
|
148
162
|
# Run the block with +dir+ as the process's cwd (the memory tools
|
|
149
163
|
# read the project from it); nil: as it is.
|
|
150
164
|
def in_dir(dir, &)
|
|
@@ -153,13 +167,7 @@ module Samagotchi
|
|
|
153
167
|
Dir.chdir(dir, &)
|
|
154
168
|
end
|
|
155
169
|
|
|
156
|
-
def stringify(value)
|
|
157
|
-
case value
|
|
158
|
-
when Hash then value.to_h { |key, item| [key.to_s, stringify(item)] }
|
|
159
|
-
when Array then value.map { |item| stringify(item) }
|
|
160
|
-
else value
|
|
161
|
-
end
|
|
162
|
-
end
|
|
170
|
+
def stringify(value) = Sandbox.stringify(value)
|
|
163
171
|
end
|
|
164
172
|
end
|
|
165
173
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../process_group"
|
|
4
|
+
|
|
3
5
|
module Samagotchi
|
|
4
6
|
module Testing
|
|
5
7
|
# Waiting with a deadline, for a test that waits on a thread or a
|
|
@@ -24,13 +26,9 @@ module Samagotchi
|
|
|
24
26
|
end
|
|
25
27
|
|
|
26
28
|
# Whether no process +pid+ is left (a killed grandchild is reaped a
|
|
27
|
-
# moment later: wait_until { process_gone?(pid) }).
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
false
|
|
31
|
-
rescue Errno::ESRCH
|
|
32
|
-
true
|
|
33
|
-
end
|
|
29
|
+
# moment later: wait_until { process_gone?(pid) }). One that isn't
|
|
30
|
+
# ours to signal is still there (ProcessGroup.alive?).
|
|
31
|
+
def process_gone?(pid) = !ProcessGroup.alive?(pid)
|
|
34
32
|
end
|
|
35
33
|
end
|
|
36
34
|
end
|