samagotchi 0.45.0 → 0.46.1

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -1
  3. data/docs/architecture.md +2 -1
  4. data/docs/cli.md +22 -7
  5. data/docs/configuration.md +105 -6
  6. data/docs/desktop.md +2 -1
  7. data/docs/internals/background-tasks.md +1 -0
  8. data/docs/internals/engine.md +1 -1
  9. data/docs/internals/llm-context-live.md +111 -0
  10. data/docs/sessions.md +2 -2
  11. data/lib/samagotchi/bridge/turn_accumulator.rb +5 -0
  12. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  13. data/lib/samagotchi/bundles/github-pr/plugin.rb +26 -5
  14. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  15. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  16. data/lib/samagotchi/config.rb +61 -4
  17. data/lib/samagotchi/desktop/macos/Panel.swift +7 -3
  18. data/lib/samagotchi/engine.rb +41 -9
  19. data/lib/samagotchi/host_model.rb +123 -0
  20. data/lib/samagotchi/host_registry.rb +21 -10
  21. data/lib/samagotchi/kernel_loop.rb +2 -1
  22. data/lib/samagotchi/llm/chat_loop.rb +109 -7
  23. data/lib/samagotchi/llm/errors.rb +8 -2
  24. data/lib/samagotchi/llm/turn_settings.rb +5 -3
  25. data/lib/samagotchi/llm_context_strategy.rb +1 -0
  26. data/lib/samagotchi/model_catalog.rb +22 -16
  27. data/lib/samagotchi/model_price.rb +54 -0
  28. data/lib/samagotchi/model_profile.rb +7 -4
  29. data/lib/samagotchi/models_command.rb +12 -5
  30. data/lib/samagotchi/process_group.rb +56 -9
  31. data/lib/samagotchi/question_desk.rb +29 -4
  32. data/lib/samagotchi/recap_setup.rb +1 -1
  33. data/lib/samagotchi/self_report.rb +2 -1
  34. data/lib/samagotchi/served_model.rb +9 -0
  35. data/lib/samagotchi/session_commands.rb +27 -17
  36. data/lib/samagotchi/session_metrics.rb +40 -5
  37. data/lib/samagotchi/terminal_ui/attached_view.rb +2 -0
  38. data/lib/samagotchi/terminal_ui/formatting.rb +47 -15
  39. data/lib/samagotchi/terminal_ui/status_row.rb +12 -6
  40. data/lib/samagotchi/terminal_ui.rb +15 -0
  41. data/lib/samagotchi/tools/delegate_wait.rb +5 -4
  42. data/lib/samagotchi/tools/task_runtime.rb +11 -2
  43. data/lib/samagotchi/version.rb +1 -1
  44. data/lib/samagotchi/web/app.rb +43 -6
  45. data/lib/samagotchi/web/public/app.js +7 -3
  46. data/lib/samagotchi/web/public/ctx.js +20 -7
  47. data/lib/samagotchi/web/public/format.js +7 -2
  48. data/lib/samagotchi/web/public/index.html +3 -0
  49. data/lib/samagotchi/web/public/model_pick.js +36 -9
  50. data/lib/samagotchi/web/public/model_picker.js +11 -6
  51. data/lib/samagotchi/web/public/question_card.js +8 -0
  52. data/lib/samagotchi/web/public/stage_view.js +1 -0
  53. data/lib/samagotchi/web/public/turn_events.js +10 -5
  54. data/lib/samagotchi/web/public/turn_model.js +16 -0
  55. data/lib/samagotchi/web/public/turn_view.js +19 -0
  56. data/lib/samagotchi/worker.rb +20 -1
  57. metadata +4 -1
@@ -54,10 +54,49 @@ module Samagotchi
54
54
  # per turn for the effective model's host
55
55
  # @param stable_length [#call, nil] text → the length of the system
56
56
  # prompt's part every session shares (SystemPrompt#stable_length), or nil
57
- def initialize(kernel:, adapter: nil, stable_length: nil)
57
+ # @param sleeper [#call, nil] waits the given seconds before a dropped
58
+ # stream's step is asked again (specs pass one that doesn't sleep)
59
+ def initialize(kernel:, adapter: nil, stable_length: nil, sleeper: nil)
58
60
  @kernel = kernel
59
61
  @adapter = adapter
60
62
  @stable_length = stable_length
63
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
64
+ end
65
+
66
+ # How many times a dropped stream's step may be asked again: +cap+, or
67
+ # retry.max when that is smaller (retry.max: 0 turns it off, as it
68
+ # does the transport's retries).
69
+ def stream_drop_limit(cap)
70
+ [cap, HTTP::RetryPolicy.from_config.max].min
71
+ rescue StandardError
72
+ cap
73
+ end
74
+
75
+ # The seconds before the +attempt+-th ask-again of a dropped stream's
76
+ # step: the transport's own backoff (retry.base_delay doubling, capped
77
+ # at retry.max_delay; LLM::HTTP::RetryPolicy), 0 when retry.max is
78
+ # spent.
79
+ def generation_retry_delay(attempt)
80
+ HTTP::RetryPolicy.from_config.delay_for(attempt) || 0.0
81
+ rescue StandardError
82
+ 0.0
83
+ end
84
+
85
+ # Waits +seconds+ in short slices, raising RequestCancelled as soon as
86
+ # one of +controllers+ (the turn's, the generation's) is cancelled, as
87
+ # LLM::HTTP's own wait between retries does.
88
+ def wait_retry(seconds, *controllers)
89
+ remaining = seconds.to_f
90
+ while remaining.positive?
91
+ cancelled = controllers.compact.find(&:cancelled?)
92
+ raise RequestCancelled, cancelled.reason if cancelled
93
+
94
+ slice = [remaining, 0.05].min
95
+ @sleeper.call(slice)
96
+ remaining -= slice
97
+ end
98
+ cancelled = controllers.compact.find(&:cancelled?)
99
+ raise RequestCancelled, cancelled.reason if cancelled
61
100
  end
62
101
 
63
102
  def provider = :chat
@@ -180,6 +219,9 @@ module Samagotchi
180
219
  # turn_settings), or nil.
181
220
  def vision = @kernel.turn_settings.vision
182
221
 
222
+ # The turn's configured price (the kernel's turn_settings), or nil.
223
+ def price = (@kernel.turn_settings&.price if @kernel.respond_to?(:turn_settings))
224
+
183
225
  # The turn's request parameters (the kernel's turn_settings).
184
226
  def sampling = @kernel.turn_settings.sampling || {}
185
227
 
@@ -461,20 +503,25 @@ module Samagotchi
461
503
  observe_context(iteration, window)
462
504
  retry_generation = @empty_retry.take_sampling!
463
505
  emit(type: :generation_started, iteration: iteration, context_window_tokens: window&.tokens,
464
- context_window_source: window&.source)
506
+ context_window_source: window&.source, price: @loop.price&.to_h)
465
507
  @loop.fire_hook(:before_generation, { type: :before_generation, iteration: iteration })
466
508
  streamed = +""
467
509
  thought = +""
468
- response = with_generation do |generation_controller|
510
+ generation_controller = nil
511
+ response = with_generation do |controller|
512
+ generation_controller = controller
513
+ # One count for the step: the ask-again without thinking fields
514
+ # doesn't get drops of its own.
515
+ drops = DropCount.new(0, 0)
469
516
  begin
470
- request(iteration, retry_generation, streamed, thought, generation_controller)
517
+ request_with_drops(iteration, retry_generation, streamed, thought, drops, controller)
471
518
  rescue BadRequest => e
472
519
  raise unless thinking_refused?(e)
473
520
 
474
521
  # Once per model: asked again without the thinking fields.
475
522
  @loop.thinking_refused!(@model_name)
476
523
  emit(type: :thinking_refused, iteration: iteration, model: @model_name, level: @loop.thinking, detail: e.detail)
477
- request(iteration, retry_generation, streamed, thought, generation_controller)
524
+ request_with_drops(iteration, retry_generation, streamed, thought, drops, controller)
478
525
  end
479
526
  rescue RequestCancelled
480
527
  raise unless generation_controller&.cancelled? && !@cancel_controller.cancelled?
@@ -522,7 +569,7 @@ module Samagotchi
522
569
  [response, nil]
523
570
  end
524
571
 
525
- def request(iteration, retry_generation, streamed, thought, generation_controller)
572
+ def request(iteration, retry_generation, streamed, thought, generation_controller, streamed_any = nil)
526
573
  @loop.adapter.chat(
527
574
  messages: @loop.wire_messages(@conversation), tools: @loop.tool_definitions, model: @model_name,
528
575
  cancel_controller: generation_controller || @cancel_controller, session_id: @loop.session_id,
@@ -533,13 +580,68 @@ module Samagotchi
533
580
  event = { type: :generation_chunk, iteration: iteration, content: reasoning + content, text: content,
534
581
  thinking: reasoning, payload: payload }
535
582
  # A tool call streaming: a steer doesn't cut it (Engine#cut_for_steer).
536
- event[:tool_call] = true if OpenAIChat.tool_call_delta?(payload)
583
+ tool_call = OpenAIChat.tool_call_delta?(payload)
584
+ event[:tool_call] = true if tool_call
585
+ streamed_any&.call if !content.to_s.empty? || !reasoning.to_s.empty? || tool_call
537
586
  emit(event)
538
587
  },
539
588
  on_retry: ->(**retry_event) { emit({ type: :generation_retrying, iteration: iteration }.merge(retry_event)) }
540
589
  )
541
590
  end
542
591
 
592
+ # A dropped stream (LLM::HTTP raises RetryExhausted once a stream
593
+ # that already showed something fails, where a retry would repeat
594
+ # output) is asked again here, from the same conversation, up to
595
+ # MAX_STREAM_DROPS times per step (fewer under a smaller retry.max;
596
+ # none with 0), after the transport's backoff: the partial reply is
597
+ # dropped and the step restarts. A refusal or a first-token timeout
598
+ # fails as before, and a cancel (before or during the wait) never
599
+ # retries.
600
+ MAX_STREAM_DROPS = 2
601
+
602
+ # A step's drops asked again (+drops+) and the HTTP requests made so
603
+ # far (+requests+: each failed request's transport attempts), so the
604
+ # error that ends the step counts every request, not the last one's.
605
+ DropCount = Struct.new(:drops, :requests)
606
+
607
+ # Runs one request, asking again on a mid-stream drop; +count+ (a
608
+ # DropCount) carries on across calls for one step.
609
+ def request_with_drops(iteration, retry_generation, streamed, thought, count, generation_controller)
610
+ streamed_any = false
611
+ request(iteration, retry_generation, streamed, thought, generation_controller, -> { streamed_any = true })
612
+ rescue RetryExhausted => e
613
+ # A cancel that landed as the stream dropped ends the turn canceled,
614
+ # not failed, whatever the drop's count.
615
+ cancelled = [@cancel_controller, generation_controller].compact.find(&:cancelled?)
616
+ raise RequestCancelled, cancelled.reason if cancelled
617
+
618
+ count.requests += e.attempts.to_i
619
+ limit = @loop.stream_drop_limit(MAX_STREAM_DROPS)
620
+ if count.drops >= limit || !streamed_any
621
+ raise if count.requests == e.attempts.to_i
622
+
623
+ raise RetryExhausted.new(attempts: count.requests, last_error: e.last_error, label: e.host)
624
+ end
625
+
626
+ count.drops += 1
627
+ attempt = count.drops
628
+ streamed.clear
629
+ thought.clear
630
+ delay = @loop.generation_retry_delay(attempt)
631
+ # restarted: the step's streamed chunks so far are void (the UIs
632
+ # and the Bridge's TurnAccumulator drop them).
633
+ emit(type: :generation_retrying, iteration: iteration, attempt: attempt, max_retries: limit,
634
+ next_delay: delay, error_class: e.class.name, error_message: e.message, status: nil, restarted: true)
635
+ @loop.wait_retry(delay, @cancel_controller, generation_controller)
636
+ retry
637
+ rescue ProviderError => e
638
+ # Another failure (a 503, a refusal) after earlier requests this
639
+ # step: its count takes theirs in.
640
+ count.requests += e.attempts.to_i
641
+ e.attempts = count.requests
642
+ raise
643
+ end
644
+
543
645
  # A 400 about reasoning, for a request that carried thinking fields
544
646
  # (not a missing-tools, image or context error).
545
647
  def thinking_refused?(error)
@@ -223,11 +223,17 @@ module Samagotchi
223
223
 
224
224
  def initialize(attempts:, last_error:, label: "llama.cpp")
225
225
  @last_error = last_error
226
- super("#{label} request failed after #{attempts} attempts: #{last_error.class}: #{last_error.message}",
226
+ super("#{label} request failed after #{self.class.count(attempts)}: #{last_error.class}: #{last_error.message}",
227
227
  host: label, retryable: false, attempts: attempts)
228
228
  end
229
229
 
230
- def summary = "network error after #{attempts} attempts (host #{host}: #{last_error.class})"
230
+ def summary = "network error after #{self.class.count(attempts)} (host #{host}: #{last_error.class})"
231
+
232
+ # The message without its leading "host ": "request failed after …".
233
+ def detail = host ? message.delete_prefix("#{host} ") : message
234
+
235
+ # "1 attempt", "3 attempts".
236
+ def self.count(attempts) = "#{attempts} attempt#{"s" unless attempts == 1}"
231
237
  end
232
238
 
233
239
  # A refused connection: nothing listens at the host's address. Not
@@ -11,12 +11,14 @@ module Samagotchi
11
11
  # (Thinking.resolve), the bare model id the turn runs on (every debug
12
12
  # dump is tagged with it) and the model's or host's configured context
13
13
  # window (ContextWindow.setting, nil for none) and LLM context strategy
14
- # (LLMContextStrategy.resolve, nil for none). The Engine sets them once
14
+ # (LLMContextStrategy.resolve, nil for none), and its price on its host
15
+ # (hosts.<name>.models.<id>.price, a ModelPrice; nil for none), what
16
+ # SessionMetrics estimates an unreported cost from. The Engine sets them once
15
17
  # per turn, in Engine#generate, after the model's host is synced.
16
- TurnSettings = Data.define(:vision, :sampling, :thinking, :model_name, :window_setting, :llm_context) do
18
+ TurnSettings = Data.define(:vision, :sampling, :thinking, :model_name, :window_setting, :llm_context, :price) do
17
19
  # No vision, no sampling, the default thinking, no model name.
18
20
  def self.none = new(vision: nil, sampling: {}.freeze, thinking: Thinking::DEFAULT, model_name: nil,
19
- window_setting: nil, llm_context: nil)
21
+ window_setting: nil, llm_context: nil, price: nil)
20
22
  end
21
23
  end
22
24
  end
@@ -181,6 +181,7 @@ module Samagotchi
181
181
  return nil if session.nil?
182
182
 
183
183
  require_relative "config"
184
+ require_relative "model_profile"
184
185
  registry ||= begin
185
186
  require_relative "host_registry"
186
187
  HostRegistry.new
@@ -7,33 +7,36 @@ module Samagotchi
7
7
  # picks from: GET /api/models and `chi models`. Every name question goes
8
8
  # to ModelRef / HostRegistry#resolve; nothing here parses a ref.
9
9
  module ModelCatalog
10
- # host: the host that listed the id; ref: the name `--model` takes for
11
- # it (see .spell).
12
- Row = Data.define(:host, :id, :ref)
13
- # rows: the default host's first, then the others by name; warnings:
14
- # "host: error" per host that failed.
10
+ # host: the host that listed (or declares) the id; ref: the name
11
+ # `--model` takes for it (see .spell); configured: declared under
12
+ # hosts.<name>.models (HostModel.rows); unavailable: a declared id of a
13
+ # host that failed to list its models (still pickable).
14
+ Row = Data.define(:host, :id, :ref, :configured, :unavailable) do
15
+ def initialize(host:, id:, ref:, configured: false, unavailable: false) = super
16
+ end
17
+ # rows: the default host's first, then the others by name, each host's
18
+ # declared ids first (HostModel.rows; a host that failed keeps only
19
+ # those); warnings: "host: error" per host that failed.
15
20
  Listing = Data.define(:rows, :warnings)
16
21
 
17
22
  module_function
18
23
 
19
24
  # @param results [Hash] list_all_models' answer
20
- # @param registry [#default_entry]
25
+ # @param registry [#default_entry, #entries]
21
26
  # @return [Listing]
22
27
  def listing(results, registry:)
23
28
  default_host = registry.default_entry&.name
24
29
  rows = []
25
30
  warnings = []
31
+ host_rows = HostModel.rows(results, registry.entries)
26
32
  results.keys.sort_by { |name| [name == default_host ? 0 : 1, name] }.each do |host|
27
- data = results[host]
28
- if data[:error]
29
- warnings << "#{host}: #{data[:error]}"
30
- next
31
- end
32
- Array(data[:models]).each do |info|
33
- id = info.id.to_s
34
- next if id.strip.empty? || id.end_with?(":batch")
33
+ warnings << "#{host}: #{results[host][:error]}" if results[host][:error]
34
+ host_rows.fetch(host, []).each do |row|
35
+ id = row.id
36
+ next if id.strip.empty? || (!row.configured && id.end_with?(":batch"))
35
37
 
36
- rows << Row.new(host: host, id: id, ref: spell(host, id, default_host))
38
+ rows << Row.new(host: host, id: id, ref: spell(host, id, default_host), configured: row.configured,
39
+ unavailable: row.unavailable)
37
40
  end
38
41
  end
39
42
  Listing.new(rows: rows, warnings: warnings)
@@ -51,13 +54,16 @@ module Samagotchi
51
54
  # go first (its rows are the "default host" group). default: the default's listed
52
55
  # row name when it resolves to one, else its resolved ref; default_typed:
53
56
  # the alias it was typed as. A row whose name resolves elsewhere (an
54
- # alias named like the id shadows it) carries shadowed_by.
57
+ # alias named like the id shadows it) carries shadowed_by; a declared id
58
+ # of a host that failed to list carries unavailable.
55
59
  # @param default_name [String, nil] default.model as configured
56
60
  # @return [Hash]
57
61
  def payload(results, registry:, default_name:)
58
62
  listing = listing(results, registry: registry)
59
63
  models = listing.rows.map do |row|
60
64
  hash = { name: row.ref, host: row.host, id: row.id }
65
+ hash[:configured] = true if row.configured
66
+ hash[:unavailable] = true if row.unavailable
61
67
  shadow = shadowed_by(row, registry)
62
68
  shadow ? hash.merge(shadowed_by: shadow) : hash
63
69
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # A model's price on a host (hosts.<name>.models.<id>.price), in USD per
5
+ # 1M tokens: what a generation's cost is estimated from when the provider
6
+ # reports none (SessionMetrics, cost_estimate). An unset cache rate is the
7
+ # input rate: that overstates cached reads, and understates Anthropic
8
+ # cache writes (billed 1.25x input for the 5 min cache, 2x for 1 h).
9
+ ModelPrice = Data.define(:input, :cache_read, :cache_write, :output) do
10
+ # @param raw [Hash, nil] the price as written
11
+ # @param where [String] for the warning
12
+ # @return [ModelPrice, nil] nil when unset or invalid (warned once)
13
+ def self.parse(raw, where)
14
+ return nil if raw.nil?
15
+
16
+ values = raw.is_a?(Hash) ? raw.transform_keys(&:to_s).slice(*ModelPrice::KEYS) : nil
17
+ valid = values && values.key?("input") && values.key?("output") &&
18
+ # YAML's .inf and .nan are Floats too: JSON can't carry them
19
+ # (hosts_json_for_env would drop every host) and NaN poisons the sums.
20
+ values.values.all? { |v| v.is_a?(Numeric) && v.finite? && !v.negative? }
21
+ unless valid
22
+ warn_once "Warning: #{where}.price needs input and output, each a number >= 0 (USD per 1M tokens); ignored"
23
+ return nil
24
+ end
25
+ new(input: values["input"], cache_read: values.fetch("cache_read", values["input"]),
26
+ cache_write: values.fetch("cache_write", values["input"]), output: values["output"])
27
+ end
28
+
29
+ # config.rb requires model_price through host_model, so config is
30
+ # required here only when a warning needs it.
31
+ def self.warn_once(message)
32
+ require_relative "config"
33
+ ConfigFile.warn_once(message)
34
+ end
35
+ private_class_method :warn_once
36
+
37
+ # USD for one generation, OpenAI usage semantics: +prompt_tokens+ holds
38
+ # the cached reads and the cache writes (OpenRouter keeps that for
39
+ # Anthropic models), and reasoning is inside +completion_tokens+.
40
+ # llama.cpp's native timings.prompt_n leaves its cache_n out, so the
41
+ # uncached part is clamped at 0: an undercount, harmless because priced
42
+ # models are remote.
43
+ def cost(prompt_tokens:, cached_tokens:, cache_write_tokens:, completion_tokens:)
44
+ uncached = (prompt_tokens - cached_tokens - cache_write_tokens).clamp(0..)
45
+ ((uncached * input) + (cached_tokens * cache_read) + (cache_write_tokens * cache_write) +
46
+ (completion_tokens * output)) / 1e6
47
+ end
48
+
49
+ def to_config = to_h.transform_keys(&:to_s)
50
+ end
51
+
52
+ # The keys a price may hold (config validation checks them too).
53
+ ModelPrice::KEYS = %w[input cache_read cache_write output].freeze
54
+ end
@@ -190,7 +190,7 @@ module Samagotchi
190
190
  #
191
191
  # Only a ref that names a host is checked (its alias resolved first, as
192
192
  # ModelRef.parse does), and only against that host's own list: a bare id
193
- # goes to whichever host lists it (HostRegistry#host_for_model), so no
193
+ # goes to whichever host lists or declares it (HostRegistry#host_for_model), so no
194
194
  # single list can judge it. Nothing is checked either without a saved
195
195
  # list for the host or with one older than ModelListStore::TTL_SECONDS
196
196
  # (a week: the host may serve different models by now).
@@ -219,6 +219,8 @@ module Samagotchi
219
219
  parsed = Samagotchi::ConfigFile.model_ref(model_name, env: env, hosts: hosts)
220
220
  host = parsed.host_name
221
221
  return nil unless host
222
+ # Declared under hosts.<name>.models: served whatever the host lists.
223
+ return nil if hosts[host.to_s.strip.downcase]&.dig(:models)&.key?(parsed.id.to_s.strip.downcase)
222
224
 
223
225
  list = lists.read(env: env)[host.to_s.strip.downcase]
224
226
  return nil if list.nil? || list.stale? || list.known?(parsed.id)
@@ -230,10 +232,11 @@ module Samagotchi
230
232
  return unknown_model_message(parsed.id, host, list.ids) if list.age < RELIST_AFTER_SECONDS
231
233
 
232
234
  fresh_ids = relist_or_nil(relist || default_relist(hosts), host, env)
233
- # A re-list that answered: the saved list catches up (the default
234
- # re-list, HostRegistry#list_models, saved it already).
235
+ # A re-list that answered: the saved list catches up. The default
236
+ # re-list, HostRegistry#list_models, saved it already; a given one
237
+ # is saved here.
235
238
  if fresh_ids
236
- Samagotchi::ModelListStore.save(host, fresh_ids, env: env)
239
+ Samagotchi::ModelListStore.save(host, fresh_ids, env: env) if relist
237
240
  return nil if fresh_ids.any? { |id| id.to_s.casecmp?(parsed.id.to_s.strip) }
238
241
 
239
242
  return unknown_model_message(parsed.id, host, fresh_ids)
@@ -23,13 +23,14 @@ module Samagotchi
23
23
  Lists the models every configured host offers, as the names
24
24
  --model takes: the default first, a default-host id bare, another
25
25
  host's as host:id, then the aliases as "name -> ref". A host that
26
- fails or doesn't answer in time is noted on stderr.
26
+ fails or doesn't answer in time is noted on stderr; the ids
27
+ declared under its hosts.<name>.models are still listed.
27
28
  TEXT only the names containing it (any case)
28
29
  --format json the default, the models (host, id, name), the aliases
29
30
  and the warnings as one JSON object
30
31
  --timeout S wait at most S seconds for the hosts; default 4
31
32
  Exit 0 when any host listed its models, 1 when none did (the
32
- default is still printed), 2 on a usage error.
33
+ default and declared names still print), 2 on a usage error.
33
34
  TEXT
34
35
 
35
36
  FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS) do |f|
@@ -59,8 +60,10 @@ module Samagotchi
59
60
  return usage_error("--timeout takes a number of seconds above 0") unless timeout&.positive?
60
61
 
61
62
  payload, listed = catalog(timeout)
63
+ # Before the filter: a down host's declared ids are listed even when TEXT hides them.
64
+ down_hosts = payload[:models].select { |m| m[:unavailable] }.map { |m| m[:host] }.uniq
62
65
  payload = filtered(payload, parsed.args.join(" ").strip)
63
- options[:format] == "json" ? print_json(payload) : print_text(payload)
66
+ options[:format] == "json" ? print_json(payload) : print_text(payload, down_hosts)
64
67
  listed ? 0 : 1
65
68
  end
66
69
 
@@ -99,14 +102,18 @@ module Samagotchi
99
102
  @stdout.puts(JSON.generate(payload.except(:filter)))
100
103
  end
101
104
 
102
- def print_text(payload)
105
+ # @param down_hosts [Array<String>] hosts that failed but declare ids
106
+ def print_text(payload, down_hosts)
103
107
  default = payload[:default]
104
108
  names = payload[:models].reject { |m| m[:shadowed_by] }.map { |m| m[:name] }
105
109
  shown_default = default && (payload[:filter].nil? || default.downcase.include?(payload[:filter].downcase))
106
110
  @stdout.puts(default) if shown_default
107
111
  (names - [default]).each { |name| @stdout.puts(name) }
108
112
  payload[:aliases].each { |a| @stdout.puts("#{a[:name]} -> #{a[:ref]}") }
109
- payload[:warnings].each { |w| @stderr.puts("chi models: #{w}") }
113
+ payload[:warnings].each do |w|
114
+ kept = down_hosts.any? { |host| w.start_with?("#{host}: ") }
115
+ @stderr.puts("chi models: #{w}#{" (its declared ids are still listed)" if kept}")
116
+ end
110
117
  end
111
118
  end
112
119
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "open3"
4
+
3
5
  module Samagotchi
4
6
  # A command run in a process group of its own, so a stop reaches what it
5
7
  # started too: TERM the group, a grace, then KILL it. Execute, TaskRuntime
@@ -51,12 +53,44 @@ module Samagotchi
51
53
 
52
54
  # Whether +pid+ runs and leads its own group, as #spawn's process does
53
55
  # (not, then, a reused pid in some other group).
54
- def leader?(pid)
55
- signalable?(pid) && Process.getpgid(pid) == pid
56
+ # @param started [String, nil] the start time #start_time read when chi
57
+ # spawned it: a process that started at another time is one that
58
+ # reused the pid, even as a group leader. nil checks the group only (a
59
+ # record from an older chi), and so does a time that can't be read
60
+ # now (a ps that failed once must not disown a live task).
61
+ # @param start_time [#call] (pid) the reader, #start_time by default
62
+ def leader?(pid, started: nil, start_time: method(:start_time))
63
+ return false unless signalable?(pid) && Process.getpgid(pid) == pid
64
+ return true if started.nil?
65
+
66
+ now = start_time.call(pid)
67
+ now.nil? || now == started
56
68
  rescue Errno::ESRCH, Errno::EPERM
57
69
  false
58
70
  end
59
71
 
72
+ # When +pid+'s process started, as an opaque string to compare: the
73
+ # start in clock ticks from /proc/PID/stat on Linux, ps's lstart (to the
74
+ # second, in UTC) elsewhere. nil when it isn't running or can't be read.
75
+ # @return [String, nil]
76
+ def start_time(pid)
77
+ return nil unless signalable?(pid)
78
+
79
+ stat = "/proc/#{pid}/stat"
80
+ return proc_start_time(File.read(stat)) if File.exist?(stat)
81
+
82
+ out, status = Open3.capture2({ "LC_ALL" => "C", "TZ" => "UTC" }, "ps", "-o", "lstart=", "-p", pid.to_s,
83
+ err: File::NULL)
84
+ time = out.strip
85
+ status.success? && !time.empty? ? time : nil
86
+ rescue SystemCallError, IOError
87
+ nil
88
+ end
89
+
90
+ # Field 22 (starttime); the command name (field 2) may hold spaces and
91
+ # parentheses, so the fields are counted after its last ")".
92
+ def proc_start_time(stat) = stat[/\A.*\)\s(.*)\z/m, 1]&.split&.[](19)
93
+
60
94
  # Whether +pid+ is still running (or a zombie not reaped yet).
61
95
  # @raise [ArgumentError] unless #signalable?
62
96
  def alive?(pid)
@@ -69,26 +103,39 @@ module Samagotchi
69
103
  true
70
104
  end
71
105
 
72
- # TERMs the group, waits up to +grace+ for +stopped+, then KILLs it.
106
+ # How long #stop waits for +stopped+ after the KILL by default.
107
+ KILL_WAIT_SEC = 1.0
108
+
109
+ # TERMs the group, waits up to +grace+ for +stopped+, then KILLs it and
110
+ # waits up to +kill_wait+ for +stopped+ again (a KILL is delivered, not
111
+ # done, when kill returns).
73
112
  # @param stopped [#call] true once it has stopped (default: the leader
74
113
  # is gone)
75
114
  # @param wait [#call] (seconds) between two checks (default: a sleep)
76
115
  # @param signal_options [Hash] for #signal (leader:, quiet:)
77
116
  # @return [Boolean] true when it stopped within the grace (no KILL sent)
78
117
  # @raise [ArgumentError] unless #signalable?
79
- def stop(pgid, grace:, poll:, stopped: -> { !alive?(pgid) }, wait: ->(seconds) { sleep(seconds) }, **signal_options)
118
+ def stop(pgid, grace:, poll:, stopped: -> { !alive?(pgid) }, wait: ->(seconds) { sleep(seconds) },
119
+ kill_wait: KILL_WAIT_SEC, **signal_options)
80
120
  check!(pgid)
81
121
  signal(pgid, "TERM", **signal_options)
82
- deadline = monotonic + grace
83
- until (done = stopped.call) || monotonic >= deadline
84
- wait.call(poll)
85
- end
86
- return true if done
122
+ return true if wait_for(stopped, grace, poll, wait)
87
123
 
88
124
  signal(pgid, "KILL", **signal_options)
125
+ wait_for(stopped, kill_wait, poll, wait)
89
126
  false
90
127
  end
91
128
 
129
+ # Checks +stopped+ every +poll+ until it is true or +seconds+ are over.
130
+ # @return [Boolean] whether it stopped
131
+ def wait_for(stopped, seconds, poll, wait)
132
+ deadline = monotonic + seconds
133
+ until (done = stopped.call) || monotonic >= deadline
134
+ wait.call(poll)
135
+ end
136
+ done ? true : false
137
+ end
138
+
92
139
  def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
93
140
 
94
141
  # Reads a pipe in chunks on a thread of its own, so the output read so
@@ -64,6 +64,13 @@ module Samagotchi
64
64
  "or anything else that changes files or state. Finish your reply with what you found " \
65
65
  "and what you would do, and wait."
66
66
 
67
+ # The cancel reason of a question no one could answer: a
68
+ # --non-interactive run has no one to ask (TerminalUI).
69
+ UNANSWERABLE_REASON = "non_interactive"
70
+ UNANSWERABLE_NOTE = "No one could answer the question: this is a non-interactive run. Don't do what you " \
71
+ "asked about, or anything else that changes files or state. Finish your reply with " \
72
+ "what you found and what you would do."
73
+
67
74
  # @param session [#call] → Session, nil
68
75
  # @param state_dir [#call] → String, the session's state dir
69
76
  # @param emit [#call] (event) → emits an event to the turn sink + observers
@@ -82,6 +89,8 @@ module Samagotchi
82
89
  @cv = @lock.new_cond
83
90
  @pending = nil
84
91
  @answer = nil
92
+ # Why the open question was cancelled (#cancel), for the sync path.
93
+ @cancel_reason = nil
85
94
  @sync_handler = nil
86
95
  # The standing question while it is @pending: {id:, fields:,
87
96
  # on_answer:, on_superseded_close:}; @shelved, one superseded by (or
@@ -106,7 +115,10 @@ module Samagotchi
106
115
  .merge(multi_select: !!payload[:multi_select], allow_freeform: !!payload[:allow_freeform]))
107
116
  # Dismissed (the card's dismiss, Esc): an answer of its own, not a
108
117
  # tool failure the model learns to avoid the tool from.
109
- result = { dismissed: true, id: result[:id], note: DISMISSED_NOTE } if result.is_a?(Hash) && result[:error] == "no answer"
118
+ if result.is_a?(Hash) && result[:error] == "no answer"
119
+ note = result[:reason] == UNANSWERABLE_REASON ? UNANSWERABLE_NOTE : DISMISSED_NOTE
120
+ result = { dismissed: true, id: result[:id], note: note }
121
+ end
110
122
  # Who answered is for the relay, not the model's tool result.
111
123
  result = result.except(:by) if result.is_a?(Hash)
112
124
  result.is_a?(String) ? result : JSON.generate(result)
@@ -213,7 +225,15 @@ module Samagotchi
213
225
  emit({ type: :question_answered, id: id, answer: ans })
214
226
  return ans
215
227
  end
216
- return sync_res if sync_res.is_a?(String) && !sync_res.strip.empty?
228
+ if sync_res.is_a?(String) && !sync_res.strip.empty?
229
+ # The handler's text is the answer: nothing stays pending, and
230
+ # every UI hears it was answered (the text as its freeform).
231
+ @lock.synchronize { @pending = nil }
232
+ clear_saved_question
233
+ emit({ type: :question_answered, id: id,
234
+ answer: { id: id.to_s, selected: [], freeform: sync_res.strip, selected_indices: [] } })
235
+ return sync_res
236
+ end
217
237
  rescue StandardError => e
218
238
  Log.warn(:turn, "question_handler_failed", echo: "[ask_user_question] sync handler failed: #{e.message}", error: e.class.name)
219
239
  end
@@ -221,9 +241,12 @@ module Samagotchi
221
241
  # CV (no cross-thread answerer exists for synchronous UIs). Clear pending
222
242
  # and return an error so the model can fallback to plain text. Generic
223
243
  # observers will discard the stale question_requested via staleness check.
224
- @lock.synchronize { @pending = nil }
244
+ reason = @lock.synchronize do
245
+ @pending = nil
246
+ @cancel_reason
247
+ end
225
248
  clear_saved_question
226
- return { error: "no answer", detail: "handler failed to capture selection", id: id }
249
+ return { error: "no answer", detail: "handler failed to capture selection", id: id, reason: reason }.compact
227
250
  end
228
251
 
229
252
  # Block until answered/cancelled (cross-thread path: WEB/Bridge/background worker)
@@ -356,6 +379,7 @@ module Samagotchi
356
379
  next if @standing
357
380
 
358
381
  pending[:status] = "cancelled"
382
+ @cancel_reason = reason.to_s
359
383
  @cv.broadcast
360
384
  pending[:id]
361
385
  end
@@ -403,6 +427,7 @@ module Samagotchi
403
427
  @lock.synchronize do
404
428
  @pending = pending
405
429
  @answer = nil
430
+ @cancel_reason = nil
406
431
  @standing = standing&.merge(id: pending[:id])
407
432
  end
408
433
  if session
@@ -49,7 +49,7 @@ module Samagotchi
49
49
  elsif !IdleTarget.host_named?(**settings)
50
50
  # A model naming no host goes where a bare --model goes
51
51
  # (HostRegistry#host_for_model: the default host unless
52
- # another's list has it), resolved at each attempt.
52
+ # another lists or declares it), resolved at each attempt.
53
53
  -> { IdleTarget.resolve(**settings) }
54
54
  else
55
55
  fixed = fixed_target(settings)
@@ -294,7 +294,8 @@ module Samagotchi
294
294
  served = ServedModel.from_props(props)
295
295
  return "unknown (no answer from the server's /props)" unless served
296
296
 
297
- ServedModel.differs?(bare, served) ? "#{served} (not #{bare}: the server serves its own model)" : served
297
+ expected = entry.models&.dig(bare.to_s.downcase)&.serves?(served)
298
+ ServedModel.differs?(bare, served) && !expected ? "#{served} (not #{bare}: the server serves its own model)" : served
298
299
  rescue StandardError => e
299
300
  "unknown (#{e.class})"
300
301
  end
@@ -17,6 +17,15 @@ module Samagotchi
17
17
  name.is_a?(String) && !name.strip.empty? ? name : nil
18
18
  end
19
19
 
20
+ # The host whose hosts.<name>.models.<id>.served names +served+ for
21
+ # +target+'s model (a gateway's round-robin targets): not a mismatch to
22
+ # warn about. nil when none does.
23
+ # @param target [HostRegistry::ModelTarget]
24
+ # @return [String, nil]
25
+ def expected_by(target, served)
26
+ target.entry.name if target.entry.models&.dig(target.bare_model.to_s.strip.downcase)&.serves?(served)
27
+ end
28
+
20
29
  # The separators a provider uses when it decorates a model name: a tag
21
30
  # (`qwen3:latest`), a date or quant suffix (`gpt-4o-2024-08-06`), an
22
31
  # owner (`@org/name`) or a path (`meta-llama/Llama-3`). Anything else