samagotchi 0.91.0 → 0.93.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 +88 -1
- data/README.md +5 -2
- data/bin/chi +8 -8
- data/docs/architecture.md +20 -16
- 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/guardrails.md +4 -5
- 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 +27 -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 +4 -4
- data/docs/testing.md +2 -1
- data/lib/samagotchi/bootstrap/probe.rb +2 -2
- 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/child_reports.rb +12 -14
- data/lib/samagotchi/client_id.rb +18 -0
- data/lib/samagotchi/config/restart_watch.rb +112 -0
- data/lib/samagotchi/config.rb +18 -59
- data/lib/samagotchi/engine.rb +26 -26
- data/lib/samagotchi/guardrails/data_readers.rb +34 -30
- data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
- data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
- data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
- data/lib/samagotchi/hook_question.rb +21 -0
- data/lib/samagotchi/hook_routes.rb +6 -6
- data/lib/samagotchi/hooks/registry.rb +7 -5
- data/lib/samagotchi/host_registry.rb +1 -1
- data/lib/samagotchi/kernel_loop.rb +14 -3
- data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
- 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_answers_command.rb +6 -6
- data/lib/samagotchi/parent_report.rb +12 -0
- data/lib/samagotchi/plugin/context.rb +6 -5
- data/lib/samagotchi/plugin/http_transport.rb +14 -3
- data/lib/samagotchi/plugin/sessions.rb +2 -3
- data/lib/samagotchi/question_desk.rb +12 -9
- data/lib/samagotchi/reply_wait/poll.rb +267 -0
- data/lib/samagotchi/reply_wait.rb +51 -190
- data/lib/samagotchi/sandbox_layout.rb +66 -0
- data/lib/samagotchi/send_command/parent_gate.rb +83 -0
- data/lib/samagotchi/send_command/wait_target.rb +55 -0
- data/lib/samagotchi/send_command.rb +34 -103
- data/lib/samagotchi/session.rb +10 -9
- data/lib/samagotchi/session_inbox.rb +31 -8
- data/lib/samagotchi/session_manager.rb +60 -35
- data/lib/samagotchi/session_metrics.rb +22 -3
- data/lib/samagotchi/sessions_command.rb +4 -4
- data/lib/samagotchi/task_watch.rb +64 -21
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
- data/lib/samagotchi/testing/bundle.rb +33 -20
- data/lib/samagotchi/testing/host.rb +2 -8
- data/lib/samagotchi/testing/install.rb +26 -17
- data/lib/samagotchi/testing/recorder.rb +6 -9
- 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/delegate.rb +10 -10
- data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
- data/lib/samagotchi/tools/delegate_relay.rb +31 -16
- data/lib/samagotchi/tools/delegate_wait.rb +4 -4
- 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/app.rb +46 -63
- data/lib/samagotchi/web/public/app.js +130 -554
- data/lib/samagotchi/web/public/cards_place.js +4 -1
- data/lib/samagotchi/web/public/images.js +6 -0
- data/lib/samagotchi/web/public/index.html +1 -1
- data/lib/samagotchi/web/public/model_pick.js +10 -0
- data/lib/samagotchi/web/public/send_turn.js +173 -0
- data/lib/samagotchi/web/public/session_actions.js +203 -0
- data/lib/samagotchi/web/public/start_page.js +236 -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 +15 -1
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
# default: 5.00 USD cap for a session with none set; absent/0/off: none
|
|
16
16
|
# child: 2.00 for delegate children (ctx.delegate?); absent: default
|
|
17
17
|
# warn_at: 0.8 the share of the cap that warns; 0/off: no warning
|
|
18
|
+
# A cap reads as a number (3, .5, 1e1, "$2" as text too); one that doesn't
|
|
19
|
+
# (cap: five) warns the user once and the next key is used, so a typo is
|
|
20
|
+
# never a silent "no cap".
|
|
18
21
|
#
|
|
19
22
|
# State: <data_dir>/sessions/<session id>.json, {"cap": 3.0, "step": 3.0},
|
|
20
23
|
# written by /cap and by Continue; "cap": null (or false) is off for the
|
|
@@ -32,12 +35,15 @@ class Plugin
|
|
|
32
35
|
# spends more, Stop denies; under hard a parent may only Stop.
|
|
33
36
|
EFFECTS = { "Continue" => :spend, "Stop" => :deny }.freeze
|
|
34
37
|
WARN_AT = 0.8
|
|
38
|
+
# A cap as text: 3, 2.5, .5, 1e1, -1 (a negative is none), after a "$".
|
|
39
|
+
NUMBER = /\A-?(?:\d+(?:\.\d*)?|\.\d+)(?:e[-+]?\d+)?\z/i
|
|
35
40
|
|
|
36
41
|
def initialize(_settings)
|
|
37
42
|
@mutex = Mutex.new
|
|
38
43
|
@resolved = nil # {session:, cap:, step:}
|
|
39
44
|
@warned = nil # [session id, cap] the share warning was given for
|
|
40
45
|
@unknown_warned = nil # the session id the "can't be enforced" notice was given for
|
|
46
|
+
@unread_warned = nil # the session id the "isn't a number" notice was given for
|
|
41
47
|
end
|
|
42
48
|
|
|
43
49
|
def register(chi)
|
|
@@ -50,13 +56,39 @@ class Plugin
|
|
|
50
56
|
end
|
|
51
57
|
end
|
|
52
58
|
|
|
53
|
-
# The cap in USD (a positive Float) or nil (none, 0, off, false
|
|
59
|
+
# The cap in USD (a positive Float) or nil (none, 0, off, false, or a
|
|
60
|
+
# value that isn't a number: .readable? tells them apart).
|
|
54
61
|
def self.amount(value)
|
|
62
|
+
number = number(value)
|
|
63
|
+
number&.positive? ? number : nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# +value+ as a finite Float (a number, or text NUMBER reads after a "$"),
|
|
67
|
+
# else nil. "5." gets its 0 first (.float_text: Ruby 3.3's Float wants
|
|
68
|
+
# one); 1e400 (Infinity) is no cap anyone reaches.
|
|
69
|
+
def self.number(value)
|
|
55
70
|
number = case value
|
|
56
71
|
when Numeric then value.to_f
|
|
57
|
-
when String
|
|
72
|
+
when String
|
|
73
|
+
text = value.strip.delete_prefix("$")
|
|
74
|
+
Float(float_text(text)) if text.match?(NUMBER)
|
|
58
75
|
end
|
|
59
|
-
number
|
|
76
|
+
number if number&.finite?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A NUMBER as Float reads it on every Ruby: a bare point ("5.", "5.e1")
|
|
80
|
+
# gets a 0 after it.
|
|
81
|
+
def self.float_text(text) = text.sub(/\.(?=e|\z)/i, ".0")
|
|
82
|
+
|
|
83
|
+
# Whether +value+ reads as a cap or as none: a number (text too), off,
|
|
84
|
+
# false or nil; not "five", true or a list.
|
|
85
|
+
def self.readable?(value)
|
|
86
|
+
case value
|
|
87
|
+
when false, nil then true
|
|
88
|
+
when Numeric then value.finite?
|
|
89
|
+
when String then value.strip.casecmp?("off") || !number(value).nil?
|
|
90
|
+
else false
|
|
91
|
+
end
|
|
60
92
|
end
|
|
61
93
|
|
|
62
94
|
# Cents, or up to four decimals under $0.10, so a small cap (/cap 0.003)
|
|
@@ -71,6 +103,7 @@ class Plugin
|
|
|
71
103
|
|
|
72
104
|
def check(ctx)
|
|
73
105
|
resolved = resolve(ctx)
|
|
106
|
+
unreadable(ctx, resolved)
|
|
74
107
|
cap = resolved[:cap]
|
|
75
108
|
return unless cap
|
|
76
109
|
|
|
@@ -101,6 +134,16 @@ class Plugin
|
|
|
101
134
|
level: :warn)
|
|
102
135
|
end
|
|
103
136
|
|
|
137
|
+
# Once per session: the settings whose cap didn't read, and what is used.
|
|
138
|
+
def unreadable(ctx, resolved)
|
|
139
|
+
bad = resolved[:unreadable]
|
|
140
|
+
return if bad.nil? || bad.empty? || !first_time?(:@unread_warned, resolved[:session])
|
|
141
|
+
|
|
142
|
+
named = bad.map { |key, value| "#{key}: #{value.inspect}" }.join(" and ")
|
|
143
|
+
used = resolved[:cap] ? "using #{resolved[:key]}: (#{money(resolved[:cap])})" : "no cap"
|
|
144
|
+
ctx.notify("cost-cap: #{named} #{bad.size == 1 ? "isn't" : "aren't"} a number or off; #{used}", level: :warn)
|
|
145
|
+
end
|
|
146
|
+
|
|
104
147
|
def at_cap(ctx, resolved, cost)
|
|
105
148
|
cap = resolved[:cap]
|
|
106
149
|
raised = raised_cap(cap, resolved[:step] || cap, cost.total)
|
|
@@ -148,12 +191,13 @@ class Plugin
|
|
|
148
191
|
save(ctx, session, "cap" => nil)
|
|
149
192
|
return "cost-cap: no cap for this session"
|
|
150
193
|
end
|
|
151
|
-
value =
|
|
194
|
+
value = self.class.amount(arg)
|
|
152
195
|
return USAGE unless value
|
|
153
196
|
|
|
154
197
|
save(ctx, session, "cap" => value, "step" => value)
|
|
155
198
|
"cost-cap: cap set to #{money(value)} (#{money(ctx.cost.total)} spent)"
|
|
156
199
|
rescue StandardError => e
|
|
200
|
+
ctx.log.warn(:cap_command_failed, error: "#{e.class}: #{e.message}")
|
|
157
201
|
"cost-cap: #{e.message}"
|
|
158
202
|
end
|
|
159
203
|
|
|
@@ -194,16 +238,17 @@ class Plugin
|
|
|
194
238
|
nil
|
|
195
239
|
end
|
|
196
240
|
|
|
241
|
+
# The first of cap:, child: (a delegate's) and default: that is set and
|
|
242
|
+
# reads (.readable?); the ones set that don't read are kept for the
|
|
243
|
+
# notice (#unreadable).
|
|
197
244
|
def configured(ctx)
|
|
198
245
|
settings = ctx.settings
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
cap = self.class.amount(settings[key])
|
|
206
|
-
{ cap: cap, step: cap }
|
|
246
|
+
keys = %w[cap child default].select { |key| settings.key?(key) }
|
|
247
|
+
keys.delete("child") if keys.include?("child") && !ctx.delegate?
|
|
248
|
+
bad = keys.reject { |key| self.class.readable?(settings[key]) }
|
|
249
|
+
key = (keys - bad).first
|
|
250
|
+
cap = key && self.class.amount(settings[key])
|
|
251
|
+
{ cap: cap, step: cap, key: key, unreadable: bad.to_h { |name| [name, settings[name]] } }
|
|
207
252
|
end
|
|
208
253
|
|
|
209
254
|
def warn_share(ctx)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "installed_gem"
|
|
4
|
+
require_relative "installed_versions"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# What a chi process another chi starts must not take from its spawner's
|
|
8
|
+
# environment, in one place: a session's worker (SessionManager's
|
|
9
|
+
# spawn_options) and `chi bundle try`'s sandboxed child
|
|
10
|
+
# (BundleCommand::Try::Sandbox) both scrub these. Each adds its own policy
|
|
11
|
+
# on top (the worker passes the CLI settings and the log file; try drops
|
|
12
|
+
# the user's config sections too).
|
|
13
|
+
module ChildEnv
|
|
14
|
+
# The spawner's guardrails settings: never a child's (docs/guardrails.md).
|
|
15
|
+
GUARDRAILS_PREFIX = "SAMAGOTCHI_GUARDRAILS_"
|
|
16
|
+
# A worker's own markers (SessionManager::WORKER_PROCESS_ENV, ...).
|
|
17
|
+
WORKER_PREFIX = "SAMAGOTCHI_WORKER_"
|
|
18
|
+
# The markers a session's execute and tasks export
|
|
19
|
+
# (Tools::Builtins.parent_env): that session's, not a child chi's.
|
|
20
|
+
SESSION_MARKERS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL SAMAGOTCHI_SESSION_THINKING
|
|
21
|
+
SAMAGOTCHI_SESSION_REASONING_HISTORY].freeze
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# The keys of +env+ a child chi must not inherit: the guardrails
|
|
26
|
+
# settings, the session markers and a smoke's fake "newest installed"
|
|
27
|
+
# (InstalledVersions::ENV_KEY, for the spawner's notices only).
|
|
28
|
+
# @return [Array<String>]
|
|
29
|
+
def spawner_keys(env = ENV)
|
|
30
|
+
env.each_key.select { |key| key.start_with?(GUARDRAILS_PREFIX) } + SESSION_MARKERS + [InstalledVersions::ENV_KEY]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# #spawner_keys mapped to nil: Process.spawn unsets a key mapped to nil.
|
|
34
|
+
# @return [Hash{String=>nil}]
|
|
35
|
+
def unset(env = ENV) = spawner_keys(env).to_h { |key| [key, nil] }
|
|
36
|
+
|
|
37
|
+
# +env+ without #spawner_keys (a copy).
|
|
38
|
+
# @param env [Hash]
|
|
39
|
+
# @return [Hash]
|
|
40
|
+
def scrub(env) = env.except(*spawner_keys(env))
|
|
41
|
+
|
|
42
|
+
# In gem mode, what Bundler changed in +env+ mapped back
|
|
43
|
+
# (InstalledGem.unbundled_env_changes): a child activates the installed
|
|
44
|
+
# gem itself rather than inherit this app's bundle. {} in a checkout.
|
|
45
|
+
# @return [Hash{String=>String, nil}]
|
|
46
|
+
def unbundled(env = ENV.to_h)
|
|
47
|
+
InstalledGem.spec ? InstalledGem.unbundled_env_changes(env) : {}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -194,12 +194,10 @@ module Samagotchi
|
|
|
194
194
|
def commit
|
|
195
195
|
@taken.each_value do |report|
|
|
196
196
|
Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
|
|
197
|
-
next cursor unless cursor.report_claim&.
|
|
197
|
+
next cursor unless cursor.report_claim&.id == report.claim_id
|
|
198
198
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
current = cursor.with(report_claim: nil)
|
|
202
|
-
current == before ? after : current
|
|
199
|
+
current = cursor.without_claim
|
|
200
|
+
current == report.before.without_claim ? report.after.without_claim : current
|
|
203
201
|
end
|
|
204
202
|
delete_rings(report.rings)
|
|
205
203
|
rescue StandardError => e
|
|
@@ -212,7 +210,7 @@ module Samagotchi
|
|
|
212
210
|
def release
|
|
213
211
|
@taken.each_value do |report|
|
|
214
212
|
Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
|
|
215
|
-
cursor.report_claim&.
|
|
213
|
+
cursor.report_claim&.id == report.claim_id ? cursor.without_claim : cursor
|
|
216
214
|
end
|
|
217
215
|
end
|
|
218
216
|
@taken = {}
|
|
@@ -234,11 +232,11 @@ module Samagotchi
|
|
|
234
232
|
return report.with(relay: nil) unless relay
|
|
235
233
|
|
|
236
234
|
question = report.relay
|
|
237
|
-
|
|
235
|
+
label = Tools::DelegateRelay.label(question)
|
|
238
236
|
line = if Session.load(report.child_id, state_dir: @state_dir).pending_question&.id.to_s != question.id.to_s
|
|
239
|
-
Tools::DelegateRelay.line(
|
|
237
|
+
Tools::DelegateRelay.line(label, "answered on the child")
|
|
240
238
|
elsif relay.relay_desk.settled?(report.child_id, question.id)
|
|
241
|
-
Tools::DelegateRelay.line(
|
|
239
|
+
Tools::DelegateRelay.line(label, "answered on a relay already; it waits for the user on the child")
|
|
242
240
|
else
|
|
243
241
|
outcome = Tools::DelegateRelay.call(report.child_id, question, relay: relay, state_dir: @state_dir)
|
|
244
242
|
return hold_back(report) if outcome.stopped
|
|
@@ -286,7 +284,7 @@ module Samagotchi
|
|
|
286
284
|
def release_report(report)
|
|
287
285
|
@taken.delete(report.child_id)
|
|
288
286
|
Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
|
|
289
|
-
cursor.report_claim&.
|
|
287
|
+
cursor.report_claim&.id == report.claim_id ? cursor.without_claim : cursor
|
|
290
288
|
end
|
|
291
289
|
nil
|
|
292
290
|
end
|
|
@@ -330,11 +328,11 @@ module Samagotchi
|
|
|
330
328
|
timeout: 0, turn: before.turn)
|
|
331
329
|
return nil unless reportable?(wait)
|
|
332
330
|
|
|
333
|
-
claim_id = before.report_claim&.
|
|
334
|
-
after = before.with(reply_file: wait.status == :done ? wait.file : before.reply_file,
|
|
335
|
-
|
|
331
|
+
claim_id = before.report_claim&.id || SecureRandom.hex(16)
|
|
332
|
+
after = before.without_claim.with(reply_file: wait.status == :done ? wait.file : before.reply_file,
|
|
333
|
+
turn: wait.status == :waiting_for_answer ? before.turn : nil)
|
|
336
334
|
.with_baseline(ReplyWait.baseline_of(wait.session))
|
|
337
|
-
claim =
|
|
335
|
+
claim = Tools::ReportClaim.new(id: claim_id, file: wait.file, cursor: after)
|
|
338
336
|
return nil unless Tools::DelegateCursors.claim_report(@session_id, child_id, expected: before, claim: claim,
|
|
339
337
|
state_dir: @state_dir)
|
|
340
338
|
|
data/lib/samagotchi/client_id.rb
CHANGED
|
@@ -46,8 +46,26 @@ module Samagotchi
|
|
|
46
46
|
# (#interactive?): a web tab, an attached chi TUI.
|
|
47
47
|
INTERACTIVE_PREFIXES = [WEB_PREFIX, TUI_PREFIX].freeze
|
|
48
48
|
|
|
49
|
+
# A turn's origin by its sender (#origin, LastTurn#origin): the ids chi
|
|
50
|
+
# runs turns for on its own, by prefix; system:reminder is "reminder",
|
|
51
|
+
# anything else "client".
|
|
52
|
+
ORIGIN_PREFIXES = { DELEGATE_PREFIX => "delegate", CHILD_PREFIX => "delegate_report",
|
|
53
|
+
CONTEXT_PREFIX => "context", TASK_PREFIX => "task" }.freeze
|
|
54
|
+
|
|
49
55
|
module_function
|
|
50
56
|
|
|
57
|
+
# What sent a turn: "delegate" (a delegating parent), "delegate_report"
|
|
58
|
+
# (a child's report), "context" (a context wake), "task" (a task finish
|
|
59
|
+
# wake), "reminder", else "client" (a person, chi send, a plugin, nil).
|
|
60
|
+
# @return [String]
|
|
61
|
+
def origin(client_id)
|
|
62
|
+
id = client_id.to_s
|
|
63
|
+
return "reminder" if id == REMINDER
|
|
64
|
+
|
|
65
|
+
ORIGIN_PREFIXES.each { |prefix, origin| return origin if id.start_with?(prefix) }
|
|
66
|
+
"client"
|
|
67
|
+
end
|
|
68
|
+
|
|
51
69
|
# Whether +client_id+ is a web tab or a chi TUI, which see a command's
|
|
52
70
|
# reply and can send the line it asks for (CacheConfirm); chi send, a
|
|
53
71
|
# delegate, a plugin, chi itself and nil are not.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Config
|
|
5
|
+
# Tells a worker or chi web when config.yml changes a setting it only
|
|
6
|
+
# reads at its start (Config::Entry#lifecycle: worker_start or web_start,
|
|
7
|
+
# and process_start): "server.port changed — applies after a worker
|
|
8
|
+
# restart". ConfigFile.watch_restart_settings! adds a Watcher;
|
|
9
|
+
# ConfigFile.read_yaml reports each new read of the file (#notify) or a
|
|
10
|
+
# file that doesn't parse (#unreadable), which tells nothing and warns
|
|
11
|
+
# once, instead of every start-only key reading as changed.
|
|
12
|
+
#
|
|
13
|
+
# Thread-safe: the watchers list sits behind one mutex; callbacks run
|
|
14
|
+
# outside it.
|
|
15
|
+
module RestartWatch
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
@watchers = []
|
|
18
|
+
|
|
19
|
+
# One process's start-only settings and their start values.
|
|
20
|
+
class Watcher
|
|
21
|
+
LIFECYCLES = {
|
|
22
|
+
worker: %i[worker_start process_start],
|
|
23
|
+
web: %i[web_start process_start]
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
attr_reader :path
|
|
27
|
+
|
|
28
|
+
# @param data [Hash, nil] config.yml as the process read it at start
|
|
29
|
+
# @param file_key [Array, nil] that read's [mtime, size]
|
|
30
|
+
def initialize(process:, path:, data:, file_key:, on_change:)
|
|
31
|
+
lifecycles = LIFECYCLES.fetch(process) { raise ArgumentError, "unknown config process #{process.inspect}" }
|
|
32
|
+
raise ArgumentError, "a config change callback is required" unless on_change
|
|
33
|
+
|
|
34
|
+
@process = process
|
|
35
|
+
@path = path
|
|
36
|
+
@entries = Config::ENTRIES.select { |entry| entry.config_exposed? && lifecycles.include?(entry.lifecycle) }
|
|
37
|
+
@values = @entries.to_h { |entry| [entry.key, Config.resolve(entry.key, file_data: data, env: {})] }
|
|
38
|
+
@noticed = {}
|
|
39
|
+
@file_key = file_key
|
|
40
|
+
@on_change = on_change
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# [callback, message] for each start-only setting the file read now
|
|
44
|
+
# (+data+, under +file_key+) changes: once per key per new value,
|
|
45
|
+
# none for a value back at its start value. Empty for a read already
|
|
46
|
+
# seen. Caller holds RestartWatch's mutex.
|
|
47
|
+
def changes(file_key, data)
|
|
48
|
+
return [] unless seen!(file_key)
|
|
49
|
+
|
|
50
|
+
@entries.filter_map do |entry|
|
|
51
|
+
value = Config.resolve(entry.key, file_data: data, env: {})
|
|
52
|
+
if @values[entry.key] == value
|
|
53
|
+
@noticed.delete(entry.key)
|
|
54
|
+
next
|
|
55
|
+
end
|
|
56
|
+
# Already told for this value: a later edit of another key.
|
|
57
|
+
next if @noticed.key?(entry.key) && @noticed[entry.key] == value
|
|
58
|
+
|
|
59
|
+
@noticed[entry.key] = value
|
|
60
|
+
[@on_change, "#{entry.key} changed — applies after #{restart}"]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Notes +file_key+ as read; false when it already was. Caller holds
|
|
65
|
+
# RestartWatch's mutex.
|
|
66
|
+
def seen!(file_key)
|
|
67
|
+
return false if @file_key == file_key
|
|
68
|
+
|
|
69
|
+
@file_key = file_key
|
|
70
|
+
true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def restart = @process == :worker ? "a worker restart" : "restarting chi web"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
module_function
|
|
79
|
+
|
|
80
|
+
# Adds a Watcher for +process+ (:worker or :web).
|
|
81
|
+
# @return [Proc] unsubscribes
|
|
82
|
+
def watch(process:, path:, data:, file_key:, &on_change)
|
|
83
|
+
watcher = Watcher.new(process: process, path: path, data: data, file_key: file_key, on_change: on_change)
|
|
84
|
+
@mutex.synchronize { @watchers << watcher }
|
|
85
|
+
-> { @mutex.synchronize { @watchers.delete(watcher) } }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Tells each watcher of +path+ which of its start-only settings the file
|
|
89
|
+
# read now (+data+, under +file_key+; nil for a missing file) changes.
|
|
90
|
+
def notify(path, file_key, data)
|
|
91
|
+
notices = @mutex.synchronize do
|
|
92
|
+
@watchers.select { |watcher| watcher.path == path }.flat_map { |watcher| watcher.changes(file_key, data) }
|
|
93
|
+
end
|
|
94
|
+
notices.each { |callback, message| callback.call(message) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The file at +path+ (under +file_key+) doesn't parse as a mapping
|
|
98
|
+
# (+problem+ says why): no watcher is told anything (its settings stay
|
|
99
|
+
# compared with the next read that parses), and a process watching it
|
|
100
|
+
# warns once per problem.
|
|
101
|
+
def unreadable(path, file_key, problem)
|
|
102
|
+
watched = @mutex.synchronize do
|
|
103
|
+
@watchers.select { |watcher| watcher.path == path }.each { |watcher| watcher.seen!(file_key) }.any?
|
|
104
|
+
end
|
|
105
|
+
return unless watched
|
|
106
|
+
|
|
107
|
+
ConfigFile.warn_once("config: #{path} doesn't parse (#{problem}); " \
|
|
108
|
+
"start-only settings are checked again when it does")
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -10,6 +10,7 @@ require_relative "model_ref"
|
|
|
10
10
|
require_relative "paths"
|
|
11
11
|
require_relative "llm_context_strategy"
|
|
12
12
|
require_relative "host_model"
|
|
13
|
+
require_relative "config/restart_watch"
|
|
13
14
|
|
|
14
15
|
module Samagotchi
|
|
15
16
|
# Parses the thinking: levels of host and model entries (it needs Config).
|
|
@@ -556,10 +557,6 @@ module Samagotchi
|
|
|
556
557
|
resolve_with_origin(entry.key, file_data: file_data, env: ENV, cli_overrides: cli_overrides)
|
|
557
558
|
end
|
|
558
559
|
|
|
559
|
-
def set_cli_overrides(overrides)
|
|
560
|
-
reload!(cli_overrides: overrides)
|
|
561
|
-
end
|
|
562
|
-
|
|
563
560
|
# Problems with the keys of a parsed config.yml, one message per key the
|
|
564
561
|
# code doesn't read (with a "did you mean" when a known key is close).
|
|
565
562
|
# Every config-exposed entry in ENTRIES is known as written; names under
|
|
@@ -750,7 +747,7 @@ module Samagotchi
|
|
|
750
747
|
cache = @yaml_cache ||= {}
|
|
751
748
|
unless File.file?(path)
|
|
752
749
|
cache.delete(path)
|
|
753
|
-
|
|
750
|
+
Config::RestartWatch.notify(path, nil, nil)
|
|
754
751
|
return nil
|
|
755
752
|
end
|
|
756
753
|
stat = File.stat(path)
|
|
@@ -758,75 +755,37 @@ module Samagotchi
|
|
|
758
755
|
hit = cache[path]
|
|
759
756
|
return hit[1] if hit && hit[0] == key
|
|
760
757
|
|
|
758
|
+
problem = nil
|
|
761
759
|
data = begin
|
|
762
760
|
YAML.safe_load_file(path, permitted_classes: [], aliases: false)
|
|
763
|
-
rescue StandardError
|
|
761
|
+
rescue StandardError => e
|
|
762
|
+
problem = "#{e.class}: #{e.message.lines.first.to_s.strip[0, 200]}"
|
|
764
763
|
nil
|
|
765
764
|
end
|
|
765
|
+
problem ||= "its top level isn't a mapping" unless data.nil? || data.is_a?(Hash)
|
|
766
766
|
data = nil unless data.is_a?(Hash)
|
|
767
767
|
cache[path] = [key, data]
|
|
768
|
-
|
|
768
|
+
if problem
|
|
769
|
+
Config::RestartWatch.unreadable(path, key, problem)
|
|
770
|
+
else
|
|
771
|
+
Config::RestartWatch.notify(path, key, data)
|
|
772
|
+
end
|
|
769
773
|
data
|
|
770
774
|
end
|
|
771
775
|
|
|
772
776
|
# Calls +on_change+ with one line ("server.port changed — applies after a
|
|
773
777
|
# worker restart") when config.yml changes a setting this process only
|
|
774
|
-
# reads at its start (Config::
|
|
775
|
-
#
|
|
776
|
-
#
|
|
777
|
-
#
|
|
778
|
+
# reads at its start (Config::RestartWatch). Checked when the file is
|
|
779
|
+
# read again (#read_yaml), once per key per new value: none at start,
|
|
780
|
+
# none for a value back at its start value, none while the file doesn't
|
|
781
|
+
# parse (that warns once).
|
|
778
782
|
# @param process [Symbol] :worker or :web
|
|
779
783
|
# @return [Proc] unsubscribes
|
|
780
|
-
def watch_restart_settings!(process:, env: ENV, &
|
|
781
|
-
lifecycles = case process
|
|
782
|
-
when :worker then %i[worker_start process_start]
|
|
783
|
-
when :web then %i[web_start process_start]
|
|
784
|
-
else raise ArgumentError, "unknown config process #{process.inspect}"
|
|
785
|
-
end
|
|
786
|
-
raise ArgumentError, "a config change callback is required" unless on_change
|
|
787
|
-
|
|
784
|
+
def watch_restart_settings!(process:, env: ENV, &)
|
|
788
785
|
path = global_path(env: env)
|
|
789
786
|
data = read_yaml(env: env, path: path)
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
path: path, process: process, entries: entries,
|
|
793
|
-
values: entries.to_h { |entry| [entry.key, Config.resolve(entry.key, file_data: data, env: {})] },
|
|
794
|
-
noticed: {},
|
|
795
|
-
file_key: File.file?(path) ? [File.stat(path).mtime.to_r, File.size(path)] : nil,
|
|
796
|
-
on_change: on_change
|
|
797
|
-
}
|
|
798
|
-
restart_watchers_mutex.synchronize { (@restart_watchers ||= []) << watcher }
|
|
799
|
-
-> { restart_watchers_mutex.synchronize { @restart_watchers&.delete(watcher) } }
|
|
800
|
-
end
|
|
801
|
-
|
|
802
|
-
# Tells each watcher of +path+ which of its start-only settings the file
|
|
803
|
-
# read now (+data+, under +file_key+) changes.
|
|
804
|
-
def notify_restart_settings(path, file_key, data)
|
|
805
|
-
notices = restart_watchers_mutex.synchronize do
|
|
806
|
-
Array(@restart_watchers).flat_map do |watcher|
|
|
807
|
-
next [] unless watcher[:path] == path && watcher[:file_key] != file_key
|
|
808
|
-
|
|
809
|
-
watcher[:file_key] = file_key
|
|
810
|
-
watcher[:entries].filter_map do |entry|
|
|
811
|
-
value = Config.resolve(entry.key, file_data: data, env: {})
|
|
812
|
-
if watcher[:values][entry.key] == value
|
|
813
|
-
watcher[:noticed].delete(entry.key)
|
|
814
|
-
next
|
|
815
|
-
end
|
|
816
|
-
# Already told for this value: a later edit of another key.
|
|
817
|
-
next if watcher[:noticed].key?(entry.key) && watcher[:noticed][entry.key] == value
|
|
818
|
-
|
|
819
|
-
watcher[:noticed][entry.key] = value
|
|
820
|
-
restart = watcher[:process] == :worker ? "a worker restart" : "restarting chi web"
|
|
821
|
-
[watcher[:on_change], "#{entry.key} changed — applies after #{restart}"]
|
|
822
|
-
end
|
|
823
|
-
end
|
|
824
|
-
end
|
|
825
|
-
notices.each { |callback, message| callback.call(message) }
|
|
826
|
-
end
|
|
827
|
-
|
|
828
|
-
def restart_watchers_mutex
|
|
829
|
-
@restart_watchers_mutex ||= Mutex.new
|
|
787
|
+
file_key = File.file?(path) ? [File.stat(path).mtime.to_r, File.size(path)] : nil
|
|
788
|
+
Config::RestartWatch.watch(process: process, path: path, data: data, file_key: file_key, &)
|
|
830
789
|
end
|
|
831
790
|
|
|
832
791
|
# Print +message+ to stderr unless this process already did. The config
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -1402,14 +1402,7 @@ module Samagotchi
|
|
|
1402
1402
|
# answer the continue offer says so (ReplyWait, ParentReport "limit")
|
|
1403
1403
|
# @param event [Hash] the turn's end event (#stop_facts)
|
|
1404
1404
|
def record_last_turn(session, outcome, seconds, origin, event, limit: nil)
|
|
1405
|
-
|
|
1406
|
-
source = if client_id.start_with?(ClientId::DELEGATE_PREFIX) then "delegate"
|
|
1407
|
-
elsif client_id.start_with?(ClientId::CHILD_PREFIX) then "delegate_report"
|
|
1408
|
-
elsif client_id.start_with?(ClientId::CONTEXT_PREFIX) then "context"
|
|
1409
|
-
elsif client_id.start_with?(ClientId::TASK_PREFIX) then "task"
|
|
1410
|
-
elsif client_id == ClientId::REMINDER then "reminder"
|
|
1411
|
-
else "client"
|
|
1412
|
-
end
|
|
1405
|
+
source = ClientId.origin(origin.is_a?(Hash) ? origin[:client_id] : nil)
|
|
1413
1406
|
enqueued_id = origin.is_a?(Hash) ? origin[:enqueued_id] : nil
|
|
1414
1407
|
session.last_turn = LastTurn.new(outcome: outcome, ended_at: Time.now.iso8601(3), seconds: seconds.round(1),
|
|
1415
1408
|
origin: source, exhausted: limit ? true : nil, limit: limit,
|
|
@@ -1523,10 +1516,9 @@ module Samagotchi
|
|
|
1523
1516
|
def guardrail_notify(message) = @hook_routes.guardrail_notify(message)
|
|
1524
1517
|
private :guardrail_notify
|
|
1525
1518
|
|
|
1519
|
+
# @param question [HookQuestion]
|
|
1526
1520
|
# @return [Hash, nil] {selected:, freeform:, selected_indices:}
|
|
1527
|
-
def hook_ask_user(question
|
|
1528
|
-
@hook_routes.ask_user(question, options, header, allow_freeform, hook, effects: effects)
|
|
1529
|
-
end
|
|
1521
|
+
def hook_ask_user(question) = @hook_routes.ask_user(question)
|
|
1530
1522
|
private :hook_ask_user
|
|
1531
1523
|
|
|
1532
1524
|
# @return [Boolean] true when a running turn was cancelled now
|
|
@@ -1828,8 +1820,9 @@ module Samagotchi
|
|
|
1828
1820
|
# that put the turn's first message in the session already (a context
|
|
1829
1821
|
# wake's note) names it with this id
|
|
1830
1822
|
#
|
|
1831
|
-
# An Interrupt (SIGINT) cancels the turn:
|
|
1832
|
-
#
|
|
1823
|
+
# An Interrupt (SIGINT) cancels the turn: what it got to is kept in the
|
|
1824
|
+
# session as after a Stop (the prompt, the loop's finished tool
|
|
1825
|
+
# iterations, a cancel note) and :turn_canceled is emitted, then the
|
|
1833
1826
|
# Interrupt is re-raised so the caller still decides whether to exit. One
|
|
1834
1827
|
# after the turn's end event (in its after_turn/session_end hooks) is
|
|
1835
1828
|
# only re-raised: the turn already ended; so is any error there. Any
|
|
@@ -1856,14 +1849,14 @@ module Samagotchi
|
|
|
1856
1849
|
complete_turn(turn, result)
|
|
1857
1850
|
warm_up_next_turn(turn, result)
|
|
1858
1851
|
result
|
|
1859
|
-
rescue Interrupt
|
|
1852
|
+
rescue Interrupt => e
|
|
1860
1853
|
# After the end event (in the after_turn/session_end hooks) the
|
|
1861
1854
|
# turn is over: no second ending, the answer stays.
|
|
1862
1855
|
raise if turn.ended
|
|
1863
1856
|
|
|
1864
1857
|
turn.controller.cancel!(:ctrl_c)
|
|
1865
1858
|
end_turn(turn, "canceled") do |seconds|
|
|
1866
|
-
[ctrl_c_messages(turn, seconds), { type: :turn_canceled, cancellation_reason: :ctrl_c, duration_ms: (seconds * 1000).round }]
|
|
1859
|
+
[ctrl_c_messages(turn, e, seconds), { type: :turn_canceled, cancellation_reason: :ctrl_c, duration_ms: (seconds * 1000).round }]
|
|
1867
1860
|
end
|
|
1868
1861
|
raise
|
|
1869
1862
|
rescue StandardError => e
|
|
@@ -2263,17 +2256,26 @@ module Samagotchi
|
|
|
2263
2256
|
end
|
|
2264
2257
|
private :kept_messages
|
|
2265
2258
|
|
|
2266
|
-
# A Ctrl-C keeps the
|
|
2267
|
-
#
|
|
2268
|
-
#
|
|
2269
|
-
|
|
2270
|
-
|
|
2259
|
+
# A Ctrl-C keeps the loop's conversation so far, as a Stop does (its
|
|
2260
|
+
# finished tool iterations, every call answered: ToolResponse.run_batch),
|
|
2261
|
+
# else the turn's messages so far (it came before the loop), with a
|
|
2262
|
+
# cancel note naming the tasks still running; nil before there are any.
|
|
2263
|
+
def ctrl_c_messages(turn, interrupt, seconds)
|
|
2264
|
+
kept = loop_conversation(interrupt) || turn.messages
|
|
2265
|
+
return nil unless kept
|
|
2271
2266
|
|
|
2272
|
-
note = TurnNote.cancelled(:ctrl_c, seconds: seconds, running_tasks: Tools::TaskRuntime.running_created_in(
|
|
2273
|
-
TurnNote.replace_trailing(
|
|
2267
|
+
note = TurnNote.cancelled(:ctrl_c, seconds: seconds, running_tasks: Tools::TaskRuntime.running_created_in(kept))
|
|
2268
|
+
TurnNote.replace_trailing(kept, note)
|
|
2274
2269
|
end
|
|
2275
2270
|
private :ctrl_c_messages
|
|
2276
2271
|
|
|
2272
|
+
# The conversation the loop had built when +error+ ended the turn
|
|
2273
|
+
# (LLM::FailedTurn), or nil.
|
|
2274
|
+
def loop_conversation(error)
|
|
2275
|
+
error.partial_conversation if error.respond_to?(:partial_conversation) && error.partial_conversation.is_a?(Array)
|
|
2276
|
+
end
|
|
2277
|
+
private :loop_conversation
|
|
2278
|
+
|
|
2277
2279
|
# A failure keeps the loop's partial conversation (else the turn's
|
|
2278
2280
|
# messages so far) with a failed note; nil when the turn never reached
|
|
2279
2281
|
# the model. The model reads why on its next turn. A turn that got to
|
|
@@ -2283,7 +2285,7 @@ module Samagotchi
|
|
|
2283
2285
|
# turn (TurnFlow#prompt_turn_failed), and roll back the others, with a
|
|
2284
2286
|
# note of their own; -p --non-interactive keeps what this keeps.
|
|
2285
2287
|
def failed_messages(turn, error)
|
|
2286
|
-
kept =
|
|
2288
|
+
kept = loop_conversation(error) || turn.messages
|
|
2287
2289
|
return nil unless kept
|
|
2288
2290
|
|
|
2289
2291
|
steps = LLM::FailedTurn.progress(turn.session.messages, kept)
|
|
@@ -2559,9 +2561,7 @@ module Samagotchi
|
|
|
2559
2561
|
messages: -> { plugin_messages },
|
|
2560
2562
|
messages_partial: -> { turn_running? && @running_turn_messages.nil? },
|
|
2561
2563
|
notify: ->(text, level, label, fallback_for: nil) { hook_notify(text, level, label, fallback_for: fallback_for) },
|
|
2562
|
-
ask_user:
|
|
2563
|
-
hook_ask_user(question, options, header, allow_freeform, hook, effects: effects)
|
|
2564
|
-
},
|
|
2564
|
+
ask_user: ->(question) { hook_ask_user(question) },
|
|
2565
2565
|
# An init task's is its own (a Ctrl-C ends a turn, not the task).
|
|
2566
2566
|
cancelled: lambda {
|
|
2567
2567
|
(task = current_init_task) ? task.cancelled? : active_cancel_controller&.cancelled?
|