samagotchi 0.49.0 → 0.51.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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -1
  3. data/bin/chi +2 -2
  4. data/docs/cli.md +33 -8
  5. data/docs/configuration.md +11 -9
  6. data/docs/hooks.md +12 -3
  7. data/docs/internals/engine.md +4 -2
  8. data/docs/sessions.md +8 -4
  9. data/docs/testing.md +2 -1
  10. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  11. data/lib/samagotchi/bridge.rb +24 -9
  12. data/lib/samagotchi/bridge_client.rb +3 -1
  13. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/loop-guard/plugin.rb +13 -1
  15. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  16. data/lib/samagotchi/delivery.rb +33 -0
  17. data/lib/samagotchi/engine.rb +1 -1
  18. data/lib/samagotchi/hooks/stream_watch.rb +9 -2
  19. data/lib/samagotchi/host_registry.rb +17 -4
  20. data/lib/samagotchi/kernel_loop.rb +28 -2
  21. data/lib/samagotchi/llm/native_tool_normalizer.rb +2 -2
  22. data/lib/samagotchi/send_command.rb +124 -10
  23. data/lib/samagotchi/session_commands.rb +10 -2
  24. data/lib/samagotchi/session_inbox.rb +27 -7
  25. data/lib/samagotchi/session_manager.rb +13 -3
  26. data/lib/samagotchi/session_metrics.rb +32 -6
  27. data/lib/samagotchi/sessions_command.rb +84 -7
  28. data/lib/samagotchi/steer_cut.rb +12 -8
  29. data/lib/samagotchi/terminal_ui/attached_loop.rb +50 -3
  30. data/lib/samagotchi/terminal_ui/event_renderer.rb +4 -2
  31. data/lib/samagotchi/terminal_ui/formatting.rb +68 -6
  32. data/lib/samagotchi/terminal_ui.rb +48 -5
  33. data/lib/samagotchi/tool_activity.rb +3 -0
  34. data/lib/samagotchi/tool_runner.rb +2 -0
  35. data/lib/samagotchi/tools/builtin_calls.rb +33 -0
  36. data/lib/samagotchi/version.rb +1 -1
  37. data/lib/samagotchi/web/app.rb +6 -2
  38. data/lib/samagotchi/web/message_parts.rb +3 -0
  39. data/lib/samagotchi/web/public/activity.js +6 -0
  40. data/lib/samagotchi/web/public/app.js +71 -29
  41. data/lib/samagotchi/web/public/index.html +17 -15
  42. data/lib/samagotchi/web/public/live_timing.js +32 -5
  43. data/lib/samagotchi/web/public/session_title.js +26 -0
  44. data/lib/samagotchi/web/public/stage_model.js +15 -0
  45. data/lib/samagotchi/web/public/stage_view.js +10 -4
  46. data/lib/samagotchi/web/public/strip.js +4 -6
  47. data/lib/samagotchi/web/public/timing.js +11 -0
  48. data/lib/samagotchi/web/public/turn_events.js +14 -2
  49. data/lib/samagotchi/web/public/turn_html.js +24 -1
  50. data/lib/samagotchi/web/public/turn_view.js +9 -3
  51. data/lib/samagotchi/web/session_hub.rb +11 -3
  52. data/lib/samagotchi/worker.rb +1 -1
  53. data/lib/samagotchi/worker_inbound.rb +8 -5
  54. data/lib/samagotchi.rb +1 -0
  55. metadata +3 -1
@@ -32,16 +32,20 @@ module Samagotchi
32
32
  @clock = clock
33
33
  @slow_logged = false
34
34
  @iteration = nil
35
+ @restarted = false
35
36
  end
36
37
 
37
- # A new generation (:generation_started).
38
- def started(iteration)
38
+ # A new generation (:generation_started), or a dropped stream's step
39
+ # asked again (restarted: true: the first fire after it carries
40
+ # restarted: true — what came before is void).
41
+ def started(iteration, restarted: false)
39
42
  @iteration = iteration
40
43
  @started_at = @last_fire_at = @clock.call
41
44
  @thinking = +""
42
45
  @text = +""
43
46
  @thinking_chars = 0
44
47
  @text_chars = 0
48
+ @restarted = restarted
45
49
  end
46
50
 
47
51
  # One chunk's new thinking and visible text.
@@ -70,6 +74,9 @@ module Samagotchi
70
74
  elapsed_ms: ((now - @started_at) * 1000).round,
71
75
  # A question would hold the HTTP read for minutes.
72
76
  ask_user: ->(**) {} }
77
+ # The first fire after a restarted retry says so, once.
78
+ event[:restarted] = true if @restarted
79
+ @restarted = false
73
80
  @thinking = +""
74
81
  @text = +""
75
82
  @last_fire_at = now
@@ -200,9 +200,11 @@ module Samagotchi
200
200
  # id it is sent as (ModelRef: one alias pass). A ref that names a host
201
201
  # goes there. A bare id goes to the hosts whose cached list has it or
202
202
  # that declare it (hosts.<name>.models, known before any /models): the
203
- # default host when it is one of them, else the first in hosts: order,
204
- # else the default host (before /models nothing is listed: no
205
- # discovery here, the latency budget). Never by a substring.
203
+ # default host when it is one of them, else the first in hosts: order.
204
+ # When none does, the hosts whose saved list (ModelListStore, another
205
+ # process's listing: a worker spawned with --model never lists) has
206
+ # it, the same way; else the default host (no discovery here, the
207
+ # latency budget). Never by a substring.
206
208
  def host_for_model(raw_model)
207
209
  ref = model_ref(raw_model)
208
210
  # ModelRef names a host only when it is one of ours
@@ -211,6 +213,7 @@ module Samagotchi
211
213
  down = ref.id.to_s.strip.downcase
212
214
  listed = @mutex.synchronize { @model_index }&.fetch(down, nil) || []
213
215
  candidates = @entries.values.select { |e| listed.include?(e.name) || e.models&.key?(down) }.map(&:name)
216
+ candidates = saved_hosts_for(down) if candidates.empty?
214
217
  default = default_entry
215
218
  name = candidates.include?(default.name) ? default.name : candidates.first
216
219
  [(name && find_entry(name)) || default, ref.id]
@@ -383,7 +386,17 @@ module Samagotchi
383
386
  # a worker without listing (`chi send --new --model`, delegate):
384
387
  # ModelListStore is what they check an id against.
385
388
  def save_model_list(name, models)
386
- ModelListStore.save(name, Array(models).map(&:id))
389
+ ModelListStore.save(name, Array(models).map(&:id), env: @env)
390
+ end
391
+
392
+ # The configured hosts, in hosts: order, whose fresh saved list
393
+ # (ModelListStore) has +down+ (a downcased id). The store never raises.
394
+ def saved_hosts_for(down)
395
+ saved = ModelListStore.read(env: @env)
396
+ @entries.each_key.select do |name|
397
+ list = saved[name]
398
+ list && !list.stale? && list.known?(down)
399
+ end
387
400
  end
388
401
 
389
402
  def fresh_list(name, entry)
@@ -952,11 +952,30 @@ module Samagotchi
952
952
 
953
953
  private
954
954
 
955
+ # Shell-like names (the harness's is `execute`). The unknown-tool error
956
+ # never echoes the name the model used — a model that sees its own wrong
957
+ # name again calls it again (a steer that named `bash` raised one model's
958
+ # bash rate from 9% to 30%); the activity event and the logs keep it.
959
+ SHELL_LIKE_TOOL_NAMES = %w[
960
+ bash sh zsh shell terminal cmd command run run_command run_shell exec execute_command
961
+ ].freeze
962
+
963
+ # The error an unknown tool name gets back. A shell-like name with
964
+ # `execute` offered this turn points at `execute`; every other unknown
965
+ # name lists what is offered. Neither echoes the wrong name.
966
+ def unknown_tool_error(name, layers)
967
+ names = @tools.names(layers: layers)
968
+ if SHELL_LIKE_TOOL_NAMES.include?(name.to_s.downcase) && names.include?("execute")
969
+ "Error: no such tool. Shell commands run with `execute` (same arguments)."
970
+ else
971
+ "Error: no such tool. Available: #{names.join(", ")}"
972
+ end
973
+ end
974
+
955
975
  def dispatch(call)
956
976
  entry = @tools.offered(call[:name], layers: llm_context_layers)
957
977
  unless entry
958
- available = @tools.names(layers: llm_context_layers).join(", ")
959
- result = "Error: unknown tool '#{call[:name]}'. Available: #{available}"
978
+ result = unknown_tool_error(call[:name], llm_context_layers)
960
979
  return {
961
980
  output: result,
962
981
  activity: ToolActivity.tool_activity_event(call[:name], call, result, registry: @tools)
@@ -965,6 +984,13 @@ module Samagotchi
965
984
 
966
985
  dump_log("tool_call", call[:content], tool: call[:name], path: call[:path], scope: call[:scope])
967
986
 
987
+ if call[:called_as]
988
+ # The model's spelling for a tool an alias ran (bash → execute); its
989
+ # rate is what the alias exists for, so it goes in the log. Tag :turn
990
+ # like the other per-call tool logs (there is no :tool tag).
991
+ Log.info(:turn, "tool_alias", name: call[:called_as], as: call[:name])
992
+ end
993
+
968
994
  result = entry.handler.call(call, tool_context)
969
995
 
970
996
  dump_log("tool_result", result, tool: call[:name])
@@ -11,8 +11,8 @@ module Samagotchi
11
11
  # the call, as it does for the Gemma and Qwen parsers.
12
12
  #
13
13
  # Unknown tool names pass through with their arguments on args:, so
14
- # `dispatch` renders its standard "unknown tool … available: …" error —
15
- # the loop feeds that back like any tool result.
14
+ # `dispatch` renders its standard "no such tool …" error (which never
15
+ # echoes the name) — the loop feeds that back like any tool result.
16
16
  class NativeToolNormalizer
17
17
  class << self
18
18
  # Map a single native tool call (LLM::ToolCall) to the internal call hash.
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "client_id"
4
+ require_relative "delivery"
4
5
  require_relative "session"
6
+ require_relative "session_commands"
5
7
  require_relative "session_inbox"
6
8
  require_relative "session_manager"
7
9
  require_relative "session_chain"
@@ -33,13 +35,14 @@ module Samagotchi
33
35
  MAX_IMAGES = ImageStore::MAX_TURN_REFS
34
36
 
35
37
  USAGE = <<~TEXT
36
- Usage: chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...
38
+ Usage: chi send [-m TEXT] [--image PATH]... [--cut] (ID|PREFIX)...
37
39
  chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [-m TEXT] [--image PATH]...
38
40
  chi send --new --continues (ID|PREFIX|last:ID) [-m TEXT] [--image PATH]...
39
41
  chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
40
42
  chi send --wait [--timeout S] [--format json] ID
41
43
  Sends a message to each session, as if typed in it: a turn starts,
42
- or a running one picks it up. A stopped session's worker starts.
44
+ or a running one takes it at its next step. A stopped session's
45
+ worker starts.
43
46
  A session command (/model x, !cmd) runs as the command; --wait
44
47
  then has no reply to wait for (exit 0, status command).
45
48
  -m TEXT the message; stdin, when piped too, goes above it as a
@@ -74,6 +77,14 @@ module Samagotchi
74
77
  nothing is sent: it waits for the session's next
75
78
  reply, a running turn's too (after exit 3, 4 or 130,
76
79
  wait again this way)
80
+ --cut ask the running turn to cut the generation it is
81
+ streaming and take the message now (a generation that
82
+ has streamed only thinking for steer.cut_after; else
83
+ the message waits for the thinking to pass it). A
84
+ message without --cut goes in at the running turn's
85
+ next step and cuts nothing. To an idle session it
86
+ sends normally. Refused with --wait into a running
87
+ turn (the steer's turn is the running one)
77
88
  --timeout S (--wait) give up after S seconds, exit 4 (the turn
78
89
  goes on); default no limit
79
90
  --format json
@@ -81,7 +92,8 @@ module Samagotchi
81
92
  status answered (text), question (question,
82
93
  answer_with), running, or failed, canceled,
83
94
  limit, no_answer, error, worker_gone, stopped,
84
- command (detail)
95
+ command (detail); with --cut also delivery (what
96
+ the worker applied) and cut (now, waits, off)
85
97
  Exit with --wait: 0 answered, 1 failed or gone, 2 usage, 3 a
86
98
  question waits, 4 still running (--timeout), 130 Ctrl-C.
87
99
  Only sessions on this machine. Answers show in the attached TUI
@@ -100,6 +112,7 @@ module Samagotchi
100
112
  f.value "--llm-context-budget", key: :llm_budget
101
113
  f.value "--image", key: :images, repeat: true
102
114
  f.switch "--wait"
115
+ f.switch "--cut"
103
116
  f.value "--timeout"
104
117
  f.value "--format"
105
118
  # Starting a turn in every live session at once is too easy to do
@@ -159,6 +172,7 @@ module Samagotchi
159
172
  def run_parsed(options)
160
173
  # With --wait stdout is the answer alone.
161
174
  @info = options[:wait] ? @stderr : @stdout
175
+ @cut = options[:cut] == true
162
176
  prompt = compose(utf8(read_stdin), utf8(options[:message]))
163
177
  # Before the wait-only case, which would drop the images.
164
178
  return usage_error("--image needs a message: pass -m TEXT or pipe it in") if prompt.nil? && !options[:images].empty?
@@ -175,6 +189,14 @@ module Samagotchi
175
189
  @stderr.puts("chi send: #{e.message}")
176
190
  return 1
177
191
  end
192
+ # A UI-only command (/stats, /exit, …) runs in the terminal and the web,
193
+ # not in the worker: sent through, the worker doesn't run it and the
194
+ # line reaches the model as a prompt, costing a whole turn. Refused
195
+ # before anything is sent (before a --new session is created too); the
196
+ # session's plugin commands are never local.
197
+ if prompt && (local = local_command(prompt))
198
+ return refuse_local_command(local, options)
199
+ end
178
200
  # A file that is not there or not an image: a run-time failure (1),
179
201
  # reported in the --format json line too; --image itself was fine.
180
202
  return CLI::Exit::FAILED unless ingest_images(options[:images])
@@ -440,6 +462,58 @@ module Samagotchi
440
462
  # (a bundle's too).
441
463
  def command_like?(text) = text.to_s.lstrip.start_with?("/", "!")
442
464
 
465
+ # The UI-only built-in command +text+ (stripped) is a line of (/stats,
466
+ # /exit, …), or nil: the worker never runs it, so chi send must not pass
467
+ # it on. What the UIs run: the line as the entry's match takes it — bare
468
+ # "exit" too (/exit's match), but not with arguments ("/exit --delete" is
469
+ # a UI line; "/stats please" is a prompt), as in a terminal.
470
+ # @return [Commands::Registry::Entry, nil]
471
+ def local_command(text)
472
+ line = text.to_s.strip
473
+ return nil unless command_like?(line) || line.match?(/\Aexit\z/i)
474
+
475
+ entry = SessionCommands.builtin_registry.lookup_local(line)
476
+ # Only the exact line the UI runs: "/stats please" is a prompt, and
477
+ # "/exit --delete" is left to the worker (the TUI runs it as /exit, the
478
+ # worker answers its unknown-command way). Bare "exit" is /exit's line
479
+ # as its match takes it; the reply names the command, /exit.
480
+ return nil unless entry
481
+ return entry if line == entry.name
482
+
483
+ line.match?(/\Aexit\z/i) ? entry : nil
484
+ end
485
+
486
+ # A UI-only command: nothing is sent (with --new, no session is created
487
+ # either). /stats points at its model-free reader and names the session;
488
+ # the others just say the worker doesn't run them. Exit 1, as other
489
+ # refusals; with --wait --format json the line is the usual error object
490
+ # (ParentWait's end-of-run line when the reply never comes).
491
+ # @return [Integer] 1
492
+ def refuse_local_command(entry, options)
493
+ if options[:new]
494
+ error_line("chi send: refused: #{local_refusal(entry.name, nil)}")
495
+ elsif options[:wait]
496
+ # --wait: the JSON line goes to stdout, @info is stderr; an unknown
497
+ # or ambiguous id gets resolve's own error line.
498
+ id = resolve(options[:ids].first) or return CLI::Exit::FAILED
499
+ @stderr.puts("#{id[0, 8]} refused: #{local_refusal(entry.name, id)}")
500
+ else
501
+ options[:ids].uniq.each do |given|
502
+ id = resolve(given) or next
503
+ @info.puts("#{id[0, 8]} refused: #{local_refusal(entry.name, id)}")
504
+ end
505
+ end
506
+ CLI::Exit::FAILED
507
+ end
508
+
509
+ # Why a UI-only command isn't sent: /stats points at its model-free
510
+ # reader (naming the session when there is one).
511
+ def local_refusal(name, id)
512
+ return "#{name} is a terminal and web command; chi send doesn't run it" unless name == "/stats"
513
+
514
+ "#{name} is a terminal and web command; use chi sessions stats #{id ? id[0, 8] : "ID"}"
515
+ end
516
+
443
517
  # --wait after a message that ran as a session command: no reply comes
444
518
  # for it. Its output shows where the session is open.
445
519
  # @return [Integer] 0
@@ -466,7 +540,11 @@ module Samagotchi
466
540
  owner = SessionManager.session_owner(id, state_dir: @state_dir)
467
541
  running = owner && session.status == Session::STATUS_RUNNING
468
542
  refs = copy_images(Session.session_dir(id, state_dir: @state_dir))
469
- result = SessionManager.deliver_turn(id, prompt: prompt, client_id: CLIENT_ID, images: refs, state_dir: @state_dir)
543
+ # --cut asks the running turn for a cut; to an idle session it is an
544
+ # ordinary message (there is nothing to cut).
545
+ delivery = @cut && running ? Delivery::CUT : nil
546
+ result = SessionManager.deliver_turn(id, prompt: prompt, client_id: CLIENT_ID, images: refs, state_dir: @state_dir,
547
+ delivery: delivery)
470
548
  unless result[:status] == :accepted
471
549
  @info.puts("#{short} failed: #{result.dig(:ack, "detail") || "could not queue it"}")
472
550
  return false
@@ -478,12 +556,12 @@ module Samagotchi
478
556
  return true
479
557
  end
480
558
 
481
- # A busy worker runs a message with images as its own next turn
482
- # rather than merging it into the running one.
483
- note = if owner.nil? then " (started its worker)"
484
- elsif running then refs.empty? ? " (the running turn picks it up)" : " (runs after the current turn)"
485
- end
486
- @info.puts("#{short} sent#{with_images}#{note}")
559
+ ack = result[:ack].is_a?(Hash) ? result[:ack] : {}
560
+ # What the worker applied, for --format json: its ack's delivery
561
+ # (next_step for an idle session) and a cut's outcome.
562
+ @sent_delivery = ack["delivery"] || delivery
563
+ @sent_cut = ack["cut"]
564
+ @info.puts("#{short} sent#{with_images}#{delivery_note(ack, delivery: delivery, running: running, owner: owner)}")
487
565
  true
488
566
  rescue SessionManager::OwnedByTUI
489
567
  @info.puts("#{short} refused: it is open in a chi REPL; messages need attached mode")
@@ -493,6 +571,42 @@ module Samagotchi
493
571
  false
494
572
  end
495
573
 
574
+ # What the line says became of the message: a plain one goes in at the
575
+ # running turn's next step (nothing cuts by default); a --cut one names
576
+ # the outcome from the ack (now, waits, off); images keep their own
577
+ # wording; an idle session's turn simply runs.
578
+ # @param ack [Hash] the Bridge's 202 body ({} for the input file)
579
+ # @return [String] "" or " (...)"
580
+ def delivery_note(ack, delivery:, running:, owner:)
581
+ return " (started its worker)" if owner.nil?
582
+ return "" unless running
583
+
584
+ # A busy worker runs a message with images as its own next turn
585
+ # rather than merging it into the running one.
586
+ return " (runs after the current turn)" unless @images.empty?
587
+
588
+ case (Delivery.cut?(delivery) ? ack["cut"].to_s : "")
589
+ when "now" then " (cut in now)"
590
+ when "waits" then " (cuts in once the thinking passes #{Config.get("steer.cut_after").to_i} s)"
591
+ when "off" then " (cutting is off; goes in at the next step)"
592
+ # No outcome: a plain message, or the input file (the Bridge went
593
+ # away), which the next step's drain takes.
594
+ else " (goes in at the running turn's next step)"
595
+ end
596
+ end
597
+
598
+ # More keys for --format json, with --cut only (the line is unchanged
599
+ # otherwise): the delivery the worker applied (next_step to an idle
600
+ # session) and a cut's outcome (now, waits, off). In P1 --cut --wait is
601
+ # refused for a running turn, so the json line says next_step.
602
+ def report_extra
603
+ return {} unless @cut
604
+
605
+ extra = { delivery: @sent_delivery || Delivery::NEXT_STEP }
606
+ extra[:cut] = @sent_cut if @sent_cut
607
+ extra
608
+ end
609
+
496
610
  # What to say when +model_name+ is known not to take images, or nil
497
611
  # (it does, or it is unknown: sent as before). Asked once per model.
498
612
  def vision_refusal(model_name, typed: nil)
@@ -71,8 +71,7 @@ module Samagotchi
71
71
  continue_answer(answer.empty? ? CONTINUE_COMMAND : answer)
72
72
  end
73
73
  # It reads the hosts only (the registry is mutex-guarded): mid-turn too.
74
- registry.register(MODELS_COMMAND, "list the hosts' models (/models <text> filters)",
75
- mid_turn: ->(_text) { :anytime }) do |text|
74
+ registry.register(MODELS_COMMAND, "list the hosts' models (/models <text> filters)", anytime: true) do |text|
76
75
  reply(models_listing(text.delete_prefix(MODELS_COMMAND).strip))
77
76
  end
78
77
  registry.register(GUARDRAILS_COMMAND, "list the guardrail rules and approvals (/guardrails revoke N)",
@@ -101,6 +100,15 @@ module Samagotchi
101
100
  # The REPL owns its session: it answers /detach with a note, and doesn't offer it.
102
101
  registry.register("/detach", "leave and keep the worker running", local: true, uis: [:attached],
103
102
  match: ->(text) { text.casecmp?("/detach") })
103
+ # /cut TEXT and /queue TEXT while a turn runs: a delivery the UIs parse
104
+ # themselves (Formatting#steer_choice) before any command check; the
105
+ # entries exist for Tab completion and /help. /queue is a real
106
+ # after-the-turn list only in the local REPL (the attached TUI's
107
+ # worker doesn't skip queued files yet), so it is offered there.
108
+ registry.register("/cut", "cut the running generation and go in now (/cut TEXT)",
109
+ local: true, match: ->(text) { text.casecmp?("/cut") || text.match?(%r{\A/cut\s+\S}) })
110
+ registry.register("/queue", "run as the next turn after this one (/queue TEXT)",
111
+ local: true, uis: [:repl], match: ->(text) { text.casecmp?("/queue") || text.match?(%r{\A/queue\s+\S}) })
104
112
  registry
105
113
  end
106
114
 
@@ -5,6 +5,7 @@ require "json"
5
5
  require "time"
6
6
  require "securerandom"
7
7
  require_relative "atomic_file"
8
+ require_relative "delivery"
8
9
  require_relative "session"
9
10
 
10
11
  module Samagotchi
@@ -34,12 +35,18 @@ module Samagotchi
34
35
  class NoteRejected < ArgumentError; end
35
36
 
36
37
  # Queue a turn's input file: JSON with the prompt and the sender's ids.
38
+ # +delivery+ is only written when it is "queue": a step-boundary
39
+ # message is the default an older worker assumes, and a cut is done by
40
+ # the sender (the Bridge) at once, not by the file.
37
41
  # @param client_id [String, nil] the sending UI
38
42
  # @param enqueued_id [String, nil] the id its ACK / :turn_enqueued carry
43
+ # @param delivery [String, nil] the wire value: "next_step" (default),
44
+ # "cut" or "queue"
39
45
  # @param images [Array<Hash>] image refs ({file:, name:}) in the
40
46
  # session's images/
41
47
  # @return [String, false] the input file's path, or false
42
- def self.write_input(session_dir, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [])
48
+ def self.write_input(session_dir, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [],
49
+ delivery: nil)
43
50
  images = Array(images)
44
51
  input_dir = File.join(session_dir, INPUT_DIR)
45
52
  FileUtils.mkdir_p(input_dir)
@@ -47,6 +54,7 @@ module Samagotchi
47
54
  path = File.join(input_dir, "#{Time.now.strftime("%Y%m%d%H%M%S%9N")}.json")
48
55
  record = { "prompt" => prompt.to_s, "client_id" => client_id, "enqueued_id" => enqueued_id,
49
56
  "no_interrupt" => (no_interrupt ? true : nil),
57
+ "delivery" => (Delivery.queue?(delivery) ? Delivery::QUEUE : nil),
50
58
  "images" => (images.empty? ? nil : images.map { |image| image.transform_keys(&:to_s) }) }.compact
51
59
  AtomicFile.write(path, JSON.generate(record))
52
60
  path
@@ -175,17 +183,20 @@ module Samagotchi
175
183
  Dir.glob(File.join(input_dir, "*.json"))
176
184
  end
177
185
 
178
- # @return [Array(String, Hash|nil, Boolean, Array<Hash>)] a claimed input
179
- # file's prompt, origin ({client_id:, enqueued_id:}, nil when it names
180
- # no sender), whether its turn runs with the raised iteration limit
181
- # (--no-interrupt), and its image refs ({file:, name:})
186
+ # @return [Array(String, Hash|nil, Boolean, Array<Hash>, String|nil)] a
187
+ # claimed input file's prompt, origin ({client_id:, enqueued_id:},
188
+ # nil when it names no sender), whether its turn runs with the raised
189
+ # iteration limit (--no-interrupt), its image refs ({file:, name:}),
190
+ # and its delivery ("queue" or nil; a file without the key is one an
191
+ # older chi wrote, so nil)
182
192
  def self.read_input(claimed_file)
183
193
  data = JSON.parse(File.read(claimed_file).to_s)
184
194
  origin = { client_id: data["client_id"], enqueued_id: data["enqueued_id"] }.compact
185
195
  images = Array(data["images"]).select { |image| image.is_a?(Hash) }.map { |image| image.transform_keys(&:to_sym) }
186
- [data["prompt"].to_s, origin.empty? ? nil : origin, data["no_interrupt"] == true, images]
196
+ [data["prompt"].to_s, origin.empty? ? nil : origin, data["no_interrupt"] == true, images,
197
+ Delivery.queue?(data["delivery"]) ? Delivery::QUEUE : nil]
187
198
  rescue JSON::ParserError
188
- [nil, nil, false, []]
199
+ [nil, nil, false, [], nil]
189
200
  end
190
201
 
191
202
  # Whether an unclaimed input file carries images (a mid-turn drain
@@ -197,6 +208,15 @@ module Samagotchi
197
208
  false
198
209
  end
199
210
 
211
+ # Whether a mid-turn drain leaves this unclaimed input file for a turn
212
+ # of its own instead of merging it at a step boundary. Today that is an
213
+ # image message (steering merges text only); the drain's file filter
214
+ # reads this one predicate, so what a boundary refuses changes in one
215
+ # place.
216
+ def self.waits_for_turn_end?(input_file)
217
+ input_has_images?(input_file)
218
+ end
219
+
200
220
  def self.claim_input_file(input_file)
201
221
  processing_path = "#{input_file}.processing"
202
222
  File.rename(input_file, processing_path)
@@ -8,6 +8,7 @@ require "rbconfig"
8
8
  require_relative "client_id"
9
9
  require_relative "atomic_file"
10
10
  require_relative "config"
11
+ require_relative "delivery"
11
12
 
12
13
  require_relative "session"
13
14
  require_relative "session_inbox"
@@ -1288,14 +1289,16 @@ module Samagotchi
1288
1289
  # boundary. The file is JSON carrying the sender's ids.
1289
1290
  # @param client_id [String, nil] the sending UI
1290
1291
  # @param enqueued_id [String, nil] the id its ACK / :turn_enqueued carry
1292
+ # @param delivery [String, nil] the wire value: "next_step" (default),
1293
+ # "cut" or "queue"
1291
1294
  # @param images [Array<Hash>] image refs ({file:, name:}) in the
1292
1295
  # session's images/
1293
1296
  # @return [String, false] the input file's path, or false.
1294
1297
  def self.write_turn_input(session_id, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, state_dir: nil,
1295
- images: [])
1298
+ images: [], delivery: nil)
1296
1299
  session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
1297
1300
  SessionInbox.write_input(session_dir, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
1298
- no_interrupt: no_interrupt, images: images)
1301
+ no_interrupt: no_interrupt, images: images, delivery: delivery)
1299
1302
  end
1300
1303
 
1301
1304
  # How long a turn waits for the Bridge of a worker a resume just spawned.
@@ -1308,6 +1311,8 @@ module Samagotchi
1308
1311
  # exit). Race-safe against a TUI taking the session, or the worker
1309
1312
  # exiting, between the resume and the write.
1310
1313
  # @param images [Array<Hash>] refs ({file:, name:}) already in images/
1314
+ # @param delivery [String, nil] what the message asks the running turn
1315
+ # for: "next_step" (default), "cut" or "queue"
1311
1316
  # @param manager [#resume_session, #write_turn_input] this class, or a
1312
1317
  # stand-in (the web's specs); #session_owner is optional
1313
1318
  # @param bridge [#call, nil] returns the BridgeClient or nil; defaults to
@@ -1319,7 +1324,8 @@ module Samagotchi
1319
1324
  # {status: :failed} when the input file couldn't be written
1320
1325
  # @raise [OwnedByTUI] a chi REPL owns the session
1321
1326
  # @raise [ArgumentError] no such session
1322
- def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], state_dir: nil, manager: self, bridge: nil)
1327
+ def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], delivery: nil, state_dir: nil, manager: self,
1328
+ bridge: nil)
1323
1329
  session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
1324
1330
  bridge ||= -> { BridgeClient.wait_for(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT) }
1325
1331
  manager.resume_session(session_id, state_dir: state_dir)
@@ -1327,6 +1333,9 @@ module Samagotchi
1327
1333
  begin
1328
1334
  options = { prompt: prompt, client_id: client_id }
1329
1335
  options[:images] = images unless images.empty?
1336
+ # An old worker ignores the field: it cuts anyway (its own default)
1337
+ # and its file never carries a queue, so a queue would steer there.
1338
+ options[:delivery] = delivery unless Delivery.next_step?(delivery)
1330
1339
  reply = client.post_turn(**options)
1331
1340
  ack = reply.json
1332
1341
  return { status: :accepted, ack: ack } if reply.status == 202 && ack.is_a?(Hash)
@@ -1347,6 +1356,7 @@ module Samagotchi
1347
1356
  enqueued_id = SecureRandom.uuid
1348
1357
  input = { prompt: prompt, client_id: client_id, enqueued_id: enqueued_id, state_dir: state_dir }
1349
1358
  input[:images] = images unless images.empty?
1359
+ input[:delivery] = delivery unless Delivery.next_step?(delivery)
1350
1360
  path = manager.write_turn_input(session_id, **input)
1351
1361
  return { status: :failed } unless path
1352
1362
 
@@ -141,6 +141,9 @@ module Samagotchi
141
141
  :cuts,
142
142
  :capped,
143
143
  :model,
144
+ # The turn the user started by answering the step-limit question with
145
+ # Continue (:turn_started's +continue+); its record carries it.
146
+ :continue,
144
147
  :id,
145
148
  :started_at,
146
149
  :started_monotonic,
@@ -376,6 +379,16 @@ module Samagotchi
376
379
  @mutex.synchronize { build_snapshot(:recent) }
377
380
  end
378
381
 
382
+ # The snapshot of a session no worker runs, read from disk (`chi
383
+ # sessions stats`): #snapshot's, but its records are the newest saved
384
+ # turn's (and its tool calls), as a live worker's snapshot carries its
385
+ # newest finished turn; #snapshot would list none, this collector having
386
+ # finished no turn of its own.
387
+ # @return [Hash]
388
+ def saved_snapshot
389
+ @mutex.synchronize { build_snapshot(:last) }
390
+ end
391
+
379
392
  # Close the open generation now, ahead of its :generation_completed
380
393
  # (which then finds it closed), and report its speed with the session's
381
394
  # running token totals: what the event carries to the UIs.
@@ -438,6 +451,9 @@ module Samagotchi
438
451
  served_model_for: @served_model_for,
439
452
  tool_calls_total: @tool_records.size + in_flight.size,
440
453
  tool_calls_by_tool: tally_into(totals[:by_tool].dup, in_flight),
454
+ # The calls the model made under an alias's name (bash run as
455
+ # execute), by its own spelling; only when there are any.
456
+ **(totals[:aliased].empty? ? {} : { tool_calls_aliased: totals[:aliased].dup }),
441
457
  tool_errors: totals[:tool_errors],
442
458
  iterations_total: totals[:iterations] + (turn&.iteration_count || 0),
443
459
  gen_latency_ms: (totals[:gen_ms] + (turn&.gen_latency_accum || 0)).round,
@@ -483,14 +499,16 @@ module Samagotchi
483
499
 
484
500
  # The records a snapshot carries, copied. :all is every record; :recent
485
501
  # the newest turn this collector finished (live before its save lands)
486
- # and the unsaved ones (capped), and the tool records of those turns and
487
- # of the running one. Records are appended in turn order, so both come
488
- # from the tails. Caller holds the mutex.
502
+ # and the unsaved ones (capped); :last the newest turn, loaded or not;
503
+ # and the tool records of those turns and of the running one. Records
504
+ # are appended in turn order, so both come from the tails. Caller holds
505
+ # the mutex.
489
506
  def records_view(mode)
490
507
  return { turn_records: @turn_records.map(&:dup), tool_records: @tool_records.map(&:dup) } if mode == :all
491
508
 
492
509
  unsaved = @turn_records.size - @persisted_turns
493
- count = unsaved.clamp(@turn_records.size > @loaded_turns ? 1 : 0, LIVE_RECORDS_CAP)
510
+ fresh = @turn_records.size > @loaded_turns ? 1 : 0
511
+ count = mode == :last ? 1 : unsaved.clamp(fresh, LIVE_RECORDS_CAP)
494
512
  turns = @turn_records.last(count)
495
513
  ids = turns.to_set { |record| record[:id] }
496
514
  ids << @turn.id if @turn
@@ -539,6 +557,7 @@ module Samagotchi
539
557
  cuts: 0,
540
558
  capped: 0,
541
559
  model: nil,
560
+ continue: event[:continue] ? true : false,
542
561
  id: event[:turn_id] || SecureRandom.uuid,
543
562
  started_at: now.iso8601(3),
544
563
  started_monotonic: monotonic_time,
@@ -575,6 +594,9 @@ module Samagotchi
575
594
  record[:cancellation_reason] = reason.to_s unless reason.nil? || reason.to_s.empty?
576
595
  record[:cancelled_by] = by.to_s unless by.nil? || by.to_s.empty?
577
596
  record[:failure] = failure if failure
597
+ # Every record says, so the report can tell a file with no Continue
598
+ # from one written before the mark.
599
+ record[:continue] = @turn.continue
578
600
  record.merge!(turn_token_fields(@turn))
579
601
  @turn_records << record
580
602
  count_turn(record)
@@ -800,6 +822,8 @@ module Samagotchi
800
822
  active = @turn.tool_calls_by_id.delete(key)
801
823
  return unless active
802
824
 
825
+ called_as = event.dig(:activity, :called_as)
826
+ active[:called_as] = called_as.to_s if called_as
803
827
  finish_tool_record(active, status.empty? ? "ok" : status, waited_ms: event[:waited_ms].to_i)
804
828
  end
805
829
  end
@@ -807,7 +831,7 @@ module Samagotchi
807
831
  # Caller holds the mutex.
808
832
  def finish_tool_record(active, status, waited_ms: 0)
809
833
  record = active.slice(
810
- :id, :turn_id, :iteration, :call_index, :tool, :started_at
834
+ :id, :turn_id, :iteration, :call_index, :tool, :called_as, :started_at
811
835
  ).merge(
812
836
  status: status,
813
837
  completed_at: now.iso8601(3),
@@ -1013,7 +1037,7 @@ module Samagotchi
1013
1037
  # (#snapshot adds the running turn). Caller holds the mutex.
1014
1038
  def reset_totals
1015
1039
  @totals = { cancellations: 0, prompt_sum: 0, completion_sum: 0, token_sources: [], iterations: 0,
1016
- gen_ms: 0, retries: 0, cuts: 0, capped: 0, by_tool: {}, tool_errors: 0, cached_sum: 0, cache_write_sum: 0,
1040
+ gen_ms: 0, retries: 0, cuts: 0, capped: 0, by_tool: {}, aliased: {}, tool_errors: 0, cached_sum: 0, cache_write_sum: 0,
1017
1041
  reprefill_sum: 0, reasoning_sum: 0,
1018
1042
  cost_sum: 0, cost_estimate_sum: 0, decode_ms_sum: 0, decode_tokens_sum: 0, last_decode_tps: nil,
1019
1043
  last_prefill_tps: nil, tps_source: nil }
@@ -1050,6 +1074,8 @@ module Samagotchi
1050
1074
  def count_tool(record)
1051
1075
  @totals[:tool_errors] += 1 if record[:status] == "error"
1052
1076
  tally_into(@totals[:by_tool], [record])
1077
+ called_as = record[:called_as].to_s
1078
+ @totals[:aliased][called_as] = @totals[:aliased].fetch(called_as, 0) + 1 unless called_as.empty?
1053
1079
  end
1054
1080
 
1055
1081
  def tally_into(counts, tools)