samagotchi 0.90.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -1
  3. data/README.md +5 -2
  4. data/bin/chi +4 -4
  5. data/docs/architecture.md +17 -13
  6. data/docs/bundle-authoring.md +6 -11
  7. data/docs/cli.md +7 -4
  8. data/docs/configuration.md +8 -4
  9. data/docs/context.md +5 -3
  10. data/docs/hooks.md +3 -3
  11. data/docs/internals/background-tasks.md +27 -2
  12. data/docs/internals/bundles.md +4 -4
  13. data/docs/internals/engine.md +21 -8
  14. data/docs/internals/prompt-caching.md +4 -0
  15. data/docs/internals/tool-guardrails.md +10 -2
  16. data/docs/memory.md +6 -0
  17. data/docs/plugins.md +30 -13
  18. data/docs/releasing.md +3 -0
  19. data/docs/sessions.md +4 -4
  20. data/lib/samagotchi/bridge.rb +11 -7
  21. data/lib/samagotchi/bundle_command/check.rb +43 -12
  22. data/lib/samagotchi/bundle_command/scaffold.rb +12 -0
  23. data/lib/samagotchi/bundle_command/test_run.rb +12 -0
  24. data/lib/samagotchi/bundle_command/try/command.rb +164 -0
  25. data/lib/samagotchi/bundle_command/try/render.rb +80 -0
  26. data/lib/samagotchi/bundle_command/try/run.rb +35 -17
  27. data/lib/samagotchi/bundle_command/try/sandbox.rb +113 -34
  28. data/lib/samagotchi/bundle_command/try/trace.rb +26 -15
  29. data/lib/samagotchi/bundle_command.rb +11 -213
  30. data/lib/samagotchi/bundle_session_settings.rb +82 -45
  31. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -5
  33. data/lib/samagotchi/bundles/cost-cap/plugin.rb +57 -12
  34. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/system/self_map.md +5 -0
  36. data/lib/samagotchi/child_env.rb +50 -0
  37. data/lib/samagotchi/client_id.rb +3 -0
  38. data/lib/samagotchi/config.rb +3 -0
  39. data/lib/samagotchi/context_note.rb +13 -6
  40. data/lib/samagotchi/engine.rb +31 -15
  41. data/lib/samagotchi/hooks/registry.rb +2 -0
  42. data/lib/samagotchi/kernel_loop.rb +14 -3
  43. data/lib/samagotchi/last_turn.rb +3 -2
  44. data/lib/samagotchi/llm/chat_loop.rb +3 -1
  45. data/lib/samagotchi/llm/errors.rb +4 -4
  46. data/lib/samagotchi/memory_bundle/builder.rb +3 -2
  47. data/lib/samagotchi/memory_bundle/installed_bundle.rb +7 -2
  48. data/lib/samagotchi/memory_bundle/installer.rb +1 -0
  49. data/lib/samagotchi/memory_bundle/manifest.rb +42 -8
  50. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  51. data/lib/samagotchi/parent_report.rb +12 -0
  52. data/lib/samagotchi/reply_wait.rb +6 -2
  53. data/lib/samagotchi/sandbox_layout.rb +66 -0
  54. data/lib/samagotchi/send_command.rb +3 -3
  55. data/lib/samagotchi/session_manager.rb +12 -20
  56. data/lib/samagotchi/task_finish_note.rb +90 -0
  57. data/lib/samagotchi/task_watch.rb +228 -0
  58. data/lib/samagotchi/terminal_ui/attached_loop.rb +19 -4
  59. data/lib/samagotchi/terminal_ui/formatting.rb +7 -2
  60. data/lib/samagotchi/testing/bundle.rb +32 -19
  61. data/lib/samagotchi/testing/install.rb +26 -17
  62. data/lib/samagotchi/testing/sandbox.rb +45 -37
  63. data/lib/samagotchi/testing/waiting.rb +5 -7
  64. data/lib/samagotchi/tool_declarations.rb +16 -0
  65. data/lib/samagotchi/tool_response.rb +28 -6
  66. data/lib/samagotchi/tools/builtins.rb +5 -1
  67. data/lib/samagotchi/tools/task_record.rb +9 -0
  68. data/lib/samagotchi/tools/task_runtime.rb +2 -2
  69. data/lib/samagotchi/tools/task_wait.rb +10 -3
  70. data/lib/samagotchi/turn_checkpoint.rb +23 -10
  71. data/lib/samagotchi/version.rb +1 -1
  72. data/lib/samagotchi/web/public/app.js +24 -14
  73. data/lib/samagotchi/web/public/cards_place.js +4 -1
  74. data/lib/samagotchi/web/public/images.js +6 -0
  75. data/lib/samagotchi/web/public/model_pick.js +10 -0
  76. data/lib/samagotchi/web/public/notify.js +4 -4
  77. data/lib/samagotchi/web/public/toasts.js +17 -9
  78. data/lib/samagotchi/web/public/turn_events.js +13 -1
  79. data/lib/samagotchi/web/public/unqueue.js +20 -4
  80. data/lib/samagotchi/worker.rb +104 -42
  81. data/lib/samagotchi/worker_idle_exit.rb +26 -3
  82. data/lib/samagotchi/worker_inbound.rb +18 -4
  83. data/lib/samagotchi/worker_task_wakes.rb +160 -0
  84. data/lib/samagotchi/worker_wakes.rb +11 -4
  85. metadata +8 -1
@@ -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
@@ -518,6 +518,22 @@ module Samagotchi
518
518
 
519
519
  GEMMA_QUOTE = '<|"|>'
520
520
 
521
+ # What a worker's finish wake (session.task_wakes not off) lets the
522
+ # model know: a task it leaves running is reported when it ends.
523
+ TASK_FINISH_LINE = "if you end your turn before it finishes, chi tells you when it does."
524
+
525
+ # +schema+ with TASK_FINISH_LINE in execute's hand-over sentence and at
526
+ # task_create's end; any other schema as given.
527
+ def with_task_finish_line(schema)
528
+ case schema[:name]
529
+ when "execute"
530
+ schema.merge(description: schema[:description].sub("and task_wait waits for it.",
531
+ "and task_wait waits for it; #{TASK_FINISH_LINE}"))
532
+ when "task_create" then schema.merge(description: "#{schema[:description]} #{TASK_FINISH_LINE.sub(/\Ai/, "I")}")
533
+ else schema
534
+ end
535
+ end
536
+
521
537
  # +schema+ without execute's description property: what a session
522
538
  # with execute.description off declares (the parameter isn't offered
523
539
  # at all). Any other schema as given.
@@ -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)
@@ -185,13 +185,17 @@ module Samagotchi
185
185
  # description parameter (execute.description off)
186
186
  # @param policy [String, nil] llm_context.policy, the sentence
187
187
  # forget_outputs' description carries
188
+ # @param task_finish_notes [Boolean] a worker's session whose finish
189
+ # wake reports a task left running (session.task_wakes not off):
190
+ # execute and task_create say so (ToolDeclarations::TASK_FINISH_LINE)
188
191
  # @return [Registry] a new registry with the built-ins, in
189
192
  # TOOL_SCHEMAS order (an Engine's own, which bundles add to), then
190
193
  # forget_outputs, offered only under the forget layer
191
- def registry(command_description: true, policy: ToolDeclarations::FORGET_OUTPUTS_POLICY)
194
+ def registry(command_description: true, policy: ToolDeclarations::FORGET_OUTPUTS_POLICY, task_finish_notes: false)
192
195
  by_name = CLASSES.to_h { |klass| [klass::NAME, klass] }
193
196
  registry = ToolDeclarations::TOOL_SCHEMAS.each_with_object(Registry.new) do |schema, built|
194
197
  schema = ToolDeclarations.without_command_description(schema) unless command_description
198
+ schema = ToolDeclarations.with_task_finish_line(schema) if task_finish_notes
195
199
  klass = by_name.fetch(schema[:name])
196
200
  handler = HANDLERS.fetch(klass::NAME) { ->(call, _kctx) { klass.call(call[:content]) } }
197
201
  built.register(klass::NAME, schema: schema, handler: handler)
@@ -44,6 +44,15 @@ module Samagotchi
44
44
  to_h.reject { |key, value| value.nil? && self.class::OPTIONAL.include?(key) }.transform_keys(&:to_s)
45
45
  end
46
46
 
47
+ # A task id as people read it: the random part a task_create id ends
48
+ # with (TaskRuntime.generate_task_id starts it with the time, the same
49
+ # for every task that second), else its first 8 chars. The web's
50
+ # turn_events.js has the JS twin (shortTaskId).
51
+ def self.short_id(id)
52
+ id = id.to_s
53
+ id[/-(\h{8})\z/, 1] || id[0, 8]
54
+ end
55
+
47
56
  def running? = status == "running"
48
57
 
49
58
  def stopped_by_user? = stop_reason == "stopped_by_user"
@@ -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,
@@ -80,12 +80,19 @@ module Samagotchi
80
80
  end
81
81
  private_class_method :cancelled_response
82
82
 
83
- # Always give the log's last lines: a test run's summary, an error. A
84
- # task the user stopped also says so.
85
- def self.finished_response(record, tail_lines)
83
+ # A finished task's text, as task_wait gives it: its status lines and
84
+ # always the log's last lines (a test run's summary, an error), then a
85
+ # stderr_tail when the record has a non-empty one. A task the user
86
+ # stopped also says so. Public: a task finish wake's note
87
+ # (TaskFinishNote) carries the same text.
88
+ # @param record [TaskRecord] a finished one
89
+ # @return [String] a RefText of its output_path when it has one
90
+ def self.finished_text(record, tail_lines: TAIL_LINES_DEFAULT)
86
91
  extra = record.stopped_by_user? ? [TaskRuntime::USER_STOP_NOTE] : []
87
92
  format_response(record, output_tail: output_tail(record, tail_lines), tail_lines: tail_lines, extra: extra)
88
93
  end
94
+
95
+ def self.finished_response(record, tail_lines) = finished_text(record, tail_lines: tail_lines)
89
96
  private_class_method :finished_response
90
97
 
91
98
  def self.output_tail(record, tail_lines)
@@ -91,16 +91,25 @@ module Samagotchi
91
91
  end
92
92
  private_class_method :settled_record
93
93
 
94
+ # A command's status (its command_ran's) as a settled outcome; any
95
+ # other status (busy, dropped) is a cancel for that reason.
96
+ COMMAND_OUTCOMES = { "ok" => "completed", "error" => "failed" }.freeze
97
+ # Its record's origin: no turn ran (ParentReport says "the command").
98
+ COMMAND_ORIGIN = "command"
99
+
94
100
  # A file-delivered command ends its sender's wait without running a
95
- # turn. Keep a terminal outcome under the same per-input settled path.
96
- def self.record_command_outcome(session_dir, enqueued_id)
101
+ # turn. Keep a terminal outcome under the same per-input settled path:
102
+ # +status+ is its command_ran's ("ok", "error", "busy", "dropped" at a
103
+ # teardown), so the wait reads completed, failed, or canceled with that
104
+ # reason.
105
+ def self.record_command_outcome(session_dir, enqueued_id, status:)
97
106
  id = enqueued_id.to_s
98
107
  return if id.empty? || !SessionInbox::ENQUEUED_ID_NAME.match?(id)
99
108
 
100
- dir = File.join(session_dir, SessionInbox::INPUT_DIR, SETTLED_DIR)
101
- FileUtils.mkdir_p(dir)
102
- record = LastTurn.new(outcome: "completed", ended_at: Time.now.iso8601(3), enqueued_id: id)
103
- AtomicFile.write(File.join(dir, id), JSON.generate(record.to_file))
109
+ outcome = COMMAND_OUTCOMES.fetch(status.to_s, "canceled")
110
+ record = LastTurn.new(outcome: outcome, cancel_reason: (status.to_s if outcome == "canceled"),
111
+ ended_at: Time.now.iso8601(3), origin: COMMAND_ORIGIN, enqueued_id: id)
112
+ write_settled(session_dir, id, record)
104
113
  rescue SystemCallError, IOError => e
105
114
  Log.warn(:worker, "command_outcome_write_failed", error: e.class.name, msg: e.message, enqueued_id: id)
106
115
  end
@@ -109,14 +118,18 @@ module Samagotchi
109
118
  id = mark.enqueued_id.to_s
110
119
  return unless SessionInbox::ENQUEUED_ID_NAME.match?(id)
111
120
 
112
- dir = File.join(session_dir, SessionInbox::INPUT_DIR, SETTLED_DIR)
113
- FileUtils.mkdir_p(dir)
114
- record = settled_record(id, ended_at: Time.now.iso8601(3), seconds: seconds)
115
- AtomicFile.write(File.join(dir, id), JSON.generate(record.to_file))
121
+ write_settled(session_dir, id, settled_record(id, ended_at: Time.now.iso8601(3), seconds: seconds))
116
122
  rescue SystemCallError => e
117
123
  # The settle goes on; a wait for that turn runs to its timeout.
118
124
  Log.warn(:worker, "settled_turn_write_failed", error: e.class.name, msg: e.message)
119
125
  end
126
+
127
+ def self.write_settled(session_dir, id, record)
128
+ dir = File.join(session_dir, SessionInbox::INPUT_DIR, SETTLED_DIR)
129
+ FileUtils.mkdir_p(dir)
130
+ AtomicFile.write(File.join(dir, id), JSON.generate(record.to_file))
131
+ end
132
+ private_class_method :write_settled
120
133
  private_class_method :record_settled
121
134
 
122
135
  # @param session [Session] the worker's session (saved as a whole)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.90.0"
4
+ VERSION = "0.92.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end