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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -1
  3. data/README.md +5 -2
  4. data/bin/chi +4 -4
  5. data/docs/architecture.md +16 -12
  6. data/docs/bundle-authoring.md +2 -1
  7. data/docs/cli.md +5 -5
  8. data/docs/configuration.md +3 -1
  9. data/docs/context.md +5 -3
  10. data/docs/hooks.md +3 -3
  11. data/docs/internals/background-tasks.md +2 -2
  12. data/docs/internals/bundles.md +4 -4
  13. data/docs/internals/engine.md +21 -8
  14. data/docs/internals/tool-guardrails.md +10 -2
  15. data/docs/memory.md +6 -0
  16. data/docs/plugins.md +30 -13
  17. data/docs/releasing.md +3 -0
  18. data/docs/sessions.md +3 -3
  19. data/lib/samagotchi/bridge.rb +8 -9
  20. data/lib/samagotchi/bundle_command/check.rb +43 -12
  21. data/lib/samagotchi/bundle_command/scaffold.rb +12 -0
  22. data/lib/samagotchi/bundle_command/test_run.rb +12 -0
  23. data/lib/samagotchi/bundle_command/try/command.rb +164 -0
  24. data/lib/samagotchi/bundle_command/try/render.rb +80 -0
  25. data/lib/samagotchi/bundle_command/try/run.rb +35 -17
  26. data/lib/samagotchi/bundle_command/try/sandbox.rb +113 -34
  27. data/lib/samagotchi/bundle_command/try/trace.rb +20 -26
  28. data/lib/samagotchi/bundle_command.rb +11 -213
  29. data/lib/samagotchi/bundle_session_settings.rb +82 -45
  30. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -5
  32. data/lib/samagotchi/bundles/cost-cap/plugin.rb +57 -12
  33. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  34. data/lib/samagotchi/child_env.rb +50 -0
  35. data/lib/samagotchi/engine.rb +22 -12
  36. data/lib/samagotchi/hooks/registry.rb +2 -0
  37. data/lib/samagotchi/kernel_loop.rb +14 -3
  38. data/lib/samagotchi/llm/chat_loop.rb +3 -1
  39. data/lib/samagotchi/llm/errors.rb +4 -4
  40. data/lib/samagotchi/memory_bundle/builder.rb +3 -2
  41. data/lib/samagotchi/memory_bundle/installed_bundle.rb +7 -2
  42. data/lib/samagotchi/memory_bundle/installer.rb +1 -0
  43. data/lib/samagotchi/memory_bundle/manifest.rb +42 -8
  44. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  45. data/lib/samagotchi/parent_report.rb +12 -0
  46. data/lib/samagotchi/reply_wait.rb +6 -2
  47. data/lib/samagotchi/sandbox_layout.rb +66 -0
  48. data/lib/samagotchi/send_command.rb +3 -3
  49. data/lib/samagotchi/session_manager.rb +12 -20
  50. data/lib/samagotchi/task_watch.rb +64 -21
  51. data/lib/samagotchi/testing/bundle.rb +32 -19
  52. data/lib/samagotchi/testing/install.rb +26 -17
  53. data/lib/samagotchi/testing/sandbox.rb +45 -37
  54. data/lib/samagotchi/testing/waiting.rb +5 -7
  55. data/lib/samagotchi/tool_response.rb +28 -6
  56. data/lib/samagotchi/tools/task_runtime.rb +2 -2
  57. data/lib/samagotchi/turn_checkpoint.rb +23 -10
  58. data/lib/samagotchi/version.rb +1 -1
  59. data/lib/samagotchi/web/public/app.js +24 -14
  60. data/lib/samagotchi/web/public/cards_place.js +4 -1
  61. data/lib/samagotchi/web/public/images.js +6 -0
  62. data/lib/samagotchi/web/public/model_pick.js +10 -0
  63. data/lib/samagotchi/web/public/toasts.js +17 -9
  64. data/lib/samagotchi/web/public/unqueue.js +20 -4
  65. data/lib/samagotchi/worker.rb +77 -185
  66. data/lib/samagotchi/worker_idle_exit.rb +20 -5
  67. data/lib/samagotchi/worker_inbound.rb +5 -5
  68. data/lib/samagotchi/worker_task_wakes.rb +160 -0
  69. data/lib/samagotchi/worker_wakes.rb +0 -3
  70. 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_* and the session markers (SESSION_MARKER_ENV).
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
- # The spawner's environment must not reach the worker's guardrails (a
248
- # parent agent starting or waking a session): Process.spawn unsets a
249
- # key mapped to nil. config.yml no longer reads them; this keeps an
250
- # older or future env-exposed one out as well.
251
- ENV.each_key { |key| child_env[key] = nil if key.start_with?(GUARDRAILS_ENV_PREFIX) }
252
- # The markers a session's execute exports (Tools::Builtins.parent_env)
253
- # are that session's: a worker started from its commands (chi send)
254
- # is another session. Its execute and tasks set its own; nothing else
255
- # it runs (a user's !cmd, an MCP server, a context command) has one.
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!(InstalledGem.unbundled_env_changes) if InstalledGem.spec
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
- Entry = Data.define(:id, :limit_sec, :note_only) do
23
- def initialize(id:, limit_sec: nil, note_only: false) = super
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 wake.
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
- def entry(id) = @mutex.synchronize { @entries[id] }
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
- entries = @mutex.synchronize { @entries.values }
111
- return [] if entries.empty?
126
+ watched = entries
127
+ return [] if watched.empty?
112
128
 
113
- entries.filter_map do |entry|
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
- next drop(entry) if error || record.nil?
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 own entry).
151
+ # watched again, the failure counted (an id watched meanwhile keeps its
152
+ # own entry).
133
153
  def put_back(entries)
134
- @mutex.synchronize { entries.each { |entry| @entries[entry.id] ||= entry } }
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 still running ({id:, command:}), for the idle exit
147
- # and /exit (WorkerIdleExit's running_tasks).
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
- ids.filter_map do |id|
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
- def drop(entry, kept: false, log: nil)
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
- Log.debug(:worker, "task_watch_dropped", task_id: entry.id, reason: log) if log && removed
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 = File.expand_path("../bundles", __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
- Plugin::Registries.new(
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 }, flag: "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[:commands]
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 = Plugin::Registries.new(
61
- commands: SessionCommands.register_builtins(Commands::Registry.new),
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
- services: Plugin::Services.new, stage_tools: ->(*) {},
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
- # commands ([name, mid_turn, annotation labels]), tools, hooks per
83
- # event ([bundle, hook, priority]; Hooks::EVENTS, then any other event
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 [Hash]
90
+ # @return [Registered]
88
91
  def registered(registries, name, inits)
89
- {
90
- commands: registries.commands.entries.select { |e| e.source == name }.map { |e| [e.name, e.mid_turn, e.annotations.map(&:label)] },
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 = ->(event) { hooks.bundle_hook_names(event).select { |bundle, _, _| bundle == name } }
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
- @root = File.realpath(Dir.mktmpdir("chi-bundle-test-"))
59
- @saved = ENV.to_h
60
- @outer = Sandbox.current
61
- ENV.each_key.select { |key| key.start_with?("SAMAGOTCHI_") && !KEPT.include?(key) }.each { |key| ENV.delete(key) }
62
- %w[config state data cache home work bin].each { |dir| FileUtils.mkdir_p(File.join(@root, dir)) }
63
- File.write(File.join(@root, "gitconfig"), "")
64
- ENV.update(
65
- "HOME" => File.join(@root, "home"),
66
- "XDG_CONFIG_HOME" => File.join(@root, "config"),
67
- "XDG_STATE_HOME" => File.join(@root, "state"),
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
- path = File.join(Paths.config_home, Paths::APP_DIR, "config.yml")
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
- def process_gone?(pid)
29
- Process.kill(0, pid)
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
- runs = calls.each_with_index.map do |call, index|
36
- run = runner.run(call, iteration: iteration, call_index: index + 1, call_count: calls.length,
37
- on_stream_event: on_stream_event, max_tool_output_chars: cap)
38
- yield run, index if block_given?
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 = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL SAMAGOTCHI_SESSION_THINKING
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,