samagotchi 0.63.0 → 0.65.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -1
  3. data/docs/architecture.md +32 -5
  4. data/docs/cli.md +41 -9
  5. data/docs/configuration.md +143 -11
  6. data/docs/internals/engine.md +25 -6
  7. data/docs/plugins.md +7 -0
  8. data/docs/sessions.md +14 -10
  9. data/docs/sub-agent.md +21 -4
  10. data/docs/testing.md +6 -0
  11. data/lib/samagotchi/answer_command.rb +5 -1
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  13. data/lib/samagotchi/bridge.rb +29 -5
  14. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
  16. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/child_reports.rb +7 -2
  19. data/lib/samagotchi/cli/parent_wait.rb +9 -2
  20. data/lib/samagotchi/client.rb +4 -2
  21. data/lib/samagotchi/composer_route.rb +90 -0
  22. data/lib/samagotchi/config.rb +11 -7
  23. data/lib/samagotchi/continue_offer.rb +17 -3
  24. data/lib/samagotchi/delivery.rb +13 -8
  25. data/lib/samagotchi/engine.rb +41 -7
  26. data/lib/samagotchi/generation_phase.rb +17 -0
  27. data/lib/samagotchi/host_config.rb +33 -12
  28. data/lib/samagotchi/host_model.rb +2 -2
  29. data/lib/samagotchi/host_registry.rb +34 -11
  30. data/lib/samagotchi/idle_client.rb +62 -25
  31. data/lib/samagotchi/idle_recap.rb +5 -2
  32. data/lib/samagotchi/idle_target.rb +30 -7
  33. data/lib/samagotchi/interrupted_turn.rb +7 -3
  34. data/lib/samagotchi/last_turn.rb +5 -2
  35. data/lib/samagotchi/llm/adapters.rb +41 -0
  36. data/lib/samagotchi/llm/anthropic_assembly.rb +210 -0
  37. data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
  38. data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
  39. data/lib/samagotchi/llm/api_key.rb +26 -9
  40. data/lib/samagotchi/llm/chat_loop.rb +113 -14
  41. data/lib/samagotchi/llm/dialect.rb +57 -0
  42. data/lib/samagotchi/llm/errors.rb +81 -12
  43. data/lib/samagotchi/llm/model_listing.rb +111 -0
  44. data/lib/samagotchi/llm/model_result.rb +9 -2
  45. data/lib/samagotchi/llm/openai_chat.rb +46 -93
  46. data/lib/samagotchi/model_profile.rb +1 -1
  47. data/lib/samagotchi/pending_input_queue.rb +10 -3
  48. data/lib/samagotchi/plugin/sessions.rb +27 -5
  49. data/lib/samagotchi/reasoning_history.rb +14 -4
  50. data/lib/samagotchi/recap_setup.rb +2 -1
  51. data/lib/samagotchi/reply_wait.rb +171 -6
  52. data/lib/samagotchi/self_report.rb +18 -28
  53. data/lib/samagotchi/send_command.rb +63 -19
  54. data/lib/samagotchi/session.rb +3 -2
  55. data/lib/samagotchi/session_commands.rb +1 -1
  56. data/lib/samagotchi/session_inbox.rb +63 -7
  57. data/lib/samagotchi/session_manager.rb +49 -20
  58. data/lib/samagotchi/session_metrics.rb +6 -11
  59. data/lib/samagotchi/steer_cut.rb +4 -0
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +55 -28
  61. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  62. data/lib/samagotchi/terminal_ui/formatting.rb +7 -22
  63. data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
  64. data/lib/samagotchi/terminal_ui.rb +72 -57
  65. data/lib/samagotchi/tool_activity.rb +5 -5
  66. data/lib/samagotchi/tools/delegate.rb +28 -1
  67. data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
  68. data/lib/samagotchi/tools/delegate_wait.rb +17 -5
  69. data/lib/samagotchi/tools/execute.rb +11 -4
  70. data/lib/samagotchi/turn_checkpoint.rb +9 -3
  71. data/lib/samagotchi/turn_note.rb +22 -5
  72. data/lib/samagotchi/version.rb +1 -1
  73. data/lib/samagotchi/web/app.rb +2 -1
  74. data/lib/samagotchi/web/public/activity.js +3 -0
  75. data/lib/samagotchi/web/public/app.js +96 -13
  76. data/lib/samagotchi/web/public/commands.js +8 -8
  77. data/lib/samagotchi/web/public/composer.js +3 -3
  78. data/lib/samagotchi/web/public/delivery_line.js +228 -0
  79. data/lib/samagotchi/web/public/index.html +29 -0
  80. data/lib/samagotchi/web/public/live_turn.js +8 -0
  81. data/lib/samagotchi/web/public/stage_model.js +27 -7
  82. data/lib/samagotchi/web/public/stage_view.js +32 -10
  83. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  84. data/lib/samagotchi/web/public/timing.js +2 -1
  85. data/lib/samagotchi/web/public/turn_events.js +22 -8
  86. data/lib/samagotchi/web/public/turn_html.js +20 -4
  87. data/lib/samagotchi/web/public/turn_model.js +29 -12
  88. data/lib/samagotchi/web/public/turn_view.js +6 -4
  89. data/lib/samagotchi/worker.rb +7 -5
  90. metadata +9 -1
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "net/http"
5
- require "uri"
6
3
  require_relative "version"
7
4
  require_relative "config"
8
5
  require_relative "context_window"
@@ -16,8 +13,7 @@ require_relative "thinking"
16
13
  require_relative "reasoning_history"
17
14
  require_relative "served_model"
18
15
  require_relative "host_registry"
19
- require_relative "llm/http"
20
- require_relative "llm/api_key"
16
+ require_relative "llm/adapters"
21
17
  require_relative "tools/memory"
22
18
  require_relative "hooks/loader"
23
19
  require_relative "memory_bundle/provenance"
@@ -273,7 +269,9 @@ module Samagotchi
273
269
  entry = HostRegistry.new(env: env).resolve(model).entry
274
270
  return "-" unless entry
275
271
 
276
- entry.chat? ? "chat (api: openai)" : "native (raw prompt)"
272
+ return "native (raw prompt)" unless entry.chat?
273
+
274
+ entry.dialect ? "chat (api: #{entry.api}, dialect: #{entry.dialect})" : "chat (api: #{entry.api})"
277
275
  end
278
276
 
279
277
  NO_PROPS = "reported per turn (the server has no /props)"
@@ -281,7 +279,7 @@ module Samagotchi
281
279
  # chi self's one server call: a single-model llama.cpp answers any name
282
280
  # with the model it loaded, and its /props names it (model_alias). One
283
281
  # GET with the probe's short timeouts; a remote host isn't asked. A local
284
- # chat host (api: openai) has no /props, so it gets a GET <base>/models
282
+ # chat host (api: openai or anthropic) has no /props, so it gets its model list
285
283
  # with the same timeouts: up/down and the ids it serves.
286
284
  def served_model_for(model, env)
287
285
  registry = HostRegistry.new(env: env)
@@ -306,36 +304,28 @@ module Samagotchi
306
304
  "unknown (#{e.class})"
307
305
  end
308
306
 
309
- # A local chat host's reachability: one GET <base>/models with the
310
- # native probe's short timeouts (Client's /props probe), no retry. Up
311
- # with the ids it serves, down when it doesn't answer.
307
+ # A local chat host's reachability: its model list through its own
308
+ # adapter (LLM::Adapters, not the registry's cached one) with the native
309
+ # probe's short timeouts (Client's /props probe), no retry. Up with the
310
+ # ids it serves, down when it doesn't answer.
312
311
  def chat_served_model_for(entry, bare, env)
313
- uri = URI("#{entry.openai_base_url}/models")
314
- request = Net::HTTP::Get.new(uri)
315
- http = LLM::HTTP.new(label: entry.name, open_timeout: Client::CONTEXT_WINDOW_PROBE_OPEN_TIMEOUT,
316
- read_timeout: Client::CONTEXT_WINDOW_PROBE_READ_TIMEOUT,
317
- retry_policy: LLM::HTTP::RetryPolicy.none,
318
- api_key: LLM::ApiKey.for(entry.api_key_env, host: entry.host, env: env))
319
- response = http.fetch(uri, request, retries: false, check_status: false, log_fields: { purpose: "models" })
320
- return "down (HTTP #{response.code})" unless response.code.to_s == "200"
321
-
322
- ids = chat_model_ids(response.body)
312
+ adapter = LLM::Adapters.for(entry, open_timeout: Client::CONTEXT_WINDOW_PROBE_OPEN_TIMEOUT,
313
+ timeout: Client::CONTEXT_WINDOW_PROBE_READ_TIMEOUT, retries: false, env: env)
314
+ ids = adapter.list_models.map(&:id).reject(&:empty?)
323
315
  return "up (no models listed)" if ids.empty?
324
316
 
325
317
  note = ids.any? { |id| id.casecmp?(bare.to_s) } ? "" : " (not #{bare})"
326
318
  "up: #{ids.join(", ")}#{note}"
319
+ rescue LLM::ProviderError => e
320
+ # A status (an error, or a 3xx such as a proxy's login page) is down;
321
+ # a 200 whose body isn't a model list (ProtocolError) is up.
322
+ return "down (HTTP #{e.status})" if e.status
323
+
324
+ e.is_a?(LLM::ProtocolError) ? "up (no models listed)" : "down (the server didn't answer; is it running?)"
327
325
  rescue StandardError
328
326
  "down (the server didn't answer; is it running?)"
329
327
  end
330
328
 
331
- def chat_model_ids(body)
332
- parsed = JSON.parse(body.to_s)
333
- data = parsed.is_a?(Hash) ? parsed["data"] : parsed
334
- Array(data).filter_map { |raw| raw.is_a?(Hash) ? (raw["id"] || raw[:id]).to_s : raw.to_s }.reject(&:empty?)
335
- rescue JSON::ParserError
336
- []
337
- end
338
-
339
329
  # Offline, so no /props probe: where nothing is configured, a native
340
330
  # llama.cpp host's chat template decides at runtime.
341
331
  def profile_for(model, env)
@@ -38,10 +38,11 @@ module Samagotchi
38
38
  CUT_OUTCOMES = %w[now waits off].freeze
39
39
 
40
40
  USAGE = <<~TEXT
41
- Usage: chi send [-m TEXT] [--image PATH]... [--cut] (ID|PREFIX)...
41
+ Usage: chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...
42
42
  chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [-m TEXT] [--image PATH]...
43
43
  chi send --new --continues (ID|PREFIX|last:ID) [-m TEXT] [--image PATH]...
44
44
  chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
45
+ chi send --queue --wait [--timeout S] [--format json] [-m TEXT] ID
45
46
  chi send --wait [--timeout S] [--format json] ID
46
47
  Sends a message to each session, as if typed in it: a turn starts,
47
48
  or a running one takes it at its next step. A stopped session's
@@ -92,6 +93,12 @@ module Samagotchi
92
93
  next step and cuts nothing. To an idle session it
93
94
  sends normally. Refused with --wait into a running
94
95
  turn (the steer's turn is the running one)
96
+ --queue run the message as a turn of its own after the running
97
+ turn ends, instead of merging it at its next step. To
98
+ an idle session it runs at once. With --wait it
99
+ waits for that turn's own answer, so a running turn
100
+ is fine. Not with --cut. Refused for a worker older
101
+ than queueing (it would steer; restart it)
95
102
  --timeout S (--wait) give up after S seconds, exit 4 (the turn
96
103
  goes on); default no limit
97
104
  --format json
@@ -99,8 +106,9 @@ module Samagotchi
99
106
  status answered (text), question (question,
100
107
  answer_with), running, or failed, canceled,
101
108
  limit, no_answer, error, worker_gone, stopped,
102
- command (detail); with --cut also delivery (what
103
- the worker applied) and cut (now, waits, off)
109
+ command (detail); with --cut or --queue also
110
+ delivery (what the worker applied: next_step, cut,
111
+ queue), with --cut also cut (now, waits, off)
104
112
  Exit with --wait: 0 answered, 1 failed or gone, 2 usage, 3 a
105
113
  question waits, 4 still running (--timeout), 130 Ctrl-C.
106
114
  Only sessions on this machine. Answers show in the attached TUI
@@ -121,6 +129,7 @@ module Samagotchi
121
129
  f.value "--image", key: :images, repeat: true
122
130
  f.switch "--wait"
123
131
  f.switch "--cut"
132
+ f.switch "--queue"
124
133
  f.value "--timeout"
125
134
  f.value "--format"
126
135
  # Starting a turn in every live session at once is too easy to do
@@ -181,6 +190,7 @@ module Samagotchi
181
190
  # With --wait stdout is the answer alone.
182
191
  @info = options[:wait] ? @stderr : @stdout
183
192
  @cut = options[:cut] == true
193
+ @queue = options[:queue] == true
184
194
  prompt = compose(utf8(read_stdin), utf8(options[:message]))
185
195
  # Before the wait-only case, which would drop the images.
186
196
  return usage_error("--image needs a message: pass -m TEXT or pipe it in") if prompt.nil? && !options[:images].empty?
@@ -236,6 +246,8 @@ module Samagotchi
236
246
  return parsed if parsed.is_a?(Integer)
237
247
 
238
248
  options = parsed.options.merge(ids: parsed.args)
249
+ return usage_error("--cut and --queue: choose one") if options[:cut] && options[:queue]
250
+
239
251
  if options[:format]
240
252
  return usage_error("--format needs --wait") unless options[:wait]
241
253
  return usage_error("--format takes text or json") unless FORMATS.include?(options[:format])
@@ -385,12 +397,15 @@ module Samagotchi
385
397
  # are taken before the message goes in, so neither an older reply nor a
386
398
  # turn that ends before the first look is mistaken for the answer. A
387
399
  # running turn is refused: a message that misses it runs next, and the
388
- # running one's reply would come back as the answer.
400
+ # running one's reply would come back as the answer. Not with --queue:
401
+ # the message's own turn is waited for (ReplyWait's turn:, its
402
+ # enqueued_id), whatever runs before it.
389
403
  # @return [Integer] the exit status
390
404
  def run_wait(prompt, options)
391
405
  id = resolve(options[:ids].first) or return 1
392
406
  session = Session.load(id, state_dir: @state_dir)
393
- if session.status == Session::STATUS_RUNNING && (owner = SessionManager.session_owner(id, state_dir: @state_dir))
407
+ if session.status == Session::STATUS_RUNNING && (owner = SessionManager.session_owner(id, state_dir: @state_dir)) &&
408
+ (!@queue || session.waiting_question(live: owner.worker?))
394
409
  # The turn waits on a question: the message would only queue
395
410
  # behind it, and the answer goes in with chi answer.
396
411
  if (waiting = session.waiting_question(live: owner.worker?))
@@ -412,7 +427,11 @@ module Samagotchi
412
427
  return 1 unless deliver(id, prompt)
413
428
  return report_command(id) if @sent_command
414
429
 
415
- wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout])
430
+ # The ack's id names the queued message's turn, also when the turn
431
+ # running at the check ended before it went in (an idle worker's ack
432
+ # says next_step, and the file still runs as that turn).
433
+ turn = @queue ? @sent_enqueued_id : nil
434
+ wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], turn: turn, queued: !turn.nil?)
416
435
  end
417
436
 
418
437
  # --wait with no message: the session's next reply, sending nothing,
@@ -421,15 +440,18 @@ module Samagotchi
421
440
  # was already reported. No message count: a note landing between turns
422
441
  # grows the messages without a turn. An idle session with no worker
423
442
  # waits for whatever wakes one (the web, chi send) rather than calling
424
- # it gone.
443
+ # it gone. The turn running now, or the one whose continue offer
444
+ # stands, is the one waited for (ReplyWait.own_turn): its reply, not a
445
+ # turn's queued behind it; with none known, the next reply.
425
446
  # @return [Integer] the exit status
426
447
  def run_wait_only(options)
427
448
  id = resolve(options[:ids].first) or return 1
428
449
  session = Session.load(id, state_dir: @state_dir)
429
450
  live = session.status == Session::STATUS_RUNNING || SessionManager.session_owner(id, state_dir: @state_dir)
451
+ turn = ReplyWait.own_turn(session, session_dir: Session.session_dir(id, state_dir: @state_dir))
430
452
  wait_for_reply(id, cursor: ReplyWait.newest_reply(id, state_dir: @state_dir),
431
453
  baseline: baseline_of(session).merge(messages: nil),
432
- timeout: options[:timeout], owner_grace: live ? self.class::WORKER_GONE_AFTER : nil)
454
+ timeout: options[:timeout], owner_grace: live ? self.class::WORKER_GONE_AFTER : nil, turn: turn)
433
455
  end
434
456
 
435
457
  # The session as it was before the message went in (ReplyWait's
@@ -535,13 +557,21 @@ module Samagotchi
535
557
  owner = SessionManager.session_owner(id, state_dir: @state_dir)
536
558
  running = owner && session.status == Session::STATUS_RUNNING
537
559
  refs = copy_images(Session.session_dir(id, state_dir: @state_dir))
538
- # --cut asks the running turn for a cut; to an idle session it is an
539
- # ordinary message (there is nothing to cut).
540
- delivery = @cut && running ? Delivery::CUT : nil
560
+ # --cut asks the running turn for a cut (to an idle session an
561
+ # ordinary message: nothing to cut). --queue always goes as a queue,
562
+ # idle or not: the Bridge acks next_step to an idle worker and the
563
+ # file still carries it, so a turn another client starts first never
564
+ # merges it, and its turn and reply carry its enqueued_id (the
565
+ # --wait's turn:); a worker older than queueing is refused.
566
+ delivery = if @queue then Delivery::QUEUE
567
+ elsif @cut && running then Delivery::CUT
568
+ end
541
569
  result = SessionManager.deliver_turn(id, prompt: prompt, client_id: CLIENT_ID, images: refs, state_dir: @state_dir,
542
570
  delivery: delivery)
543
571
  unless result[:status] == :accepted
544
- @info.puts("#{short} failed: #{result.dig(:ack, "detail") || "could not queue it"}")
572
+ # A worker older than queueing would steer it: refused, not sent.
573
+ word = result.dig(:ack, "error") == Delivery::QUEUE_UNAVAILABLE["error"] ? "refused" : "failed"
574
+ @info.puts("#{short} #{word}: #{result.dig(:ack, "detail") || "could not queue it"}")
545
575
  return false
546
576
  end
547
577
 
@@ -556,6 +586,7 @@ module Samagotchi
556
586
  # (next_step for an idle session) and a cut's outcome.
557
587
  @sent_delivery = ack["delivery"] || delivery
558
588
  @sent_cut = ack["cut"]
589
+ @sent_enqueued_id = (ack["enqueued_id"] || ack[:enqueued_id])&.to_s
559
590
  @info.puts("#{short} sent#{with_images}#{delivery_note(ack, delivery: delivery, running: running, owner: owner)}")
560
591
  true
561
592
  rescue SessionManager::OwnedByTUI
@@ -568,12 +599,14 @@ module Samagotchi
568
599
 
569
600
  # What the line says became of the message: a plain one goes in at the
570
601
  # running turn's next step (nothing cuts by default); a --cut one names
571
- # the outcome from the ack (now, waits, off); images keep their own
572
- # wording; an idle session's turn simply runs.
602
+ # the outcome from the ack (now, waits, off); a --queue one runs after
603
+ # the turn (or now, when the turn ended before it went in); images keep
604
+ # their own wording; an idle session's turn simply runs.
573
605
  # @param ack [Hash] the Bridge's 202 body ({} for the input file)
574
606
  # @return [String] "" or " (...)"
575
607
  def delivery_note(ack, delivery:, running:, owner:)
576
608
  return " (started its worker)" if owner.nil?
609
+ return queue_note(ack, running: running) if Delivery.queue?(delivery)
577
610
  return "" unless running
578
611
 
579
612
  # A busy worker runs a message with images as its own next turn
@@ -589,12 +622,23 @@ module Samagotchi
589
622
  " #{TerminalUI::Formatting.steer_cut_note(outcome.to_sym, ack["cut_after"])}"
590
623
  end
591
624
 
592
- # More keys for --format json, with --cut only (the line is unchanged
593
- # otherwise): the delivery the worker applied (next_step to an idle
594
- # session) and a cut's outcome (now, waits, off). In P1 --cut --wait is
595
- # refused for a running turn, so the json line says next_step.
625
+ # A --queue, worded from the Bridge's ack: queue when a turn ran as it
626
+ # went in (one another client started too), next_step when none did
627
+ # (it runs now). The input file (no Bridge) carries the queue to
628
+ # whichever turn runs: worded from the session as chi read it.
629
+ def queue_note(ack, running:)
630
+ queued = ack.key?("delivery") ? ack["delivery"] == Delivery::QUEUE : running
631
+ return " (queued: runs after the current turn)" if queued
632
+
633
+ running ? " (the running turn had ended: it runs now)" : " (nothing running: it runs now)"
634
+ end
635
+
636
+ # More keys for --format json, with --cut or --queue only (the line is
637
+ # unchanged otherwise): the delivery the worker applied (next_step to
638
+ # an idle session) and a cut's outcome (now, waits, off). --cut --wait
639
+ # is refused for a running turn, so its json line says next_step.
596
640
  def report_extra
597
- return {} unless @cut
641
+ return {} unless @cut || @queue
598
642
 
599
643
  extra = { delivery: @sent_delivery || Delivery::NEXT_STEP }
600
644
  extra[:cut] = @sent_cut if @sent_cut
@@ -722,14 +722,15 @@ module Samagotchi
722
722
  symbolize_empty_answer(message)
723
723
  end
724
724
 
725
- # An empty-answer note's marker (TurnNote.empty) and its steps, as
726
- # they were saved.
725
+ # An empty-answer note's marker (TurnNote.empty), its steps and its
726
+ # refusal, as they were saved.
727
727
  def symbolize_empty_answer(message)
728
728
  marker = message[:empty_answer]
729
729
  return message unless marker.is_a?(Hash)
730
730
 
731
731
  marker = marker.transform_keys(&:to_sym)
732
732
  marker[:steps] = marker[:steps].map { |step| step.is_a?(Hash) ? step.transform_keys(&:to_sym) : step } if marker[:steps].is_a?(Array)
733
+ marker[:refusal] = marker[:refusal].transform_keys(&:to_sym) if marker[:refusal].is_a?(Hash)
733
734
  message.merge(empty_answer: marker)
734
735
  end
735
736
 
@@ -116,7 +116,7 @@ module Samagotchi
116
116
  registry.register("/detach", "leave and keep the worker running", local: true, uis: [:attached],
117
117
  match: ->(text) { text.casecmp?("/detach") })
118
118
  # /cut TEXT and /queue TEXT while a turn runs: a delivery the UIs parse
119
- # themselves (Formatting#steer_choice) before any command check; the
119
+ # themselves (ComposerRoute.steer_choice) before any command check; the
120
120
  # entries exist for Tab completion and /help. The web composer sends
121
121
  # /cut too. /queue is a real after-the-turn list only in the local
122
122
  # REPL (the attached TUI's worker doesn't skip queued files yet), so
@@ -158,11 +158,39 @@ module Samagotchi
158
158
  nil
159
159
  end
160
160
 
161
- def self.write_output(session_dir, response)
161
+ # A turn's reply: output/<timestamp>.txt, or
162
+ # output/<timestamp>-<enqueued_id>.txt for a turn that ran an input
163
+ # with an id, so a wait for that input's turn (ReplyWait's turn:) finds
164
+ # its own reply. The names still sort by time ("-" sorts before ".",
165
+ # so an id'd name and a bare one of the same timestamp keep order);
166
+ # every reader globs *.txt.
167
+ # @param enqueued_id [String, nil] the turn's input id (LastTurn#enqueued_id)
168
+ def self.write_output(session_dir, response, enqueued_id: nil)
162
169
  output_dir = File.join(session_dir, OUTPUT_DIR)
163
170
  FileUtils.mkdir_p(output_dir)
164
171
  timestamp = Time.now.strftime("%Y%m%d%H%M%S%9N")
165
- AtomicFile.write(File.join(output_dir, "#{timestamp}.txt"), response.to_s)
172
+ AtomicFile.write(File.join(output_dir, "#{output_name(timestamp, enqueued_id)}.txt"), response.to_s)
173
+ end
174
+
175
+ # An id that is safe in a file name (a Bridge's or deliver_turn's
176
+ # uuid); anything else leaves the name bare.
177
+ ENQUEUED_ID_NAME = /\A[A-Za-z0-9_-]{1,64}\z/
178
+
179
+ def self.output_name(timestamp, enqueued_id)
180
+ id = enqueued_id.to_s
181
+ ENQUEUED_ID_NAME.match?(id) ? "#{timestamp}-#{id}" : timestamp
182
+ end
183
+ private_class_method :output_name
184
+
185
+ # @return [String, nil] the reply filename of the turn that ran the
186
+ # input +enqueued_id+, or nil while it has none
187
+ def self.reply_for(session_dir, enqueued_id)
188
+ dir = File.join(session_dir, OUTPUT_DIR)
189
+ id = enqueued_id.to_s
190
+ return nil if id.empty? || !Dir.exist?(dir)
191
+
192
+ suffix = "-#{id}.txt"
193
+ Dir.children(dir).select { |f| f.end_with?(suffix) }.max
166
194
  end
167
195
 
168
196
  # The output files' text, oldest first; with +since_time+ only files
@@ -218,12 +246,40 @@ module Samagotchi
218
246
  end
219
247
 
220
248
  # Whether a mid-turn drain leaves this unclaimed input file for a turn
221
- # of its own instead of merging it at a step boundary. Today that is an
222
- # image message (steering merges text only); the drain's file filter
223
- # reads this one predicate, so what a boundary refuses changes in one
224
- # place.
249
+ # of its own instead of merging it at a step boundary: a queued message
250
+ # (`delivery: queue`) or an image message (steering merges text only).
251
+ # The drain skips such a file without stopping at it, so a later text
252
+ # line still merges; the drain's file filter reads this one predicate,
253
+ # so what a boundary leaves changes in one place.
225
254
  def self.waits_for_turn_end?(input_file)
226
- input_has_images?(input_file)
255
+ data = JSON.parse(File.read(input_file))
256
+ data.is_a?(Hash) && (Array(data["images"]).any? || Delivery.queue?(data["delivery"]))
257
+ rescue JSON::ParserError, SystemCallError
258
+ false
259
+ end
260
+
261
+ # Whether an input file carrying +enqueued_id+ still waits unclaimed in
262
+ # input/ (no worker has taken it for a turn or a step yet).
263
+ def self.input_waiting?(session_dir, enqueued_id) = !waiting_input(session_dir, enqueued_id).nil?
264
+
265
+ # How the unclaimed input file carrying +enqueued_id+ waits: :turn_end
266
+ # (a queue or an image message: it runs as a turn of its own, never
267
+ # merged, #waits_for_turn_end?), :merges (a running turn's drain may
268
+ # take it as a steer), or nil when none waits.
269
+ # @return [Symbol, nil]
270
+ def self.waiting_input(session_dir, enqueued_id)
271
+ id = enqueued_id.to_s
272
+ return nil if id.empty?
273
+
274
+ find_new_input_files(session_dir).each do |file|
275
+ data = JSON.parse(File.read(file))
276
+ next unless data.is_a?(Hash) && data["enqueued_id"] == id
277
+
278
+ return Array(data["images"]).any? || Delivery.queue?(data["delivery"]) ? :turn_end : :merges
279
+ rescue JSON::ParserError, SystemCallError
280
+ next
281
+ end
282
+ nil
227
283
  end
228
284
 
229
285
  def self.claim_input_file(input_file)
@@ -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), the hosts and the absolute log file. It unsets
209
- # SAMAGOTCHI_GUARDRAILS_* and the session markers (SESSION_MARKER_ENV).
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
- # Propagate hosts config for multi-host routing
227
- begin
228
- require_relative "config"
229
- hosts_json = ConfigFile.hosts_json_for_env
230
- child_env["SAMAGOTCHI_HOSTS_JSON"] = hosts_json if hosts_json && !hosts_json.strip.empty?
231
- rescue StandardError
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: "user").status == 202
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 images, {status: :timeout,
1171
- # ack:} when it took the request but never answered (no file then), or
1172
- # {status: :failed} when the input file couldn't be written
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" } }
@@ -883,7 +883,7 @@ module Samagotchi
883
883
  payload = event[:payload]
884
884
  payload = {} unless payload.is_a?(Hash)
885
885
 
886
- @mutex.synchronize { note_first_chunk(payload, event) }
886
+ @mutex.synchronize { note_first_chunk(event) }
887
887
  usage = TokenUsage.from_payload(payload)
888
888
  if usage
889
889
  @mutex.synchronize { note_server_usage(usage) if @turn }
@@ -915,21 +915,16 @@ module Samagotchi
915
915
  turn.gen_had_server = true
916
916
  end
917
917
 
918
- # The first chunk with content, reasoning or a tool call starts the
919
- # generation's decode clock (an estimated speed runs from it). Caller
920
- # holds the mutex.
921
- def note_first_chunk(payload, event)
918
+ # The first chunk with content, reasoning or a tool call (the loop marks
919
+ # its event tool_call: true, whatever the wire) starts the generation's
920
+ # decode clock (an estimated speed runs from it). Caller holds the mutex.
921
+ def note_first_chunk(event)
922
922
  return unless @turn && @turn.gen_open && @turn.gen_first_chunk_at.nil?
923
- return unless !event[:content].to_s.empty? || tool_call_delta?(payload)
923
+ return unless !event[:content].to_s.empty? || event[:tool_call]
924
924
 
925
925
  @turn.gen_first_chunk_at = monotonic_time
926
926
  end
927
927
 
928
- def tool_call_delta?(payload)
929
- choice = payload["choices"].is_a?(Array) ? payload["choices"].first : nil
930
- choice.is_a?(Hash) && choice["delta"].is_a?(Hash) && !Array(choice["delta"]["tool_calls"]).empty?
931
- end
932
-
933
928
  def event_content_chars(payload, event)
934
929
  len = payload_content(payload, event).length
935
930
  len.positive? ? len : nil
@@ -82,6 +82,10 @@ module Samagotchi
82
82
  @waiting.epoch
83
83
  end
84
84
 
85
+ # The running generation's phase (GenerationPhase::Snapshot), nil with
86
+ # none running.
87
+ def generation_snapshot = @phase.snapshot
88
+
85
89
  # The turn's stream: the generation's phase follows it, and a thinking
86
90
  # chunk rechecks a waiting message. +progress+: a chunk's lanes
87
91
  # (.lanes), read from it when not given.