samagotchi 0.63.0 → 0.64.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 +46 -1
- data/docs/architecture.md +12 -2
- data/docs/cli.md +16 -8
- data/docs/configuration.md +15 -8
- data/docs/internals/engine.md +10 -3
- data/docs/plugins.md +7 -0
- data/docs/sessions.md +11 -8
- data/docs/sub-agent.md +21 -4
- data/docs/testing.md +6 -0
- data/lib/samagotchi/answer_command.rb +5 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +18 -4
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/child_reports.rb +7 -2
- data/lib/samagotchi/cli/parent_wait.rb +9 -2
- data/lib/samagotchi/composer_route.rb +90 -0
- data/lib/samagotchi/config.rb +6 -3
- data/lib/samagotchi/continue_offer.rb +17 -3
- data/lib/samagotchi/delivery.rb +13 -8
- data/lib/samagotchi/engine.rb +2 -0
- data/lib/samagotchi/host_config.rb +6 -6
- data/lib/samagotchi/host_model.rb +2 -2
- data/lib/samagotchi/interrupted_turn.rb +7 -3
- data/lib/samagotchi/last_turn.rb +5 -2
- data/lib/samagotchi/pending_input_queue.rb +10 -3
- data/lib/samagotchi/plugin/sessions.rb +27 -5
- data/lib/samagotchi/reply_wait.rb +171 -6
- data/lib/samagotchi/send_command.rb +63 -19
- data/lib/samagotchi/session_commands.rb +1 -1
- data/lib/samagotchi/session_inbox.rb +63 -7
- data/lib/samagotchi/session_manager.rb +49 -20
- data/lib/samagotchi/terminal_ui/attached_loop.rb +47 -26
- data/lib/samagotchi/terminal_ui/formatting.rb +3 -19
- data/lib/samagotchi/terminal_ui.rb +72 -57
- data/lib/samagotchi/tool_activity.rb +5 -5
- data/lib/samagotchi/tools/delegate.rb +28 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
- data/lib/samagotchi/tools/delegate_wait.rb +17 -5
- data/lib/samagotchi/tools/execute.rb +11 -4
- data/lib/samagotchi/turn_checkpoint.rb +9 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +6 -0
- data/lib/samagotchi/web/public/commands.js +8 -8
- data/lib/samagotchi/web/public/composer.js +3 -3
- data/lib/samagotchi/web/public/timing.js +2 -1
- data/lib/samagotchi/web/public/turn_model.js +13 -3
- data/lib/samagotchi/worker.rb +7 -5
- metadata +2 -1
|
@@ -205,8 +205,9 @@ module Samagotchi
|
|
|
205
205
|
# child inherits this process's ENV and reads config.yml itself (as it
|
|
206
206
|
# is when the worker starts); opts[:env] adds to it (merged, not
|
|
207
207
|
# replaced) what it can't read there: this `chi`'s CLI settings
|
|
208
|
-
# (Config.cli_env)
|
|
209
|
-
# SAMAGOTCHI_GUARDRAILS_
|
|
208
|
+
# (Config.cli_env) and the absolute log file. It unsets
|
|
209
|
+
# SAMAGOTCHI_GUARDRAILS_*, the session markers (SESSION_MARKER_ENV) and
|
|
210
|
+
# SAMAGOTCHI_HOSTS_JSON (HOSTS_JSON_ENV).
|
|
210
211
|
# XDG_CONFIG_HOME passes: whoever starts or
|
|
211
212
|
# wakes the worker picks which config.yml it reads (docs/guardrails.md).
|
|
212
213
|
private_class_method def self.spawn_options(session)
|
|
@@ -223,14 +224,12 @@ module Samagotchi
|
|
|
223
224
|
Log.warn(:worker, "cwd_gone", sid: session.id, dir: dir, cwd: Dir.pwd)
|
|
224
225
|
end
|
|
225
226
|
child_env = {}
|
|
226
|
-
#
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
nil
|
|
233
|
-
end
|
|
227
|
+
# The worker reads config.yml's hosts: as it starts, like every other
|
|
228
|
+
# setting: a host added or changed after this process (a parent
|
|
229
|
+
# worker, `chi web`) started reaches it. Unset, not just left out: a
|
|
230
|
+
# worker an older chi started still has one, and its delegates would
|
|
231
|
+
# inherit its start-time hosts.
|
|
232
|
+
child_env[HOSTS_JSON_ENV] = nil
|
|
234
233
|
# A worker gets no CLI args: --model, --thinking, --log-level and the
|
|
235
234
|
# other flags this chi was started with.
|
|
236
235
|
child_env.merge!(Config.cli_env)
|
|
@@ -694,7 +693,7 @@ module Samagotchi
|
|
|
694
693
|
# so a resume right after spawns a fresh worker instead of finding the
|
|
695
694
|
# dying one.
|
|
696
695
|
# A turn still running is canceled first (STOP_CANCEL_WAIT), as the
|
|
697
|
-
# web's Cancel does: its worker saves it. The TERM stops (and saves)
|
|
696
|
+
# web's Cancel does, with reason STOP_CANCEL_REASON: its worker saves it. The TERM stops (and saves)
|
|
698
697
|
# what still runs (WorkerSignals), with a short grace after the marker.
|
|
699
698
|
# @return [Boolean, nil] with +wait+: whether the owner was gone in time
|
|
700
699
|
# @raise [OwnedByTUI] when the interactive TUI owns the session
|
|
@@ -714,7 +713,14 @@ module Samagotchi
|
|
|
714
713
|
wait_for_owner_release(session_id, timeout: wait, state_dir: sd) if wait
|
|
715
714
|
end
|
|
716
715
|
|
|
716
|
+
# ConfigFile.hosts_config's override of config.yml's hosts:, which no
|
|
717
|
+
# spawned worker inherits (spawn_options).
|
|
718
|
+
HOSTS_JSON_ENV = "SAMAGOTCHI_HOSTS_JSON"
|
|
719
|
+
|
|
717
720
|
STOP_CANCEL_WAIT = 3.0
|
|
721
|
+
# The cancel reason of a turn a session stop cancels (Bridge::CANCEL_REASONS):
|
|
722
|
+
# not the user's Stop, which only ends the turn ("user").
|
|
723
|
+
STOP_CANCEL_REASON = "session_stopped"
|
|
718
724
|
|
|
719
725
|
# Cancel the session's running turn over its Bridge and wait (up to
|
|
720
726
|
# STOP_CANCEL_WAIT) for the worker to save it. Best effort: without a
|
|
@@ -723,7 +729,7 @@ module Samagotchi
|
|
|
723
729
|
return unless Session.load(session_id, state_dir: state_dir).status == Session::STATUS_RUNNING
|
|
724
730
|
|
|
725
731
|
client = BridgeClient.discover(session_id, session_dir: Session.session_dir(session_id, state_dir: state_dir))
|
|
726
|
-
return unless client && client.cancel(reason:
|
|
732
|
+
return unless client && client.cancel(reason: STOP_CANCEL_REASON).status == 202
|
|
727
733
|
|
|
728
734
|
BridgeClient.poll(STOP_CANCEL_WAIT) do
|
|
729
735
|
Session.load(session_id, state_dir: state_dir).status != Session::STATUS_RUNNING
|
|
@@ -1167,9 +1173,12 @@ module Samagotchi
|
|
|
1167
1173
|
# the session's sidecar, waiting TURN_BRIDGE_WAIT for a new worker's
|
|
1168
1174
|
# @return [Hash] {status: :accepted, ack: Hash} (the Bridge's reply, or
|
|
1169
1175
|
# {status:, enqueued_id:, session_id:} for a file), {status: :refused,
|
|
1170
|
-
# code:, ack:} when the Bridge refused the
|
|
1171
|
-
#
|
|
1172
|
-
#
|
|
1176
|
+
# code:, ack:} when the Bridge refused the request (any 4xx with an
|
|
1177
|
+
# error: bad images, a delivery it doesn't run) or a queue would reach
|
|
1178
|
+
# a worker that doesn't queue (no file then: it would steer),
|
|
1179
|
+
# {status: :timeout, ack:} when it took the request but never answered
|
|
1180
|
+
# (no file then), or {status: :failed} when the input file couldn't be
|
|
1181
|
+
# written
|
|
1173
1182
|
# @raise [OwnedByTUI] a chi REPL owns the session
|
|
1174
1183
|
# @raise [ArgumentError] no such session
|
|
1175
1184
|
def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], delivery: nil, state_dir: nil, manager: self,
|
|
@@ -1178,20 +1187,28 @@ module Samagotchi
|
|
|
1178
1187
|
bridge ||= -> { BridgeClient.wait_for(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT) }
|
|
1179
1188
|
manager.resume_session(session_id, state_dir: state_dir)
|
|
1180
1189
|
if (client = bridge.call)
|
|
1190
|
+
# A worker from before queueing (no "delivery" feature) ignores the
|
|
1191
|
+
# field or refuses it; either way a file would steer the running turn.
|
|
1192
|
+
return queue_unavailable if Delivery.queue?(delivery) && !worker_queues?(session_dir)
|
|
1193
|
+
|
|
1181
1194
|
begin
|
|
1182
1195
|
options = { prompt: prompt, client_id: client_id }
|
|
1183
1196
|
options[:images] = images unless images.empty?
|
|
1184
|
-
# An old worker ignores the field: it cuts anyway (its own default)
|
|
1185
|
-
# and its file never carries a queue, so a queue would steer there.
|
|
1197
|
+
# An old worker ignores the field: it cuts anyway (its own default).
|
|
1186
1198
|
options[:delivery] = delivery unless Delivery.next_step?(delivery)
|
|
1187
1199
|
reply = client.post_turn(**options)
|
|
1188
1200
|
ack = reply.json
|
|
1189
1201
|
return { status: :accepted, ack: ack } if reply.status == 202 && ack.is_a?(Hash)
|
|
1190
|
-
if ack.is_a?(Hash) && ack["error"] == "bad_images"
|
|
1191
|
-
return { status: :refused, code: reply.status, ack: ack }
|
|
1192
|
-
end
|
|
1193
1202
|
# Read after its deadline and dropped: a file would run it after all.
|
|
1194
1203
|
return turn_timeout if ack.is_a?(Hash) && ack["error"] == "deadline_passed"
|
|
1204
|
+
# Any other 4xx with an error is a refusal (bad images, a delivery
|
|
1205
|
+
# it doesn't run, a client bug): a file would bypass it, and a
|
|
1206
|
+
# refused queue would steer. A 413 (a body over the Bridge's
|
|
1207
|
+
# limit, a big piped chi send) and a 5xx keep the file fallback
|
|
1208
|
+
# below, which carries the delivery (a queue too).
|
|
1209
|
+
if (400..499).cover?(reply.status) && reply.status != 413 && ack.is_a?(Hash) && ack["error"]
|
|
1210
|
+
return { status: :refused, code: reply.status, ack: ack }
|
|
1211
|
+
end
|
|
1195
1212
|
rescue Errno::ETIMEDOUT
|
|
1196
1213
|
# The Bridge drops a turn it reads after the request's deadline
|
|
1197
1214
|
# (BridgeClient#post_turn), so a worker that wakes later won't run
|
|
@@ -1222,6 +1239,18 @@ module Samagotchi
|
|
|
1222
1239
|
{ status: :accepted, ack: { status: "accepted", enqueued_id: enqueued_id, session_id: session_id } }
|
|
1223
1240
|
end
|
|
1224
1241
|
|
|
1242
|
+
# Whether the session's live worker runs delivery: queue (its sidecar
|
|
1243
|
+
# names the feature). No sidecar to read (the client came from a stand-in,
|
|
1244
|
+
# or it went between the wait and here): true, the post finds out.
|
|
1245
|
+
private_class_method def self.worker_queues?(session_dir)
|
|
1246
|
+
sidecar = WorkerSidecar.read(session_dir)
|
|
1247
|
+
sidecar.nil? || sidecar.features.include?(Delivery::QUEUE_FEATURE)
|
|
1248
|
+
end
|
|
1249
|
+
|
|
1250
|
+
private_class_method def self.queue_unavailable
|
|
1251
|
+
{ status: :refused, code: 409, ack: Delivery::QUEUE_UNAVAILABLE }
|
|
1252
|
+
end
|
|
1253
|
+
|
|
1225
1254
|
private_class_method def self.turn_timeout
|
|
1226
1255
|
{ status: :timeout, ack: { "error" => "worker_timeout",
|
|
1227
1256
|
"detail" => "the session's worker did not answer, so the message was not sent" } }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../client_id"
|
|
4
|
+
require_relative "../composer_route"
|
|
4
5
|
require_relative "event_renderer"
|
|
5
6
|
require_relative "formatting"
|
|
6
7
|
require_relative "../prompt_history"
|
|
@@ -387,61 +388,72 @@ module Samagotchi
|
|
|
387
388
|
end
|
|
388
389
|
|
|
389
390
|
# /cut TEXT and /queue TEXT are this UI's own deliveries, read before
|
|
390
|
-
# every command check (SessionCommands registers them
|
|
391
|
-
# /help only). A plain line sends no delivery: it goes in
|
|
392
|
-
# running turn's next step, and never cuts.
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
end
|
|
391
|
+
# every command check (ComposerRoute; SessionCommands registers them
|
|
392
|
+
# for Tab and /help only). A plain line sends no delivery: it goes in
|
|
393
|
+
# at the running turn's next step, and never cuts.
|
|
394
|
+
route = ComposerRoute.call(text, running: @running, registry: command_registry)
|
|
395
|
+
return submit_delivery(route) if route.choice
|
|
396
396
|
|
|
397
397
|
# The terminal's own commands (SessionCommands registers them, the
|
|
398
398
|
# REPL reads the same words). Before the continue offer: the
|
|
399
399
|
# leaving ones are no answer to it.
|
|
400
|
-
local =
|
|
400
|
+
local = route.local
|
|
401
401
|
return submit(nil) if local == :detach
|
|
402
402
|
return SessionCommands.delete_on_exit?(text) ? exit_and_delete : exit_worker if local == :exit
|
|
403
403
|
return archive_after_turn if local == :archive && (@running || !@queued_ids.empty?)
|
|
404
404
|
return exit_and_archive if local == :archive
|
|
405
405
|
|
|
406
406
|
if @continue_offer
|
|
407
|
-
return answer_continue(text) unless local ||
|
|
407
|
+
return answer_continue(text) unless local || route.kind == :command
|
|
408
408
|
|
|
409
409
|
# Not an answer: the read left no echo, so show what ran.
|
|
410
410
|
echo_answer(text)
|
|
411
411
|
end
|
|
412
|
-
return if
|
|
412
|
+
return if route.kind == :empty
|
|
413
413
|
|
|
414
414
|
if local == :stats
|
|
415
415
|
show_stats
|
|
416
416
|
elsif local == :recap
|
|
417
417
|
show_recap
|
|
418
|
-
elsif
|
|
418
|
+
elsif route.kind == :command
|
|
419
419
|
# The history keeps !cmds, as the REPL's does (prompts: #send_prompt).
|
|
420
420
|
persist_recent_history(text) if PromptHistory.shell_line?(text)
|
|
421
421
|
send_command(text)
|
|
422
|
-
elsif
|
|
422
|
+
elsif route.kind == :typo
|
|
423
423
|
# A typo of a command (/modle) is not a prompt: say so, send nothing.
|
|
424
|
-
@screen.commit(
|
|
424
|
+
@screen.commit(route.reply)
|
|
425
425
|
else
|
|
426
426
|
send_prompt(text)
|
|
427
427
|
end
|
|
428
428
|
nil
|
|
429
429
|
end
|
|
430
430
|
|
|
431
|
-
# /cut TEXT and /queue TEXT
|
|
432
|
-
#
|
|
433
|
-
#
|
|
434
|
-
#
|
|
431
|
+
# /cut TEXT and /queue TEXT (+route+ has a choice). During a turn the
|
|
432
|
+
# text goes with its delivery: a cut, or queued to run as a turn of
|
|
433
|
+
# its own after this one. A worker that doesn't queue (its sidecar
|
|
434
|
+
# names no "delivery" feature) would steer a queue, so it is not sent.
|
|
435
|
+
# At the prompt both send the text as typed, a normal turn: no local
|
|
436
|
+
# word or typo check (the worker's Bridge runs a session command as
|
|
437
|
+
# the command).
|
|
438
|
+
# @param route [ComposerRoute::Route]
|
|
435
439
|
# @return [nil]
|
|
436
|
-
def submit_delivery(
|
|
437
|
-
return @screen.commit(STEER_CHOICE_USAGE) if
|
|
438
|
-
return @screen.commit(
|
|
440
|
+
def submit_delivery(route)
|
|
441
|
+
return @screen.commit(STEER_CHOICE_USAGE) if route.kind == :usage
|
|
442
|
+
return @screen.commit(QUEUE_NEEDS_NEWER_WORKER) if Delivery.queue?(route.delivery) && !worker_queues?
|
|
439
443
|
|
|
440
444
|
# To an idle session it is an ordinary prompt: no delivery to send.
|
|
441
|
-
send_prompt(text, delivery:
|
|
445
|
+
send_prompt(route.text, delivery: route.delivery)
|
|
442
446
|
nil
|
|
443
447
|
end
|
|
444
448
|
|
|
449
|
+
# Whether the attached worker runs delivery: queue (its sidecar names
|
|
450
|
+
# Delivery::QUEUE_FEATURE). No sidecar to read: true, its Bridge
|
|
451
|
+
# answers (a P0-P2 worker refuses a queue: #send_prompt).
|
|
452
|
+
def worker_queues?
|
|
453
|
+
sidecar = WorkerSidecar.read(Session.session_dir(@client.session_id, state_dir: @state_dir))
|
|
454
|
+
sidecar.nil? || sidecar.features.include?(Delivery::QUEUE_FEATURE)
|
|
455
|
+
end
|
|
456
|
+
|
|
445
457
|
# @return [Symbol] :detach
|
|
446
458
|
def detach(line)
|
|
447
459
|
@view.finish_thinking_spinner
|
|
@@ -601,9 +613,9 @@ module Samagotchi
|
|
|
601
613
|
end
|
|
602
614
|
|
|
603
615
|
QUEUED_NOTE = "(queued: runs after this turn)"
|
|
604
|
-
|
|
605
|
-
#
|
|
606
|
-
|
|
616
|
+
QUEUE_RUNS_NOW = "(the turn had ended: runs as the next turn)"
|
|
617
|
+
# /queue to a worker from before queueing, which would steer it.
|
|
618
|
+
QUEUE_NEEDS_NEWER_WORKER = "(/queue needs a newer worker: restart it with chi sessions restart, then send again)"
|
|
607
619
|
|
|
608
620
|
# A command queued for after the running turn (command_queued with
|
|
609
621
|
# waits): its line now (another client's) and a note; its command_ran
|
|
@@ -709,14 +721,20 @@ module Samagotchi
|
|
|
709
721
|
# What a prompt's 202 says became of it, or nil when there is nothing
|
|
710
722
|
# to say: the dim line both TUIs print for a delivery. A plain line
|
|
711
723
|
# for a running turn goes in at its next step; a cut's outcome comes
|
|
712
|
-
# from the ack (the worker knows whether cutting is on for its model)
|
|
724
|
+
# from the ack (the worker knows whether cutting is on for its model);
|
|
725
|
+
# a queued one waits for the turn's end (the ack says queue).
|
|
713
726
|
# An idle session's prompt simply runs, and a prompt with images runs
|
|
714
727
|
# as its own next turn (never merging mid-turn), so nothing is said.
|
|
715
728
|
# @param ack [Hash, nil] the Bridge's 202 body
|
|
716
729
|
# @param delivery [String, nil] what was asked for
|
|
717
730
|
# @return [String, nil]
|
|
718
731
|
def sent_note(ack, delivery: nil, images: [], running: false)
|
|
719
|
-
return nil unless running &&
|
|
732
|
+
return nil unless running && ack.is_a?(Hash)
|
|
733
|
+
# The Bridge acks queue only while a turn ran at enqueue; a /queue
|
|
734
|
+
# it acks next_step found the turn over: it is the next turn.
|
|
735
|
+
return QUEUED_NOTE if ack["delivery"] == Delivery::QUEUE
|
|
736
|
+
return QUEUE_RUNS_NOW if Delivery.queue?(delivery)
|
|
737
|
+
return nil unless images.empty?
|
|
720
738
|
|
|
721
739
|
return NEXT_STEP_NOTE unless delivery == Delivery::CUT
|
|
722
740
|
|
|
@@ -748,6 +766,8 @@ module Samagotchi
|
|
|
748
766
|
detail = reply.json&.fetch("error", nil)
|
|
749
767
|
# Read after its deadline and dropped (BridgeClient::DEADLINE_SHARE).
|
|
750
768
|
return @screen.commit("could not send the prompt (#{LATE})") if detail == "deadline_passed"
|
|
769
|
+
# A worker from 0.51 to 0.63 refuses a queue (nothing was sent).
|
|
770
|
+
return @screen.commit(QUEUE_NEEDS_NEWER_WORKER) if detail == "delivery_unavailable"
|
|
751
771
|
|
|
752
772
|
@screen.commit("could not send the prompt (#{[reply.status, detail].compact.join(" ")})")
|
|
753
773
|
rescue SystemCallError, IOError => e
|
|
@@ -1411,7 +1431,8 @@ module Samagotchi
|
|
|
1411
1431
|
return if own?(event[:client_id])
|
|
1412
1432
|
|
|
1413
1433
|
@shown_enqueued << event[:enqueued_id]
|
|
1414
|
-
|
|
1434
|
+
line = prompt_line(event[:client_id], event[:prompt])
|
|
1435
|
+
@screen.commit(event[:delivery] == Delivery::QUEUE ? "#{line} #{QUEUED_NOTE}" : line)
|
|
1415
1436
|
end
|
|
1416
1437
|
|
|
1417
1438
|
def start_turn(event)
|
|
@@ -108,14 +108,8 @@ module Samagotchi
|
|
|
108
108
|
CUT_NOW_NOTE = "(cut in now)"
|
|
109
109
|
CUT_WAITS_NOTE = "(cuts in once the thinking passes %d s)"
|
|
110
110
|
CUT_OFF_NOTE = "(cutting is off; goes in at the next step)"
|
|
111
|
+
# A bare /cut or /queue (ComposerRoute's :usage).
|
|
111
112
|
STEER_CHOICE_USAGE = "(/cut and /queue need a message: /cut TEXT, /queue TEXT)"
|
|
112
|
-
# The UIs' own words for a delivery, listed for Tab and /help only
|
|
113
|
-
# (a local: entry, so no command or bundle may use the names).
|
|
114
|
-
CUT_COMMAND = "/cut"
|
|
115
|
-
QUEUE_COMMAND = "/queue"
|
|
116
|
-
# The word, then any whitespace and the text (as the registry's
|
|
117
|
-
# entries match them, so a line Tab or /help knows is never a prompt).
|
|
118
|
-
STEER_CHOICE = /\A(#{CUT_COMMAND}|#{QUEUE_COMMAND})(?:\s+(.*))?\z/m
|
|
119
113
|
|
|
120
114
|
# @param outcome [Symbol] SteerCut#cut_for_steer's answer: :now, :waits or :off
|
|
121
115
|
# @param after [Integer, nil] the run's cut wait (the model's own
|
|
@@ -133,17 +127,6 @@ module Samagotchi
|
|
|
133
127
|
# The same words, for a UI that includes the module.
|
|
134
128
|
def steer_cut_note(outcome, after = nil) = Formatting.steer_cut_note(outcome, after)
|
|
135
129
|
|
|
136
|
-
# The choice a mid-turn line carries, as [:cut, "TEXT"] or
|
|
137
|
-
# [:queue, "TEXT"]; nil for any other line. The text is "" when the
|
|
138
|
-
# line named the choice without one.
|
|
139
|
-
# @param line [String, nil]
|
|
140
|
-
# @return [Array(Symbol, String), nil]
|
|
141
|
-
def steer_choice(line)
|
|
142
|
-
match = STEER_CHOICE.match(line.to_s.strip) or return nil
|
|
143
|
-
|
|
144
|
-
[match[1] == CUT_COMMAND ? :cut : :queue, match[2].to_s.strip]
|
|
145
|
-
end
|
|
146
|
-
|
|
147
130
|
# "✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k tokens (paid
|
|
148
131
|
# off)": a batch of LLM context edits went in (LLMContextNotice builds
|
|
149
132
|
# the line; the web prints the same), dim like the retry row.
|
|
@@ -363,7 +346,8 @@ module Samagotchi
|
|
|
363
346
|
|
|
364
347
|
# A cancel's reason as the UIs name it (anything else as it is);
|
|
365
348
|
# spec/shared/labels_matrix.json pins it for both.
|
|
366
|
-
CANCEL_REASONS = { "ctrl_c" => "Ctrl-C", "user" => "stopped", "hook" => "by a hook", "worker_stopped" => "worker stopped"
|
|
349
|
+
CANCEL_REASONS = { "ctrl_c" => "Ctrl-C", "user" => "stopped", "hook" => "by a hook", "worker_stopped" => "worker stopped",
|
|
350
|
+
"session_stopped" => "session stopped" }.freeze
|
|
367
351
|
# A canceled turn's mark: a stop someone chose (Ctrl-C, Stop, a hook),
|
|
368
352
|
# not an error's ✕ (timing.js STOP_MARK; the labels matrix pins it).
|
|
369
353
|
STOP_MARK = "■"
|
|
@@ -25,6 +25,7 @@ require_relative "tools/memory"
|
|
|
25
25
|
require_relative "output_formatter"
|
|
26
26
|
require_relative "turn_flow"
|
|
27
27
|
require_relative "session_commands"
|
|
28
|
+
require_relative "composer_route"
|
|
28
29
|
require_relative "terminal_ui/attached_view"
|
|
29
30
|
require_relative "terminal_ui/between_turns"
|
|
30
31
|
require_relative "terminal_ui/event_renderer"
|
|
@@ -296,11 +297,11 @@ module Samagotchi
|
|
|
296
297
|
|
|
297
298
|
if @prompt && @non_interactive
|
|
298
299
|
# A /cut TEXT / /queue TEXT prompt runs its text as the turn's
|
|
299
|
-
# prompt, the same as the idle REPL does (
|
|
300
|
+
# prompt, the same as the idle REPL does (ComposerRoute.steer_choice);
|
|
300
301
|
# a bare /cut / /queue (no text) is refused above as a UI command,
|
|
301
302
|
# so only the text here. The images are the text's, not the words'.
|
|
302
303
|
one_shot_prompt =
|
|
303
|
-
if (choice = steer_choice(@prompt)) && !choice.last.empty?
|
|
304
|
+
if (choice = ComposerRoute.steer_choice(@prompt)) && !choice.last.empty?
|
|
304
305
|
choice.last
|
|
305
306
|
else
|
|
306
307
|
@prompt
|
|
@@ -834,34 +835,35 @@ module Samagotchi
|
|
|
834
835
|
return if input.empty?
|
|
835
836
|
|
|
836
837
|
# /cut TEXT and /queue TEXT are deliveries only while a turn runs; at
|
|
837
|
-
# the prompt both run the text as a normal turn, as their usage says
|
|
838
|
-
|
|
839
|
-
|
|
838
|
+
# the prompt both run the text as a normal turn, as their usage says
|
|
839
|
+
# (the route is the text's).
|
|
840
|
+
route = ComposerRoute.call(input, running: false, registry: command_registry)
|
|
841
|
+
return @surface.commit(STEER_CHOICE_USAGE) if route.kind == :usage
|
|
840
842
|
|
|
841
|
-
|
|
842
|
-
end
|
|
843
|
+
input = route.text if route.choice
|
|
843
844
|
|
|
844
845
|
# /model, /models, !rollback, !cmd, /continue (shared with workers);
|
|
845
846
|
# an anytime command's cards print as it shows them (btw's
|
|
846
847
|
# "thinking…" before the answer). Piped input has nobody to answer a
|
|
847
848
|
# confirm (the next line is already written): a costly /thinking or
|
|
848
849
|
# /reasoning-history applies at once.
|
|
849
|
-
|
|
850
|
-
command
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
850
|
+
case route.kind
|
|
851
|
+
when :command
|
|
852
|
+
confirm = !piped_input?
|
|
853
|
+
command = if command_registry.anytime?(input)
|
|
854
|
+
@engine.running_anytime { @commands.run(input, confirm: confirm) }
|
|
855
|
+
else
|
|
856
|
+
@commands.run(input, confirm: confirm)
|
|
857
|
+
end
|
|
858
|
+
if command
|
|
859
|
+
show_command_result(command)
|
|
860
|
+
persist_recent_history(input) if command.shell
|
|
861
|
+
return
|
|
862
|
+
end
|
|
863
|
+
when :local then return if show_local_command(input)
|
|
862
864
|
# A typo of a command (/modle) is not a prompt: say so, send nothing.
|
|
863
|
-
|
|
864
|
-
|
|
865
|
+
when :typo then return @surface.commit(route.reply)
|
|
866
|
+
end
|
|
865
867
|
|
|
866
868
|
@turn_flow.before_prompt_turn
|
|
867
869
|
persist_recent_history(input)
|
|
@@ -1164,29 +1166,42 @@ module Samagotchi
|
|
|
1164
1166
|
#
|
|
1165
1167
|
# A command that waits for the turn's end (/model X, !cmd) and every line
|
|
1166
1168
|
# after it wait in @after_turn, in the order typed (D1), with the image
|
|
1167
|
-
# lines (which never steer): once the turn ends they come
|
|
1168
|
-
# steering it didn't merge
|
|
1169
|
-
# after
|
|
1169
|
+
# lines (which never steer) and /queue's: once the turn ends they come
|
|
1170
|
+
# back with the steering it didn't merge, in the order sent (each line
|
|
1171
|
+
# the turn takes gets the next @input_seq), ahead of anything sent after
|
|
1172
|
+
# Ctrl-C.
|
|
1170
1173
|
def with_steering(&)
|
|
1171
1174
|
return yield unless @repl_input
|
|
1172
1175
|
|
|
1173
1176
|
@after_turn = []
|
|
1174
1177
|
@command_waits = false
|
|
1175
|
-
@
|
|
1178
|
+
@input_seq = 0
|
|
1179
|
+
@repl_input.during_turn(method(:steer_line), leftovers: -> { leftover_lines }, &)
|
|
1180
|
+
end
|
|
1181
|
+
|
|
1182
|
+
# The turn's leftovers in send order: steering it didn't merge and the
|
|
1183
|
+
# lines that waited for its end.
|
|
1184
|
+
def leftover_lines
|
|
1185
|
+
(@pending_input_queue.drain_entries + lines_after_turn).sort_by { |seq, _| seq || 0 }.map(&:last)
|
|
1176
1186
|
end
|
|
1177
1187
|
|
|
1178
|
-
# The
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1188
|
+
# The next line's place in send order (#leftover_lines).
|
|
1189
|
+
def next_input_seq
|
|
1190
|
+
@input_seq = (@input_seq || 0) + 1
|
|
1191
|
+
end
|
|
1192
|
+
|
|
1193
|
+
# The [seq, line] entries that waited for the turn's end, taken from
|
|
1194
|
+
# @after_turn. After a canceled or failed turn (Ctrl-C raises through
|
|
1195
|
+
# here; a failure too) a !cmd among them is dropped (D4): running it
|
|
1196
|
+
# would close the !rollback the turn left.
|
|
1182
1197
|
def lines_after_turn
|
|
1183
|
-
|
|
1198
|
+
entries = @after_turn.slice!(0..)
|
|
1184
1199
|
ended = if @active_cancel_controller&.cancelled? || $!.is_a?(Interrupt) then "canceled"
|
|
1185
1200
|
elsif $! then "failed"
|
|
1186
1201
|
end
|
|
1187
|
-
return
|
|
1202
|
+
return entries unless ended
|
|
1188
1203
|
|
|
1189
|
-
|
|
1204
|
+
entries.reject do |_seq, line|
|
|
1190
1205
|
next false unless command_registry.lookup(line)&.id == :shell
|
|
1191
1206
|
|
|
1192
1207
|
@surface.commit("turn #{ended}: #{line.strip} not run; send it again")
|
|
@@ -1207,28 +1222,32 @@ module Samagotchi
|
|
|
1207
1222
|
# to end and runs as a turn of its own.
|
|
1208
1223
|
# @return [Boolean, :back] whether the turn took it, :back to put it back
|
|
1209
1224
|
def steer_line(line)
|
|
1210
|
-
|
|
1225
|
+
route = ComposerRoute.call(line, running: true, registry: command_registry) unless line.nil?
|
|
1226
|
+
local = route&.local
|
|
1211
1227
|
return exit_after_turn(delete: SessionCommands.delete_on_exit?(line)) if line.nil? || local == :exit
|
|
1212
1228
|
return archive_after_turn if local == :archive
|
|
1213
1229
|
return detach_note if local == :detach
|
|
1214
1230
|
return false if @active_cancel_controller&.cancelled?
|
|
1215
1231
|
|
|
1216
|
-
|
|
1217
|
-
#
|
|
1218
|
-
|
|
1219
|
-
|
|
1232
|
+
case route.kind
|
|
1233
|
+
# Before every command check (ComposerRoute): /cut and /queue are the
|
|
1234
|
+
# UIs' own words for a delivery (listed for Tab and /help, never run).
|
|
1235
|
+
when :usage
|
|
1236
|
+
@surface.commit(STEER_CHOICE_USAGE)
|
|
1237
|
+
return true
|
|
1238
|
+
when :deliver then return submit_steer_choice(route.delivery, route.text)
|
|
1239
|
+
when :command
|
|
1240
|
+
policy = command_registry.mid_turn(line)
|
|
1241
|
+
return start_anytime_command(line) if policy == :anytime
|
|
1242
|
+
return command_queued(line) if policy == :queue
|
|
1220
1243
|
end
|
|
1221
|
-
|
|
1222
|
-
policy = command_registry.mid_turn(line)
|
|
1223
|
-
return start_anytime_command(line) if policy == :anytime
|
|
1224
|
-
return command_queued(line) if policy == :queue
|
|
1225
1244
|
# A command, never steering text.
|
|
1226
|
-
return command_during_turn(line) if local
|
|
1227
|
-
return true if
|
|
1245
|
+
return command_during_turn(line) if %i[local command].include?(route.kind)
|
|
1246
|
+
return true if route.kind == :empty
|
|
1228
1247
|
|
|
1229
1248
|
# A typo of a command (/modle) is not steering text: say so, take it.
|
|
1230
|
-
if
|
|
1231
|
-
@surface.commit(
|
|
1249
|
+
if route.kind == :typo
|
|
1250
|
+
@surface.commit(route.reply)
|
|
1232
1251
|
return true
|
|
1233
1252
|
end
|
|
1234
1253
|
# Steering merges text only: a line with images runs as the next turn.
|
|
@@ -1236,7 +1255,7 @@ module Samagotchi
|
|
|
1236
1255
|
# Behind a command that waits for the turn's end, a line waits too.
|
|
1237
1256
|
return wait_for_turn_end(line) if @command_waits
|
|
1238
1257
|
|
|
1239
|
-
@pending_input_queue.push(
|
|
1258
|
+
@pending_input_queue.push(route.text, seq: next_input_seq)
|
|
1240
1259
|
@surface.commit(NEXT_STEP_NOTE)
|
|
1241
1260
|
true
|
|
1242
1261
|
end
|
|
@@ -1245,25 +1264,21 @@ module Samagotchi
|
|
|
1245
1264
|
# otherwise read as prompts (SessionCommands registers them for Tab and
|
|
1246
1265
|
# help only). A cut asks the engine to cut the generation only thinking
|
|
1247
1266
|
# too long; a queue waits for the turn's end (the REPL's own after-turn
|
|
1248
|
-
# list).
|
|
1249
|
-
#
|
|
1267
|
+
# list). A line with images never steers (as a plain one doesn't).
|
|
1268
|
+
# @param delivery [String] Delivery::CUT or Delivery::QUEUE (ComposerRoute)
|
|
1250
1269
|
# @return [Boolean] whether the turn took it
|
|
1251
|
-
def submit_steer_choice(
|
|
1252
|
-
if text.empty?
|
|
1253
|
-
@surface.commit(STEER_CHOICE_USAGE)
|
|
1254
|
-
return true
|
|
1255
|
-
end
|
|
1270
|
+
def submit_steer_choice(delivery, text)
|
|
1256
1271
|
return image_line_waits(text) if ImageInput.extract(text).any?
|
|
1257
1272
|
|
|
1258
1273
|
# A /queue waits for the turn's end; a /cut behind a command that
|
|
1259
1274
|
# already waits there goes after it too.
|
|
1260
|
-
if
|
|
1275
|
+
if Delivery.queue?(delivery) || @command_waits
|
|
1261
1276
|
@surface.commit(COMMAND_QUEUED)
|
|
1262
1277
|
return wait_for_turn_end(text)
|
|
1263
1278
|
end
|
|
1264
1279
|
|
|
1265
1280
|
epoch = @engine.input_epoch
|
|
1266
|
-
@pending_input_queue.push(text)
|
|
1281
|
+
@pending_input_queue.push(text, seq: next_input_seq)
|
|
1267
1282
|
# With no session running the cut is :off; the text still waits for
|
|
1268
1283
|
# the running turn's next step.
|
|
1269
1284
|
@surface.commit(steer_cut_note(@engine.cut_for_steer(nil, epoch: epoch), @engine.steer_cut_after))
|
|
@@ -1278,7 +1293,7 @@ module Samagotchi
|
|
|
1278
1293
|
# +line+ runs once the turn has ended, in the order typed (#with_steering).
|
|
1279
1294
|
# @return [true] the turn took it
|
|
1280
1295
|
def wait_for_turn_end(line)
|
|
1281
|
-
@after_turn << line
|
|
1296
|
+
@after_turn << [next_input_seq, line]
|
|
1282
1297
|
true
|
|
1283
1298
|
end
|
|
1284
1299
|
|
|
@@ -211,14 +211,14 @@ module Samagotchi
|
|
|
211
211
|
# "stopped" for a
|
|
212
212
|
# task_wait the user's Stop ended (the task itself runs on), for one
|
|
213
213
|
# whose task was stopped (the stop-task button, or the model's task_stop),
|
|
214
|
-
# and for a command the user's Stop (or a signal to chi
|
|
215
|
-
# from starting: a cancel, not a failure (its text still
|
|
216
|
-
# for the model).
|
|
214
|
+
# and for a command the user's Stop (or a signal to chi, a session stop)
|
|
215
|
+
# killed or kept from starting: a cancel, not a failure (its text still
|
|
216
|
+
# starts "Error:" for the model).
|
|
217
217
|
def tool_activity_status(result, tool_name = nil, command: nil)
|
|
218
218
|
text = result.to_s
|
|
219
|
-
return "stopped" if [Tools::Execute::NOT_RUN_ON_STOP, Tools::Execute::
|
|
219
|
+
return "stopped" if [Tools::Execute::NOT_RUN_ON_STOP, *Tools::Execute::NOT_RUN.values].include?(text)
|
|
220
220
|
if tool_name == Tools::Execute::NAME &&
|
|
221
|
-
text.start_with?(Tools::Execute::STOPPED_BY_USER, Tools::Execute::
|
|
221
|
+
text.start_with?(Tools::Execute::STOPPED_BY_USER, *Tools::Execute::STOPPED_BY.values)
|
|
222
222
|
return "stopped"
|
|
223
223
|
end
|
|
224
224
|
return "error" if text.start_with?("Error:")
|
|
@@ -203,7 +203,9 @@ module Samagotchi
|
|
|
203
203
|
delivered = SessionManager.deliver_turn(id, prompt: task, client_id: "#{ClientId::DELEGATE_PREFIX}#{parent.id[0, 8]}",
|
|
204
204
|
state_dir: state_dir)
|
|
205
205
|
case delivered[:status]
|
|
206
|
-
when :accepted
|
|
206
|
+
when :accepted
|
|
207
|
+
DelegateWait.mark_turn(parent.id, id, follow_up_turn(id, delivered[:ack], state_dir: state_dir), state_dir: state_dir)
|
|
208
|
+
id
|
|
207
209
|
when :refused then "Error: session #{id[0, 8]} refused the message (#{delivered.dig(:ack, "error")})"
|
|
208
210
|
when :timeout then "Error: the worker of session #{id[0, 8]} did not answer in time, so the message was not sent"
|
|
209
211
|
else "Error: the message to session #{id[0, 8]} could not be written"
|
|
@@ -215,6 +217,31 @@ module Samagotchi
|
|
|
215
217
|
end
|
|
216
218
|
private_class_method :follow_up
|
|
217
219
|
|
|
220
|
+
# The child's turn a delivered follow-up joins, for the wait
|
|
221
|
+
# (DelegateCursor#turn): the running turn's (its checkpoint mark), the
|
|
222
|
+
# follow-up merging into it; else the ack's enqueued_id, the follow-up
|
|
223
|
+
# running as a turn of its own. Only from a worker that names
|
|
224
|
+
# turn_ids; nil otherwise, or when the running turn has no id (a
|
|
225
|
+
# delegate's first turn): the wait takes the next reply.
|
|
226
|
+
# A follow-up steered into a turn that ended before it merged runs as
|
|
227
|
+
# its own turn, yet the wait is on the ended one (LOW, as before).
|
|
228
|
+
# One the child's idle look missed being merged into a turn starting
|
|
229
|
+
# then never runs as its own: ReplyWait falls back to that turn's
|
|
230
|
+
# reply (merged_elsewhere?).
|
|
231
|
+
# @return [String, nil]
|
|
232
|
+
def self.follow_up_turn(id, ack, state_dir:)
|
|
233
|
+
return nil unless ReplyWait.turn_ids?(Session.session_dir(id, state_dir: state_dir))
|
|
234
|
+
|
|
235
|
+
child = Session.load(id, state_dir: state_dir)
|
|
236
|
+
return child.interrupted_turn&.enqueued_id if child.status == Session::STATUS_RUNNING
|
|
237
|
+
|
|
238
|
+
enqueued_id = ack.is_a?(Hash) ? (ack["enqueued_id"] || ack[:enqueued_id]) : nil
|
|
239
|
+
enqueued_id.to_s.empty? ? nil : enqueued_id.to_s
|
|
240
|
+
rescue ArgumentError
|
|
241
|
+
nil
|
|
242
|
+
end
|
|
243
|
+
private_class_method :follow_up_turn
|
|
244
|
+
|
|
218
245
|
# The model as typed (spawn_session stores its resolved ref), else the
|
|
219
246
|
# parent's. No existence check here: spawn_session checks the host
|
|
220
247
|
# (check_host!, its UnknownHost comes back as this tool's Error: line)
|