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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +79 -1
- data/README.md +5 -2
- data/bin/chi +4 -4
- data/docs/architecture.md +17 -13
- data/docs/bundle-authoring.md +6 -11
- data/docs/cli.md +7 -4
- data/docs/configuration.md +8 -4
- data/docs/context.md +5 -3
- data/docs/hooks.md +3 -3
- data/docs/internals/background-tasks.md +27 -2
- data/docs/internals/bundles.md +4 -4
- data/docs/internals/engine.md +21 -8
- data/docs/internals/prompt-caching.md +4 -0
- data/docs/internals/tool-guardrails.md +10 -2
- data/docs/memory.md +6 -0
- data/docs/plugins.md +30 -13
- data/docs/releasing.md +3 -0
- data/docs/sessions.md +4 -4
- data/lib/samagotchi/bridge.rb +11 -7
- 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 +26 -15
- 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 +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +5 -0
- data/lib/samagotchi/child_env.rb +50 -0
- data/lib/samagotchi/client_id.rb +3 -0
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/context_note.rb +13 -6
- data/lib/samagotchi/engine.rb +31 -15
- data/lib/samagotchi/hooks/registry.rb +2 -0
- data/lib/samagotchi/kernel_loop.rb +14 -3
- data/lib/samagotchi/last_turn.rb +3 -2
- 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_finish_note.rb +90 -0
- data/lib/samagotchi/task_watch.rb +228 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +19 -4
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -2
- 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_declarations.rb +16 -0
- data/lib/samagotchi/tool_response.rb +28 -6
- data/lib/samagotchi/tools/builtins.rb +5 -1
- data/lib/samagotchi/tools/task_record.rb +9 -0
- data/lib/samagotchi/tools/task_runtime.rb +2 -2
- data/lib/samagotchi/tools/task_wait.rb +10 -3
- 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/notify.js +4 -4
- data/lib/samagotchi/web/public/toasts.js +17 -9
- data/lib/samagotchi/web/public/turn_events.js +13 -1
- data/lib/samagotchi/web/public/unqueue.js +20 -4
- data/lib/samagotchi/worker.rb +104 -42
- data/lib/samagotchi/worker_idle_exit.rb +26 -3
- data/lib/samagotchi/worker_inbound.rb +18 -4
- data/lib/samagotchi/worker_task_wakes.rb +160 -0
- data/lib/samagotchi/worker_wakes.rb +11 -4
- 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 =
|
|
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
|
|
@@ -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
|
-
|
|
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)
|
|
@@ -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 =
|
|
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
|
-
#
|
|
84
|
-
#
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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)
|