samagotchi 0.91.0 → 0.92.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 +58 -1
- data/README.md +5 -2
- data/bin/chi +4 -4
- data/docs/architecture.md +16 -12
- 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/hooks.md +3 -3
- data/docs/internals/background-tasks.md +2 -2
- data/docs/internals/bundles.md +4 -4
- data/docs/internals/engine.md +21 -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 +3 -3
- 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/engine.rb +22 -12
- data/lib/samagotchi/hooks/registry.rb +2 -0
- data/lib/samagotchi/kernel_loop.rb +14 -3
- 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_report.rb +12 -0
- data/lib/samagotchi/reply_wait.rb +6 -2
- data/lib/samagotchi/sandbox_layout.rb +66 -0
- data/lib/samagotchi/send_command.rb +3 -3
- data/lib/samagotchi/session_manager.rb +12 -20
- data/lib/samagotchi/task_watch.rb +64 -21
- data/lib/samagotchi/testing/bundle.rb +32 -19
- data/lib/samagotchi/testing/install.rb +26 -17
- 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/task_runtime.rb +2 -2
- data/lib/samagotchi/turn_checkpoint.rb +23 -10
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +24 -14
- data/lib/samagotchi/web/public/cards_place.js +4 -1
- data/lib/samagotchi/web/public/images.js +6 -0
- data/lib/samagotchi/web/public/model_pick.js +10 -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 +6 -1
|
@@ -5,6 +5,7 @@ require "json"
|
|
|
5
5
|
require "time"
|
|
6
6
|
require "securerandom"
|
|
7
7
|
require "rbconfig"
|
|
8
|
+
require_relative "child_env"
|
|
8
9
|
require_relative "client_id"
|
|
9
10
|
require_relative "analytics_file"
|
|
10
11
|
require_relative "atomic_file"
|
|
@@ -210,7 +211,7 @@ module Samagotchi
|
|
|
210
211
|
# is when the worker starts); opts[:env] adds to it (merged, not
|
|
211
212
|
# replaced) what it can't read there: this `chi`'s CLI settings
|
|
212
213
|
# (Config.cli_env) and the absolute log file. It unsets
|
|
213
|
-
# SAMAGOTCHI_GUARDRAILS_
|
|
214
|
+
# what ChildEnv lists (SAMAGOTCHI_GUARDRAILS_*, the session markers).
|
|
214
215
|
# XDG_CONFIG_HOME passes: whoever starts or
|
|
215
216
|
# wakes the worker picks which config.yml it reads (docs/guardrails.md).
|
|
216
217
|
# XDG_STATE_HOME and XDG_CONFIG_HOME, when set, pass made absolute
|
|
@@ -244,18 +245,15 @@ module Samagotchi
|
|
|
244
245
|
# And the spawner's XDG dirs, absolute: a relative XDG_STATE_HOME or
|
|
245
246
|
# XDG_CONFIG_HOME would otherwise be read from the session's directory.
|
|
246
247
|
child_env.merge!(Paths.absolute_xdg)
|
|
247
|
-
#
|
|
248
|
-
#
|
|
249
|
-
#
|
|
250
|
-
# older or future env-exposed one out as
|
|
251
|
-
|
|
252
|
-
#
|
|
253
|
-
#
|
|
254
|
-
#
|
|
255
|
-
|
|
256
|
-
SESSION_MARKER_ENV.each { |key| child_env[key] = nil }
|
|
257
|
-
# A smoke's fake "newest installed" is for this process's notices only.
|
|
258
|
-
child_env[InstalledVersions::ENV_KEY] = nil
|
|
248
|
+
# Unset (Process.spawn unsets a key mapped to nil) what no child chi
|
|
249
|
+
# takes from its spawner (ChildEnv): the spawner's guardrails settings
|
|
250
|
+
# (a parent agent starting or waking a session; config.yml no longer
|
|
251
|
+
# reads them, this keeps an older or future env-exposed one out as
|
|
252
|
+
# well); the session markers, a session's own (a worker started from
|
|
253
|
+
# its commands, chi send, is another session: its execute and tasks
|
|
254
|
+
# set its own; nothing else it runs has one); and a smoke's fake
|
|
255
|
+
# "newest installed", for this process's notices only.
|
|
256
|
+
child_env.merge!(ChildEnv.unset)
|
|
259
257
|
# A worker process: run_session_loop traps signals, and leaves this
|
|
260
258
|
# process's tree.
|
|
261
259
|
child_env[WORKER_PROCESS_ENV] = "1"
|
|
@@ -263,17 +261,11 @@ module Samagotchi
|
|
|
263
261
|
# A gem-mode worker activates the newest installed chi itself: under
|
|
264
262
|
# `bundle exec` (a Gemfile that has samagotchi) Bundler's RUBYOPT would
|
|
265
263
|
# hold it to the bundle's version, and its badge would never clear.
|
|
266
|
-
child_env.merge!(
|
|
264
|
+
child_env.merge!(ChildEnv.unbundled)
|
|
267
265
|
opts[:env] = child_env unless child_env.empty?
|
|
268
266
|
opts
|
|
269
267
|
end
|
|
270
268
|
|
|
271
|
-
GUARDRAILS_ENV_PREFIX = "SAMAGOTCHI_GUARDRAILS_"
|
|
272
|
-
WORKER_ENV_PREFIX = "SAMAGOTCHI_WORKER_"
|
|
273
|
-
# Tools::Builtins.parent_env's (Tools::TaskRuntime::MARKER_ENV_KEYS).
|
|
274
|
-
SESSION_MARKER_ENV = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL SAMAGOTCHI_SESSION_THINKING
|
|
275
|
-
SAMAGOTCHI_SESSION_REASONING_HISTORY].freeze
|
|
276
|
-
|
|
277
269
|
# List all sessions, reading status from persisted session.json files.
|
|
278
270
|
# +project_root+: only that project's sessions (nil: every session).
|
|
279
271
|
# +include_archived+: archived sessions too (Session#archived says which).
|
|
@@ -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
|
|
|
@@ -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!
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -28,20 +28,42 @@ module Samagotchi
|
|
|
28
28
|
|
|
29
29
|
# Runs +calls+ in order (ToolRunner#run each), yielding each run and its
|
|
30
30
|
# index as it finishes. +emit+ takes the dispatch events, the runner's
|
|
31
|
-
# own go to +on_stream_event+.
|
|
31
|
+
# own go to +on_stream_event+. An Interrupt (Ctrl-C) mid-batch yields an
|
|
32
|
+
# #interrupted run for every call left (the one it cut short too), so
|
|
33
|
+
# the loop's conversation answers each call, then goes on up: the turn
|
|
34
|
+
# keeps its progress (Engine#ctrl_c_messages).
|
|
32
35
|
# @return [Array<ToolRun>] the runs
|
|
33
36
|
def run_batch(runner, calls, iteration:, emit:, on_stream_event:, cap:)
|
|
37
|
+
# Before anything can be interrupted: the rescue counts it.
|
|
38
|
+
runs = []
|
|
34
39
|
emit.call({ type: :tool_dispatch_started, iteration: iteration, call_count: calls.length })
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
yield
|
|
39
|
-
run
|
|
40
|
+
calls.each_with_index do |call, index|
|
|
41
|
+
runs << runner.run(call, iteration: iteration, call_index: index + 1, call_count: calls.length,
|
|
42
|
+
on_stream_event: on_stream_event, max_tool_output_chars: cap)
|
|
43
|
+
yield runs.last, index if block_given?
|
|
40
44
|
end
|
|
41
45
|
emit.call({ type: :tool_dispatch_completed, iteration: iteration, call_count: calls.length })
|
|
42
46
|
runs
|
|
47
|
+
rescue Interrupt
|
|
48
|
+
calls.each_with_index.drop(runs.size).each { |call, index| yield interrupted(call[:name]), index } if block_given?
|
|
49
|
+
raise
|
|
43
50
|
end
|
|
44
51
|
|
|
52
|
+
# The result of a call an Interrupt cut short (or never started): the
|
|
53
|
+
# model reads that it may have partly run.
|
|
54
|
+
def interrupted(name)
|
|
55
|
+
text = interrupted_text(name)
|
|
56
|
+
ToolRun.new(output: text, capped_output: text)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
INTERRUPTED = "Interrupted (Ctrl-C) before it returned: it may have partly run."
|
|
60
|
+
|
|
61
|
+
def interrupted_text(name) = "[#{name}] #{INTERRUPTED}"
|
|
62
|
+
|
|
63
|
+
# Whether +content+ is an #interrupted result, whatever tool it names
|
|
64
|
+
# (an aliased call's saved name is the model's, the text the tool's).
|
|
65
|
+
def interrupted_text?(content) = content.to_s.match?(/\A\[[^\]\s]+\] #{Regexp.escape(INTERRUPTED)}\z/)
|
|
66
|
+
|
|
45
67
|
# The runs' activity, for the turn's tool list (none for a call whose
|
|
46
68
|
# dispatcher raised).
|
|
47
69
|
def activities(runs)
|
|
@@ -6,6 +6,7 @@ require "securerandom"
|
|
|
6
6
|
require "shellwords"
|
|
7
7
|
require "time"
|
|
8
8
|
require_relative "../atomic_file"
|
|
9
|
+
require_relative "../child_env"
|
|
9
10
|
require_relative "../log"
|
|
10
11
|
require_relative "../process_group"
|
|
11
12
|
require_relative "shell"
|
|
@@ -29,8 +30,7 @@ module Samagotchi
|
|
|
29
30
|
OUTPUT_TAIL_READ_BYTES = 16 * 1024
|
|
30
31
|
SANITIZED_ENV_KEYS = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_VERSION].freeze
|
|
31
32
|
# Set by chi (Builtins.parent_env); the model's env can't change it.
|
|
32
|
-
MARKER_ENV_KEYS =
|
|
33
|
-
SAMAGOTCHI_SESSION_REASONING_HISTORY].freeze
|
|
33
|
+
MARKER_ENV_KEYS = ChildEnv::SESSION_MARKERS
|
|
34
34
|
# task_wait and task_get add it to a task the user stopped (the web's
|
|
35
35
|
# stop-task button), so the model doesn't rerun the job.
|
|
36
36
|
# A record whose pid isn't a process group chi started (missing, 0, 1,
|