samagotchi 0.91.0 → 0.92.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -1
- data/README.md +5 -2
- data/bin/chi +4 -4
- data/docs/architecture.md +16 -12
- data/docs/bundle-authoring.md +2 -1
- data/docs/cli.md +5 -5
- data/docs/configuration.md +3 -1
- data/docs/context.md +5 -3
- data/docs/hooks.md +3 -3
- data/docs/internals/background-tasks.md +2 -2
- data/docs/internals/bundles.md +4 -4
- data/docs/internals/engine.md +21 -8
- data/docs/internals/tool-guardrails.md +10 -2
- data/docs/memory.md +6 -0
- data/docs/plugins.md +30 -13
- data/docs/releasing.md +3 -0
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bridge.rb +8 -9
- data/lib/samagotchi/bundle_command/check.rb +43 -12
- data/lib/samagotchi/bundle_command/scaffold.rb +12 -0
- data/lib/samagotchi/bundle_command/test_run.rb +12 -0
- data/lib/samagotchi/bundle_command/try/command.rb +164 -0
- data/lib/samagotchi/bundle_command/try/render.rb +80 -0
- data/lib/samagotchi/bundle_command/try/run.rb +35 -17
- data/lib/samagotchi/bundle_command/try/sandbox.rb +113 -34
- data/lib/samagotchi/bundle_command/try/trace.rb +20 -26
- data/lib/samagotchi/bundle_command.rb +11 -213
- data/lib/samagotchi/bundle_session_settings.rb +82 -45
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -5
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +57 -12
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_env.rb +50 -0
- data/lib/samagotchi/engine.rb +22 -12
- data/lib/samagotchi/hooks/registry.rb +2 -0
- data/lib/samagotchi/kernel_loop.rb +14 -3
- data/lib/samagotchi/llm/chat_loop.rb +3 -1
- data/lib/samagotchi/llm/errors.rb +4 -4
- data/lib/samagotchi/memory_bundle/builder.rb +3 -2
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +7 -2
- data/lib/samagotchi/memory_bundle/installer.rb +1 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +42 -8
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
- data/lib/samagotchi/parent_report.rb +12 -0
- data/lib/samagotchi/reply_wait.rb +6 -2
- data/lib/samagotchi/sandbox_layout.rb +66 -0
- data/lib/samagotchi/send_command.rb +3 -3
- data/lib/samagotchi/session_manager.rb +12 -20
- data/lib/samagotchi/task_watch.rb +64 -21
- data/lib/samagotchi/testing/bundle.rb +32 -19
- data/lib/samagotchi/testing/install.rb +26 -17
- data/lib/samagotchi/testing/sandbox.rb +45 -37
- data/lib/samagotchi/testing/waiting.rb +5 -7
- data/lib/samagotchi/tool_response.rb +28 -6
- data/lib/samagotchi/tools/task_runtime.rb +2 -2
- data/lib/samagotchi/turn_checkpoint.rb +23 -10
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +24 -14
- data/lib/samagotchi/web/public/cards_place.js +4 -1
- data/lib/samagotchi/web/public/images.js +6 -0
- data/lib/samagotchi/web/public/model_pick.js +10 -0
- data/lib/samagotchi/web/public/toasts.js +17 -9
- data/lib/samagotchi/web/public/unqueue.js +20 -4
- data/lib/samagotchi/worker.rb +77 -185
- data/lib/samagotchi/worker_idle_exit.rb +20 -5
- data/lib/samagotchi/worker_inbound.rb +5 -5
- data/lib/samagotchi/worker_task_wakes.rb +160 -0
- data/lib/samagotchi/worker_wakes.rb +0 -3
- metadata +6 -1
|
@@ -2,10 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
require "open3"
|
|
4
4
|
require "rbconfig"
|
|
5
|
+
require_relative "../../process_group"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
class BundleCommand
|
|
8
9
|
module Try
|
|
10
|
+
# The sandboxed child chi of `chi bundle try`, in a process group of
|
|
11
|
+
# its own (ProcessGroup). A --timeout sends it INT (a Ctrl-C: the turn
|
|
12
|
+
# is saved), then after INTERRUPT_GRACE ProcessGroup.stop stops the
|
|
13
|
+
# group and the groups its processes started. After any exit the
|
|
14
|
+
# group is stopped only while something of the child's is still in it
|
|
15
|
+
# (#stop_stragglers): a group signal to a leader reaped with nothing
|
|
16
|
+
# left would reach whatever reuses its pid. What left the group and
|
|
17
|
+
# its parent (a delegate's worker, a task once the child exited) is
|
|
18
|
+
# Sandbox#stop_leftovers', by the records in the sandbox.
|
|
9
19
|
class Run
|
|
10
20
|
Result = Data.define(:exit_status, :stdout, :stderr, :session_id, :timed_out)
|
|
11
21
|
INTERRUPT_GRACE = 5
|
|
@@ -30,18 +40,19 @@ module Samagotchi
|
|
|
30
40
|
streams = [stdout, stderr]
|
|
31
41
|
stdin.close
|
|
32
42
|
readers = streams.map { |stream| Thread.new { read_stream(stream) } }
|
|
33
|
-
attach_child(trap_state, pid)
|
|
43
|
+
attach_child(trap_state, pid, wait)
|
|
34
44
|
timed_out = false
|
|
35
45
|
status = wait_for(wait, timeout)
|
|
36
46
|
if status == :timeout
|
|
37
47
|
timed_out = true
|
|
38
|
-
|
|
48
|
+
ProcessGroup.signal(pid, "INT")
|
|
39
49
|
status = wait_for(wait, INTERRUPT_GRACE)
|
|
40
50
|
if status == :timeout
|
|
41
|
-
|
|
51
|
+
ProcessGroup.stop(pid, grace: GROUP_TERM_GRACE, poll: POLL_INTERVAL, stopped: -> { !wait.alive? })
|
|
42
52
|
status = wait.value
|
|
43
53
|
end
|
|
44
54
|
end
|
|
55
|
+
trap_state[:pid] = nil
|
|
45
56
|
out, err = finish_readers(readers, streams)
|
|
46
57
|
code = timed_out ? CLI::Exit::FAILED : process_exit_status(status)
|
|
47
58
|
Result.new(exit_status: code, stdout: out, stderr: err, session_id: err[/^Session: (\S+)/, 1],
|
|
@@ -50,7 +61,8 @@ module Samagotchi
|
|
|
50
61
|
rescue SystemCallError => e
|
|
51
62
|
raise Sandbox::Error, "chi bundle try: couldn't run chi: #{e.message}"
|
|
52
63
|
ensure
|
|
53
|
-
|
|
64
|
+
trap_state[:pid] = nil
|
|
65
|
+
stop_stragglers(pid) if pid
|
|
54
66
|
close_streams(streams)
|
|
55
67
|
stop_readers(readers)
|
|
56
68
|
restore_traps(old_traps)
|
|
@@ -79,12 +91,13 @@ module Samagotchi
|
|
|
79
91
|
end
|
|
80
92
|
|
|
81
93
|
def trap_forwarding
|
|
82
|
-
state = { pid: nil, pending: nil }
|
|
94
|
+
state = { pid: nil, wait: nil, pending: nil }
|
|
83
95
|
old = {}
|
|
84
96
|
%w[INT TERM].each do |signal|
|
|
85
97
|
old[signal] = Signal.trap(signal) do
|
|
86
98
|
if state[:pid]
|
|
87
|
-
|
|
99
|
+
# Not once its waiter reaped it: that group may be gone.
|
|
100
|
+
ProcessGroup.signal(state[:pid], signal) if state[:wait]&.alive?
|
|
88
101
|
else
|
|
89
102
|
state[:pending] ||= signal
|
|
90
103
|
end
|
|
@@ -93,27 +106,32 @@ module Samagotchi
|
|
|
93
106
|
[state, old]
|
|
94
107
|
end
|
|
95
108
|
|
|
96
|
-
def attach_child(state, pid)
|
|
109
|
+
def attach_child(state, pid, wait)
|
|
110
|
+
state[:wait] = wait
|
|
97
111
|
state[:pid] = pid
|
|
98
112
|
pending = state[:pending]
|
|
99
113
|
state[:pending] = nil
|
|
100
|
-
|
|
114
|
+
ProcessGroup.signal(pid, pending) if pending
|
|
101
115
|
end
|
|
102
116
|
|
|
103
117
|
def restore_traps(old)
|
|
104
118
|
old.each { |signal, handler| Signal.trap(signal, handler) }
|
|
105
119
|
end
|
|
106
120
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
121
|
+
# Stops what is left in the child's group once the child (its
|
|
122
|
+
# leader) is reaped: a descendant still holding its pipes. Only
|
|
123
|
+
# while one is in it: no process left means no group, and the pid
|
|
124
|
+
# may be reused by now. While a member is left the group's id
|
|
125
|
+
# can't be reused; the reaped leader's pid can, so a group whose
|
|
126
|
+
# member is the pid itself is someone else's.
|
|
127
|
+
def stop_stragglers(pid)
|
|
128
|
+
return unless ProcessGroup.signalable?(pid) && ProcessGroup.group_alive?(pid)
|
|
129
|
+
|
|
130
|
+
members = ProcessGroup.process_table.select { |_pid, _ppid, group| group == pid }.map(&:first)
|
|
131
|
+
return if members.empty? || members.include?(pid)
|
|
112
132
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
sleep(GROUP_TERM_GRACE)
|
|
116
|
-
signal_group(pid, "KILL")
|
|
133
|
+
ProcessGroup.stop(pid, grace: GROUP_TERM_GRACE, poll: POLL_INTERVAL,
|
|
134
|
+
stopped: -> { !ProcessGroup.group_alive?(pid) })
|
|
117
135
|
end
|
|
118
136
|
|
|
119
137
|
def read_stream(stream)
|
|
@@ -6,11 +6,18 @@ require "rbconfig"
|
|
|
6
6
|
require "tmpdir"
|
|
7
7
|
require "yaml"
|
|
8
8
|
require_relative "../../config"
|
|
9
|
+
require_relative "../../child_env"
|
|
9
10
|
require_relative "../../cli/exit"
|
|
10
|
-
require_relative "../../
|
|
11
|
+
require_relative "../../memory_bundle/system_bundle"
|
|
11
12
|
require_relative "../../memory_bundle"
|
|
12
13
|
require_relative "../../memory_bundle/shas"
|
|
14
|
+
require_relative "../../owner_lock"
|
|
15
|
+
require_relative "../../process_group"
|
|
16
|
+
require_relative "../../sandbox_layout"
|
|
17
|
+
require_relative "../../session"
|
|
13
18
|
require_relative "../../session_manager"
|
|
19
|
+
require_relative "../../tools/task_record"
|
|
20
|
+
require_relative "../../tools/task_runtime"
|
|
14
21
|
|
|
15
22
|
module Samagotchi
|
|
16
23
|
class BundleCommand
|
|
@@ -19,18 +26,46 @@ module Samagotchi
|
|
|
19
26
|
LIB_DIR = File.expand_path("../../..", __dir__)
|
|
20
27
|
CHI = File.expand_path("../../../../bin/chi", __dir__)
|
|
21
28
|
SHIPPED_DIR = MemoryBundle::SourceNormalizer::SHIPPED_DIR
|
|
29
|
+
# The config.yml sections the sandbox drops (and their SAMAGOTCHI_<SECTION>_* env forms); context:
|
|
30
|
+
# loses only its sources: (#write_config).
|
|
22
31
|
CONFIG_DENYLIST = %w[
|
|
23
32
|
bundles hooks guardrails memories append_system_prompt recap session log web update kitty tmux broadcast
|
|
24
|
-
context_sources
|
|
25
33
|
].freeze
|
|
26
34
|
CONFIG_DENYLIST_ENV_PREFIXES = CONFIG_DENYLIST.map do |section|
|
|
27
35
|
prefix = if section == "guardrails"
|
|
28
|
-
|
|
36
|
+
ChildEnv::GUARDRAILS_PREFIX.delete_suffix("_")
|
|
29
37
|
else
|
|
30
38
|
"SAMAGOTCHI_#{section.upcase}"
|
|
31
39
|
end
|
|
32
40
|
prefix
|
|
33
41
|
end.freeze
|
|
42
|
+
# `chi bundle install PATH --force` for each PATH in ARGV, in one
|
|
43
|
+
# process (bin/chi's start, once): an install that fails prints
|
|
44
|
+
# what it printed and exits with its status; the ones before it
|
|
45
|
+
# print nothing.
|
|
46
|
+
INSTALL_SCRIPT = <<~RUBY
|
|
47
|
+
require "stringio"
|
|
48
|
+
require "samagotchi/utf8_default"
|
|
49
|
+
Samagotchi::Utf8Default.apply!
|
|
50
|
+
require "samagotchi/config"
|
|
51
|
+
Samagotchi::ConfigFile.load!
|
|
52
|
+
require "samagotchi"
|
|
53
|
+
require "samagotchi/bundle_command"
|
|
54
|
+
ARGV.each do |path|
|
|
55
|
+
out = StringIO.new
|
|
56
|
+
err = StringIO.new
|
|
57
|
+
status = Samagotchi::BundleCommand.new(["install", path, "--force"], stdout: out, stderr: err).run
|
|
58
|
+
next if status.zero?
|
|
59
|
+
|
|
60
|
+
$stdout.write(out.string)
|
|
61
|
+
$stderr.write(err.string)
|
|
62
|
+
exit(status)
|
|
63
|
+
end
|
|
64
|
+
RUBY
|
|
65
|
+
# How long a leftover worker or task gets after TERM (a worker saves
|
|
66
|
+
# its session) before ProcessGroup.stop KILLs it.
|
|
67
|
+
LEFTOVER_GRACE = 3.0
|
|
68
|
+
LEFTOVER_POLL = 0.05
|
|
34
69
|
class Error < StandardError
|
|
35
70
|
attr_reader :status
|
|
36
71
|
|
|
@@ -40,7 +75,7 @@ module Samagotchi
|
|
|
40
75
|
end
|
|
41
76
|
end
|
|
42
77
|
|
|
43
|
-
attr_reader :root, :config_home, :state_home, :work_dir
|
|
78
|
+
attr_reader :root, :config_home, :state_home, :work_dir
|
|
44
79
|
|
|
45
80
|
def initialize(path, bundle_settings: {}, cost_cap: 1, cost_cap_override: false)
|
|
46
81
|
@path = File.realpath(path)
|
|
@@ -48,12 +83,11 @@ module Samagotchi
|
|
|
48
83
|
@cost_cap = cost_cap
|
|
49
84
|
@cost_cap_override = cost_cap_override
|
|
50
85
|
read_manifest
|
|
51
|
-
@
|
|
52
|
-
@
|
|
53
|
-
@
|
|
54
|
-
@
|
|
55
|
-
|
|
56
|
-
@work_dir = File.realpath(@work_dir)
|
|
86
|
+
@layout = SandboxLayout.create("chi-bundle-try-")
|
|
87
|
+
@root = @layout.root
|
|
88
|
+
@config_home = @layout.config_home
|
|
89
|
+
@state_home = @layout.state_home
|
|
90
|
+
@work_dir = @layout.work_dir
|
|
57
91
|
write_config
|
|
58
92
|
@child_env = build_child_env
|
|
59
93
|
rescue StandardError
|
|
@@ -63,20 +97,43 @@ module Samagotchi
|
|
|
63
97
|
|
|
64
98
|
def child_env = @child_env.dup
|
|
65
99
|
|
|
100
|
+
# Installs the tried bundle, then the shipped guardrails and
|
|
101
|
+
# cost-cap (unless it is one of them), each as `chi bundle install
|
|
102
|
+
# PATH --force` in one child chi with the sandbox's environment.
|
|
103
|
+
# @return [MemoryBundle::Manifest]
|
|
104
|
+
# @raise [Error] what the first install that failed printed
|
|
66
105
|
def install
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
106
|
+
paths = [@path] + %w[guardrails cost-cap].reject { |name| @manifest.name == name }
|
|
107
|
+
.map { |name| File.join(SHIPPED_DIR, name) }
|
|
108
|
+
out, err, status = Open3.capture3(child_env, RbConfig.ruby, "-I", LIB_DIR, "-e", INSTALL_SCRIPT, *paths,
|
|
109
|
+
chdir: work_dir)
|
|
110
|
+
unless status.success?
|
|
111
|
+
message = [out, err].reject(&:empty?).join("\n").strip
|
|
112
|
+
raise Error, "chi bundle try: #{message.empty? ? "bundle install failed" : message}"
|
|
72
113
|
end
|
|
73
114
|
@manifest
|
|
74
115
|
end
|
|
75
116
|
|
|
76
117
|
def cleanup
|
|
118
|
+
stop_leftovers
|
|
77
119
|
FileUtils.rm_rf(root) if root && File.exist?(root)
|
|
78
120
|
end
|
|
79
121
|
|
|
122
|
+
# Stops what the run started that outlives its child chi: the
|
|
123
|
+
# workers of its delegates (they leave the child's process tree,
|
|
124
|
+
# SessionManager.detach_from_spawner) and its tasks (a group of
|
|
125
|
+
# their own each, reparented once the child exits). Found by their
|
|
126
|
+
# records in the sandbox, so nothing outside it: a session's lock
|
|
127
|
+
# held by a group leader, a running task's record whose pid still
|
|
128
|
+
# leads its group and started when the record says (#same_process?). Workers
|
|
129
|
+
# first: one may start a task as it stops.
|
|
130
|
+
def stop_leftovers
|
|
131
|
+
return unless root && File.directory?(root)
|
|
132
|
+
|
|
133
|
+
leftover_workers.each { |pid| stop_group(pid) }
|
|
134
|
+
leftover_tasks.each { |pid| stop_group(pid) }
|
|
135
|
+
end
|
|
136
|
+
|
|
80
137
|
private
|
|
81
138
|
|
|
82
139
|
def write_config
|
|
@@ -92,9 +149,7 @@ module Samagotchi
|
|
|
92
149
|
data["bundles"][@manifest.name] ||= {}
|
|
93
150
|
data["bundles"][@manifest.name].merge!(@bundle_settings)
|
|
94
151
|
data["bundles"]["cost-cap"]["cap"] = @cost_cap if @cost_cap_override
|
|
95
|
-
|
|
96
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
97
|
-
File.write(path, YAML.dump(data))
|
|
152
|
+
@layout.write_config(data)
|
|
98
153
|
end
|
|
99
154
|
|
|
100
155
|
def read_manifest
|
|
@@ -121,36 +176,60 @@ module Samagotchi
|
|
|
121
176
|
raise Error, "chi bundle try: manifest.yml is not valid YAML (#{e.message})"
|
|
122
177
|
end
|
|
123
178
|
|
|
124
|
-
def
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
179
|
+
def leftover_workers
|
|
180
|
+
sessions = File.join(state_home, Session::STATE_SUBDIR)
|
|
181
|
+
Dir.glob(File.join(sessions, "*", OwnerLock::FILE)).filter_map do |lock|
|
|
182
|
+
pid = OwnerLock.owner(File.dirname(lock))&.pid
|
|
183
|
+
pid if ProcessGroup.leader?(pid)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def leftover_tasks
|
|
188
|
+
pattern = File.join(work_dir, Tools::TaskRuntime::TASKS_DIR, "*", Tools::TaskRuntime::METADATA_FILENAME)
|
|
189
|
+
Dir.glob(pattern).filter_map do |file|
|
|
190
|
+
record = Tools::TaskRecord.from_file(JSON.parse(File.read(file)))
|
|
191
|
+
record.pid if record&.running? && same_process?(record)
|
|
192
|
+
rescue JSON::ParserError, SystemCallError
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Stricter than TaskRuntime.chis_group?: a record with no start time
|
|
198
|
+
# (a hand-over not adopted yet) or a start time that can't be read
|
|
199
|
+
# now is skipped, never a group whose leader may reuse the pid.
|
|
200
|
+
def same_process?(record)
|
|
201
|
+
started = record.pid_started
|
|
202
|
+
return false unless started && ProcessGroup.signalable?(record.pid)
|
|
203
|
+
|
|
204
|
+
ProcessGroup.start_time(record.pid) == started && ProcessGroup.leader?(record.pid)
|
|
205
|
+
end
|
|
128
206
|
|
|
129
|
-
|
|
130
|
-
|
|
207
|
+
def stop_group(pid)
|
|
208
|
+
ProcessGroup.stop(pid, grace: LEFTOVER_GRACE, poll: LEFTOVER_POLL)
|
|
209
|
+
rescue ArgumentError, SystemCallError
|
|
210
|
+
nil
|
|
131
211
|
end
|
|
132
212
|
|
|
213
|
+
# The user's environment (their hosts and keys) minus what no child
|
|
214
|
+
# chi takes from its spawner (ChildEnv), the worker markers, this
|
|
215
|
+
# chi's log and test settings, and the env forms of the config
|
|
216
|
+
# sections the sandbox drops.
|
|
133
217
|
def build_child_env
|
|
134
|
-
env = ENV.to_h
|
|
135
|
-
SessionManager::SESSION_MARKER_ENV.each { |key| env.delete(key) }
|
|
218
|
+
env = ChildEnv.scrub(ENV.to_h)
|
|
136
219
|
env.delete("SAMAGOTCHI_LOG_FILE")
|
|
137
220
|
env.delete("SAMAGOTCHI_LOG_DISABLE")
|
|
138
|
-
env.delete(
|
|
221
|
+
env.delete(MemoryBundle::SystemBundle::SKIP_ENV)
|
|
139
222
|
env.delete("SAMAGOTCHI_ENV") if env["SAMAGOTCHI_ENV"] == "test"
|
|
140
|
-
env.delete("SAMAGOTCHI_INSTALLED_VERSION")
|
|
141
223
|
env.delete_if do |key, _|
|
|
142
|
-
key.start_with?(
|
|
224
|
+
key.start_with?(ChildEnv::WORKER_PREFIX) || key == SessionManager::BOOT_FALLBACK_ENV ||
|
|
143
225
|
CONFIG_DENYLIST_ENV_PREFIXES.any? { |prefix| key == prefix || key.start_with?("#{prefix}_") }
|
|
144
226
|
end
|
|
145
227
|
env.merge!(
|
|
146
228
|
"HOME" => Dir.home,
|
|
147
229
|
"SAMAGOTCHI_KEYCHAIN" => "on",
|
|
148
|
-
|
|
149
|
-
"XDG_STATE_HOME" => state_home,
|
|
150
|
-
"SAMAGOTCHI_RECAP_ENABLED" => "false",
|
|
151
|
-
"SAMAGOTCHI_CACHE_WARMUP" => "off"
|
|
230
|
+
**@layout.env
|
|
152
231
|
)
|
|
153
|
-
env.merge!(
|
|
232
|
+
env.merge!(ChildEnv.unbundled(env))
|
|
154
233
|
env
|
|
155
234
|
end
|
|
156
235
|
end
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require_relative "../../analytics_file"
|
|
5
|
+
require_relative "../../bridge/card_store"
|
|
6
|
+
require_relative "../../session"
|
|
5
7
|
require_relative "../../session_metrics"
|
|
8
|
+
require_relative "../../tool_response"
|
|
6
9
|
|
|
7
10
|
module Samagotchi
|
|
8
11
|
class BundleCommand
|
|
@@ -15,25 +18,23 @@ module Samagotchi
|
|
|
15
18
|
|
|
16
19
|
class TraceReader
|
|
17
20
|
DENIAL_TEXT = "No one to approve it (non-interactive run)."
|
|
18
|
-
SESSION_ID = /\A[A-Za-z0-9_-]+\z/
|
|
19
21
|
STDERR_LIMIT = 4000
|
|
20
22
|
|
|
21
23
|
def initialize(state_home:, session_id:)
|
|
22
24
|
@state_home = state_home
|
|
23
|
-
@session_id = session_id if
|
|
25
|
+
@session_id = session_id if Session.valid_id?(session_id)
|
|
24
26
|
end
|
|
25
27
|
|
|
26
28
|
def read(exit_code:, answer:, stderr:, sandbox:, timed_out: false)
|
|
27
29
|
return empty_trace(exit_code, answer, stderr, sandbox, timed_out) unless @session_id
|
|
28
30
|
|
|
29
|
-
sessions_dir = File.join(@state_home,
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
session = json_file(session_path) || {}
|
|
31
|
+
sessions_dir = File.join(@state_home, Session::STATE_SUBDIR)
|
|
32
|
+
session_dir = Session.session_dir(@session_id, state_dir: sessions_dir)
|
|
33
|
+
session = json_file(Session.session_file(@session_id, state_dir: sessions_dir)) || {}
|
|
33
34
|
messages = Array(session["messages"]).select { |message| message.is_a?(Hash) }
|
|
34
35
|
analytics = AnalyticsFile.read(session_dir) || {}
|
|
35
36
|
entries = card_entries(session_dir)
|
|
36
|
-
tool_rows, denied = tools(messages, analytics
|
|
37
|
+
tool_rows, denied = tools(messages, analytics)
|
|
37
38
|
summary = SessionMetrics.saved_summary(session_dir)
|
|
38
39
|
cost, estimate = cost_summary(summary)
|
|
39
40
|
questions = entries.select { |entry| entry["type"] == "question" }
|
|
@@ -62,7 +63,11 @@ module Samagotchi
|
|
|
62
63
|
timed_out: timed_out)
|
|
63
64
|
end
|
|
64
65
|
|
|
65
|
-
|
|
66
|
+
# One row per saved call. A turn a --timeout interrupt cut (a
|
|
67
|
+
# Ctrl-C) saves its calls too, the one it cut short answered as
|
|
68
|
+
# interrupted (ToolResponse.interrupted): a record has no row of its
|
|
69
|
+
# own, so no call shows twice.
|
|
70
|
+
def tools(messages, analytics)
|
|
66
71
|
records = Array(analytics["tool_records"]).select { |record| record.is_a?(Hash) }
|
|
67
72
|
.to_h do |record|
|
|
68
73
|
[[record["iteration"].to_i, record["call_index"].to_i], record]
|
|
@@ -86,6 +91,7 @@ module Samagotchi
|
|
|
86
91
|
is_denied = response && response["content"].to_s.include?(DENIAL_TEXT)
|
|
87
92
|
denied << index if is_denied
|
|
88
93
|
status = is_denied ? "denied" : record&.dig("status").to_s
|
|
94
|
+
status = "interrupted" if status.empty? && response && ToolResponse.interrupted_text?(response["content"])
|
|
89
95
|
status = "unknown" if status.empty?
|
|
90
96
|
{
|
|
91
97
|
index: index,
|
|
@@ -96,24 +102,9 @@ module Samagotchi
|
|
|
96
102
|
}
|
|
97
103
|
end
|
|
98
104
|
end
|
|
99
|
-
rows += unsaved_rows(records, iteration, index) if timed_out
|
|
100
105
|
[rows, denied]
|
|
101
106
|
end
|
|
102
107
|
|
|
103
|
-
# A turn cut by a --timeout interrupt saves only the messages before
|
|
104
|
-
# it (a Ctrl-C), while its tool records stay: the records past the
|
|
105
|
-
# last saved model message are its calls, without their arguments.
|
|
106
|
-
# Only then: a normal run's loop also counts iterations it saves no
|
|
107
|
-
# model message for (a cut's retry, an empty-answer nudge).
|
|
108
|
-
def unsaved_rows(records, saved_iterations, index)
|
|
109
|
-
records.select { |(iteration, _), _| iteration > saved_iterations }.sort.map do |_, record|
|
|
110
|
-
index += 1
|
|
111
|
-
status = record["status"].to_s
|
|
112
|
-
{ index: index, name: record["called_as"] || record["tool"] || "tool", args: "",
|
|
113
|
-
status: status.empty? ? "unknown" : status, duration_ms: record["duration_ms"].to_i }
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
|
|
117
108
|
def call_name(call)
|
|
118
109
|
function = call["function"].is_a?(Hash) ? call["function"] : {}
|
|
119
110
|
call["name"] || function["name"] || "tool"
|
|
@@ -132,10 +123,13 @@ module Samagotchi
|
|
|
132
123
|
args.to_s.gsub(/\s+/, " ").strip[0, 200]
|
|
133
124
|
end
|
|
134
125
|
|
|
126
|
+
# The listing's placement fields: nothing to place in a one-turn run.
|
|
127
|
+
LISTING_FIELDS = %w[turns_since current earlier].freeze
|
|
128
|
+
|
|
129
|
+
# The saved cards as the web reads them (CardStore.saved), with
|
|
130
|
+
# string keys and values, as the JSON trace prints them.
|
|
135
131
|
def card_entries(session_dir)
|
|
136
|
-
|
|
137
|
-
entries = data.is_a?(Hash) ? data["entries"] : []
|
|
138
|
-
Array(entries).select { |entry| entry.is_a?(Hash) }
|
|
132
|
+
JSON.parse(JSON.generate(Bridge::CardStore.saved(session_dir))).map { |entry| entry.except(*LISTING_FIELDS) }
|
|
139
133
|
end
|
|
140
134
|
|
|
141
135
|
def unanswerable?(question)
|