samagotchi 0.92.0 → 0.94.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 +53 -1
- data/README.md +1 -1
- data/bin/chi +4 -4
- data/docs/architecture.md +4 -4
- data/docs/cli.md +1 -1
- data/docs/guardrails.md +4 -5
- data/docs/hooks.md +1 -1
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +6 -0
- data/docs/memory.md +26 -1
- data/docs/plugins.md +37 -0
- data/docs/releasing.md +2 -2
- data/docs/sessions.md +1 -1
- data/docs/testing.md +11 -2
- data/lib/samagotchi/bootstrap/probe.rb +2 -2
- data/lib/samagotchi/bootstrap_command.rb +1 -0
- data/lib/samagotchi/bundle_command.rb +1 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/model-notes/manifest.yml +11 -0
- data/lib/samagotchi/bundles/model-notes/model_notes_deepseek.md +7 -0
- data/lib/samagotchi/bundles/model-notes/notes/README.md +37 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- 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 +4 -14
- 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 +5 -5
- data/lib/samagotchi/host_registry.rb +1 -1
- data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
- data/lib/samagotchi/memory_bundle/profile.rb +22 -4
- data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -1
- data/lib/samagotchi/parent_answers_command.rb +6 -6
- 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 +47 -190
- 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 +31 -100
- data/lib/samagotchi/session.rb +10 -9
- data/lib/samagotchi/session_inbox.rb +31 -8
- data/lib/samagotchi/session_manager.rb +48 -15
- data/lib/samagotchi/session_metrics.rb +22 -3
- data/lib/samagotchi/sessions_command.rb +4 -4
- 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 +1 -1
- data/lib/samagotchi/testing/host.rb +2 -8
- data/lib/samagotchi/testing/recorder.rb +6 -9
- data/lib/samagotchi/testing/sandbox.rb +26 -0
- 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/update_command.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +46 -63
- data/lib/samagotchi/web/public/app.js +137 -638
- data/lib/samagotchi/web/public/dock_reserve.js +103 -0
- data/lib/samagotchi/web/public/index.html +4 -4
- 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/toast_place.js +1 -1
- metadata +14 -1
data/lib/samagotchi/session.rb
CHANGED
|
@@ -15,6 +15,7 @@ require_relative "prompt_note"
|
|
|
15
15
|
|
|
16
16
|
require_relative "paths"
|
|
17
17
|
require_relative "project_scope"
|
|
18
|
+
require_relative "guardrails/parent_levels"
|
|
18
19
|
|
|
19
20
|
module Samagotchi
|
|
20
21
|
# archive_store requires this file.
|
|
@@ -228,19 +229,19 @@ module Samagotchi
|
|
|
228
229
|
# it (SessionSetup.of), a delegate child takes its parent's.
|
|
229
230
|
attr_reader :parent_answers
|
|
230
231
|
|
|
231
|
-
# @param value [String, Symbol, nil]
|
|
232
|
-
#
|
|
232
|
+
# @param value [String, Symbol, nil] read as
|
|
233
|
+
# Guardrails::ParentPolicy.parse_level: a level, or nil for none ("",
|
|
234
|
+
# "default"); a value that isn't a level is kept as written, stripped
|
|
233
235
|
# (read as hard: ParentPolicy.session_level)
|
|
234
236
|
def parent_answers=(value)
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
237
|
+
level = Guardrails::ParentPolicy.parse_level(value)
|
|
238
|
+
@parent_answers = case level
|
|
239
|
+
when :unset then nil
|
|
240
|
+
when :invalid then value.to_s.strip
|
|
241
|
+
else level
|
|
242
|
+
end
|
|
238
243
|
end
|
|
239
244
|
|
|
240
|
-
# The levels and "default" (Guardrails::ParentPolicy::ORDER and UNSET;
|
|
241
|
-
# not required here: that file loads the guardrails).
|
|
242
|
-
PARENT_ANSWERS_WORDS = %w[hard standard soft default].freeze
|
|
243
|
-
|
|
244
245
|
# The hook that stopped the last turn (stop_turn, or a cut with no retry
|
|
245
246
|
# left), e.g. "loop-guard"; nil when it ended otherwise.
|
|
246
247
|
def stopped_by
|
|
@@ -289,8 +289,11 @@ module Samagotchi
|
|
|
289
289
|
# turn takes it (the Bridge's POST turn/dequeue, under the event log):
|
|
290
290
|
# only one that waits for the turn's end (a queue or an image message,
|
|
291
291
|
# #waits_for_turn_end?); a next-step file may be merged at any boundary.
|
|
292
|
-
#
|
|
293
|
-
# one winner)
|
|
292
|
+
# Its tombstone is written first, then the file claimed as the drain
|
|
293
|
+
# does (the rename is the race's one winner) and deleted; a claim a
|
|
294
|
+
# turn won takes the tombstone back. Tombstone first: a ReplyWait look
|
|
295
|
+
# that finds the file claimed and no tombstone knows a turn took it
|
|
296
|
+
# (never a removal half done, which it would read as a merge).
|
|
294
297
|
# @return [Array(Symbol, Input)] [:removed, the input it held],
|
|
295
298
|
# [:not_queued, nil] for a next-step file, [:claimed, nil] when a
|
|
296
299
|
# turn took it (its *.json.processing is there), [:none, nil] for
|
|
@@ -304,10 +307,24 @@ module Samagotchi
|
|
|
304
307
|
next unless data.is_a?(Hash) && data["enqueued_id"] == id
|
|
305
308
|
return [:not_queued, nil] unless Array(data["images"]).any? || Delivery.queue?(data["delivery"])
|
|
306
309
|
|
|
307
|
-
claimed = claim_input_file(file) or return [:claimed, nil]
|
|
308
|
-
input = read_input(claimed)
|
|
309
310
|
mark_removed(session_dir, id)
|
|
310
|
-
|
|
311
|
+
unless (claimed = claim_input_file(file))
|
|
312
|
+
unmark_removed(session_dir, id)
|
|
313
|
+
return [:claimed, nil]
|
|
314
|
+
end
|
|
315
|
+
# The claimed file goes whatever the read does: a claimed file left
|
|
316
|
+
# with the tombstone would read as a removal still under way
|
|
317
|
+
# (ReplyWait) until the wait's timeout. One that can't be read was
|
|
318
|
+
# still taken back (no prompt to give). A process killed between
|
|
319
|
+
# the claim and this delete leaves both: that wait then times out,
|
|
320
|
+
# never a wrong answer (a stale *.json.processing is never run).
|
|
321
|
+
input = begin
|
|
322
|
+
read_input(claimed)
|
|
323
|
+
rescue SystemCallError
|
|
324
|
+
UNREADABLE_INPUT
|
|
325
|
+
ensure
|
|
326
|
+
FileUtils.rm_f(claimed)
|
|
327
|
+
end
|
|
311
328
|
return [:removed, input]
|
|
312
329
|
rescue JSON::ParserError, SystemCallError
|
|
313
330
|
next
|
|
@@ -316,7 +333,7 @@ module Samagotchi
|
|
|
316
333
|
end
|
|
317
334
|
|
|
318
335
|
# Whether a claimed input file (*.json.processing: a turn's, now or
|
|
319
|
-
# left by a crash) carries +id+.
|
|
336
|
+
# left by a crash, or a removal's before it deletes it) carries +id+.
|
|
320
337
|
def self.claimed_input?(session_dir, id)
|
|
321
338
|
Dir.glob(File.join(session_dir, INPUT_DIR, "*.json.processing")).any? do |file|
|
|
322
339
|
data = JSON.parse(File.read(file))
|
|
@@ -325,7 +342,6 @@ module Samagotchi
|
|
|
325
342
|
false
|
|
326
343
|
end
|
|
327
344
|
end
|
|
328
|
-
private_class_method :claimed_input?
|
|
329
345
|
|
|
330
346
|
def self.mark_removed(session_dir, id)
|
|
331
347
|
return unless ENQUEUED_ID_NAME.match?(id)
|
|
@@ -338,8 +354,15 @@ module Samagotchi
|
|
|
338
354
|
end
|
|
339
355
|
private_class_method :mark_removed
|
|
340
356
|
|
|
357
|
+
def self.unmark_removed(session_dir, id)
|
|
358
|
+
FileUtils.rm_f(File.join(session_dir, INPUT_DIR, REMOVED_DIR, id)) if ENQUEUED_ID_NAME.match?(id)
|
|
359
|
+
end
|
|
360
|
+
private_class_method :unmark_removed
|
|
361
|
+
|
|
341
362
|
# @return [Boolean] whether the queued input +enqueued_id+ was removed
|
|
342
|
-
# before it ran (#take_queued_input's tombstone)
|
|
363
|
+
# before it ran (#take_queued_input's tombstone), or is being removed:
|
|
364
|
+
# the tombstone goes first, so while its claimed file is still there
|
|
365
|
+
# (#claimed_input?) the removal may yet lose to a turn's claim
|
|
343
366
|
def self.removed?(session_dir, enqueued_id)
|
|
344
367
|
id = enqueued_id.to_s
|
|
345
368
|
ENQUEUED_ID_NAME.match?(id) && File.exist?(File.join(session_dir, INPUT_DIR, REMOVED_DIR, id))
|
|
@@ -80,7 +80,7 @@ module Samagotchi
|
|
|
80
80
|
|
|
81
81
|
def initialize(session_id)
|
|
82
82
|
@session_id = session_id
|
|
83
|
-
super("session #{session_id} is
|
|
83
|
+
super("session #{session_id} is open in a chi REPL; close it there first")
|
|
84
84
|
end
|
|
85
85
|
end
|
|
86
86
|
|
|
@@ -1140,6 +1140,39 @@ module Samagotchi
|
|
|
1140
1140
|
# How long a turn waits for the Bridge of a worker a resume just spawned.
|
|
1141
1141
|
TURN_BRIDGE_WAIT = 5.0
|
|
1142
1142
|
|
|
1143
|
+
# What #deliver_turn did with a message.
|
|
1144
|
+
# +status+ :accepted; :refused (the Bridge refused it, or a queue would
|
|
1145
|
+
# reach a worker that doesn't queue); :timeout (the Bridge took it and
|
|
1146
|
+
# never answered); :failed (the input file couldn't be written).
|
|
1147
|
+
# +via+ :bridge (the worker's Bridge answered) or :file (the input-file
|
|
1148
|
+
# fallback); nil when nothing went in.
|
|
1149
|
+
# +ack+ the Bridge's reply body, or the file's {"status", "enqueued_id",
|
|
1150
|
+
# "session_id"}: string keys either way, {} for none.
|
|
1151
|
+
# +code+ a refusal's HTTP status.
|
|
1152
|
+
DeliverResult = Data.define(:status, :via, :ack, :code) do
|
|
1153
|
+
def initialize(status:, via: nil, ack: {}, code: nil) = super(status:, via:, ack: ack || {}, code:)
|
|
1154
|
+
|
|
1155
|
+
def accepted? = status == :accepted
|
|
1156
|
+
|
|
1157
|
+
# Sent as an input file (no Bridge): its turn and outcome carry the
|
|
1158
|
+
# file's enqueued_id, which a wait follows (chi send --wait).
|
|
1159
|
+
def file? = via == :file
|
|
1160
|
+
|
|
1161
|
+
# @return [String, nil] the id the message's turn will carry
|
|
1162
|
+
def enqueued_id
|
|
1163
|
+
id = ack["enqueued_id"].to_s
|
|
1164
|
+
id.empty? ? nil : id
|
|
1165
|
+
end
|
|
1166
|
+
|
|
1167
|
+
# Whether the worker took the line as a session command (a POST /turn
|
|
1168
|
+
# answered with its command_id).
|
|
1169
|
+
def command? = !ack["command_id"].to_s.empty?
|
|
1170
|
+
|
|
1171
|
+
def error = ack["error"]
|
|
1172
|
+
|
|
1173
|
+
def detail = ack["detail"]
|
|
1174
|
+
end
|
|
1175
|
+
|
|
1143
1176
|
# Hand a user message to the session the way a UI does, for the web
|
|
1144
1177
|
# composer and `chi send` alike: wakes the worker when none runs, posts
|
|
1145
1178
|
# through its Bridge so every live UI sees :turn_enqueued, and falls back
|
|
@@ -1153,14 +1186,12 @@ module Samagotchi
|
|
|
1153
1186
|
# stand-in (the web's specs); #session_owner is optional
|
|
1154
1187
|
# @param bridge [#call, nil] returns the BridgeClient or nil; defaults to
|
|
1155
1188
|
# the session's sidecar, waiting TURN_BRIDGE_WAIT for a new worker's
|
|
1156
|
-
# @return [
|
|
1157
|
-
#
|
|
1158
|
-
# code:, ack:} when the Bridge refused the request (any 4xx with an
|
|
1189
|
+
# @return [DeliverResult] :accepted (via the Bridge or a file),
|
|
1190
|
+
# :refused when the Bridge refused the request (any 4xx with an
|
|
1159
1191
|
# error: bad images, a delivery it doesn't run) or a queue would reach
|
|
1160
|
-
# a worker that doesn't queue (no file then: it would steer),
|
|
1161
|
-
#
|
|
1162
|
-
#
|
|
1163
|
-
# written
|
|
1192
|
+
# a worker that doesn't queue (no file then: it would steer), :timeout
|
|
1193
|
+
# when it took the request but never answered (no file then), or
|
|
1194
|
+
# :failed when the input file couldn't be written
|
|
1164
1195
|
# @raise [OwnedByTUI] a chi REPL owns the session
|
|
1165
1196
|
# @raise [ArgumentError] no such session
|
|
1166
1197
|
def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], delivery: nil, state_dir: nil, manager: self,
|
|
@@ -1180,7 +1211,7 @@ module Samagotchi
|
|
|
1180
1211
|
options[:delivery] = delivery unless Delivery.next_step?(delivery)
|
|
1181
1212
|
reply = client.post_turn(**options)
|
|
1182
1213
|
ack = reply.json
|
|
1183
|
-
return
|
|
1214
|
+
return DeliverResult.new(status: :accepted, via: :bridge, ack: ack) if reply.status == 202 && ack.is_a?(Hash)
|
|
1184
1215
|
# Read after its deadline and dropped: a file would run it after all.
|
|
1185
1216
|
return turn_timeout if ack.is_a?(Hash) && ack["error"] == "deadline_passed"
|
|
1186
1217
|
# Any other 4xx with an error is a refusal (bad images, a delivery
|
|
@@ -1189,7 +1220,7 @@ module Samagotchi
|
|
|
1189
1220
|
# limit, a big piped chi send) and a 5xx keep the file fallback
|
|
1190
1221
|
# below, which carries the delivery (a queue too).
|
|
1191
1222
|
if (400..499).cover?(reply.status) && reply.status != 413 && ack.is_a?(Hash) && ack["error"]
|
|
1192
|
-
return
|
|
1223
|
+
return DeliverResult.new(status: :refused, via: :bridge, code: reply.status, ack: ack)
|
|
1193
1224
|
end
|
|
1194
1225
|
rescue Errno::ETIMEDOUT
|
|
1195
1226
|
# The Bridge drops a turn it reads after the request's deadline
|
|
@@ -1205,7 +1236,7 @@ module Samagotchi
|
|
|
1205
1236
|
input[:images] = images unless images.empty?
|
|
1206
1237
|
input[:delivery] = delivery unless Delivery.next_step?(delivery)
|
|
1207
1238
|
path = manager.write_turn_input(session_id, **input)
|
|
1208
|
-
return
|
|
1239
|
+
return DeliverResult.new(status: :failed) unless path
|
|
1209
1240
|
|
|
1210
1241
|
owner = delivery_owner(manager, session_id, state_dir)
|
|
1211
1242
|
# A TUI that took the session between the resume and the write never
|
|
@@ -1218,7 +1249,8 @@ module Samagotchi
|
|
|
1218
1249
|
# wake a new one. (The exiting worker also looks for input it left;
|
|
1219
1250
|
# one the first resume spawned that is still starting is left be.)
|
|
1220
1251
|
manager.resume_session(session_id, state_dir: state_dir) if owner.nil?
|
|
1221
|
-
|
|
1252
|
+
DeliverResult.new(status: :accepted, via: :file,
|
|
1253
|
+
ack: { "status" => "accepted", "enqueued_id" => enqueued_id, "session_id" => session_id })
|
|
1222
1254
|
end
|
|
1223
1255
|
|
|
1224
1256
|
# Whether the session's live worker runs delivery: queue (its sidecar
|
|
@@ -1230,12 +1262,13 @@ module Samagotchi
|
|
|
1230
1262
|
end
|
|
1231
1263
|
|
|
1232
1264
|
private_class_method def self.queue_unavailable
|
|
1233
|
-
|
|
1265
|
+
DeliverResult.new(status: :refused, code: 409, ack: Delivery::QUEUE_UNAVAILABLE)
|
|
1234
1266
|
end
|
|
1235
1267
|
|
|
1236
1268
|
private_class_method def self.turn_timeout
|
|
1237
|
-
|
|
1238
|
-
|
|
1269
|
+
DeliverResult.new(status: :timeout, via: :bridge,
|
|
1270
|
+
ack: { "error" => "worker_timeout",
|
|
1271
|
+
"detail" => "the session's worker did not answer, so the message was not sent" })
|
|
1239
1272
|
end
|
|
1240
1273
|
|
|
1241
1274
|
private_class_method def self.delivery_owner(manager, session_id, state_dir)
|
|
@@ -7,6 +7,7 @@ require "fileutils"
|
|
|
7
7
|
require "securerandom"
|
|
8
8
|
require_relative "analytics_file"
|
|
9
9
|
require_relative "atomic_file"
|
|
10
|
+
require_relative "log"
|
|
10
11
|
require_relative "token_usage"
|
|
11
12
|
require_relative "model_price"
|
|
12
13
|
require_relative "session"
|
|
@@ -278,6 +279,8 @@ module Samagotchi
|
|
|
278
279
|
# in snapshot order. Taken before @mutex, never under it: the write's
|
|
279
280
|
# IO doesn't hold up the events.
|
|
280
281
|
@persist_mutex = Mutex.new
|
|
282
|
+
# The error classes #persist has logged (under @persist_mutex).
|
|
283
|
+
@persist_failures = Set.new
|
|
281
284
|
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
282
285
|
@wall_clock = wall_clock || -> { Time.now }
|
|
283
286
|
@session_id = nil
|
|
@@ -481,7 +484,8 @@ module Samagotchi
|
|
|
481
484
|
|
|
482
485
|
# Persist the summary snapshot, with every record, to a sibling
|
|
483
486
|
# analytics.json in the session directory. Atomic write; failures are
|
|
484
|
-
# swallowed (analytics must never break the running session)
|
|
487
|
+
# swallowed (analytics must never break the running session), each
|
|
488
|
+
# error class logged once (WARN turn analytics_persist_failed).
|
|
485
489
|
# @param state_dir [String, nil]
|
|
486
490
|
# @param running_turn [Boolean] also save the running turn's record so
|
|
487
491
|
# far (a worker's turn checkpoint): as the turn a worker killed now
|
|
@@ -490,13 +494,28 @@ module Samagotchi
|
|
|
490
494
|
# turn's own end writes over it. Only the file has it.
|
|
491
495
|
# @return [Boolean] true on success
|
|
492
496
|
def persist(state_dir: nil, running_turn: false)
|
|
493
|
-
@persist_mutex.synchronize
|
|
494
|
-
|
|
497
|
+
@persist_mutex.synchronize do
|
|
498
|
+
persist_now(state_dir, running_turn)
|
|
499
|
+
rescue StandardError => e
|
|
500
|
+
note_persist_failure(e)
|
|
501
|
+
false
|
|
502
|
+
end
|
|
503
|
+
rescue StandardError # a ThreadError from the mutex, a log that raised: still never raises
|
|
495
504
|
false
|
|
496
505
|
end
|
|
497
506
|
|
|
498
507
|
private
|
|
499
508
|
|
|
509
|
+
# Logs a #persist failure the first time its error class is seen (under
|
|
510
|
+
# the persist mutex): side requests and checkpoints persist too, so a
|
|
511
|
+
# lasting failure (a full disk, a read-only dir) would log on every one.
|
|
512
|
+
def note_persist_failure(error)
|
|
513
|
+
return if @persist_failures.include?(error.class)
|
|
514
|
+
|
|
515
|
+
@persist_failures << error.class
|
|
516
|
+
Log.warn(:turn, "analytics_persist_failed", error: error.class.name, msg: error.message.to_s[0, 300])
|
|
517
|
+
end
|
|
518
|
+
|
|
500
519
|
# #persist's work, under the persist mutex.
|
|
501
520
|
def persist_now(state_dir, running_turn)
|
|
502
521
|
sid, data, turn_count = @mutex.synchronize do
|
|
@@ -365,9 +365,9 @@ module Samagotchi
|
|
|
365
365
|
begin
|
|
366
366
|
id = Samagotchi::Session.resolve_id(given)
|
|
367
367
|
released = Samagotchi::SessionManager.stop_session(id, wait: 10)
|
|
368
|
-
rescue Samagotchi::SessionManager::OwnedByTUI
|
|
368
|
+
rescue Samagotchi::SessionManager::OwnedByTUI => e
|
|
369
369
|
@stdout.flush
|
|
370
|
-
@stderr.puts
|
|
370
|
+
@stderr.puts e.message
|
|
371
371
|
next false
|
|
372
372
|
rescue ArgumentError => e
|
|
373
373
|
@stdout.flush
|
|
@@ -394,9 +394,9 @@ module Samagotchi
|
|
|
394
394
|
begin
|
|
395
395
|
id = Samagotchi::Session.resolve_id(given)
|
|
396
396
|
result = Samagotchi::SessionManager.restart_session(id)
|
|
397
|
-
rescue Samagotchi::SessionManager::OwnedByTUI
|
|
397
|
+
rescue Samagotchi::SessionManager::OwnedByTUI => e
|
|
398
398
|
@stdout.flush
|
|
399
|
-
@stderr.puts
|
|
399
|
+
@stderr.puts e.message
|
|
400
400
|
next false
|
|
401
401
|
rescue Samagotchi::SessionManager::RestartRefused => e
|
|
402
402
|
@stdout.flush
|
|
@@ -1639,9 +1639,9 @@ module Samagotchi
|
|
|
1639
1639
|
end
|
|
1640
1640
|
|
|
1641
1641
|
def reminder_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::SYSTEM_PREFIX)
|
|
1642
|
-
def report_origin?(origin) = origin[:client_id]
|
|
1643
|
-
def context_origin?(origin) = origin[:client_id]
|
|
1644
|
-
def task_origin?(origin) = origin[:client_id]
|
|
1642
|
+
def report_origin?(origin) = ClientId.origin(origin[:client_id]) == "delegate_report"
|
|
1643
|
+
def context_origin?(origin) = ClientId.origin(origin[:client_id]) == "context"
|
|
1644
|
+
def task_origin?(origin) = ClientId.origin(origin[:client_id]) == "task"
|
|
1645
1645
|
# A turn with no prompt: "(continuing)"; a context wake's or a task
|
|
1646
1646
|
# finish wake's label alone.
|
|
1647
1647
|
def continuing_text(origin) = context_origin?(origin) || task_origin?(origin) ? nil : "(continuing)"
|
|
@@ -215,11 +215,14 @@ module Samagotchi
|
|
|
215
215
|
return fallback unless client_id
|
|
216
216
|
|
|
217
217
|
id = client_id.to_s
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
218
|
+
case ClientId.origin(id)
|
|
219
|
+
when "context"
|
|
220
|
+
name = id.delete_prefix(ClientId::CONTEXT_PREFIX)
|
|
221
|
+
return "context #{name} changed" unless name.empty?
|
|
222
|
+
when "task"
|
|
223
|
+
task = id.delete_prefix(ClientId::TASK_PREFIX)
|
|
224
|
+
return "task #{Tools::TaskRecord.short_id(task)} finished" unless task.empty?
|
|
225
|
+
end
|
|
223
226
|
|
|
224
227
|
prefix = id[/\A[^:]*:/]
|
|
225
228
|
CLIENT_LABELS[id] || (prefix && CLIENT_LABELS[prefix]) || AUTOMATIC_LABEL
|
|
@@ -339,7 +339,7 @@ module Samagotchi
|
|
|
339
339
|
def model_answers = @recorder.model_answers
|
|
340
340
|
|
|
341
341
|
# Queue ask_user answers ({selected:, freeform:, selected_indices:},
|
|
342
|
-
# or a callable given the
|
|
342
|
+
# or a callable given the HookQuestion).
|
|
343
343
|
def answer(*answers)
|
|
344
344
|
@recorder.answers.concat(answers)
|
|
345
345
|
self
|
|
@@ -27,10 +27,7 @@ module Samagotchi
|
|
|
27
27
|
messages: -> { recorder[:messages] },
|
|
28
28
|
messages_partial: -> { recorder[:messages_partial] },
|
|
29
29
|
notify: ->(text, level, label, fallback_for: nil) { recorder.notify(text, level, label, fallback_for) },
|
|
30
|
-
ask_user:
|
|
31
|
-
recorder.ask_user(question: question, options: options, header: header, allow_freeform: allow_freeform, hook: hook,
|
|
32
|
-
effects: effects)
|
|
33
|
-
},
|
|
30
|
+
ask_user: ->(question) { recorder.ask_user(question) },
|
|
34
31
|
cancelled: -> { recorder[:cancelled] },
|
|
35
32
|
card: ->(**card) { recorder.card(**card) },
|
|
36
33
|
ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil|
|
|
@@ -89,10 +86,7 @@ module Samagotchi
|
|
|
89
86
|
def build(recorder)
|
|
90
87
|
Hooks::Runtime.new(
|
|
91
88
|
notify: ->(text:, level:, hook:, fallback_for: nil) { recorder.notify(text, level, hook, fallback_for) },
|
|
92
|
-
ask_user:
|
|
93
|
-
recorder.ask_user(question: question, options: options, header: header, allow_freeform: allow_freeform, hook: hook,
|
|
94
|
-
effects: effects)
|
|
95
|
-
},
|
|
89
|
+
ask_user: ->(question) { recorder.ask_user(question) },
|
|
96
90
|
stop_turn: ->(reason:, hook:) { recorder.stop(:turn, reason, hook) },
|
|
97
91
|
steer: ->(text:, hook:) { recorder.steer(text, hook) },
|
|
98
92
|
stop_generation: ->(reason:, hook:) { recorder.stop(:generation, reason, hook) }
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "../plugin/card"
|
|
4
4
|
require_relative "../plugin/context"
|
|
5
5
|
require_relative "../guardrails/parent_policy"
|
|
6
|
+
require_relative "../hook_question"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
module Testing
|
|
@@ -13,11 +14,6 @@ module Samagotchi
|
|
|
13
14
|
Steer = Data.define(:text, :hook)
|
|
14
15
|
# A stop_turn or stop_generation (+kind+ :turn or :generation).
|
|
15
16
|
Stop = Data.define(:kind, :reason, :hook)
|
|
16
|
-
# An ask_user question; effects: its options' tags as the bundle gave
|
|
17
|
-
# them (label => effect), nil for none.
|
|
18
|
-
Question = Data.define(:question, :options, :header, :allow_freeform, :hook, :effects) do
|
|
19
|
-
def initialize(effects: nil, **) = super
|
|
20
|
-
end
|
|
21
17
|
# An ask_model request: the request Context built (SideQuestion), its
|
|
22
18
|
# limits, cancel controller and the model: it named (nil: the session's;
|
|
23
19
|
# the kit doesn't check it against any hosts), and +source+, the asking
|
|
@@ -107,11 +103,12 @@ module Samagotchi
|
|
|
107
103
|
# Effects chi would refuse (a label the options don't have, an
|
|
108
104
|
# unknown effect: Guardrails::ParentPolicy.option_tags) ask no one, as
|
|
109
105
|
# in chi: nil, nothing recorded, no answer taken.
|
|
110
|
-
|
|
111
|
-
|
|
106
|
+
# @param question [HookQuestion] recorded as asked (#questions; its
|
|
107
|
+
# effects as the bundle gave them: label => effect, nil for none)
|
|
108
|
+
def ask_user(question)
|
|
109
|
+
return nil if Guardrails::ParentPolicy.option_tags(question.options, question.effects) == false
|
|
112
110
|
|
|
113
|
-
record(@questions,
|
|
114
|
-
allow_freeform: allow_freeform, hook: hook, effects: effects))
|
|
111
|
+
record(@questions, question)
|
|
115
112
|
answer = @mutex.synchronize { @answers.shift }
|
|
116
113
|
answer.respond_to?(:call) ? answer.call(@questions.last) : answer
|
|
117
114
|
end
|
|
@@ -124,6 +124,32 @@ module Samagotchi
|
|
|
124
124
|
File.join(memory_dir(scope, cwd: cwd || Dir.pwd), "#{name}.md")
|
|
125
125
|
end
|
|
126
126
|
|
|
127
|
+
# The model notes chi loads into the prompt for +model_id+ (the bare
|
|
128
|
+
# id, no host prefix: `deepseek/deepseek-v4.1-flash`), from this
|
|
129
|
+
# sandbox's memories (the system scope's, then the project scope's for
|
|
130
|
+
# the checkout at +cwd+), matched as chi matches them: each `models:`
|
|
131
|
+
# glob against the id and its model key.
|
|
132
|
+
# @param small [Boolean, nil] whether the model counts as a small one
|
|
133
|
+
# for a `small` entry; nil: chi's own rule (guardrails.small_models)
|
|
134
|
+
# @return [Array<#name, #scope, #chars, #body>] the notes in prompt
|
|
135
|
+
# order (`.map(&:name)` for the names); none: []
|
|
136
|
+
def model_notes_for(model_id, small: nil, cwd: nil)
|
|
137
|
+
require_relative "../model_notes"
|
|
138
|
+
require_relative "../model_overlay"
|
|
139
|
+
small_check = small.nil? ? nil : -> { small }
|
|
140
|
+
in_dir(cwd) do
|
|
141
|
+
ModelNotes.for(name: model_id, key: ModelOverlay.key_for(model_id), small: small_check, warn: false)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The sizes chi warns over (it still loads the notes): one note's
|
|
146
|
+
# body (+:note+) and all of a model's notes (+:total+), in chars.
|
|
147
|
+
# @return [Hash{Symbol => Integer}]
|
|
148
|
+
def model_note_limits
|
|
149
|
+
require_relative "../model_notes"
|
|
150
|
+
{ note: ModelNotes::NOTE_LIMIT, total: ModelNotes::TOTAL_LIMIT }
|
|
151
|
+
end
|
|
152
|
+
|
|
127
153
|
# @return [String] a temp folder for the session's cwd
|
|
128
154
|
def work_dir = File.join(@root, "work")
|
|
129
155
|
|
|
@@ -234,11 +234,11 @@ module Samagotchi
|
|
|
234
234
|
DelegateWait.mark_seen(parent.id, child, state_dir: state_dir)
|
|
235
235
|
delivered = SessionManager.deliver_turn(id, prompt: task, client_id: "#{ClientId::DELEGATE_PREFIX}#{parent.id[0, 8]}",
|
|
236
236
|
state_dir: state_dir)
|
|
237
|
-
case delivered
|
|
237
|
+
case delivered.status
|
|
238
238
|
when :accepted
|
|
239
|
-
DelegateWait.mark_turn(parent.id, id, follow_up_turn(id, delivered
|
|
239
|
+
DelegateWait.mark_turn(parent.id, id, follow_up_turn(id, delivered.enqueued_id, state_dir: state_dir), state_dir: state_dir)
|
|
240
240
|
id
|
|
241
|
-
when :refused then "Error: session #{id[0, 8]} refused the message (#{delivered.
|
|
241
|
+
when :refused then "Error: session #{id[0, 8]} refused the message (#{delivered.error})"
|
|
242
242
|
when :timeout then "Error: the worker of session #{id[0, 8]} did not answer in time, so the message was not sent"
|
|
243
243
|
else "Error: the message to session #{id[0, 8]} could not be written"
|
|
244
244
|
end
|
|
@@ -251,24 +251,24 @@ module Samagotchi
|
|
|
251
251
|
|
|
252
252
|
# The child's turn a delivered follow-up joins, for the wait
|
|
253
253
|
# (DelegateCursor#turn): the running turn's (its checkpoint mark), the
|
|
254
|
-
# follow-up merging into it; else the
|
|
255
|
-
# running as a turn of its own. Only
|
|
256
|
-
# turn_ids; nil otherwise, or when the
|
|
257
|
-
# delegate's first turn): the wait takes the
|
|
254
|
+
# follow-up merging into it; else the delivery's +enqueued_id+, the
|
|
255
|
+
# follow-up running as a turn of its own. Only
|
|
256
|
+
# from a worker that names turn_ids; nil otherwise, or when the
|
|
257
|
+
# running turn has no id (a delegate's first turn): the wait takes the
|
|
258
|
+
# next reply.
|
|
258
259
|
# A follow-up steered into a turn that ended before it merged runs as
|
|
259
260
|
# its own turn, yet the wait is on the ended one (LOW, as before).
|
|
260
261
|
# One the child's idle look missed being merged into a turn starting
|
|
261
262
|
# then never runs as its own: ReplyWait falls back to that turn's
|
|
262
263
|
# reply (merged_elsewhere?).
|
|
263
264
|
# @return [String, nil]
|
|
264
|
-
def self.follow_up_turn(id,
|
|
265
|
+
def self.follow_up_turn(id, enqueued_id, state_dir:)
|
|
265
266
|
return nil unless ReplyWait.turn_ids?(Session.session_dir(id, state_dir: state_dir))
|
|
266
267
|
|
|
267
268
|
child = Session.load(id, state_dir: state_dir)
|
|
268
269
|
return child.interrupted_turn&.enqueued_id if child.status == Session::STATUS_RUNNING
|
|
269
270
|
|
|
270
|
-
enqueued_id
|
|
271
|
-
enqueued_id.to_s.empty? ? nil : enqueued_id.to_s
|
|
271
|
+
enqueued_id
|
|
272
272
|
rescue ArgumentError
|
|
273
273
|
nil
|
|
274
274
|
end
|
|
@@ -4,6 +4,7 @@ require "json"
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require_relative "../atomic_file"
|
|
6
6
|
require_relative "../session"
|
|
7
|
+
require_relative "../reply_wait"
|
|
7
8
|
|
|
8
9
|
module Samagotchi
|
|
9
10
|
module Tools
|
|
@@ -14,32 +15,54 @@ module Samagotchi
|
|
|
14
15
|
# of the child's turn the last follow-up went to (Delegate.follow_up),
|
|
15
16
|
# whose own reply a wait takes (ReplyWait's turn:); nil once that turn
|
|
16
17
|
# was reported, or when unknown (a new child, an older worker).
|
|
17
|
-
# +report_claim+: a report reserved until the parent's turn is settled
|
|
18
|
+
# +report_claim+: a report reserved until the parent's turn is settled
|
|
19
|
+
# (ReportClaim).
|
|
18
20
|
DelegateCursor = Data.define(:reply_file, :last_turn, :question_id, :messages, :turn, :report_claim) do
|
|
19
21
|
def initialize(reply_file: nil, last_turn: nil, question_id: nil, messages: nil, turn: nil, report_claim: nil) = super
|
|
20
22
|
|
|
21
23
|
def self.empty = new
|
|
22
24
|
|
|
23
|
-
#
|
|
25
|
+
# The cursor with no report reserved: what it is once the reserving
|
|
26
|
+
# turn is settled, and how two cursors compare apart from a claim.
|
|
27
|
+
def without_claim = with(report_claim: nil)
|
|
28
|
+
|
|
29
|
+
# @param baseline [ReplyWait::Baseline, nil] ReplyWait.baseline_of's
|
|
24
30
|
def with_baseline(baseline)
|
|
25
|
-
|
|
26
|
-
with(messages: b[:messages], question_id: b[:question_id], last_turn: b[:last_turn])
|
|
31
|
+
with(messages: baseline&.messages, question_id: baseline&.question_id, last_turn: baseline&.last_turn)
|
|
27
32
|
end
|
|
28
33
|
|
|
29
|
-
# @return [
|
|
34
|
+
# @return [ReplyWait::Baseline, nil] ReplyWait's baseline:, nil when
|
|
35
|
+
# none was taken
|
|
30
36
|
def baseline
|
|
31
37
|
return nil if messages.nil?
|
|
32
38
|
|
|
33
|
-
|
|
39
|
+
ReplyWait::Baseline.new(messages: messages, question_id: question_id, last_turn: last_turn)
|
|
34
40
|
end
|
|
35
41
|
|
|
36
|
-
def to_json_hash = to_h.transform_keys(&:to_s).compact
|
|
42
|
+
def to_json_hash = to_h.merge(report_claim: report_claim&.to_json_hash).transform_keys(&:to_s).compact
|
|
37
43
|
|
|
38
44
|
def self.from_json_hash(data)
|
|
39
45
|
return empty unless data.is_a?(Hash)
|
|
40
46
|
|
|
41
47
|
new(reply_file: data["reply_file"], last_turn: data["last_turn"], question_id: data["question_id"],
|
|
42
|
-
messages: data["messages"], turn: data["turn"], report_claim: data["report_claim"])
|
|
48
|
+
messages: data["messages"], turn: data["turn"], report_claim: ReportClaim.from_json_hash(data["report_claim"]))
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A delegate report reserved in the parent's cursor (ChildReports) until
|
|
53
|
+
# the parent's turn that took it is kept or rolled back: +id+ the
|
|
54
|
+
# claim's id (the taking turn's Report#claim_id), +file+ the reply file
|
|
55
|
+
# reported (nil for a question), +cursor+ the DelegateCursor the report
|
|
56
|
+
# moves the parent to once kept (its own claim empty). In delegates.json
|
|
57
|
+
# it is {"id", "file", "cursor"}, the file's key kept even when nil.
|
|
58
|
+
ReportClaim = Data.define(:id, :file, :cursor) do
|
|
59
|
+
def to_json_hash = { "id" => id, "file" => file, "cursor" => cursor.to_json_hash }
|
|
60
|
+
|
|
61
|
+
# @return [ReportClaim, nil] nil for no claim (or not a Hash)
|
|
62
|
+
def self.from_json_hash(data)
|
|
63
|
+
return nil unless data.is_a?(Hash)
|
|
64
|
+
|
|
65
|
+
new(id: data["id"], file: data["file"], cursor: DelegateCursor.from_json_hash(data["cursor"]))
|
|
43
66
|
end
|
|
44
67
|
end
|
|
45
68
|
|
|
@@ -77,6 +100,7 @@ module Samagotchi
|
|
|
77
100
|
end
|
|
78
101
|
|
|
79
102
|
# Reserve a report only if the cursor read to build it is still current.
|
|
103
|
+
# @param claim [ReportClaim]
|
|
80
104
|
# @return [DelegateCursor, nil] the reserved cursor, or nil if another path moved it
|
|
81
105
|
def claim_report(parent_id, child_id, expected:, claim:, state_dir:)
|
|
82
106
|
locked(parent_id, state_dir: state_dir) do
|