samagotchi 0.67.0 → 0.68.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -1
  3. data/docs/architecture.md +6 -3
  4. data/docs/internals/engine.md +58 -9
  5. data/docs/sessions.md +1 -1
  6. data/docs/testing.md +1 -1
  7. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  8. data/lib/samagotchi/effective_model.rb +283 -0
  9. data/lib/samagotchi/engine.rb +211 -358
  10. data/lib/samagotchi/level_setting_command.rb +99 -0
  11. data/lib/samagotchi/llm_context_strategy.rb +5 -10
  12. data/lib/samagotchi/reasoning_history_command.rb +29 -90
  13. data/lib/samagotchi/session.rb +22 -8
  14. data/lib/samagotchi/session_setting.rb +86 -0
  15. data/lib/samagotchi/thinking_command.rb +24 -76
  16. data/lib/samagotchi/thinking_notices.rb +169 -0
  17. data/lib/samagotchi/version.rb +1 -1
  18. data/lib/samagotchi/web/app.rb +55 -84
  19. data/lib/samagotchi/web/public/app.js +74 -505
  20. data/lib/samagotchi/web/public/bubbles.js +291 -0
  21. data/lib/samagotchi/web/public/cards_place.js +184 -0
  22. data/lib/samagotchi/web/public/chip_popover.js +111 -0
  23. data/lib/samagotchi/web/public/command_list.js +12 -10
  24. data/lib/samagotchi/web/public/index.html +15 -7
  25. data/lib/samagotchi/web/public/info_bar.js +3 -3
  26. data/lib/samagotchi/web/public/info_bar_model.js +110 -0
  27. data/lib/samagotchi/web/public/init_row.js +21 -10
  28. data/lib/samagotchi/web/public/level_chip.js +29 -80
  29. data/lib/samagotchi/web/public/lists_view.js +64 -9
  30. data/lib/samagotchi/web/public/llm_context_chip.js +36 -105
  31. data/lib/samagotchi/web/public/model_picker.js +26 -22
  32. data/lib/samagotchi/web/public/ref_scan.js +1 -7
  33. data/lib/samagotchi/web/public/refs.js +13 -0
  34. data/lib/samagotchi/web/public/stage_frame.js +179 -0
  35. data/lib/samagotchi/web/public/stage_model.js +33 -0
  36. data/lib/samagotchi/web/public/stage_slots.js +130 -0
  37. data/lib/samagotchi/web/public/stage_view.js +56 -335
  38. data/lib/samagotchi/web/public/stage_watch.js +65 -0
  39. data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
  40. data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
  41. data/lib/samagotchi/worker.rb +11 -16
  42. metadata +14 -1
@@ -14,6 +14,9 @@ require_relative "context_status"
14
14
  require_relative "steer"
15
15
  require_relative "turn_note"
16
16
  require_relative "model_profile"
17
+ require_relative "effective_model"
18
+ require_relative "session_setting"
19
+ require_relative "thinking_notices"
17
20
  require_relative "cancellation_controller"
18
21
  require_relative "context_window"
19
22
  require_relative "kernel_loop"
@@ -134,15 +137,17 @@ module Samagotchi
134
137
  # is used (the REPL checks the model it starts on, #switch_model!).
135
138
  @default_model_name = ModelProfile.required_model_name(model_name)
136
139
  ModelProfile.check_host!(model_name, hosts: @host_registry.entries) unless model_name.to_s.strip.empty?
137
- @effective_model_name = @default_model_name
140
+ # Guards the model choice and the profile cached for it (#store_profile).
141
+ @profile_mutex = Mutex.new
142
+ typed = model_typed.to_s.strip
143
+ @model_choice = ModelChoice.new(name: @default_model_name, typed: typed.empty? ? @default_model_name : typed)
138
144
  # An injected client (specs) stands in for every host's client.
139
145
  @host_registry.client_override = client if client
140
- @client = @host_registry.resolve(@effective_model_name).client
146
+ @client = effective_model.client
141
147
  # A caller's profile pins it (until a model switch); otherwise
142
148
  # #profile_resolution decides on first need (see there), so building an
143
149
  # Engine makes no network call.
144
150
  @given_profile = profile ? ModelProfile.normalize(profile) : nil
145
- @typed_model_name = model_typed.to_s.strip.empty? ? @default_model_name : model_typed.to_s.strip
146
151
  @profile_resolution = nil
147
152
  # Ensure the built-in system bundle is installed (lazy, warn-only).
148
153
  # This is the single seam for both TUI and non-TUI (web/worker) paths.
@@ -195,6 +200,9 @@ module Samagotchi
195
200
  # The session's toolkit: the kits (the plugins' too, chi.toolkit),
196
201
  # what its next turn declares, the turn-start apply.
197
202
  @toolkit_binding = build_toolkit_binding
203
+ # The notices a turn's thinking level leads to (once per session and host).
204
+ @thinking_notices = ThinkingNotices.new(session: -> { @session }, profile: -> { self.profile },
205
+ notify: ->(text, level, source) { hook_notify(text, level, source) })
198
206
  # The tools plugins publish without declaring (chi.tool_source), and
199
207
  # the search and call tools that reach them (Tools::ToolIndex).
200
208
  @tool_index = Tools::ToolIndex.new
@@ -288,7 +296,7 @@ module Samagotchi
288
296
  def wire_kernel(kernel, muted_memories)
289
297
  # A given kernel (specs) gets the Engine's store, hooks and tools, as
290
298
  # the one built here does.
291
- @kernel = kernel || KernelLoop.new(client: @client, profile: @given_profile, model_name: @effective_model_name,
299
+ @kernel = kernel || KernelLoop.new(client: @client, profile: @given_profile, model_name: effective_model_name,
292
300
  hooks: @hooks, reminder_store: @reminder_store, tools: @tools)
293
301
  @kernel.reminder_store = @reminder_store
294
302
  @kernel.hooks = @hooks
@@ -373,8 +381,8 @@ module Samagotchi
373
381
  end
374
382
 
375
383
  # The backend for the next turn: the loop the effective model's host speaks.
376
- def backend
377
- backend_for(@host_registry.resolve(@effective_model_name))
384
+ def backend(model = effective_model)
385
+ backend_for(model)
378
386
  end
379
387
 
380
388
  # Record that activity happened (user input or a completed turn): the
@@ -522,14 +530,17 @@ module Samagotchi
522
530
 
523
531
  # @return [SessionMetrics] the per-session analytics collector
524
532
  attr_reader :metrics
525
- # effective_model_name: the model as given (maybe an alias; HostRegistry#resolve
526
- # applies it); typed_model_name: as typed at start or at the last /model.
527
- attr_reader :default_model_name, :effective_model_name, :typed_model_name
533
+
534
+ # The model as given (maybe an alias; HostRegistry#resolve applies it).
535
+ def effective_model_name = @model_choice.name
536
+
537
+ # The model as typed at start or at the last /model.
538
+ def typed_model_name = @model_choice.typed
528
539
 
529
540
  # The resolved ref a session stores for the effective model (ModelRef#ref:
530
541
  # the alias applied, "host:id" when it names a host).
531
542
  def effective_model_ref
532
- model_ref_for(@effective_model_name)
543
+ model_ref_for(effective_model_name)
533
544
  end
534
545
 
535
546
  # The effective model as the system prompt names it (SystemPrompt::ModelIdentity):
@@ -537,11 +548,11 @@ module Samagotchi
537
548
  # only, the model its /props names when that differs. Never a new
538
549
  # request: only the /props answer profile resolution already cached.
539
550
  def model_identity
540
- target = @host_registry.resolve(@effective_model_name)
541
- entry = target.entry
551
+ model = effective_model
552
+ entry = model.entry
542
553
  host = entry && "host #{entry.name}, #{entry.url || "#{entry.host}:#{entry.port}"}"
543
- SystemPrompt::ModelIdentity.new(ref: effective_model_ref, host: host, key: @model_key,
544
- served: prompt_served_model(target), fallback_key: @fallback_model_key)
554
+ SystemPrompt::ModelIdentity.new(ref: model.ref, host: host, key: @model_key,
555
+ served: model.prompt_served, fallback_key: @fallback_model_key)
545
556
  end
546
557
 
547
558
  # The model notes for the effective model (ModelNotes): matched on its
@@ -586,10 +597,10 @@ module Samagotchi
586
597
  summary = error.respond_to?(:summary) ? error.summary : error.message
587
598
  return summary unless error.is_a?(LLM::ProviderError) && error.host
588
599
 
589
- note = @host_registry.alias_note(@effective_model_name)
600
+ note = @host_registry.alias_note(effective_model_name)
590
601
  note && note.end_with?("on host #{error.host}") ? "#{summary} (#{note})" : summary
591
- rescue StandardError
592
- summary
602
+ rescue StandardError => e
603
+ EffectiveModel::Unresolved.fallback(:alias_note_failed, effective_model_name, e, summary)
593
604
  end
594
605
 
595
606
  # +name+'s resolved ref (ModelRef#ref).
@@ -603,7 +614,8 @@ module Samagotchi
603
614
  def store_model!(session)
604
615
  ref = effective_model_ref
605
616
  session.model_name = ref
606
- session.model_typed = @typed_model_name.to_s == ref ? nil : @typed_model_name
617
+ typed = typed_model_name
618
+ session.model_typed = typed.to_s == ref ? nil : typed
607
619
  session
608
620
  end
609
621
 
@@ -622,11 +634,17 @@ module Samagotchi
622
634
  # and kept, so the system prompt and the server's KV prefix stay stable;
623
635
  # #switch_model! starts over, and a failed server probe is retried before
624
636
  # the next turn (#refresh_profile!). The kernel follows each resolution.
637
+ # Kept with the model choice it was resolved for: one that finishes
638
+ # after a switch is neither kept nor applied (#store_profile).
625
639
  # @return [ModelProfile::Resolution]
626
640
  def profile_resolution
627
- @profile_resolution ||= apply_profile(resolve_profile)
641
+ choice = @model_choice
642
+ cached = @profile_resolution
643
+ return cached.last if cached&.first.equal?(choice)
644
+
645
+ store_profile(choice, resolve_profile(choice))
628
646
  end
629
- attr_reader :host_registry, :client
647
+ attr_reader :default_model_name, :host_registry, :client
630
648
 
631
649
  # @return [Commands::Registry] the commands this session runs: the
632
650
  # built-ins, and the ones bundle plugins add
@@ -645,8 +663,9 @@ module Samagotchi
645
663
  # (before aliases were applied there): that key is read when the
646
664
  # target's overlay is missing (KernelLoop#sync_model_key!).
647
665
  def sync_model_key!
648
- @model_key = ModelOverlay.key_for(bare_model_name(@effective_model_name))
649
- typed_key = ModelOverlay.key_for(@host_registry.parse_qualified_model(@typed_model_name).last)
666
+ choice = @model_choice
667
+ @model_key = ModelOverlay.key_for(bare_model_name(choice.name))
668
+ typed_key = ModelOverlay.key_for(@host_registry.parse_qualified_model(choice.typed).last)
650
669
  @fallback_model_key = typed_key == @model_key ? nil : typed_key
651
670
  @kernel.sync_model_key!(@model_key, fallback: @fallback_model_key)
652
671
  end
@@ -655,17 +674,17 @@ module Samagotchi
655
674
  # The effective model's bare name for the guardrails' models: rules;
656
675
  # nil without a model name.
657
676
  def guardrail_model_name
658
- name = @effective_model_name && bare_model_name(@effective_model_name)
677
+ model = effective_model_name
678
+ name = model && bare_model_name(model)
659
679
  name.to_s.strip.empty? ? nil : name
660
680
  end
661
681
 
662
682
  # Point the kernel (and a chat backend) at the effective model's host
663
683
  # (after /model, --model, resume).
664
- def sync_kernel_client!
665
- target = @host_registry.resolve(@effective_model_name)
666
- @client = target.client
667
- @kernel.client = target.client if @kernel.client != target.client
668
- backend_for(target)
684
+ def sync_kernel_client!(model = effective_model)
685
+ @client = model.client
686
+ @kernel.client = model.client if @kernel.client != model.client
687
+ backend_for(model)
669
688
  end
670
689
 
671
690
  # Subscribe a persistent observer to engine events.
@@ -966,11 +985,14 @@ module Samagotchi
966
985
  # The alias is applied where the model is resolved (HostRegistry#resolve),
967
986
  # once; checked here: an unknown host, an alias naming another host.
968
987
  resolved = ModelProfile.check_host!(ModelProfile.required_model_name(model_name), hosts: @host_registry.entries)
969
- @effective_model_name = resolved
970
- @typed_model_name = typed.to_s.strip.empty? ? model_name : typed.to_s.strip
971
- # A profile given to .new was for the starting model.
972
- @given_profile = nil
973
- @profile_resolution = nil
988
+ choice = ModelChoice.new(name: resolved, typed: typed.to_s.strip.empty? ? model_name : typed.to_s.strip)
989
+ @profile_mutex.synchronize do
990
+ # One assignment: a reader on another thread sees the old pair or the new one.
991
+ @model_choice = choice
992
+ # A profile given to .new was for the starting model.
993
+ @given_profile = nil
994
+ @profile_resolution = nil
995
+ end
974
996
  sync_model_key!
975
997
  @prompt_builder.reset!
976
998
  # The notes the new model's prompt loads at its next build, so /model,
@@ -1041,22 +1063,24 @@ module Samagotchi
1041
1063
  # @return [Hash] @metrics.snapshot, filled in
1042
1064
  def stats_snapshot
1043
1065
  snapshot = @metrics.snapshot
1044
- target = @host_registry.resolve(@effective_model_name)
1045
- served, served_for = served_model_for(snapshot, target: target)
1066
+ # One resolve for the whole snapshot (its readers' rescues stay theirs);
1067
+ # a model that doesn't resolve raises here, as it always has.
1068
+ model = effective_model
1069
+ served, served_for = served_model_for(snapshot, model: model)
1046
1070
  snapshot = snapshot.merge(served_model: served, served_model_for: served_for,
1047
- served_expected_by: served_expected_by(served, target: target), llm_context: llm_context_summary,
1048
- thinking: thinking_explained&.summary,
1049
- reasoning_history: reasoning_history_explained&.summary, prompt_notes: prompt_notes.map(&:to_h),
1050
- prompt_files: @prompt_builder.prompt_files, price_host: target.entry.name)
1071
+ served_expected_by: model.served_expected_by(served),
1072
+ **setting_chips(SessionSetting::STATS, model),
1073
+ prompt_notes: prompt_notes.map(&:to_h), prompt_files: @prompt_builder.prompt_files,
1074
+ price_host: model.host_name)
1051
1075
  unless snapshot.dig(:context, :window_tokens)
1052
- window = current_context_window(target)
1076
+ window = current_context_window(model)
1053
1077
  if window
1054
1078
  snapshot = snapshot.merge(context: snapshot[:context].merge(window_tokens: window.tokens,
1055
1079
  window_source: window.source.to_s))
1056
1080
  end
1057
1081
  end
1058
1082
  # The chat loop uses no prompt profile: drop one a native turn reported.
1059
- return snapshot.except(:profile, :profile_source) if target.entry.chat?
1083
+ return snapshot.except(:profile, :profile_source) if model.chat?
1060
1084
 
1061
1085
  unless snapshot[:profile]
1062
1086
  resolution = profile_resolution
@@ -1069,10 +1093,11 @@ module Samagotchi
1069
1093
  # no turn yet (it woke after an idle exit): nil when either count is
1070
1094
  # unknown or context.status is off. Counted as the turns count it:
1071
1095
  # against the effective model's llm_context budget when it has one.
1072
- def saved_context_status(context)
1096
+ def saved_context_status(context, model: :current)
1073
1097
  return nil unless context
1074
1098
 
1075
- ContextStatus.new(llm_context: turn_llm_context)
1099
+ llm_context = model == :current ? turn_llm_context : model&.llm_context_explained(llm_context_session_args)&.resolved
1100
+ ContextStatus.new(llm_context: llm_context)
1076
1101
  .display_for(used_tokens: context[:used_tokens], window_tokens: context[:window_tokens])
1077
1102
  end
1078
1103
 
@@ -1080,10 +1105,7 @@ module Samagotchi
1080
1105
  # (LLMContextStrategy.explain), nil when it can't be resolved.
1081
1106
  # @return [LLMContextStrategy::Explained, nil]
1082
1107
  def llm_context_explained
1083
- target = @host_registry.resolve(@effective_model_name)
1084
- LLMContextStrategy.explain(target, names: model_lookup_names(target), **llm_context_session_args)
1085
- rescue StandardError
1086
- nil
1108
+ model_setting(nil) { |model| model.llm_context_explained(llm_context_session_args) }
1087
1109
  end
1088
1110
 
1089
1111
  # #llm_context_explained's summary (Explained#summary), nil when it
@@ -1095,7 +1117,7 @@ module Samagotchi
1095
1117
 
1096
1118
  # The session's own llm_context values, nil when it has none.
1097
1119
  # @return [LLMContextOverride, nil]
1098
- def llm_context_override = @session&.llm_context
1120
+ def llm_context_override = SessionSetting::LLM_CONTEXT.own(@session)
1099
1121
 
1100
1122
  # Set the session's own llm_context values (an empty override unsets
1101
1123
  # them); the next turn's start resolves them, never a running one (its
@@ -1104,23 +1126,18 @@ module Samagotchi
1104
1126
  def llm_context_override=(override)
1105
1127
  raise ArgumentError, "no session yet" unless @session
1106
1128
 
1107
- @session.llm_context = override
1129
+ SessionSetting::LLM_CONTEXT.set(@session, override)
1108
1130
  # The status a later client reads counts against the new budget.
1109
1131
  @last_context_status = saved_context_status(@metrics.snapshot[:context]) || @last_context_status if @last_context_status
1110
1132
  end
1111
1133
 
1112
1134
  # The effective model is on a chat host (api: openai or anthropic), whose loop uses
1113
1135
  # no prompt profile.
1114
- def chat_model? = @host_registry.resolve(@effective_model_name).entry.chat?
1136
+ def chat_model? = effective_model.chat?
1115
1137
 
1116
1138
  # "temperature=0.6 (hosts.work)" for /model: the effective model's
1117
1139
  # configured sampling, nil when none.
1118
- def sampling_summary
1119
- target = @host_registry.resolve(@effective_model_name)
1120
- SamplingSettings.summary(target, names: model_lookup_names(target))
1121
- rescue StandardError
1122
- nil
1123
- end
1140
+ def sampling_summary = model_setting(nil, &:sampling_summary)
1124
1141
 
1125
1142
  # "off (models: qwen)", "low (session)" for /model: the effective
1126
1143
  # model's thinking level and where it came from, nil when none is set.
@@ -1134,10 +1151,7 @@ module Samagotchi
1134
1151
  # turn_sampling; on any error, the global and its name.
1135
1152
  # @return [Array(Integer, String|nil)]
1136
1153
  def steer_cut_after_explained
1137
- target = @host_registry.resolve(@effective_model_name)
1138
- SteerCutAfter.resolve(target, names: model_lookup_names(target))
1139
- rescue StandardError
1140
- [SteerCut.cut_after, "steer.cut_after"]
1154
+ model_setting([SteerCut.cut_after, "steer.cut_after"], &:steer_cut_after_explained)
1141
1155
  end
1142
1156
 
1143
1157
  # #steer_cut_after_explained's seconds: what SteerCut#cut_for_steer
@@ -1159,10 +1173,7 @@ module Samagotchi
1159
1173
  # +own+: the session's own level to resolve with (the current one by
1160
1174
  # default; /thinking asks what a change would resolve to).
1161
1175
  def thinking_explained(own: thinking_override)
1162
- target = @host_registry.resolve(@effective_model_name)
1163
- Thinking.explain(target, names: model_lookup_names(target), session: own)
1164
- rescue StandardError
1165
- nil
1176
+ model_setting(nil) { |model| model.thinking_explained(own: own) }
1166
1177
  end
1167
1178
 
1168
1179
  # "drop (models: qwen)" for /model: the effective chat model's
@@ -1170,14 +1181,7 @@ module Samagotchi
1170
1181
  # default, the model runs on the native loop (which ignores it) or it
1171
1182
  # can't be resolved.
1172
1183
  def reasoning_history_summary
1173
- target = @host_registry.resolve(@effective_model_name)
1174
- return nil unless target.entry&.chat?
1175
-
1176
- level, source = ReasoningHistory.resolve(target, names: model_lookup_names(target),
1177
- session: reasoning_history_override)
1178
- source ? ReasoningHistory.label(level, source) : nil
1179
- rescue StandardError
1180
- nil
1184
+ model_setting(nil) { |model| model.reasoning_history_summary(own: reasoning_history_override) }
1181
1185
  end
1182
1186
 
1183
1187
  # The effective chat model's reasoning_history level with where it came
@@ -1189,12 +1193,7 @@ module Samagotchi
1189
1193
  # +own+: the session's own level to resolve with (the current one by
1190
1194
  # default; a command asks what a change would resolve to).
1191
1195
  def reasoning_history_explained(native: false, own: reasoning_history_override)
1192
- target = @host_registry.resolve(@effective_model_name)
1193
- return nil unless native || target.entry&.chat?
1194
-
1195
- ReasoningHistory.explain(target, names: model_lookup_names(target), session: own)
1196
- rescue StandardError
1197
- nil
1196
+ model_setting(nil) { |model| model.reasoning_history_explained(own: own, native: native) }
1198
1197
  end
1199
1198
 
1200
1199
  # What a reasoning_history change from level +from+ to level +to+ costs
@@ -1203,10 +1202,7 @@ module Samagotchi
1203
1202
  # marked as refusing it may still get :history (one needless ask).
1204
1203
  # @return [Symbol, nil]
1205
1204
  def reasoning_history_cache_cost(from, to)
1206
- target = @host_registry.resolve(@effective_model_name)
1207
- ReasoningHistory.cache_cost(from, to, target: target, conversation: @session&.messages)
1208
- rescue StandardError
1209
- nil
1205
+ model_setting(nil) { |model| model.reasoning_history_cache_cost(from, to, conversation: @session&.messages) }
1210
1206
  end
1211
1207
 
1212
1208
  # Whether the session has a model reply (a cache to restart at all).
@@ -1216,7 +1212,7 @@ module Samagotchi
1216
1212
 
1217
1213
  # The session's own reasoning_history level, nil when it has none.
1218
1214
  # @return [Symbol, nil]
1219
- def reasoning_history_override = @session&.reasoning_history
1215
+ def reasoning_history_override = SessionSetting::REASONING_HISTORY.own(@session)
1220
1216
 
1221
1217
  # Set the session's own reasoning_history level (nil unsets it); the
1222
1218
  # next turn's start resolves it. The caller saves the session.
@@ -1224,19 +1220,18 @@ module Samagotchi
1224
1220
  def reasoning_history_override=(level)
1225
1221
  raise ArgumentError, "no session yet" unless @session
1226
1222
 
1227
- @session.reasoning_history = level
1223
+ SessionSetting::REASONING_HISTORY.set(@session, level)
1228
1224
  end
1229
1225
 
1230
1226
  # The session's own thinking level, nil when it has none.
1231
1227
  # @return [Symbol, nil]
1232
- def thinking_override = @session&.thinking
1228
+ def thinking_override = SessionSetting::THINKING.own(@session)
1233
1229
 
1234
1230
  # What a thinking level change costs the effective model's prompt
1235
1231
  # cache (Thinking.cache_cost): :full, :provider or :tail.
1236
1232
  # @return [Symbol]
1237
1233
  def thinking_cache_cost
1238
- target = @host_registry.resolve(@effective_model_name)
1239
- Thinking.cache_cost(target, profile: target.entry&.chat? ? nil : profile&.name)
1234
+ effective_model.thinking_cache_cost { profile }
1240
1235
  end
1241
1236
 
1242
1237
  # Set the session's own thinking level (nil unsets it); the next turn's
@@ -1246,7 +1241,7 @@ module Samagotchi
1246
1241
  def thinking_override=(level)
1247
1242
  raise ArgumentError, "no session yet" unless @session
1248
1243
 
1249
- @session.thinking = level
1244
+ SessionSetting::THINKING.set(@session, level)
1250
1245
  end
1251
1246
 
1252
1247
  # The model the server serves for the current model, and the name asked
@@ -1255,17 +1250,15 @@ module Samagotchi
1255
1250
  # else [nil, nil].
1256
1251
  # @return [Array(String, String), Array(nil, nil)]
1257
1252
  def served_model(probe: true)
1258
- served_model_for(@metrics.served_report, target: probe ? @host_registry.resolve(@effective_model_name) : nil)
1253
+ served_model_for(@metrics.served_report, model: probe ? effective_model : nil)
1259
1254
  end
1260
1255
 
1261
1256
  # The host whose hosts.<name>.models.<id>.served names +served+ for the
1262
1257
  # current model (a gateway's round-robin targets): not a mismatch to
1263
1258
  # warn about. nil when none does.
1264
1259
  # @return [String, nil]
1265
- def served_expected_by(served, target: nil)
1266
- ServedModel.expected_by(target || @host_registry.resolve(@effective_model_name), served)
1267
- rescue StandardError
1268
- nil
1260
+ def served_expected_by(served)
1261
+ model_setting(nil) { |model| model.served_expected_by(served) }
1269
1262
  end
1270
1263
 
1271
1264
  def served_expected?(served) = !served_expected_by(served).nil?
@@ -1297,6 +1290,10 @@ module Samagotchi
1297
1290
  def session_state_snapshot
1298
1291
  served_pair = served_model(probe: false)
1299
1292
  metrics = @metrics.snapshot
1293
+ # One resolve for the model's fields; nil when it doesn't resolve (the
1294
+ # Bridge's /state and reset frames rely on this never raising): those
1295
+ # fields are nil then, as their own rescues always gave.
1296
+ model = model_setting(nil) { |resolved| resolved }
1300
1297
  {
1301
1298
  status: @session&.status,
1302
1299
  message_count: (@session&.messages || []).size,
@@ -1312,12 +1309,9 @@ module Samagotchi
1312
1309
  model_name: effective_model_ref,
1313
1310
  served_model: served_pair[0],
1314
1311
  served_model_for: served_pair[1],
1315
- served_expected_by: served_expected_by(served_pair[0]),
1316
- context_status: @last_context_status&.dup || saved_context_status(metrics[:context]),
1317
- llm_context: llm_context_summary,
1318
- thinking: thinking_explained&.summary,
1319
- reasoning_history: reasoning_history_explained&.summary,
1320
- toolkit: toolkit_summary,
1312
+ served_expected_by: model&.served_expected_by(served_pair[0]),
1313
+ context_status: @last_context_status&.dup || saved_context_status(metrics[:context], model: model),
1314
+ **setting_chips(SessionSetting::ALL, model),
1321
1315
  recap_enabled: !@recap.nil?,
1322
1316
  recap_min_user_turns: @recap&.min_user_turns,
1323
1317
  recap_inactivity_seconds: @recap&.inactivity&.to_i
@@ -1673,15 +1667,17 @@ module Samagotchi
1673
1667
  # the chat loop's leaves out the raw-prompt tool text, since its tools go
1674
1668
  # as schemas. Built once per loop (and again after a model switch) so the
1675
1669
  # prompt prefix, and the server's KV cache for it, stay stable.
1676
- # @param target [HostRegistry::ModelTarget, nil]
1670
+ # @param model [EffectiveModel, nil]
1677
1671
  # @return [String]
1678
1672
  # The native prompt depends on the thinking level too (Gemma's token,
1679
- # Qwen's turn preamble): a level change builds it again.
1680
- def system_prompt(target = nil)
1681
- target ||= @host_registry.resolve(@effective_model_name)
1682
- chat = target.entry.chat?
1683
- level = chat ? nil : thinking_level(target)
1684
- prompt = @prompt_builder.build(chat: chat, thinking: level, layers: llm_context_layers(target))
1673
+ # Qwen's turn preamble): a level change builds it again. A build that
1674
+ # misses the cache names the model and the level through its own
1675
+ # lambdas (#model_identity, #turn_thinking), which resolve again.
1676
+ def system_prompt(model = nil)
1677
+ model ||= effective_model
1678
+ chat = model.chat?
1679
+ level = chat ? nil : thinking_level(model)
1680
+ prompt = @prompt_builder.build(chat: chat, thinking: level, layers: model.llm_context_layers(llm_context_session_args))
1685
1681
  # The memory indexes this prompt holds: /stats, the snapshot, analytics.json.
1686
1682
  @metrics.memory_index = @prompt_builder.memory_index
1687
1683
  # The model notes it carries: the session records them (/stats, /model, the web).
@@ -1689,16 +1685,6 @@ module Samagotchi
1689
1685
  prompt
1690
1686
  end
1691
1687
 
1692
- # The LLM context layers +target+'s turns run under: the native prompt
1693
- # declares the tools they add (forget_outputs under forget), so a
1694
- # model switch may change the tool list. [] under none.
1695
- def llm_context_layers(target)
1696
- LLMContextStrategy.resolve(target, names: model_lookup_names(target), **llm_context_session_args).active_layers
1697
- rescue StandardError
1698
- []
1699
- end
1700
- private :llm_context_layers
1701
-
1702
1688
  # The base prompt (specs, plugins' declarations).
1703
1689
  def assist_system_prompt(chat: false, thinking: nil)
1704
1690
  @prompt_builder.base(chat: chat, thinking: thinking)
@@ -2029,14 +2015,16 @@ module Samagotchi
2029
2015
  # sampling and thinking, the thinking resolved before the hooks run.
2030
2016
  # The model name is added in #generate, after the hooks that may switch
2031
2017
  # the model.
2018
+ # One resolve for all of them: nothing runs between them that could
2019
+ # change what a resolve gives.
2032
2020
  def turn_settings(session)
2033
- vision = turn_vision(session)
2034
- sampling = turn_sampling
2035
- thinking_target = @host_registry.resolve(@effective_model_name)
2036
- @turn_thinking = [thinking_level(thinking_target), thinking_target]
2037
- announce_thinking_level(*@turn_thinking)
2038
- LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: @turn_thinking.first,
2039
- reasoning_history: reasoning_history_level(thinking_target), model_name: nil,
2021
+ model = effective_model
2022
+ vision = turn_vision(session, model)
2023
+ sampling = model.sampling
2024
+ level = thinking_level(model)
2025
+ @thinking_notices.turn_started(level, model.target)
2026
+ LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: level,
2027
+ reasoning_history: reasoning_history_level(model), model_name: nil,
2040
2028
  window_setting: nil, llm_context: nil, price: nil)
2041
2029
  end
2042
2030
  private :turn_settings
@@ -2058,12 +2046,12 @@ module Samagotchi
2058
2046
  def warm_up_next_turn(turn, result)
2059
2047
  return if result.canceled? || result.resumable?
2060
2048
 
2061
- target = @host_registry.resolve(@effective_model_name)
2062
- client = target.client
2063
- return unless warmup_target?(target, client)
2049
+ model = effective_model
2050
+ client = model.client
2051
+ return unless warmup_target?(model, client)
2064
2052
  return if reminders_due? || @turn_state.carried_steers? || @next_turn_waiting&.call
2065
2053
 
2066
- messages = ContextNote.with_system_head(turn.session.messages.dup, { role: "system", content: system_prompt(target) })
2054
+ messages = ContextNote.with_system_head(turn.session.messages.dup, { role: "system", content: system_prompt(model) })
2067
2055
  # Under the next turn's strategy (a /model switch may have changed it);
2068
2056
  # the last turn's when it can't be resolved.
2069
2057
  prompt, images = @kernel.warmup_prompt(messages, llm_context: turn_llm_context || @kernel.turn_settings&.llm_context)
@@ -2080,8 +2068,8 @@ module Samagotchi
2080
2068
  # Never a paid or remote host (cache.warmup auto means local only), nor
2081
2069
  # the chat loop (Splash keeps its own cache; llama.cpp's chat path is
2082
2070
  # not measured), nor a non-llama.cpp transport.
2083
- def warmup_target?(target, client)
2084
- return false if target.entry.chat? || target.entry.remote?
2071
+ def warmup_target?(model, client)
2072
+ return false if model.chat? || model.entry.remote?
2085
2073
  return false unless client.respond_to?(:warm_up) && client.respond_to?(:transport) && client.transport.native?
2086
2074
 
2087
2075
  PromptWarmup.enabled?
@@ -2122,16 +2110,19 @@ module Samagotchi
2122
2110
 
2123
2111
  # Ask the effective model's backend.
2124
2112
  def generate(turn, max_iterations:, max_tool_output_chars:, pending_input:)
2125
- sync_kernel_client!
2113
+ # One resolve for the request: nothing runs between these reads that
2114
+ # could change what a resolve gives (the hooks ran before).
2115
+ model = effective_model
2116
+ sync_kernel_client!(model)
2126
2117
  # Route model name as bare (without host prefix) to the transport;
2127
2118
  # host selection already done via active client.
2128
- bare_for_backend = bare_model_name(@effective_model_name)
2119
+ bare_for_backend = bare_model_name(model.name)
2129
2120
  # The turn's settings, set once here. The chat loop dispatches tools
2130
2121
  # through the kernel without its #run: tag those dumps with this
2131
2122
  # turn's model, not the last native one.
2132
- explained = llm_context_explained
2133
- @kernel.turn_settings = turn.settings.with(model_name: bare_for_backend, window_setting: turn_window_setting,
2134
- llm_context: explained&.resolved, price: turn_price)
2123
+ explained = model.llm_context_explained(llm_context_session_args)
2124
+ @kernel.turn_settings = turn.settings.with(model_name: bare_for_backend, window_setting: model.window_setting,
2125
+ llm_context: explained&.resolved, price: model.price)
2135
2126
  log_llm_context(explained)
2136
2127
 
2137
2128
  turn.limit = @no_interrupt ? IterationLimit.for(no_interrupt: true) : max_iterations || IterationLimit.for
@@ -2139,7 +2130,7 @@ module Samagotchi
2139
2130
  # end keeps it, so a Continue resumes this run.
2140
2131
  run_start = turn.messages.length
2141
2132
  reasoning_since = resumed_run_start(turn, run_start)
2142
- result = backend.complete(
2133
+ result = backend(model).complete(
2143
2134
  messages: turn.messages,
2144
2135
  max_iterations: turn.limit,
2145
2136
  on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller),
@@ -2353,164 +2344,30 @@ module Samagotchi
2353
2344
 
2354
2345
  # The turn's VisionContext: the session's images folder, and whether the
2355
2346
  # effective model can see images, asked only when a request carries one.
2356
- def turn_vision(session)
2357
- target = @host_registry.resolve(@effective_model_name)
2347
+ def turn_vision(session, model = effective_model)
2358
2348
  VisionContext.new(session_dir: Session.session_dir(session.id, state_dir: session_state_dir),
2359
- capability: lambda {
2360
- VisionSupport.for(target, profile: profile, adapter: vision_adapter(target), names: model_lookup_names(target))
2361
- })
2349
+ capability: -> { model.vision_support(profile) })
2362
2350
  end
2363
2351
 
2364
2352
  # The effective model's request parameters (hosts: and models:
2365
2353
  # sampling), read per turn: /model can change the model between turns.
2366
- def turn_sampling
2367
- target = @host_registry.resolve(@effective_model_name)
2368
- SamplingSettings.for(target, names: model_lookup_names(target))
2369
- rescue StandardError
2370
- SamplingSettings::EMPTY
2371
- end
2354
+ def turn_sampling = model_setting(SamplingSettings::EMPTY, &:sampling)
2372
2355
 
2373
2356
  # The effective model's thinking level (Thinking.resolve), read each turn.
2374
- def turn_thinking
2375
- thinking_level(@host_registry.resolve(@effective_model_name))
2376
- rescue StandardError
2377
- Thinking::DEFAULT
2378
- end
2379
-
2380
- # An effort on a native host, which has no knob for it: said once per
2381
- # session and host.
2382
- def announce_thinking_level(level, target)
2383
- return if target.entry.chat? || Thinking.native(level, profile).honoured
2384
-
2385
- thinking_notice_once(:unsupported, target, :info,
2386
- "#{level} isn't supported by native #{profile.name} on #{target.entry.name}; " \
2387
- "thinking stays as the model has it")
2388
- rescue StandardError
2389
- nil
2390
- end
2391
-
2392
- # An effort on a llama.cpp chat host whose chat template takes none
2393
- # (its /props says so): said once per session and host, as for a native
2394
- # host. Read from the /props answer the turn's window probe left in the
2395
- # cache, so it asks the server nothing.
2396
- def announce_effort_ignored
2397
- level, target = @turn_thinking
2398
- return unless target&.entry&.chat? && Thinking::EFFORTS.include?(level)
2399
-
2400
- client = target.client
2401
- props = client.cached_server_props(model: target.bare_model)
2402
- return unless Thinking.effort_ignored?(level, props)
2403
-
2404
- thinking_notice_once(:unsupported, target, :info,
2405
- "#{level} isn't supported by #{target.bare_model}'s chat template on #{target.entry.name} " \
2406
- "(/props: supports_reasoning_effort false); thinking stays as the model has it")
2407
- rescue StandardError
2408
- nil
2409
- end
2410
-
2411
- # Whether this generation's thinking lane said anything: nil while it
2412
- # streamed none, false while only whitespace (Gemma 4's empty thought
2413
- # with thinking off), true once a real thought came.
2414
- def note_generation_thought(thinking)
2415
- return if thinking.empty?
2416
-
2417
- @generation_thought ||= thinking.match?(/\S/)
2418
- end
2419
-
2420
- # Thinking off, and the model thought anyway: logged each time, said
2421
- # once per session and host. A thinking lane of whitespace alone is no
2422
- # thought.
2423
- def check_thinking_honoured(event)
2424
- level, target = @turn_thinking
2425
- chars = event[:thinking_chars].to_i
2426
- return unless level == :off && chars.positive? && target
2427
- return if @generation_thought == false
2428
-
2429
- Log.warn(:model, "thinking_not_honoured", level: level, host: target.entry.name, model: target.bare_model, chars: chars)
2430
- thinking_notice_once(:not_honoured, target, :warn,
2431
- "off wasn't honoured by #{target.bare_model} on #{target.entry.name} " \
2432
- "(#{chars} chars of thinking); a sampling: override on the host or model may turn it off " \
2433
- "(see Thinking in docs/configuration.md)")
2434
- rescue StandardError
2435
- nil
2436
- end
2357
+ def turn_thinking = model_setting(Thinking::DEFAULT) { |model| thinking_level(model) }
2437
2358
 
2438
- # The host refused the level's request fields (gpt-oss can't turn
2439
- # thinking off) and the chat loop sent the request without them: said
2440
- # once per session and host, standing in for the not-honoured notice.
2441
- def thinking_refused(event)
2442
- _level, target = @turn_thinking
2443
- return unless target
2444
-
2445
- Log.warn(:model, "thinking_refused", level: event[:level], host: target.entry.name, model: event[:model],
2446
- detail: event[:detail])
2447
- (@thinking_notices ||= Set.new) << [@session&.id, target.entry.name, :not_honoured]
2448
- thinking_notice_once(:refused, target, :warn,
2449
- "#{target.entry.name} refused thinking: #{event[:level]} for #{event[:model]} (#{event[:detail]}); " \
2450
- "sent without it, so thinking stays as the model has it")
2451
- rescue StandardError
2452
- nil
2453
- end
2454
-
2455
- # The host refused a sampling field (the empty-answer retry's
2456
- # temperature on a model that takes none) and the chat loop asked again
2457
- # without them, as it will for the model from now on: a log line.
2458
- def sampling_refused(event)
2459
- Log.info(:model, "sampling_refused", model: event[:model], detail: event[:detail])
2460
- rescue StandardError
2461
- nil
2462
- end
2463
-
2464
- # The host refused the model's reasoning sent back (reasoning_history)
2465
- # and the chat loop asked the step again without it, as it will from
2466
- # now on: logged, and said once per session and host.
2467
- def reasoning_history_refused(event)
2468
- _level, target = @turn_thinking
2469
- return unless target
2470
-
2471
- Log.warn(:model, "reasoning_history_refused", level: event[:level], host: target.entry.name, model: event[:model],
2472
- detail: event[:detail])
2473
- return unless (@thinking_notices ||= Set.new).add?([@session&.id, target.entry.name, :reasoning_history_refused])
2474
-
2475
- hook_notify("#{target.entry.name} refused reasoning sent back (reasoning_history: #{event[:level]}) for " \
2476
- "#{event[:model]} (#{event[:detail]}); sent without it from now on", :warn, "reasoning history")
2477
- rescue StandardError
2478
- nil
2479
- end
2480
-
2481
- def thinking_notice_once(kind, target, level, text)
2482
- key = [@session&.id, target.entry.name, kind]
2483
- return unless (@thinking_notices ||= Set.new).add?(key)
2484
-
2485
- hook_notify(text, level, "thinking")
2486
- end
2487
-
2488
- # +target+'s ReasoningHistory level, the session's own first; the
2359
+ # +model+'s ReasoningHistory level, the session's own first; the
2489
2360
  # default when it can't be resolved.
2490
- def reasoning_history_level(target)
2491
- ReasoningHistory.resolve(target, names: model_lookup_names(target), session: reasoning_history_override).first
2492
- rescue StandardError
2493
- ReasoningHistory::DEFAULT
2494
- end
2361
+ def reasoning_history_level(model) = model.reasoning_history(own: reasoning_history_override)
2495
2362
 
2496
- # +target+'s thinking level (Thinking.resolve), the session's own first.
2497
- def thinking_level(target)
2498
- Thinking.resolve(target, names: model_lookup_names(target), session: thinking_override).first
2499
- rescue StandardError
2500
- Thinking::DEFAULT
2501
- end
2502
-
2503
- def vision_adapter(target)
2504
- target.entry.chat? ? @host_registry.adapter_for(target.entry) : nil
2505
- rescue StandardError
2506
- nil
2507
- end
2363
+ # +model+'s thinking level (Thinking.resolve), the session's own first.
2364
+ def thinking_level(model) = model.thinking(own: thinking_override)
2508
2365
 
2509
2366
  # A model known not to see images fails a turn with images up front.
2510
2367
  def refuse_images!(vision)
2511
2368
  return if vision.sendable?
2512
2369
 
2513
- host = @host_registry.resolve(@effective_model_name).entry.name
2370
+ host = effective_model.host_name
2514
2371
  raise LLM::VisionUnsupported.new("#{host}: #{vision.refusal_reason}", host: host)
2515
2372
  end
2516
2373
 
@@ -2605,14 +2462,14 @@ module Samagotchi
2605
2462
  session.messages = messages
2606
2463
  end
2607
2464
 
2608
- # Pick the loop for a target; the chat loop is pointed at the target
2609
- # host's adapter (one per host, kept for its cached model list).
2610
- def backend_for(target)
2611
- return @native_backend unless target.entry.chat?
2465
+ # Pick the loop for +model+ (EffectiveModel); the chat loop is pointed
2466
+ # at its host's adapter (one per host, kept for its cached model list).
2467
+ def backend_for(model)
2468
+ return @native_backend unless model.chat?
2612
2469
 
2613
2470
  @chat_backend_mutex.synchronize do
2614
2471
  @chat_backend ||= LLM::ChatLoop.new(kernel: @kernel, stable_length: ->(text) { @prompt_builder.stable_length(text) })
2615
- @chat_backend.adapter = @host_registry.adapter_for(target.entry)
2472
+ @chat_backend.adapter = @host_registry.adapter_for(model.entry)
2616
2473
  @chat_backend.session_id = session&.id
2617
2474
  @chat_backend
2618
2475
  end
@@ -2793,7 +2650,7 @@ module Samagotchi
2793
2650
  # and bare model name, as a turn resolves them.
2794
2651
  # @return [IdleTarget]
2795
2652
  def session_model_recap_target
2796
- IdleTarget.of_model(@host_registry, @effective_model_name)
2653
+ IdleTarget.of_model(@host_registry, effective_model_name)
2797
2654
  end
2798
2655
 
2799
2656
  # Build the idle reminders job. Always created (reminders are opt-in
@@ -2840,29 +2697,26 @@ module Samagotchi
2840
2697
  @steer_cut.observe(event, progress)
2841
2698
  case event[:type]
2842
2699
  when :generation_started
2843
- @generation_thought = nil
2700
+ @thinking_notices.generation_started
2844
2701
  watch&.started(event[:iteration])
2845
2702
  when :generation_retrying
2846
2703
  # A dropped stream's step asked again: it streams from the start.
2847
2704
  if event[:restarted]
2848
- @generation_thought = nil
2705
+ @thinking_notices.generation_started
2849
2706
  watch&.started(event[:iteration], restarted: true)
2850
2707
  end
2851
- when :generation_chunk then note_generation_thought(progress[:thinking])
2708
+ when :generation_chunk then @thinking_notices.note_thought(progress[:thinking])
2852
2709
  end
2853
2710
  # The chat loop asked again without the thinking fields or the
2854
2711
  # reasoning sent back: a notice, not an event of its own.
2855
- next thinking_refused(event) if event[:type] == :thinking_refused
2856
- next reasoning_history_refused(event) if event[:type] == :reasoning_history_refused
2857
- next sampling_refused(event) if event[:type] == :sampling_refused
2712
+ next @thinking_notices.thinking_refused(event) if event[:type] == :thinking_refused
2713
+ next @thinking_notices.reasoning_history_refused(event) if event[:type] == :reasoning_history_refused
2714
+ next @thinking_notices.sampling_refused(event) if event[:type] == :sampling_refused
2858
2715
 
2859
2716
  event = with_generation_report(event) if event[:type] == :generation_completed
2860
2717
  emit_event(on_event, event)
2861
2718
  watch.feed(**progress) if watch && progress
2862
- if event[:type] == :generation_completed
2863
- check_thinking_honoured(event)
2864
- announce_effort_ignored
2865
- end
2719
+ @thinking_notices.generation_completed(event) if event[:type] == :generation_completed
2866
2720
  end
2867
2721
  end
2868
2722
 
@@ -2919,38 +2773,13 @@ module Samagotchi
2919
2773
  emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names, read_names: read_names })
2920
2774
  end
2921
2775
 
2922
- # The window as the target's loop would see it (see ChatLoop#context_window:
2923
- # a remote chat host has no /props, only its model list).
2924
- def current_context_window(target)
2925
- client = target.client
2926
- adapter = nil
2927
- if target.entry.chat?
2928
- adapter = @host_registry.adapter_for(target.entry)
2929
- client = nil if adapter.remote?
2930
- end
2931
- ContextWindow.resolve(client: client, model: target.bare_model, adapter: adapter,
2932
- setting: ContextWindow.setting(target, names: model_lookup_names(target)))
2933
- rescue StandardError
2934
- nil
2935
- end
2936
-
2937
2776
  # The effective model's models:/hosts: window_tokens, read per turn
2938
2777
  # (after the hooks that may switch the model).
2939
- def turn_window_setting
2940
- target = @host_registry.resolve(@effective_model_name)
2941
- ContextWindow.setting(target, names: model_lookup_names(target))
2942
- rescue StandardError
2943
- nil
2944
- end
2778
+ def turn_window_setting = model_setting(nil, &:window_setting)
2945
2779
 
2946
2780
  # The effective model's price on its host (hosts.<name>.models.<id>.price),
2947
2781
  # by the requested id, never the served one; read per turn as the window is.
2948
- def turn_price
2949
- target = @host_registry.resolve(@effective_model_name)
2950
- target.entry.models&.dig(target.bare_model.to_s.strip.downcase)&.price
2951
- rescue StandardError
2952
- nil
2953
- end
2782
+ def turn_price = model_setting(nil, &:price)
2954
2783
 
2955
2784
  # The LLM context strategy the next turn runs under: the session's own
2956
2785
  # (Session#llm_context), else the effective model's (models:/hosts:
@@ -2964,57 +2793,82 @@ module Samagotchi
2964
2793
  private :llm_context_session_args
2965
2794
 
2966
2795
  # See #served_model. A report for another name (before a /model switch)
2967
- # doesn't count. Without a target, no probe. A remote chat host has no
2968
- # /props: nil until a turn.
2969
- def served_model_for(snapshot, target: nil)
2970
- asked = bare_model_name(@effective_model_name)
2796
+ # doesn't count. Without a model (EffectiveModel), no probe. A remote
2797
+ # chat host has no /props: nil until a turn. The name asked is never
2798
+ # resolved (HostRegistry#bare_name), so it holds on a broken resolve.
2799
+ def served_model_for(snapshot, model: nil)
2800
+ asked = bare_model_name(model ? model.name : effective_model_name)
2971
2801
  return [snapshot[:served_model], asked] if snapshot[:served_model] && snapshot[:served_model_for] == asked
2972
- return [nil, nil] unless target
2802
+ return [nil, nil] unless model
2973
2803
 
2974
- client = target.client
2975
- if target.entry.chat?
2976
- adapter = @host_registry.adapter_for(target.entry)
2977
- return [nil, nil] if adapter.remote?
2978
- end
2979
- served = ServedModel.from_props(client.server_props(model: target.bare_model))
2980
- served ? [served, asked] : [nil, nil]
2804
+ model.served_from_props(asked)
2805
+ # Only bare_model_name and the snapshot lookup are left to fail here:
2806
+ # the probe's failure is EffectiveModel#served_from_props's.
2981
2807
  rescue StandardError
2982
2808
  [nil, nil]
2983
2809
  end
2984
2810
 
2985
- # What a local native host's cached /props names, when it isn't the
2986
- # model asked for; nil for a chat or remote host, or before any probe.
2987
- def prompt_served_model(target)
2988
- entry = target.entry
2989
- return nil if entry.nil? || entry.chat? || entry.remote? || !target.client.respond_to?(:cached_server_props)
2811
+ # The window as +model+'s loop would see it (EffectiveModel#context_window).
2812
+ def current_context_window(model) = model.context_window
2990
2813
 
2991
- served = ServedModel.from_props(target.client.cached_server_props(model: target.bare_model))
2992
- served if ServedModel.differs?(target.bare_model, served)
2993
- rescue StandardError
2994
- nil
2814
+ # +settings+' chips (SessionSetting), the model's from +model+ (nil
2815
+ # when it doesn't resolve): /stats, the session state, a :command_ran.
2816
+ def setting_chips(settings, model)
2817
+ settings.to_h { |setting| [setting.key, setting.model? ? setting.chip_for(model, @session) : toolkit_summary] }
2995
2818
  end
2996
2819
 
2997
- # ── Prompt profile ─────────────────────────────────────────────────────────
2820
+ # The effective model now (EffectiveModel.of): resolved on each call,
2821
+ # never kept (config.yml, a model listing and another process's saved
2822
+ # lists all move a resolve). Raises as HostRegistry#resolve does.
2823
+ # @return [EffectiveModel]
2824
+ def effective_model(choice = @model_choice) = EffectiveModel.of(@host_registry, choice)
2998
2825
 
2999
- def resolve_profile
3000
- if @given_profile
3001
- return ModelProfile::Resolution.new(profile: @given_profile, source: :given, detail: nil, retry: false)
2826
+ # The block's answer for the effective model, or +fallback+ when the
2827
+ # model doesn't resolve: the readers whose rescue always covered the
2828
+ # resolve too. Each setting's own failure is the EffectiveModel's.
2829
+ def model_setting(fallback)
2830
+ # One read: the log line names the model that failed, even if a
2831
+ # /model switched it meanwhile.
2832
+ choice = @model_choice
2833
+ model = begin
2834
+ effective_model(choice)
2835
+ rescue StandardError => e
2836
+ return EffectiveModel::Unresolved.fallback(:effective_model_unresolved, choice.name, e, fallback)
3002
2837
  end
2838
+ yield model
2839
+ end
3003
2840
 
3004
- target = @host_registry.resolve(@effective_model_name)
3005
- ModelProfile.resolve(names: model_lookup_names(target), entry: target.entry, client: target.client, bare_model: target.bare_model)
2841
+ # ── Prompt profile ─────────────────────────────────────────────────────────
2842
+
2843
+ # +choice+'s profile (ModelProfile.resolve; may probe /props), or the
2844
+ # one given to .new.
2845
+ def resolve_profile(choice)
2846
+ given = @given_profile
2847
+ return ModelProfile::Resolution.new(profile: given, source: :given, detail: nil, retry: false) if given
2848
+
2849
+ EffectiveModel.of(@host_registry, choice).profile_resolution
3006
2850
  end
3007
2851
 
3008
- # The names a models: entry may be under (HostRegistry#lookup_names):
3009
- # the model as typed at start or at the last /model, and what it became.
3010
- def model_lookup_names(target)
3011
- @host_registry.lookup_names(@typed_model_name, resolved: @effective_model_name, target: target)
2852
+ # Keeps +resolution+ as +choice+'s and applies it, unless the model
2853
+ # switched while it was resolved (a /stats on a Bridge thread probing
2854
+ # as a /model ran): then it is only returned, and the new model's is
2855
+ # resolved on its first need.
2856
+ # @return [ModelProfile::Resolution]
2857
+ def store_profile(choice, resolution)
2858
+ @profile_mutex.synchronize do
2859
+ return resolution unless @model_choice.equal?(choice)
2860
+
2861
+ apply_profile(resolution)
2862
+ @profile_resolution = [choice, resolution]
2863
+ end
2864
+ resolution
3012
2865
  end
3013
2866
 
3014
2867
  # Everything that holds a profile follows the resolution: the kernel's
3015
2868
  # prompt format and parser, and the system prompts built for the old one.
3016
2869
  def apply_profile(resolution)
3017
- @prompt_builder&.reset! if @profile_resolution && @profile_resolution.profile.name != resolution.profile.name
2870
+ kept = @profile_resolution&.last
2871
+ @prompt_builder&.reset! if kept && kept.profile.name != resolution.profile.name
3018
2872
  @kernel.use_profile!(resolution)
3019
2873
  resolution
3020
2874
  end
@@ -3023,11 +2877,10 @@ module Samagotchi
3023
2877
  # server probe failed (unreachable, or 503 while loading a model). A
3024
2878
  # profile that changes here drops the cached system prompts.
3025
2879
  def refresh_profile!
3026
- if @profile_resolution&.retry?
3027
- @profile_resolution = apply_profile(resolve_profile)
3028
- else
3029
- profile_resolution
3030
- end
2880
+ choice, kept = @profile_resolution
2881
+ return profile_resolution unless choice.equal?(@model_choice) && kept.retry?
2882
+
2883
+ store_profile(choice, resolve_profile(choice))
3031
2884
  end
3032
2885
  end
3033
2886
  end