samagotchi 0.82.0 → 0.84.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 +28 -1
- data/README.md +2 -2
- data/bin/chi +27 -2
- data/docs/architecture.md +3 -0
- data/docs/cli.md +1 -0
- data/docs/configuration.md +52 -9
- data/docs/hooks.md +1 -1
- data/docs/internals/bundles.md +1 -1
- data/docs/memory.md +1 -1
- data/docs/plugins.md +85 -3
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +13 -1
- data/docs/sub-agent.md +8 -0
- data/lib/samagotchi/bundle_session_settings.rb +212 -0
- data/lib/samagotchi/bundles/core/manifest.yml +4 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +13 -0
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +235 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +25 -8
- data/lib/samagotchi/engine.rb +2 -1
- data/lib/samagotchi/memory_bundle/builder.rb +3 -2
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +6 -3
- data/lib/samagotchi/memory_bundle/installer.rb +2 -1
- data/lib/samagotchi/memory_bundle/manifest.rb +24 -2
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
- data/lib/samagotchi/plugin/context.rb +40 -3
- data/lib/samagotchi/reply_wait.rb +13 -15
- data/lib/samagotchi/send_command.rb +24 -7
- data/lib/samagotchi/session.rb +16 -1
- data/lib/samagotchi/session_metrics/cost.rb +37 -0
- data/lib/samagotchi/session_metrics.rb +9 -0
- data/lib/samagotchi/session_setup.rb +14 -6
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -5
- data/lib/samagotchi/terminal_ui.rb +7 -2
- data/lib/samagotchi/testing/bundle.rb +31 -5
- data/lib/samagotchi/testing/host.rb +26 -1
- data/lib/samagotchi/testing/recorder.rb +4 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +45 -7
- data/lib/samagotchi/tools/delegate_wait.rb +40 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +13 -4
- metadata +5 -1
|
@@ -41,6 +41,9 @@ module Samagotchi
|
|
|
41
41
|
# cmd: '{ruby} {bundle_dir}/scripts/pr_context.rb {url}'
|
|
42
42
|
# why: GitHub PR # optional
|
|
43
43
|
# every_seconds: 300 # optional
|
|
44
|
+
# session_settings: [cap] # optional — the settings a session may set
|
|
45
|
+
# # (chi --set cost-cap.cap=3), read by the
|
|
46
|
+
# # plugin from ctx.settings at call time
|
|
44
47
|
# includes: [a, b] # optional — a profile (meta bundle): the
|
|
45
48
|
# # shipped bundles it installs; its dir holds
|
|
46
49
|
# # only manifest.yml (MemoryBundle::Profile)
|
|
@@ -60,7 +63,7 @@ module Samagotchi
|
|
|
60
63
|
# the description a mapping gave, for the files that have one; hooks:
|
|
61
64
|
# name → BundleHook.
|
|
62
65
|
attr_reader :name, :version, :scope, :description, :files, :file_descriptions, :hooks, :trust_level, :plugin, :requires_chi, :needs, :includes,
|
|
63
|
-
:scripts, :context_providers
|
|
66
|
+
:scripts, :context_providers, :session_settings
|
|
64
67
|
|
|
65
68
|
def initialize(path:)
|
|
66
69
|
@path = path
|
|
@@ -84,6 +87,7 @@ module Samagotchi
|
|
|
84
87
|
@scripts = parse_scripts(raw["scripts"])
|
|
85
88
|
@context_providers = parse_context_providers(raw["context_providers"])
|
|
86
89
|
@includes = parse_includes(raw["includes"])
|
|
90
|
+
@session_settings = self.class.parse_session_settings(raw["session_settings"])
|
|
87
91
|
end
|
|
88
92
|
|
|
89
93
|
# A profile: it installs the bundles in includes instead of files.
|
|
@@ -136,7 +140,8 @@ module Samagotchi
|
|
|
136
140
|
# Writes a fresh manifest.yml from computed checksums at `dir`.
|
|
137
141
|
# +file_descriptions+ (name → text) writes those files as mappings.
|
|
138
142
|
def self.write(dir:, name:, version:, files:, scope: nil, description: "", hooks: nil, trust_level: nil,
|
|
139
|
-
plugin: nil, requires_chi: nil, needs: nil, scripts: {}, context_providers: [], file_descriptions: {}
|
|
143
|
+
plugin: nil, requires_chi: nil, needs: nil, scripts: {}, context_providers: [], file_descriptions: {},
|
|
144
|
+
session_settings: [])
|
|
140
145
|
FileUtils.mkdir_p(dir)
|
|
141
146
|
manifest = {
|
|
142
147
|
"name" => name,
|
|
@@ -159,9 +164,26 @@ module Samagotchi
|
|
|
159
164
|
manifest["needs"] = needs.map { |n| n.transform_keys(&:to_s).compact } unless needs.empty?
|
|
160
165
|
manifest["scripts"] = scripts.transform_keys(&:to_s) unless scripts.empty?
|
|
161
166
|
manifest["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
|
|
167
|
+
manifest["session_settings"] = Array(session_settings).map(&:to_s) unless Array(session_settings).empty?
|
|
162
168
|
File.write(File.join(dir, "manifest.yml"), YAML.dump(manifest))
|
|
163
169
|
end
|
|
164
170
|
|
|
171
|
+
# A setting key a session may set (BundleSessionSettings::KEY).
|
|
172
|
+
SESSION_SETTING = /\A[A-Za-z0-9_][A-Za-z0-9_-]*\z/
|
|
173
|
+
|
|
174
|
+
# session_settings: → the keys, [] when absent; anything but a list of
|
|
175
|
+
# plain key names is a ValidationError.
|
|
176
|
+
def self.parse_session_settings(raw)
|
|
177
|
+
return [] if raw.nil?
|
|
178
|
+
|
|
179
|
+
keys = raw.is_a?(Array) ? raw : nil
|
|
180
|
+
unless keys&.all? { |key| (key.is_a?(String) || key.is_a?(Symbol)) && key.to_s.match?(SESSION_SETTING) }
|
|
181
|
+
raise ValidationError, "session_settings: a list of setting names (#{raw.inspect})"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
keys.map(&:to_s).uniq
|
|
185
|
+
end
|
|
186
|
+
|
|
165
187
|
# needs: → [{command:, why:, hint:}] (why/hint a String or nil), [] when
|
|
166
188
|
# absent. Takes the manifest's string-keyed YAML or the symbol-keyed
|
|
167
189
|
# form provenance stores. Duplicates merge (the first one's why/hint
|
|
@@ -141,7 +141,7 @@ module Samagotchi
|
|
|
141
141
|
# @param context_providers [Array<ContextProviders::Provider>]
|
|
142
142
|
def write(files:, scope:, version:, source_path:, hooks: {}, trust_level: nil, source_commit: nil, hooks_files: {},
|
|
143
143
|
guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil, conflicts: [], includes: nil,
|
|
144
|
-
scripts_files: {}, context_providers: [], file_descriptions: {})
|
|
144
|
+
scripts_files: {}, context_providers: [], file_descriptions: {}, session_settings: [])
|
|
145
145
|
FileUtils.mkdir_p(@bundle_dir)
|
|
146
146
|
bases_dir = File.join(@bundle_dir, "bases")
|
|
147
147
|
FileUtils.mkdir_p(bases_dir)
|
|
@@ -239,6 +239,7 @@ module Samagotchi
|
|
|
239
239
|
end
|
|
240
240
|
end
|
|
241
241
|
manifest_data["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
|
|
242
|
+
manifest_data["session_settings"] = Array(session_settings).map(&:to_s) unless Array(session_settings).empty?
|
|
242
243
|
|
|
243
244
|
write_manifest(manifest_data)
|
|
244
245
|
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require_relative "../log"
|
|
5
5
|
require_relative "../paths"
|
|
6
|
+
require_relative "../session_metrics/cost"
|
|
6
7
|
require_relative "../guardrails/context"
|
|
7
8
|
require_relative "../idle_client"
|
|
8
9
|
require_relative "service"
|
|
@@ -51,10 +52,11 @@ module Samagotchi
|
|
|
51
52
|
# ctx.http call without cancel: uses, nil for none: an init task's own,
|
|
52
53
|
# else the running turn's for work done for it (the turn's thread, or a
|
|
53
54
|
# plugin handler: +handler+) until the turn has ended.
|
|
55
|
+
# +cost+ returns the session's spend now (SessionMetrics::Cost).
|
|
54
56
|
Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
|
|
55
57
|
:ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
|
|
56
58
|
:steer, :stop_turn, :stop_generation, :frontend, :toolkit, :declared_tools, :use_toolkit,
|
|
57
|
-
:cancel_controller, keyword_init: true)
|
|
59
|
+
:cancel_controller, :cost, keyword_init: true)
|
|
58
60
|
|
|
59
61
|
# ctx.ask_model failed: the model couldn't be reached, timed out, or
|
|
60
62
|
# sent nothing usable. The message says why, for the user.
|
|
@@ -95,8 +97,24 @@ module Samagotchi
|
|
|
95
97
|
# @return [String] the bundle the plugin came with
|
|
96
98
|
attr_reader :bundle
|
|
97
99
|
|
|
98
|
-
#
|
|
99
|
-
|
|
100
|
+
# config.yml `bundles: <bundle>:` with the session's own values for
|
|
101
|
+
# the bundle laid over it (chi --set BUNDLE.KEY=VALUE, through the
|
|
102
|
+
# Host's setup: SessionSetup#bundle_settings), string keys, frozen.
|
|
103
|
+
# Plugin init and chi.ctx run before the session is bound: with no
|
|
104
|
+
# session id yet it is the config alone, and it is built again once
|
|
105
|
+
# per session id (the session's values never change after it starts).
|
|
106
|
+
# A plugin's initialize(settings) gets the config only: a value a
|
|
107
|
+
# session may set is read here, at call time.
|
|
108
|
+
# @return [Hash]
|
|
109
|
+
def settings
|
|
110
|
+
id = session_id
|
|
111
|
+
cached = @session_settings
|
|
112
|
+
return cached.last if cached && cached.first == id
|
|
113
|
+
|
|
114
|
+
merged = id ? overlay(@settings, session_values) : @settings
|
|
115
|
+
@session_settings = [id, merged].freeze
|
|
116
|
+
merged
|
|
117
|
+
end
|
|
100
118
|
|
|
101
119
|
# @return [Logger]
|
|
102
120
|
attr_reader :log
|
|
@@ -140,6 +158,11 @@ module Samagotchi
|
|
|
140
158
|
# current_model_only names it
|
|
141
159
|
def model_key = @host.model_key&.call
|
|
142
160
|
|
|
161
|
+
# @return [SessionMetrics::Cost] the session's spend so far, read now:
|
|
162
|
+
# #total, #reported, #estimated, #side (Float USD) and #known?
|
|
163
|
+
# (docs/plugins.md); zero and known with no session
|
|
164
|
+
def cost = @host.cost&.call || SessionMetrics::Cost.zero
|
|
165
|
+
|
|
143
166
|
# @return [String, nil] the name of the session's toolkit ("full" for
|
|
144
167
|
# one from before toolkits; nil with no session)
|
|
145
168
|
def toolkit = @host.toolkit&.call
|
|
@@ -412,6 +435,20 @@ module Samagotchi
|
|
|
412
435
|
nil
|
|
413
436
|
end
|
|
414
437
|
|
|
438
|
+
# The session's own values for this bundle ({} without any, or when
|
|
439
|
+
# the Host can't tell them).
|
|
440
|
+
def session_values
|
|
441
|
+
values = @host.setup&.call&.bundle_settings
|
|
442
|
+
values.is_a?(Hash) ? values.fetch(@bundle, {}) : {}
|
|
443
|
+
rescue StandardError => e
|
|
444
|
+
@log.warn("session_settings_unread", error: "#{e.class}: #{e.message}")
|
|
445
|
+
{}
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def overlay(config, values)
|
|
449
|
+
values.empty? ? config : deep_freeze(config.merge(values))
|
|
450
|
+
end
|
|
451
|
+
|
|
415
452
|
def deep_freeze(value)
|
|
416
453
|
case value
|
|
417
454
|
when Hash then value.to_h { |k, v| [deep_freeze(k), deep_freeze(v)] }.freeze
|
|
@@ -181,8 +181,9 @@ module Samagotchi
|
|
|
181
181
|
(ended = session.last_turn&.ended_at) && !ended.to_s.empty? &&
|
|
182
182
|
baseline && ended == baseline[:last_turn]
|
|
183
183
|
file = newest_reply(id, state_dir: state_dir)
|
|
184
|
-
|
|
185
|
-
|
|
184
|
+
path = File.join(reply_dir(id, state_dir: state_dir), file) if file
|
|
185
|
+
if file && reply_is_turns?(path, ended)
|
|
186
|
+
reply = Result.new(status: :done, text: File.read(path), file: file)
|
|
186
187
|
reply.session = settled(session, id, state_dir: state_dir)
|
|
187
188
|
return reply
|
|
188
189
|
end
|
|
@@ -194,6 +195,9 @@ module Samagotchi
|
|
|
194
195
|
!(session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == turn) &&
|
|
195
196
|
(ended = session.last_turn&.ended_at) && !ended.to_s.empty? &&
|
|
196
197
|
baseline && ended == baseline[:last_turn]
|
|
198
|
+
# The turn-specific reply lookup above already found its file, if
|
|
199
|
+
# one exists. Without it, don't use the newest file as a fallback:
|
|
200
|
+
# it could belong to another turn.
|
|
197
201
|
return no_reply(session, baseline, fresh: true)
|
|
198
202
|
end
|
|
199
203
|
|
|
@@ -440,19 +444,13 @@ module Samagotchi
|
|
|
440
444
|
cursor.nil? || file > cursor
|
|
441
445
|
end
|
|
442
446
|
|
|
443
|
-
# Whether the reply file +
|
|
444
|
-
# not an earlier turn's: the worker records
|
|
445
|
-
#
|
|
446
|
-
#
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
def reply_is_turns?(file, ended_at)
|
|
451
|
-
name = file.to_s
|
|
452
|
-
return false if name.length < 23
|
|
453
|
-
|
|
454
|
-
Time.parse(ended_at.to_s) <= Time.strptime(name[0, 14], "%Y%m%d%H%M%S") + (name[14, 9].to_f / (10**9))
|
|
455
|
-
rescue ArgumentError
|
|
447
|
+
# Whether the reply file at +path+ is the turn that ended at +ended_at+'s,
|
|
448
|
+
# not an earlier turn's: the worker records ended_at before writing the
|
|
449
|
+
# reply, so its mtime is at least the turn's end and earlier replies'
|
|
450
|
+
# mtimes precede it. Unlike the filename, mtime is independent of TZ.
|
|
451
|
+
def reply_is_turns?(path, ended_at)
|
|
452
|
+
File.mtime(path) >= Time.parse(ended_at.to_s)
|
|
453
|
+
rescue ArgumentError, SystemCallError
|
|
456
454
|
false
|
|
457
455
|
end
|
|
458
456
|
|
|
@@ -7,6 +7,7 @@ require_relative "session"
|
|
|
7
7
|
require_relative "session_commands"
|
|
8
8
|
require_relative "session_inbox"
|
|
9
9
|
require_relative "session_manager"
|
|
10
|
+
require_relative "bundle_session_settings"
|
|
10
11
|
require_relative "session_chain"
|
|
11
12
|
require_relative "context_quote"
|
|
12
13
|
require_relative "reply_wait"
|
|
@@ -39,7 +40,7 @@ module Samagotchi
|
|
|
39
40
|
|
|
40
41
|
USAGE = <<~TEXT
|
|
41
42
|
Usage: chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...
|
|
42
|
-
chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [--toolkit NAME] [-m TEXT] [--image PATH]...
|
|
43
|
+
chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [--toolkit NAME] [--set BUNDLE.KEY=VALUE]... [-m TEXT] [--image PATH]...
|
|
43
44
|
chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT] [--image PATH]...
|
|
44
45
|
chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
|
|
45
46
|
chi send --queue --wait [--timeout S] [--format json] [-m TEXT] ID
|
|
@@ -65,8 +66,9 @@ module Samagotchi
|
|
|
65
66
|
(--new) start the next link of that session's chain:
|
|
66
67
|
in its folder, on its model (or --model's; one whose
|
|
67
68
|
host is gone falls back to the default, with a
|
|
68
|
-
warning), LLM context, thinking level and
|
|
69
|
-
with a note carrying its recap; it
|
|
69
|
+
warning), LLM context, thinking level, toolkit and
|
|
70
|
+
bundle settings, with a note carrying its recap; it
|
|
71
|
+
is archived.
|
|
70
72
|
last:ID names the chain's latest link.
|
|
71
73
|
Without -m the new session waits idle. Refused for a
|
|
72
74
|
session continued already (naming the next link) or
|
|
@@ -84,6 +86,12 @@ module Samagotchi
|
|
|
84
86
|
(full, lean, child, a config or bundle one; default
|
|
85
87
|
config's toolkit.default), and tools declared on top
|
|
86
88
|
of it or left out (repeatable, a comma list too)
|
|
89
|
+
--set BUNDLE.KEY=VALUE
|
|
90
|
+
(--new) a bundle setting for this session, over
|
|
91
|
+
config.yml's bundles: (repeatable; a key its bundle's
|
|
92
|
+
manifest lists in session_settings:; 3, 2.5, true,
|
|
93
|
+
false and off are typed, the rest is text:
|
|
94
|
+
--set cost-cap.cap=3)
|
|
87
95
|
--wait wait for the answer and print it (one session); the
|
|
88
96
|
other lines go to stderr. Exit 3: it waits for an
|
|
89
97
|
answer from you: the question, its options and the
|
|
@@ -141,6 +149,7 @@ module Samagotchi
|
|
|
141
149
|
f.value "--toolkit"
|
|
142
150
|
f.value "--tool", key: :tools, repeat: true
|
|
143
151
|
f.value "--no-tool", key: :no_tools, repeat: true
|
|
152
|
+
f.value "--set", key: :sets, repeat: true
|
|
144
153
|
f.value "--image", key: :images, repeat: true
|
|
145
154
|
f.switch "--wait"
|
|
146
155
|
f.switch "--cut"
|
|
@@ -284,6 +293,7 @@ module Samagotchi
|
|
|
284
293
|
%i[dir model continues thinking toolkit].each { |key| return usage_error("--#{key} needs --new") if options[key] }
|
|
285
294
|
TOOLKIT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") unless Array(options[key]).empty? }
|
|
286
295
|
LLM_CONTEXT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") if options[key] }
|
|
296
|
+
return usage_error("--set needs --new") unless Array(options[:sets]).empty?
|
|
287
297
|
return usage_error("give session ids") if options[:ids].empty?
|
|
288
298
|
|
|
289
299
|
options
|
|
@@ -302,10 +312,11 @@ module Samagotchi
|
|
|
302
312
|
if options[:continues]
|
|
303
313
|
taken = [("--dir" if options[:dir])] +
|
|
304
314
|
LLM_CONTEXT_FLAGS.filter_map { |key, flag| flag if options[key] } + [("--thinking" if options[:thinking])] +
|
|
305
|
-
[("--toolkit" if options[:toolkit])] + TOOLKIT_FLAGS.filter_map { |key, flag| flag unless Array(options[key]).empty? }
|
|
315
|
+
[("--toolkit" if options[:toolkit])] + TOOLKIT_FLAGS.filter_map { |key, flag| flag unless Array(options[key]).empty? } +
|
|
316
|
+
[("--set" unless Array(options[:sets]).empty?)]
|
|
306
317
|
unless taken.compact.empty?
|
|
307
|
-
return usage_error("--continues takes the previous session's folder, LLM context, thinking level
|
|
308
|
-
"toolkit; " \
|
|
318
|
+
return usage_error("--continues takes the previous session's folder, LLM context, thinking level, " \
|
|
319
|
+
"toolkit and bundle settings; " \
|
|
309
320
|
"leave out #{taken.compact.join(", ")}")
|
|
310
321
|
end
|
|
311
322
|
end
|
|
@@ -327,6 +338,11 @@ module Samagotchi
|
|
|
327
338
|
end
|
|
328
339
|
toolkit = Toolkit.from_flags(name: options[:toolkit], add: options[:tools], remove: options[:no_tools])
|
|
329
340
|
options[:toolkit_value] = toolkit if toolkit
|
|
341
|
+
begin
|
|
342
|
+
options[:bundle_settings] = BundleSessionSettings.from_flags(options[:sets]) unless Array(options[:sets]).empty?
|
|
343
|
+
rescue BundleSessionSettings::Error => e
|
|
344
|
+
return usage_error(e.message)
|
|
345
|
+
end
|
|
330
346
|
|
|
331
347
|
if options[:dir]
|
|
332
348
|
options[:dir] = File.expand_path(options[:dir])
|
|
@@ -376,7 +392,8 @@ module Samagotchi
|
|
|
376
392
|
model_name: options[:model],
|
|
377
393
|
setup: SessionSetup.new(llm_context: options[:llm_context],
|
|
378
394
|
thinking: options[:thinking],
|
|
379
|
-
toolkit: options[:toolkit_value]
|
|
395
|
+
toolkit: options[:toolkit_value],
|
|
396
|
+
bundle_settings: options[:bundle_settings] || {}),
|
|
380
397
|
state_dir: @state_dir)
|
|
381
398
|
end
|
|
382
399
|
rescue SessionContinue::Refused, SessionManager::ArchiveRefused, SessionManager::OwnedByTUI => e
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -208,6 +208,18 @@ module Samagotchi
|
|
|
208
208
|
@toolkit = value.is_a?(Toolkit) ? value : Toolkit.from_file(value)
|
|
209
209
|
end
|
|
210
210
|
|
|
211
|
+
# The session's own bundle settings (chi --set BUNDLE.KEY=VALUE):
|
|
212
|
+
# {bundle => {key => value}}, frozen, {} for none and for a session from
|
|
213
|
+
# before them. Given when it was created and never changed; a respawn,
|
|
214
|
+
# a continue and a fork keep them (SessionSetup.of), a delegate child
|
|
215
|
+
# doesn't. Plugin::Context#settings lays them over config.yml's.
|
|
216
|
+
attr_reader :bundle_settings
|
|
217
|
+
|
|
218
|
+
# @param value [Hash, nil] the session file's form too
|
|
219
|
+
def bundle_settings=(value)
|
|
220
|
+
@bundle_settings = BundleSessionSettings.normalize(value)
|
|
221
|
+
end
|
|
222
|
+
|
|
211
223
|
# The hook that stopped the last turn (stop_turn, or a cut with no retry
|
|
212
224
|
# left), e.g. "loop-guard"; nil when it ended otherwise.
|
|
213
225
|
def stopped_by
|
|
@@ -242,7 +254,7 @@ module Samagotchi
|
|
|
242
254
|
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
|
|
243
255
|
last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
|
|
244
256
|
continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
|
|
245
|
-
toolkit: nil, interrupted_turn: nil)
|
|
257
|
+
toolkit: nil, bundle_settings: {}, interrupted_turn: nil)
|
|
246
258
|
@id = id
|
|
247
259
|
@metadata_version = metadata_version
|
|
248
260
|
@mode = mode
|
|
@@ -272,6 +284,7 @@ module Samagotchi
|
|
|
272
284
|
self.reasoning_history = reasoning_history
|
|
273
285
|
self.appended_prompt_files = appended_prompt_files
|
|
274
286
|
self.toolkit = toolkit
|
|
287
|
+
self.bundle_settings = bundle_settings
|
|
275
288
|
self.interrupted_turn = interrupted_turn
|
|
276
289
|
@turn_checkpoint = nil
|
|
277
290
|
@archived = false
|
|
@@ -369,6 +382,7 @@ module Samagotchi
|
|
|
369
382
|
reasoning_history: setup.reasoning_history,
|
|
370
383
|
appended_prompt_files: setup.appended_prompt_files,
|
|
371
384
|
toolkit: setup.toolkit,
|
|
385
|
+
bundle_settings: setup.bundle_settings,
|
|
372
386
|
continues: continues
|
|
373
387
|
)
|
|
374
388
|
end
|
|
@@ -434,6 +448,7 @@ module Samagotchi
|
|
|
434
448
|
"reasoning_history" => nil,
|
|
435
449
|
"appended_prompt_files" => [],
|
|
436
450
|
"toolkit" => nil,
|
|
451
|
+
"bundle_settings" => {},
|
|
437
452
|
"interrupted_turn" => nil
|
|
438
453
|
}.freeze
|
|
439
454
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
class SessionMetrics
|
|
5
|
+
# The session's spend so far (SessionMetrics#cost, what a bundle's
|
|
6
|
+
# ctx.cost reads), in USD: +reported+ by providers (usage.cost) and
|
|
7
|
+
# +estimated+ from configured prices (ModelPrice.estimate: a generation
|
|
8
|
+
# is one or the other, never both), each the main loop's and the side
|
|
9
|
+
# requests' together; +side+ is the side requests' share of the total
|
|
10
|
+
# (recap, /btw, ask_model). +known+ is false while generations or side
|
|
11
|
+
# requests ran but none was reported or priced: a cap can't be enforced
|
|
12
|
+
# on such a session. A session with some priced and some unpriced
|
|
13
|
+
# generations reads known (the unpriced ones count 0).
|
|
14
|
+
# Bundles use its methods only, never this constant's name.
|
|
15
|
+
Cost = Data.define(:reported, :estimated, :side, :known) do
|
|
16
|
+
def self.zero = new(reported: 0.0, estimated: 0.0, side: 0.0, known: true)
|
|
17
|
+
|
|
18
|
+
# @param totals [Totals] the session's sums, the running turn folded in
|
|
19
|
+
# @param side [SideTotals]
|
|
20
|
+
# @return [Cost]
|
|
21
|
+
def self.of(totals, side)
|
|
22
|
+
side_reported = side.cost_sum.to_f
|
|
23
|
+
side_estimated = side.cost_estimate_sum.to_f
|
|
24
|
+
reported = totals.cost_sum.to_f + side_reported
|
|
25
|
+
estimated = totals.cost_estimate_sum.to_f + side_estimated
|
|
26
|
+
ran = (totals.prompt_sum + totals.completion_sum).positive? || side.requests.positive?
|
|
27
|
+
new(reported: reported, estimated: estimated, side: side_reported + side_estimated,
|
|
28
|
+
known: !ran || reported.positive? || estimated.positive?)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Float] reported + estimated, side requests included
|
|
32
|
+
def total = reported + estimated
|
|
33
|
+
|
|
34
|
+
def known? = known
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -12,6 +12,7 @@ require_relative "model_price"
|
|
|
12
12
|
require_relative "session"
|
|
13
13
|
require_relative "session_metrics/totals"
|
|
14
14
|
require_relative "session_metrics/model_segment"
|
|
15
|
+
require_relative "session_metrics/cost"
|
|
15
16
|
require_relative "session_metrics/side_totals"
|
|
16
17
|
require_relative "side_usage"
|
|
17
18
|
|
|
@@ -365,6 +366,14 @@ module Samagotchi
|
|
|
365
366
|
end
|
|
366
367
|
end
|
|
367
368
|
|
|
369
|
+
# The session's spend now (ctx.cost): its turns' sums with the running
|
|
370
|
+
# turn's finished generations folded in, and its side requests.
|
|
371
|
+
# Safe from any thread.
|
|
372
|
+
# @return [Cost]
|
|
373
|
+
def cost
|
|
374
|
+
@mutex.synchronize { Cost.of(@totals.with_running(@turn), @side) }
|
|
375
|
+
end
|
|
376
|
+
|
|
368
377
|
# Event sink. Safe to call from any thread; errors are isolated by the
|
|
369
378
|
# caller (SessionObserver / TerminalUI) but we still guard internally.
|
|
370
379
|
# @param event [Hash]
|
|
@@ -4,6 +4,7 @@ require_relative "llm_context_override"
|
|
|
4
4
|
require_relative "reasoning_history"
|
|
5
5
|
require_relative "thinking"
|
|
6
6
|
require_relative "toolkit"
|
|
7
|
+
require_relative "bundle_session_settings"
|
|
7
8
|
|
|
8
9
|
module Samagotchi
|
|
9
10
|
# A session's own settings that come before its model's, carried as one
|
|
@@ -15,20 +16,26 @@ module Samagotchi
|
|
|
15
16
|
# +appended_prompt_files+ the absolute paths chi --append-system-prompt
|
|
16
17
|
# named (their text goes into the system prompt; [] with none),
|
|
17
18
|
# +toolkit+ a Toolkit or nil (SessionManager.spawn_session gives the
|
|
18
|
-
# session config's toolkit.default then)
|
|
19
|
+
# session config's toolkit.default then), +bundle_settings+ the bundle
|
|
20
|
+
# settings chi --set gave ({bundle => {key => value}}, frozen; {} with
|
|
21
|
+
# none), which Plugin::Context#settings lays over config.yml's.
|
|
19
22
|
#
|
|
20
23
|
# It doesn't save itself: the session keeps each setting in its own field
|
|
21
24
|
# and file key, and resolves it on its own. A delegate child starts
|
|
22
25
|
# without one (but the toolkit its delegate call named); a continue and a
|
|
23
26
|
# fork copy their session's (.of).
|
|
24
|
-
SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history, :appended_prompt_files, :toolkit
|
|
25
|
-
|
|
27
|
+
SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history, :appended_prompt_files, :toolkit,
|
|
28
|
+
:bundle_settings) do
|
|
29
|
+
def initialize(llm_context: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [], toolkit: nil,
|
|
30
|
+
bundle_settings: {})
|
|
26
31
|
super(llm_context: llm_context, thinking: thinking, reasoning_history: reasoning_history,
|
|
27
|
-
appended_prompt_files: Array(appended_prompt_files).map(&:to_s).freeze, toolkit: toolkit
|
|
32
|
+
appended_prompt_files: Array(appended_prompt_files).map(&:to_s).freeze, toolkit: toolkit,
|
|
33
|
+
bundle_settings: BundleSessionSettings.normalize(bundle_settings))
|
|
28
34
|
end
|
|
29
35
|
|
|
30
36
|
def empty?
|
|
31
|
-
llm_context.nil? && thinking.nil? && reasoning_history.nil? && appended_prompt_files.empty? && toolkit.nil?
|
|
37
|
+
llm_context.nil? && thinking.nil? && reasoning_history.nil? && appended_prompt_files.empty? && toolkit.nil? &&
|
|
38
|
+
bundle_settings.empty?
|
|
32
39
|
end
|
|
33
40
|
|
|
34
41
|
# A session's own setup, for a session that starts from it. Its
|
|
@@ -40,7 +47,8 @@ module Samagotchi
|
|
|
40
47
|
return new if session.nil?
|
|
41
48
|
|
|
42
49
|
new(llm_context: session.llm_context, thinking: session.thinking, reasoning_history: session.reasoning_history,
|
|
43
|
-
appended_prompt_files: session.appended_prompt_files, toolkit: Toolkit.of(session.toolkit)
|
|
50
|
+
appended_prompt_files: session.appended_prompt_files, toolkit: Toolkit.of(session.toolkit),
|
|
51
|
+
bundle_settings: session.bundle_settings)
|
|
44
52
|
end
|
|
45
53
|
end
|
|
46
54
|
end
|
|
@@ -50,7 +50,9 @@ module Samagotchi
|
|
|
50
50
|
# session's toolkit (config's toolkit.default without); an existing
|
|
51
51
|
# session keeps its own
|
|
52
52
|
# @param toolkit_flags [Array<String>] which of those were given, for
|
|
53
|
-
# the line an existing session's run prints
|
|
53
|
+
# the line an existing session's run prints (--set too)
|
|
54
|
+
# @param bundle_settings [Hash] --set's values (BundleSessionSettings):
|
|
55
|
+
# a new session's setup carries them; an existing session keeps its own
|
|
54
56
|
# @param state_dir [String, nil] where the sessions are (the default
|
|
55
57
|
# state dir by default): the worker's, for the attached loop too
|
|
56
58
|
# @return [Symbol] :detached, :closed when the worker went away,
|
|
@@ -58,11 +60,12 @@ module Samagotchi
|
|
|
58
60
|
# pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
|
|
59
61
|
def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
|
|
60
62
|
memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], toolkit: nil,
|
|
61
|
-
toolkit_flags: [], state_dir: nil)
|
|
63
|
+
toolkit_flags: [], bundle_settings: {}, state_dir: nil)
|
|
62
64
|
state_dir ||= Session.default_state_dir
|
|
63
65
|
client = connect(attach: attach, shared: shared, resume: resume, model: model, state_dir: state_dir,
|
|
64
66
|
memories: memories, muted_memories: muted_memories, llm_context: llm_context, thinking: thinking,
|
|
65
|
-
appended_prompt_files: appended_prompt_files, toolkit: toolkit, toolkit_flags: toolkit_flags
|
|
67
|
+
appended_prompt_files: appended_prompt_files, toolkit: toolkit, toolkit_flags: toolkit_flags,
|
|
68
|
+
bundle_settings: bundle_settings)
|
|
66
69
|
commands = []
|
|
67
70
|
commands << "/model #{model}" if model && (attach || resume)
|
|
68
71
|
commands << LLMContextOverride.command_line(llm_context) if (attach || resume) && !llm_context.empty?
|
|
@@ -131,7 +134,7 @@ module Samagotchi
|
|
|
131
134
|
# @raise [Error]
|
|
132
135
|
def connect(attach: nil, shared: false, resume: nil, model: nil, state_dir: nil, wait: BRIDGE_WAIT,
|
|
133
136
|
memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], toolkit: nil,
|
|
134
|
-
toolkit_flags: [], err: $stderr)
|
|
137
|
+
toolkit_flags: [], bundle_settings: {}, err: $stderr)
|
|
135
138
|
sd = state_dir || Session.default_state_dir
|
|
136
139
|
if attach || resume
|
|
137
140
|
warn_memory_flags_ignored(attach || resume, memories, muted_memories, err, appended_prompt_files: appended_prompt_files,
|
|
@@ -153,7 +156,7 @@ module Samagotchi
|
|
|
153
156
|
setup: SessionSetup.new(llm_context: llm_context.empty? ? nil : LLMContextOverride.update(nil, llm_context),
|
|
154
157
|
thinking: Thinking.session_level(thinking),
|
|
155
158
|
appended_prompt_files: appended_prompt_files,
|
|
156
|
-
toolkit: toolkit))
|
|
159
|
+
toolkit: toolkit, bundle_settings: bundle_settings))
|
|
157
160
|
end
|
|
158
161
|
client = BridgeClient.wait_for(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
|
|
159
162
|
client || raise(Error, "the worker for session #{session.id} did not start its Bridge in time")
|
|
@@ -100,12 +100,16 @@ module Samagotchi
|
|
|
100
100
|
# @param toolkit [Toolkit, nil] --toolkit/--tool/--no-tool: a new
|
|
101
101
|
# session's toolkit (config's toolkit.default without); a resumed one
|
|
102
102
|
# keeps its own, with a line naming +toolkit_flags+ (the flags given)
|
|
103
|
+
# @param bundle_settings [Hash] --set's values (BundleSessionSettings):
|
|
104
|
+
# a new session's own; a resumed one keeps its own (--set is in
|
|
105
|
+
# +toolkit_flags+ then)
|
|
103
106
|
def initialize(prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
|
|
104
107
|
spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {}, thinking: nil,
|
|
105
|
-
appended_prompt_files: [], toolkit: nil, toolkit_flags: [])
|
|
108
|
+
appended_prompt_files: [], toolkit: nil, toolkit_flags: [], bundle_settings: {})
|
|
106
109
|
@scratch = scratch
|
|
107
110
|
@toolkit = toolkit
|
|
108
111
|
@toolkit_flags = toolkit_flags
|
|
112
|
+
@bundle_settings = bundle_settings
|
|
109
113
|
@thinking_word = thinking
|
|
110
114
|
@prompt = prompt
|
|
111
115
|
@llm_context_words = llm_context
|
|
@@ -274,7 +278,8 @@ module Samagotchi
|
|
|
274
278
|
preloaded_memory_names: @requested_memories,
|
|
275
279
|
muted_memory_names: @muted_memory_names,
|
|
276
280
|
scratch: @scratch,
|
|
277
|
-
setup: SessionSetup.new(appended_prompt_files: @appended_prompt_files, toolkit: @toolkit || Toolkit.configured
|
|
281
|
+
setup: SessionSetup.new(appended_prompt_files: @appended_prompt_files, toolkit: @toolkit || Toolkit.configured,
|
|
282
|
+
bundle_settings: @bundle_settings)
|
|
278
283
|
))
|
|
279
284
|
# --llm-context*: the session's own values, over a resumed one's.
|
|
280
285
|
# Saved at once on a resumed one: leaving before a turn keeps them.
|
|
@@ -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 "../session_setup"
|
|
14
15
|
require_relative "sandbox"
|
|
15
16
|
require_relative "recorder"
|
|
16
17
|
require_relative "host"
|
|
@@ -74,11 +75,15 @@ module Samagotchi
|
|
|
74
75
|
# @param dir [String] the bundle's folder (its manifest.yml)
|
|
75
76
|
# @param settings [Hash] config.yml `bundles: <name>:`
|
|
76
77
|
# @param host [Hash] session values (Recorder::DEFAULTS' keys)
|
|
78
|
+
# @param session_settings [Hash] the session's own values for the
|
|
79
|
+
# bundle (chi --set), keys its manifest lists in session_settings:;
|
|
80
|
+
# ctx.settings lays them over +settings+ (Plugin::Context#settings),
|
|
81
|
+
# initialize(settings) doesn't see them
|
|
77
82
|
# @param sandbox [Sandbox, nil] default Sandbox.current, else one of
|
|
78
83
|
# its own, left at #shutdown
|
|
79
84
|
# @return [Bundle]
|
|
80
|
-
def load(dir, settings: {}, host: {}, sandbox: nil)
|
|
81
|
-
new(dir, settings: settings, host: host, sandbox: sandbox).tap(&:load!)
|
|
85
|
+
def load(dir, settings: {}, host: {}, session_settings: {}, sandbox: nil)
|
|
86
|
+
new(dir, settings: settings, host: host, session_settings: session_settings, sandbox: sandbox).tap(&:load!)
|
|
82
87
|
end
|
|
83
88
|
|
|
84
89
|
# A bundle chi ships (lib/samagotchi/bundles/<name>), as #load.
|
|
@@ -118,14 +123,16 @@ module Samagotchi
|
|
|
118
123
|
# @return [Integer] how often chi was told the tools changed
|
|
119
124
|
attr_reader :tools_changes
|
|
120
125
|
|
|
121
|
-
def initialize(dir, settings: {}, host: {}, sandbox: nil)
|
|
126
|
+
def initialize(dir, settings: {}, host: {}, session_settings: {}, sandbox: nil)
|
|
122
127
|
@dir = File.expand_path(dir)
|
|
123
128
|
@manifest = MemoryBundle::Manifest.read(dir: @dir)
|
|
124
129
|
@name = @manifest.name
|
|
125
130
|
@settings = stringify(settings || {})
|
|
131
|
+
@session_moves = 1
|
|
132
|
+
own = session_settings.nil? || session_settings.empty? ? {} : { setup: setup_with(session_settings, host[:setup]) }
|
|
126
133
|
@own_sandbox = sandbox.nil? && Sandbox.current.nil?
|
|
127
134
|
@sandbox = sandbox || Sandbox.current || Sandbox.enter
|
|
128
|
-
@recorder = Recorder.new(cwd: @sandbox.work_dir, **host)
|
|
135
|
+
@recorder = Recorder.new(cwd: @sandbox.work_dir, **host, **own)
|
|
129
136
|
@sessions = Sessions.new
|
|
130
137
|
@http = FakeHttp.new
|
|
131
138
|
@inits = []
|
|
@@ -309,7 +316,16 @@ module Samagotchi
|
|
|
309
316
|
end
|
|
310
317
|
|
|
311
318
|
# Change the session's values (cwd:, messages:, running: …).
|
|
312
|
-
|
|
319
|
+
# +session_settings+ changes the session's own values for the bundle
|
|
320
|
+
# (as Bundle.load's) and moves it to a new session id
|
|
321
|
+
# ("test-session-2", or the session_id: given beside it): chi never
|
|
322
|
+
# changes them within a session, and ctx.settings is built once per
|
|
323
|
+
# session id.
|
|
324
|
+
def set(session_settings: nil, **values)
|
|
325
|
+
unless session_settings.nil?
|
|
326
|
+
values[:setup] = setup_with(session_settings, @recorder[:setup])
|
|
327
|
+
values[:session_id] ||= "#{@recorder[:session_id] || "test-session"}-#{@session_moves += 1}"
|
|
328
|
+
end
|
|
313
329
|
@recorder.set(**values)
|
|
314
330
|
self
|
|
315
331
|
end
|
|
@@ -363,6 +379,16 @@ module Samagotchi
|
|
|
363
379
|
result
|
|
364
380
|
end
|
|
365
381
|
|
|
382
|
+
# +setup+ (the recorder's, nil for none) with +session_settings+ as
|
|
383
|
+
# this bundle's own values, checked against the manifest's
|
|
384
|
+
# session_settings: as chi --set checks them.
|
|
385
|
+
# @raise [ArgumentError] a key the manifest doesn't list
|
|
386
|
+
def setup_with(session_settings, setup)
|
|
387
|
+
values = { @name => stringify(session_settings) }
|
|
388
|
+
BundleSessionSettings.check!(values, bundles: { @name => @manifest.session_settings }, flag: "session_settings")
|
|
389
|
+
(setup || SessionSetup.new).with(bundle_settings: values)
|
|
390
|
+
end
|
|
391
|
+
|
|
366
392
|
def stringify(value)
|
|
367
393
|
case value
|
|
368
394
|
when Hash then value.to_h { |key, item| [key.to_s, stringify(item)] }
|