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
|
@@ -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
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -1828,8 +1828,9 @@ module Samagotchi
|
|
|
1828
1828
|
# that put the turn's first message in the session already (a context
|
|
1829
1829
|
# wake's note) names it with this id
|
|
1830
1830
|
#
|
|
1831
|
-
# An Interrupt (SIGINT) cancels the turn:
|
|
1832
|
-
#
|
|
1831
|
+
# An Interrupt (SIGINT) cancels the turn: what it got to is kept in the
|
|
1832
|
+
# session as after a Stop (the prompt, the loop's finished tool
|
|
1833
|
+
# iterations, a cancel note) and :turn_canceled is emitted, then the
|
|
1833
1834
|
# Interrupt is re-raised so the caller still decides whether to exit. One
|
|
1834
1835
|
# after the turn's end event (in its after_turn/session_end hooks) is
|
|
1835
1836
|
# only re-raised: the turn already ended; so is any error there. Any
|
|
@@ -1856,14 +1857,14 @@ module Samagotchi
|
|
|
1856
1857
|
complete_turn(turn, result)
|
|
1857
1858
|
warm_up_next_turn(turn, result)
|
|
1858
1859
|
result
|
|
1859
|
-
rescue Interrupt
|
|
1860
|
+
rescue Interrupt => e
|
|
1860
1861
|
# After the end event (in the after_turn/session_end hooks) the
|
|
1861
1862
|
# turn is over: no second ending, the answer stays.
|
|
1862
1863
|
raise if turn.ended
|
|
1863
1864
|
|
|
1864
1865
|
turn.controller.cancel!(:ctrl_c)
|
|
1865
1866
|
end_turn(turn, "canceled") do |seconds|
|
|
1866
|
-
[ctrl_c_messages(turn, seconds), { type: :turn_canceled, cancellation_reason: :ctrl_c, duration_ms: (seconds * 1000).round }]
|
|
1867
|
+
[ctrl_c_messages(turn, e, seconds), { type: :turn_canceled, cancellation_reason: :ctrl_c, duration_ms: (seconds * 1000).round }]
|
|
1867
1868
|
end
|
|
1868
1869
|
raise
|
|
1869
1870
|
rescue StandardError => e
|
|
@@ -2263,17 +2264,26 @@ module Samagotchi
|
|
|
2263
2264
|
end
|
|
2264
2265
|
private :kept_messages
|
|
2265
2266
|
|
|
2266
|
-
# A Ctrl-C keeps the
|
|
2267
|
-
#
|
|
2268
|
-
#
|
|
2269
|
-
|
|
2270
|
-
|
|
2267
|
+
# A Ctrl-C keeps the loop's conversation so far, as a Stop does (its
|
|
2268
|
+
# finished tool iterations, every call answered: ToolResponse.run_batch),
|
|
2269
|
+
# else the turn's messages so far (it came before the loop), with a
|
|
2270
|
+
# cancel note naming the tasks still running; nil before there are any.
|
|
2271
|
+
def ctrl_c_messages(turn, interrupt, seconds)
|
|
2272
|
+
kept = loop_conversation(interrupt) || turn.messages
|
|
2273
|
+
return nil unless kept
|
|
2271
2274
|
|
|
2272
|
-
note = TurnNote.cancelled(:ctrl_c, seconds: seconds, running_tasks: Tools::TaskRuntime.running_created_in(
|
|
2273
|
-
TurnNote.replace_trailing(
|
|
2275
|
+
note = TurnNote.cancelled(:ctrl_c, seconds: seconds, running_tasks: Tools::TaskRuntime.running_created_in(kept))
|
|
2276
|
+
TurnNote.replace_trailing(kept, note)
|
|
2274
2277
|
end
|
|
2275
2278
|
private :ctrl_c_messages
|
|
2276
2279
|
|
|
2280
|
+
# The conversation the loop had built when +error+ ended the turn
|
|
2281
|
+
# (LLM::FailedTurn), or nil.
|
|
2282
|
+
def loop_conversation(error)
|
|
2283
|
+
error.partial_conversation if error.respond_to?(:partial_conversation) && error.partial_conversation.is_a?(Array)
|
|
2284
|
+
end
|
|
2285
|
+
private :loop_conversation
|
|
2286
|
+
|
|
2277
2287
|
# A failure keeps the loop's partial conversation (else the turn's
|
|
2278
2288
|
# messages so far) with a failed note; nil when the turn never reached
|
|
2279
2289
|
# the model. The model reads why on its next turn. A turn that got to
|
|
@@ -2283,7 +2293,7 @@ module Samagotchi
|
|
|
2283
2293
|
# turn (TurnFlow#prompt_turn_failed), and roll back the others, with a
|
|
2284
2294
|
# note of their own; -p --non-interactive keeps what this keeps.
|
|
2285
2295
|
def failed_messages(turn, error)
|
|
2286
|
-
kept =
|
|
2296
|
+
kept = loop_conversation(error) || turn.messages
|
|
2287
2297
|
return nil unless kept
|
|
2288
2298
|
|
|
2289
2299
|
steps = LLM::FailedTurn.progress(turn.session.messages, kept)
|
|
@@ -93,6 +93,8 @@ module Samagotchi
|
|
|
93
93
|
lambda do |payload|
|
|
94
94
|
yield(payload)
|
|
95
95
|
rescue Exception => e # rubocop:disable Lint/RescueException -- :deny fails closed on anything
|
|
96
|
+
# Ctrl-C and kill signals are the user's, whatever the policy.
|
|
97
|
+
raise if e.is_a?(SignalException)
|
|
96
98
|
raise unless policy == :deny || e.is_a?(StandardError) || e.is_a?(ScriptError)
|
|
97
99
|
|
|
98
100
|
Hooks.record_failure(label, event, e)
|
|
@@ -243,7 +243,9 @@ module Samagotchi
|
|
|
243
243
|
streamed_thinking: turn.streamed_thinking)
|
|
244
244
|
end
|
|
245
245
|
finish(turn)
|
|
246
|
-
rescue StandardError => e
|
|
246
|
+
rescue StandardError, Interrupt => e
|
|
247
|
+
# A failure, or a Ctrl-C, carries the conversation so far: the
|
|
248
|
+
# Engine keeps the turn's finished iterations.
|
|
247
249
|
LLM::FailedTurn.attach(e, turn && duplicate_conversation(turn.conversation))
|
|
248
250
|
raise
|
|
249
251
|
end
|
|
@@ -671,8 +673,17 @@ module Samagotchi
|
|
|
671
673
|
def dispatch_calls(turn, calls)
|
|
672
674
|
turn.qwen_attempts = 0
|
|
673
675
|
turn.qwen_partial = nil
|
|
674
|
-
runs =
|
|
675
|
-
|
|
676
|
+
runs = []
|
|
677
|
+
begin
|
|
678
|
+
ToolResponse.run_batch(tool_runner, calls, iteration: turn.iteration, emit: turn.emit,
|
|
679
|
+
on_stream_event: turn.on_stream_event, cap: turn.cap) { |run, _| runs << run }
|
|
680
|
+
rescue Interrupt
|
|
681
|
+
# A Ctrl-C mid-batch: the entry still answers every call (the
|
|
682
|
+
# finished runs, then run_batch's interrupted ones), for #run to
|
|
683
|
+
# hand on with the conversation.
|
|
684
|
+
turn.conversation << ToolResponse.joined(runs, ids: ToolIds.next_ids(turn.conversation, runs.size))
|
|
685
|
+
raise
|
|
686
|
+
end
|
|
676
687
|
turn.tool_activity.concat(ToolResponse.activities(runs))
|
|
677
688
|
# One entry for the batch: the capped outputs joined (ToolResponse.joined),
|
|
678
689
|
# an id per run (ToolIds).
|
|
@@ -90,7 +90,9 @@ module Samagotchi
|
|
|
90
90
|
conversation = Array(messages).map(&:dup)
|
|
91
91
|
Run.new(self, conversation, on_stream_event, cancel_controller, model_name, pending_input, start: reasoning_since)
|
|
92
92
|
.call(max_iterations: max_iterations || 1, cap: KernelLoop.resolve_output_char_cap(max_tool_output_chars))
|
|
93
|
-
rescue StandardError => e
|
|
93
|
+
rescue StandardError, Interrupt => e
|
|
94
|
+
# A failure, or a Ctrl-C (its calls answered: ToolResponse.run_batch),
|
|
95
|
+
# carries the conversation so far for the Engine to keep.
|
|
94
96
|
FailedTurn.attach(e, conversation && plain(conversation))
|
|
95
97
|
raise
|
|
96
98
|
end
|
|
@@ -547,10 +547,10 @@ module Samagotchi
|
|
|
547
547
|
end
|
|
548
548
|
end
|
|
549
549
|
|
|
550
|
-
# Marks an error that ended a turn
|
|
551
|
-
# built by then (the prompt plus completed
|
|
552
|
-
# streamed reply is not kept), so the caller can
|
|
553
|
-
# salvage does.
|
|
550
|
+
# Marks an error that ended a turn (or the Interrupt of a Ctrl-C) with
|
|
551
|
+
# the conversation the loop had built by then (the prompt plus completed
|
|
552
|
+
# tool iterations; a half streamed reply is not kept), so the caller can
|
|
553
|
+
# keep it, as a cancel's salvage does.
|
|
554
554
|
module FailedTurn
|
|
555
555
|
# +kept_steps+: set by the Engine when the turn's work stays
|
|
556
556
|
# (Engine#failed_messages): the tool results it got to; nil when it
|
|
@@ -202,7 +202,7 @@ module Samagotchi
|
|
|
202
202
|
parts.scripts.each_value { |src| FileUtils.cp(src, File.join(staging, "scripts", File.basename(src))) }
|
|
203
203
|
end
|
|
204
204
|
|
|
205
|
-
# manifest.yml for the staged bundle: requires_chi, needs, session_settings and the
|
|
205
|
+
# manifest.yml for the staged bundle: requires_chi, needs, session_settings (typed) and the
|
|
206
206
|
# file descriptions come from the installed bundle's record, the
|
|
207
207
|
# trust_level from the CLI else the record.
|
|
208
208
|
def write_manifest(staging, name, version, files_map, parts)
|
|
@@ -224,7 +224,8 @@ module Samagotchi
|
|
|
224
224
|
scripts: parts.scripts.transform_values { |src| "sha256:#{Digest::SHA256.hexdigest(File.binread(src))}" },
|
|
225
225
|
context_providers: parts.context_providers,
|
|
226
226
|
file_descriptions: file_descriptions(record, files_map),
|
|
227
|
-
session_settings: record&.session_settings || []
|
|
227
|
+
session_settings: record&.session_settings || [],
|
|
228
|
+
session_setting_types: record&.session_setting_types || {}
|
|
228
229
|
)
|
|
229
230
|
end
|
|
230
231
|
|
|
@@ -22,15 +22,19 @@ module Samagotchi
|
|
|
22
22
|
# includes a profile's recorded members, nil for a plain bundle
|
|
23
23
|
# session_settings
|
|
24
24
|
# the keys a session may set (chi --set), [] when none
|
|
25
|
+
# session_setting_types
|
|
26
|
+
# {key => type} for the typed ones (Manifest::SESSION_SETTING_TYPES), {} when none
|
|
25
27
|
# dev a dev install (`chi bundle install --dev`): its source is
|
|
26
28
|
# the folder it links to and its files carry Provenance::DEV_SHA
|
|
27
29
|
# error why manifest.json couldn't be read (the rest empty), else nil
|
|
28
30
|
class InstalledBundle < Data.define(:name, :version, :scope, :source, :installed_at, :trust_level, :source_commit,
|
|
29
31
|
:requires_chi, :files, :hooks, :guardrails, :scripts, :plugin, :needs,
|
|
30
|
-
:includes, :context_providers, :session_settings, :
|
|
32
|
+
:includes, :context_providers, :session_settings, :session_setting_types, :dev,
|
|
33
|
+
:error)
|
|
31
34
|
OPTIONAL = { version: nil, scope: nil, source: nil, installed_at: nil, trust_level: nil, source_commit: nil,
|
|
32
35
|
requires_chi: nil, files: {}, hooks: {}, guardrails: {}, scripts: {}, plugin: nil, needs: nil,
|
|
33
|
-
includes: nil, context_providers: nil, session_settings: [],
|
|
36
|
+
includes: nil, context_providers: nil, session_settings: [], session_setting_types: {}, dev: false,
|
|
37
|
+
error: nil }.freeze
|
|
34
38
|
|
|
35
39
|
def initialize(name:, **fields)
|
|
36
40
|
super(name: name, **OPTIONAL, **fields)
|
|
@@ -63,6 +67,7 @@ module Samagotchi
|
|
|
63
67
|
plugin: PluginRef.parse(h["plugin"]), needs: h["needs"], context_providers: h["context_providers"],
|
|
64
68
|
includes: h["includes"].is_a?(Array) ? h["includes"].map(&:to_s) : nil,
|
|
65
69
|
session_settings: h["session_settings"].is_a?(Array) ? h["session_settings"].map(&:to_s) : [],
|
|
70
|
+
session_setting_types: mapping(h["session_setting_types"]) { |type| type.to_s },
|
|
66
71
|
dev: h["dev"] == true)
|
|
67
72
|
end
|
|
68
73
|
|
|
@@ -440,6 +440,7 @@ module Samagotchi
|
|
|
440
440
|
context_providers: @manifest.context_providers,
|
|
441
441
|
file_descriptions: @manifest.file_descriptions,
|
|
442
442
|
session_settings: @manifest.session_settings,
|
|
443
|
+
session_setting_types: @manifest.session_setting_types,
|
|
443
444
|
dev: @dev
|
|
444
445
|
)
|
|
445
446
|
end
|
|
@@ -43,7 +43,9 @@ module Samagotchi
|
|
|
43
43
|
# every_seconds: 300 # optional
|
|
44
44
|
# session_settings: [cap] # optional — the settings a session may set
|
|
45
45
|
# # (chi --set cost-cap.cap=3), read by the
|
|
46
|
-
# # plugin from ctx.settings at call time
|
|
46
|
+
# # plugin from ctx.settings at call time;
|
|
47
|
+
# # or {cap: number}: each with the type
|
|
48
|
+
# # --set checks (SESSION_SETTING_TYPES)
|
|
47
49
|
# includes: [a, b] # optional — a profile (meta bundle): the
|
|
48
50
|
# # shipped bundles it installs; its dir holds
|
|
49
51
|
# # only manifest.yml (MemoryBundle::Profile)
|
|
@@ -63,7 +65,7 @@ module Samagotchi
|
|
|
63
65
|
# the description a mapping gave, for the files that have one; hooks:
|
|
64
66
|
# name → BundleHook.
|
|
65
67
|
attr_reader :name, :version, :scope, :description, :files, :file_descriptions, :hooks, :trust_level, :plugin, :requires_chi, :needs, :includes,
|
|
66
|
-
:scripts, :context_providers, :session_settings
|
|
68
|
+
:scripts, :context_providers, :session_settings, :session_setting_types
|
|
67
69
|
|
|
68
70
|
def initialize(path:)
|
|
69
71
|
@path = path
|
|
@@ -88,6 +90,7 @@ module Samagotchi
|
|
|
88
90
|
@context_providers = parse_context_providers(raw["context_providers"])
|
|
89
91
|
@includes = parse_includes(raw["includes"])
|
|
90
92
|
@session_settings = self.class.parse_session_settings(raw["session_settings"])
|
|
93
|
+
@session_setting_types = self.class.parse_session_setting_types(raw["session_settings"])
|
|
91
94
|
end
|
|
92
95
|
|
|
93
96
|
# A profile: it installs the bundles in includes instead of files.
|
|
@@ -141,7 +144,7 @@ module Samagotchi
|
|
|
141
144
|
# +file_descriptions+ (name → text) writes those files as mappings.
|
|
142
145
|
def self.write(dir:, name:, version:, files:, scope: nil, description: "", hooks: nil, trust_level: nil,
|
|
143
146
|
plugin: nil, requires_chi: nil, needs: nil, scripts: {}, context_providers: [], file_descriptions: {},
|
|
144
|
-
session_settings: [])
|
|
147
|
+
session_settings: [], session_setting_types: {})
|
|
145
148
|
FileUtils.mkdir_p(dir)
|
|
146
149
|
manifest = {
|
|
147
150
|
"name" => name,
|
|
@@ -164,26 +167,57 @@ module Samagotchi
|
|
|
164
167
|
manifest["needs"] = needs.map { |n| n.transform_keys(&:to_s).compact } unless needs.empty?
|
|
165
168
|
manifest["scripts"] = scripts.transform_keys(&:to_s) unless scripts.empty?
|
|
166
169
|
manifest["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
|
|
167
|
-
manifest["session_settings"] =
|
|
170
|
+
manifest["session_settings"] = session_settings_entry(session_settings, session_setting_types) unless Array(session_settings).empty?
|
|
168
171
|
File.write(File.join(dir, "manifest.yml"), YAML.dump(manifest))
|
|
169
172
|
end
|
|
170
173
|
|
|
171
174
|
# A setting key a session may set (BundleSessionSettings::KEY).
|
|
172
175
|
SESSION_SETTING = /\A[A-Za-z0-9_][A-Za-z0-9_-]*\z/
|
|
173
176
|
|
|
174
|
-
#
|
|
175
|
-
#
|
|
177
|
+
# The types a session setting may be given (session_settings: {key:
|
|
178
|
+
# type}), checked by BundleSessionSettings.check!: number (or off),
|
|
179
|
+
# boolean, string; any (or a list's key) takes every scalar.
|
|
180
|
+
SESSION_SETTING_TYPES = %w[number boolean string any].freeze
|
|
181
|
+
|
|
182
|
+
# session_settings: → the keys, [] when absent: a list of plain key
|
|
183
|
+
# names, or a mapping of them to types; anything else is a
|
|
184
|
+
# ValidationError.
|
|
176
185
|
def self.parse_session_settings(raw)
|
|
177
186
|
return [] if raw.nil?
|
|
178
187
|
|
|
179
|
-
keys = raw.is_a?(
|
|
188
|
+
keys = raw.is_a?(Hash) && raw.values.all?(String) ? raw.keys : raw
|
|
189
|
+
keys = nil unless keys.is_a?(Array)
|
|
180
190
|
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})"
|
|
191
|
+
raise ValidationError, "session_settings: a list of setting names, or a mapping of them to types (#{raw.inspect})"
|
|
182
192
|
end
|
|
183
193
|
|
|
184
194
|
keys.map(&:to_s).uniq
|
|
185
195
|
end
|
|
186
196
|
|
|
197
|
+
# session_settings: → {key => type} for the keys a mapping types
|
|
198
|
+
# (any left out), {} for a list or none.
|
|
199
|
+
# @raise [ValidationError] a type that isn't one of SESSION_SETTING_TYPES
|
|
200
|
+
def self.parse_session_setting_types(raw)
|
|
201
|
+
return {} unless raw.is_a?(Hash)
|
|
202
|
+
|
|
203
|
+
raw.each_with_object({}) do |(key, type), types|
|
|
204
|
+
unless SESSION_SETTING_TYPES.include?(type)
|
|
205
|
+
raise ValidationError, "session_settings: #{key}'s type is #{SESSION_SETTING_TYPES[0..-2].join(", ")} or " \
|
|
206
|
+
"#{SESSION_SETTING_TYPES.last}, not #{type.inspect}"
|
|
207
|
+
end
|
|
208
|
+
types[key.to_s] = type unless type == "any"
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# session_settings: as written back: a list without types, else a
|
|
213
|
+
# mapping (an untyped key as any).
|
|
214
|
+
def self.session_settings_entry(keys, types)
|
|
215
|
+
keys = Array(keys).map(&:to_s)
|
|
216
|
+
return keys if types.nil? || types.empty?
|
|
217
|
+
|
|
218
|
+
keys.to_h { |key| [key, types[key] || "any"] }
|
|
219
|
+
end
|
|
220
|
+
|
|
187
221
|
# needs: → [{command:, why:, hint:}] (why/hint a String or nil), [] when
|
|
188
222
|
# absent. Takes the manifest's string-keyed YAML or the symbol-keyed
|
|
189
223
|
# form provenance stores. Duplicates merge (the first one's why/hint
|
|
@@ -154,7 +154,7 @@ module Samagotchi
|
|
|
154
154
|
# source, recorded as DEV_SHA (no sha256 check) with "dev": true
|
|
155
155
|
def write(files:, scope:, version:, source_path:, hooks: {}, trust_level: nil, source_commit: nil, hooks_files: {},
|
|
156
156
|
guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil, conflicts: [], includes: nil,
|
|
157
|
-
scripts_files: {}, context_providers: [], file_descriptions: {}, session_settings: [], dev: false)
|
|
157
|
+
scripts_files: {}, context_providers: [], file_descriptions: {}, session_settings: [], session_setting_types: {}, dev: false)
|
|
158
158
|
FileUtils.mkdir_p(@bundle_dir)
|
|
159
159
|
bases_dir = File.join(@bundle_dir, "bases")
|
|
160
160
|
FileUtils.mkdir_p(bases_dir)
|
|
@@ -255,6 +255,7 @@ module Samagotchi
|
|
|
255
255
|
end
|
|
256
256
|
manifest_data["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
|
|
257
257
|
manifest_data["session_settings"] = Array(session_settings).map(&:to_s) unless Array(session_settings).empty?
|
|
258
|
+
manifest_data["session_setting_types"] = session_setting_types.to_h { |k, v| [k.to_s, v.to_s] } unless session_setting_types.to_h.empty?
|
|
258
259
|
|
|
259
260
|
write_manifest(manifest_data)
|
|
260
261
|
end
|
|
@@ -6,6 +6,7 @@ require "json"
|
|
|
6
6
|
require_relative "cli/exit"
|
|
7
7
|
require_relative "pending_question"
|
|
8
8
|
require_relative "session"
|
|
9
|
+
require_relative "turn_checkpoint"
|
|
9
10
|
require_relative "guardrails/parent_policy"
|
|
10
11
|
|
|
11
12
|
module Samagotchi
|
|
@@ -339,6 +340,8 @@ module Samagotchi
|
|
|
339
340
|
end
|
|
340
341
|
|
|
341
342
|
def no_reply_line(result, session_id = nil, strategy: nil)
|
|
343
|
+
return command_line(result) if result.origin == TurnCheckpoint::COMMAND_ORIGIN
|
|
344
|
+
|
|
342
345
|
case result.outcome
|
|
343
346
|
when "failed" then failed_line(result)
|
|
344
347
|
when "canceled"
|
|
@@ -360,6 +363,15 @@ module Samagotchi
|
|
|
360
363
|
end
|
|
361
364
|
end
|
|
362
365
|
|
|
366
|
+
# A file-delivered session command's outcome: no turn ran for it.
|
|
367
|
+
def command_line(result)
|
|
368
|
+
case result.outcome
|
|
369
|
+
when "completed" then "the command ran (no turn)"
|
|
370
|
+
when "failed" then "the command failed"
|
|
371
|
+
else "the command was refused (#{result.cancel_reason || "canceled"})"
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
363
375
|
# A failed turn's line; one whose work stayed says so, so the task isn't
|
|
364
376
|
# sent again over what it changed.
|
|
365
377
|
def failed_line(result)
|
|
@@ -59,8 +59,11 @@ module Samagotchi
|
|
|
59
59
|
# :canceled and :timeout
|
|
60
60
|
# @!attribute turn_id [String, nil] the id of the turn this result is
|
|
61
61
|
# about, when known
|
|
62
|
+
# @!attribute origin [String, nil] "command" for a :no_reply that a
|
|
63
|
+
# file-delivered session command settled (no turn ran:
|
|
64
|
+
# TurnCheckpoint.record_command_outcome); nil otherwise
|
|
62
65
|
Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, :error_kind, :retryable, :cancel_reason,
|
|
63
|
-
:stopped_by, :kept_steps, :session, :turn_id, keyword_init: true)
|
|
66
|
+
:stopped_by, :kept_steps, :session, :turn_id, :origin, keyword_init: true)
|
|
64
67
|
|
|
65
68
|
module_function
|
|
66
69
|
|
|
@@ -163,7 +166,8 @@ module Samagotchi
|
|
|
163
166
|
# command finished without a turn. After the reply check: a worker
|
|
164
167
|
# killed between writing the reply and its idle save left the reply.
|
|
165
168
|
if turn && (settled = TurnCheckpoint.settled_turn(session_dir, turn))
|
|
166
|
-
return Result.new(status: :no_reply, outcome: settled.outcome, session: session,
|
|
169
|
+
return Result.new(status: :no_reply, outcome: settled.outcome, session: session, origin: settled.origin,
|
|
170
|
+
**settled.stop_facts)
|
|
167
171
|
end
|
|
168
172
|
|
|
169
173
|
case session.status
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "yaml"
|
|
6
|
+
require_relative "paths"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
# The temp folders a sandboxed chi runs in, shared by `chi bundle try`'s
|
|
10
|
+
# child (BundleCommand::Try::Sandbox) and the test kit
|
|
11
|
+
# (Testing::Sandbox): one realpath'd root holding config/, state/,
|
|
12
|
+
# work/ and whatever else the sandbox wants, the XDG pair pointing at
|
|
13
|
+
# them, and the switches every sandbox turns off. Where the environment
|
|
14
|
+
# goes is each sandbox's own: try builds its child's Hash, the kit
|
|
15
|
+
# changes ENV.
|
|
16
|
+
class SandboxLayout
|
|
17
|
+
DIRS = %w[config state work].freeze
|
|
18
|
+
# No recap request and no cache warm-up request in a sandbox.
|
|
19
|
+
QUIET_ENV = { "SAMAGOTCHI_RECAP_ENABLED" => "false", "SAMAGOTCHI_CACHE_WARMUP" => "off" }.freeze
|
|
20
|
+
|
|
21
|
+
# A new temp root (Dir.mktmpdir +prefix+, its realpath) with DIRS and
|
|
22
|
+
# +extra+ folders in it.
|
|
23
|
+
# @return [SandboxLayout]
|
|
24
|
+
def self.create(prefix, extra: [])
|
|
25
|
+
root = File.realpath(Dir.mktmpdir(prefix))
|
|
26
|
+
(DIRS + extra).each { |dir| FileUtils.mkdir_p(File.join(root, dir)) }
|
|
27
|
+
new(root)
|
|
28
|
+
rescue StandardError
|
|
29
|
+
FileUtils.rm_rf(root) if root
|
|
30
|
+
raise
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [String]
|
|
34
|
+
attr_reader :root
|
|
35
|
+
|
|
36
|
+
def initialize(root)
|
|
37
|
+
@root = root
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def dir(name) = File.join(root, name)
|
|
41
|
+
|
|
42
|
+
def config_home = dir("config")
|
|
43
|
+
|
|
44
|
+
def state_home = dir("state")
|
|
45
|
+
|
|
46
|
+
def work_dir = dir("work")
|
|
47
|
+
|
|
48
|
+
# XDG_CONFIG_HOME and XDG_STATE_HOME at the sandbox's, and QUIET_ENV.
|
|
49
|
+
# @return [Hash{String=>String}]
|
|
50
|
+
def env = { "XDG_CONFIG_HOME" => config_home, "XDG_STATE_HOME" => state_home }.merge(QUIET_ENV)
|
|
51
|
+
|
|
52
|
+
# config.yml under +config_home+ (default the sandbox's): a Hash (as
|
|
53
|
+
# YAML) or YAML text.
|
|
54
|
+
# @return [String] its path
|
|
55
|
+
def write_config(data, config_home: self.config_home)
|
|
56
|
+
path = File.join(config_home, Paths::APP_DIR, "config.yml")
|
|
57
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
58
|
+
File.write(path, data.is_a?(String) ? data : YAML.dump(data))
|
|
59
|
+
path
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def remove
|
|
63
|
+
FileUtils.rm_rf(root)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -93,9 +93,9 @@ module Samagotchi
|
|
|
93
93
|
--set BUNDLE.KEY=VALUE
|
|
94
94
|
(--new) a bundle setting for this session, over
|
|
95
95
|
config.yml's bundles: (repeatable; a key its bundle's
|
|
96
|
-
manifest lists in session_settings:; 3,
|
|
97
|
-
|
|
98
|
-
--set cost-cap.cap=3)
|
|
96
|
+
manifest lists in session_settings:; numbers (3,
|
|
97
|
+
.5) and booleans (true, off, no) are typed, the
|
|
98
|
+
rest is text: --set cost-cap.cap=3)
|
|
99
99
|
--parent-answers LEVEL
|
|
100
100
|
(--new) what a parent agent may answer on its
|
|
101
101
|
questions: hard, standard or soft, only stricter than
|