samagotchi 0.67.0 → 0.69.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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/docs/architecture.md +7 -4
  4. data/docs/cli.md +6 -1
  5. data/docs/configuration.md +28 -6
  6. data/docs/guardrails.md +23 -1
  7. data/docs/hooks.md +4 -0
  8. data/docs/internals/bundles.md +1 -1
  9. data/docs/internals/engine.md +73 -17
  10. data/docs/internals/prompt-caching.md +9 -2
  11. data/docs/plugins.md +42 -19
  12. data/docs/releasing.md +14 -6
  13. data/docs/sessions.md +3 -3
  14. data/docs/testing.md +8 -2
  15. data/lib/samagotchi/bootstrap/probe.rb +1 -3
  16. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  18. data/lib/samagotchi/config.rb +0 -19
  19. data/lib/samagotchi/effective_model.rb +283 -0
  20. data/lib/samagotchi/engine.rb +216 -360
  21. data/lib/samagotchi/guardrails/targets.rb +9 -2
  22. data/lib/samagotchi/host_config.rb +1 -29
  23. data/lib/samagotchi/host_model.rb +0 -7
  24. data/lib/samagotchi/kernel_loop.rb +24 -7
  25. data/lib/samagotchi/level_setting_command.rb +99 -0
  26. data/lib/samagotchi/llm/errors.rb +47 -1
  27. data/lib/samagotchi/llm/http.rb +20 -2
  28. data/lib/samagotchi/llm_context_stale.rb +6 -0
  29. data/lib/samagotchi/llm_context_strategy.rb +5 -10
  30. data/lib/samagotchi/memory_bundle/manifest.rb +3 -0
  31. data/lib/samagotchi/model_price.rb +2 -4
  32. data/lib/samagotchi/reasoning_history_command.rb +29 -90
  33. data/lib/samagotchi/session.rb +22 -8
  34. data/lib/samagotchi/session_metrics.rb +4 -1
  35. data/lib/samagotchi/session_setting.rb +86 -0
  36. data/lib/samagotchi/system_prompt.rb +19 -3
  37. data/lib/samagotchi/terminal_ui/formatting.rb +5 -2
  38. data/lib/samagotchi/thinking_command.rb +24 -76
  39. data/lib/samagotchi/thinking_notices.rb +169 -0
  40. data/lib/samagotchi/tool_activity.rb +3 -0
  41. data/lib/samagotchi/tool_row_fields.rb +4 -2
  42. data/lib/samagotchi/tool_runner.rb +24 -3
  43. data/lib/samagotchi/toolkit.rb +27 -14
  44. data/lib/samagotchi/toolkit_binding.rb +8 -2
  45. data/lib/samagotchi/toolkit_command.rb +20 -4
  46. data/lib/samagotchi/tools/builtin_calls.rb +45 -1
  47. data/lib/samagotchi/tools/registry.rb +95 -8
  48. data/lib/samagotchi/tools/tool_index.rb +223 -72
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +69 -93
  51. data/lib/samagotchi/web/message_parts.rb +5 -2
  52. data/lib/samagotchi/web/public/activity.js +3 -1
  53. data/lib/samagotchi/web/public/app.js +196 -526
  54. data/lib/samagotchi/web/public/bubbles.js +291 -0
  55. data/lib/samagotchi/web/public/cards_place.js +184 -0
  56. data/lib/samagotchi/web/public/chains.js +19 -2
  57. data/lib/samagotchi/web/public/chip_popover.js +111 -0
  58. data/lib/samagotchi/web/public/command_list.js +12 -10
  59. data/lib/samagotchi/web/public/families_view.js +15 -0
  60. data/lib/samagotchi/web/public/index.html +21 -11
  61. data/lib/samagotchi/web/public/info_bar.js +3 -3
  62. data/lib/samagotchi/web/public/info_bar_model.js +110 -0
  63. data/lib/samagotchi/web/public/init_row.js +21 -10
  64. data/lib/samagotchi/web/public/level_chip.js +29 -80
  65. data/lib/samagotchi/web/public/lists_view.js +64 -9
  66. data/lib/samagotchi/web/public/llm_context_chip.js +36 -105
  67. data/lib/samagotchi/web/public/model_picker.js +47 -28
  68. data/lib/samagotchi/web/public/ref_scan.js +1 -7
  69. data/lib/samagotchi/web/public/refs.js +13 -0
  70. data/lib/samagotchi/web/public/session_state.js +5 -0
  71. data/lib/samagotchi/web/public/stage_frame.js +179 -0
  72. data/lib/samagotchi/web/public/stage_model.js +33 -0
  73. data/lib/samagotchi/web/public/stage_slots.js +130 -0
  74. data/lib/samagotchi/web/public/stage_view.js +56 -335
  75. data/lib/samagotchi/web/public/stage_watch.js +65 -0
  76. data/lib/samagotchi/web/public/stream_handlers.js +5 -2
  77. data/lib/samagotchi/web/public/timing.js +3 -1
  78. data/lib/samagotchi/web/public/toast_place.js +16 -0
  79. data/lib/samagotchi/web/public/toolkit_chip.js +11 -4
  80. data/lib/samagotchi/web/public/turn_html.js +4 -2
  81. data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
  82. data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
  83. data/lib/samagotchi/worker.rb +11 -16
  84. metadata +15 -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
@@ -204,6 +212,10 @@ module Samagotchi
204
212
  @lifecycle_mutex = Mutex.new
205
213
  @shut_down = false
206
214
  load_plugins if plugins
215
+ # Registered from what the sources published while they loaded, and
216
+ # with no plugins too: a toolkit's left-out built-ins are reachable
217
+ # either way (declared per toolkit, Tools::Registry).
218
+ @tool_index.declare(@tools, failures: @plugin_failures)
207
219
  guardrail_rules
208
220
  # The Engine owns the reminder store; its kernel (built here, or a
209
221
  # spec's) gets it, so tool calls via KernelLoop and reminder injection
@@ -288,7 +300,7 @@ module Samagotchi
288
300
  def wire_kernel(kernel, muted_memories)
289
301
  # A given kernel (specs) gets the Engine's store, hooks and tools, as
290
302
  # the one built here does.
291
- @kernel = kernel || KernelLoop.new(client: @client, profile: @given_profile, model_name: @effective_model_name,
303
+ @kernel = kernel || KernelLoop.new(client: @client, profile: @given_profile, model_name: effective_model_name,
292
304
  hooks: @hooks, reminder_store: @reminder_store, tools: @tools)
293
305
  @kernel.reminder_store = @reminder_store
294
306
  @kernel.hooks = @hooks
@@ -373,8 +385,8 @@ module Samagotchi
373
385
  end
374
386
 
375
387
  # 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))
388
+ def backend(model = effective_model)
389
+ backend_for(model)
378
390
  end
379
391
 
380
392
  # Record that activity happened (user input or a completed turn): the
@@ -522,14 +534,17 @@ module Samagotchi
522
534
 
523
535
  # @return [SessionMetrics] the per-session analytics collector
524
536
  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
537
+
538
+ # The model as given (maybe an alias; HostRegistry#resolve applies it).
539
+ def effective_model_name = @model_choice.name
540
+
541
+ # The model as typed at start or at the last /model.
542
+ def typed_model_name = @model_choice.typed
528
543
 
529
544
  # The resolved ref a session stores for the effective model (ModelRef#ref:
530
545
  # the alias applied, "host:id" when it names a host).
531
546
  def effective_model_ref
532
- model_ref_for(@effective_model_name)
547
+ model_ref_for(effective_model_name)
533
548
  end
534
549
 
535
550
  # The effective model as the system prompt names it (SystemPrompt::ModelIdentity):
@@ -537,11 +552,11 @@ module Samagotchi
537
552
  # only, the model its /props names when that differs. Never a new
538
553
  # request: only the /props answer profile resolution already cached.
539
554
  def model_identity
540
- target = @host_registry.resolve(@effective_model_name)
541
- entry = target.entry
555
+ model = effective_model
556
+ entry = model.entry
542
557
  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)
558
+ SystemPrompt::ModelIdentity.new(ref: model.ref, host: host, key: @model_key,
559
+ served: model.prompt_served, fallback_key: @fallback_model_key)
545
560
  end
546
561
 
547
562
  # The model notes for the effective model (ModelNotes): matched on its
@@ -586,10 +601,10 @@ module Samagotchi
586
601
  summary = error.respond_to?(:summary) ? error.summary : error.message
587
602
  return summary unless error.is_a?(LLM::ProviderError) && error.host
588
603
 
589
- note = @host_registry.alias_note(@effective_model_name)
604
+ note = @host_registry.alias_note(effective_model_name)
590
605
  note && note.end_with?("on host #{error.host}") ? "#{summary} (#{note})" : summary
591
- rescue StandardError
592
- summary
606
+ rescue StandardError => e
607
+ EffectiveModel::Unresolved.fallback(:alias_note_failed, effective_model_name, e, summary)
593
608
  end
594
609
 
595
610
  # +name+'s resolved ref (ModelRef#ref).
@@ -603,7 +618,8 @@ module Samagotchi
603
618
  def store_model!(session)
604
619
  ref = effective_model_ref
605
620
  session.model_name = ref
606
- session.model_typed = @typed_model_name.to_s == ref ? nil : @typed_model_name
621
+ typed = typed_model_name
622
+ session.model_typed = typed.to_s == ref ? nil : typed
607
623
  session
608
624
  end
609
625
 
@@ -622,11 +638,17 @@ module Samagotchi
622
638
  # and kept, so the system prompt and the server's KV prefix stay stable;
623
639
  # #switch_model! starts over, and a failed server probe is retried before
624
640
  # the next turn (#refresh_profile!). The kernel follows each resolution.
641
+ # Kept with the model choice it was resolved for: one that finishes
642
+ # after a switch is neither kept nor applied (#store_profile).
625
643
  # @return [ModelProfile::Resolution]
626
644
  def profile_resolution
627
- @profile_resolution ||= apply_profile(resolve_profile)
645
+ choice = @model_choice
646
+ cached = @profile_resolution
647
+ return cached.last if cached&.first.equal?(choice)
648
+
649
+ store_profile(choice, resolve_profile(choice))
628
650
  end
629
- attr_reader :host_registry, :client
651
+ attr_reader :default_model_name, :host_registry, :client
630
652
 
631
653
  # @return [Commands::Registry] the commands this session runs: the
632
654
  # built-ins, and the ones bundle plugins add
@@ -645,8 +667,9 @@ module Samagotchi
645
667
  # (before aliases were applied there): that key is read when the
646
668
  # target's overlay is missing (KernelLoop#sync_model_key!).
647
669
  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)
670
+ choice = @model_choice
671
+ @model_key = ModelOverlay.key_for(bare_model_name(choice.name))
672
+ typed_key = ModelOverlay.key_for(@host_registry.parse_qualified_model(choice.typed).last)
650
673
  @fallback_model_key = typed_key == @model_key ? nil : typed_key
651
674
  @kernel.sync_model_key!(@model_key, fallback: @fallback_model_key)
652
675
  end
@@ -655,17 +678,17 @@ module Samagotchi
655
678
  # The effective model's bare name for the guardrails' models: rules;
656
679
  # nil without a model name.
657
680
  def guardrail_model_name
658
- name = @effective_model_name && bare_model_name(@effective_model_name)
681
+ model = effective_model_name
682
+ name = model && bare_model_name(model)
659
683
  name.to_s.strip.empty? ? nil : name
660
684
  end
661
685
 
662
686
  # Point the kernel (and a chat backend) at the effective model's host
663
687
  # (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)
688
+ def sync_kernel_client!(model = effective_model)
689
+ @client = model.client
690
+ @kernel.client = model.client if @kernel.client != model.client
691
+ backend_for(model)
669
692
  end
670
693
 
671
694
  # Subscribe a persistent observer to engine events.
@@ -966,11 +989,14 @@ module Samagotchi
966
989
  # The alias is applied where the model is resolved (HostRegistry#resolve),
967
990
  # once; checked here: an unknown host, an alias naming another host.
968
991
  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
992
+ choice = ModelChoice.new(name: resolved, typed: typed.to_s.strip.empty? ? model_name : typed.to_s.strip)
993
+ @profile_mutex.synchronize do
994
+ # One assignment: a reader on another thread sees the old pair or the new one.
995
+ @model_choice = choice
996
+ # A profile given to .new was for the starting model.
997
+ @given_profile = nil
998
+ @profile_resolution = nil
999
+ end
974
1000
  sync_model_key!
975
1001
  @prompt_builder.reset!
976
1002
  # The notes the new model's prompt loads at its next build, so /model,
@@ -1041,22 +1067,24 @@ module Samagotchi
1041
1067
  # @return [Hash] @metrics.snapshot, filled in
1042
1068
  def stats_snapshot
1043
1069
  snapshot = @metrics.snapshot
1044
- target = @host_registry.resolve(@effective_model_name)
1045
- served, served_for = served_model_for(snapshot, target: target)
1070
+ # One resolve for the whole snapshot (its readers' rescues stay theirs);
1071
+ # a model that doesn't resolve raises here, as it always has.
1072
+ model = effective_model
1073
+ served, served_for = served_model_for(snapshot, model: model)
1046
1074
  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)
1075
+ served_expected_by: model.served_expected_by(served),
1076
+ **setting_chips(SessionSetting::STATS, model),
1077
+ prompt_notes: prompt_notes.map(&:to_h), prompt_files: @prompt_builder.prompt_files,
1078
+ price_host: model.host_name)
1051
1079
  unless snapshot.dig(:context, :window_tokens)
1052
- window = current_context_window(target)
1080
+ window = current_context_window(model)
1053
1081
  if window
1054
1082
  snapshot = snapshot.merge(context: snapshot[:context].merge(window_tokens: window.tokens,
1055
1083
  window_source: window.source.to_s))
1056
1084
  end
1057
1085
  end
1058
1086
  # The chat loop uses no prompt profile: drop one a native turn reported.
1059
- return snapshot.except(:profile, :profile_source) if target.entry.chat?
1087
+ return snapshot.except(:profile, :profile_source) if model.chat?
1060
1088
 
1061
1089
  unless snapshot[:profile]
1062
1090
  resolution = profile_resolution
@@ -1069,10 +1097,11 @@ module Samagotchi
1069
1097
  # no turn yet (it woke after an idle exit): nil when either count is
1070
1098
  # unknown or context.status is off. Counted as the turns count it:
1071
1099
  # against the effective model's llm_context budget when it has one.
1072
- def saved_context_status(context)
1100
+ def saved_context_status(context, model: :current)
1073
1101
  return nil unless context
1074
1102
 
1075
- ContextStatus.new(llm_context: turn_llm_context)
1103
+ llm_context = model == :current ? turn_llm_context : model&.llm_context_explained(llm_context_session_args)&.resolved
1104
+ ContextStatus.new(llm_context: llm_context)
1076
1105
  .display_for(used_tokens: context[:used_tokens], window_tokens: context[:window_tokens])
1077
1106
  end
1078
1107
 
@@ -1080,10 +1109,7 @@ module Samagotchi
1080
1109
  # (LLMContextStrategy.explain), nil when it can't be resolved.
1081
1110
  # @return [LLMContextStrategy::Explained, nil]
1082
1111
  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
1112
+ model_setting(nil) { |model| model.llm_context_explained(llm_context_session_args) }
1087
1113
  end
1088
1114
 
1089
1115
  # #llm_context_explained's summary (Explained#summary), nil when it
@@ -1095,7 +1121,7 @@ module Samagotchi
1095
1121
 
1096
1122
  # The session's own llm_context values, nil when it has none.
1097
1123
  # @return [LLMContextOverride, nil]
1098
- def llm_context_override = @session&.llm_context
1124
+ def llm_context_override = SessionSetting::LLM_CONTEXT.own(@session)
1099
1125
 
1100
1126
  # Set the session's own llm_context values (an empty override unsets
1101
1127
  # them); the next turn's start resolves them, never a running one (its
@@ -1104,23 +1130,18 @@ module Samagotchi
1104
1130
  def llm_context_override=(override)
1105
1131
  raise ArgumentError, "no session yet" unless @session
1106
1132
 
1107
- @session.llm_context = override
1133
+ SessionSetting::LLM_CONTEXT.set(@session, override)
1108
1134
  # The status a later client reads counts against the new budget.
1109
1135
  @last_context_status = saved_context_status(@metrics.snapshot[:context]) || @last_context_status if @last_context_status
1110
1136
  end
1111
1137
 
1112
1138
  # The effective model is on a chat host (api: openai or anthropic), whose loop uses
1113
1139
  # no prompt profile.
1114
- def chat_model? = @host_registry.resolve(@effective_model_name).entry.chat?
1140
+ def chat_model? = effective_model.chat?
1115
1141
 
1116
1142
  # "temperature=0.6 (hosts.work)" for /model: the effective model's
1117
1143
  # 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
1144
+ def sampling_summary = model_setting(nil, &:sampling_summary)
1124
1145
 
1125
1146
  # "off (models: qwen)", "low (session)" for /model: the effective
1126
1147
  # model's thinking level and where it came from, nil when none is set.
@@ -1134,10 +1155,7 @@ module Samagotchi
1134
1155
  # turn_sampling; on any error, the global and its name.
1135
1156
  # @return [Array(Integer, String|nil)]
1136
1157
  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"]
1158
+ model_setting([SteerCut.cut_after, "steer.cut_after"], &:steer_cut_after_explained)
1141
1159
  end
1142
1160
 
1143
1161
  # #steer_cut_after_explained's seconds: what SteerCut#cut_for_steer
@@ -1159,10 +1177,7 @@ module Samagotchi
1159
1177
  # +own+: the session's own level to resolve with (the current one by
1160
1178
  # default; /thinking asks what a change would resolve to).
1161
1179
  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
1180
+ model_setting(nil) { |model| model.thinking_explained(own: own) }
1166
1181
  end
1167
1182
 
1168
1183
  # "drop (models: qwen)" for /model: the effective chat model's
@@ -1170,14 +1185,7 @@ module Samagotchi
1170
1185
  # default, the model runs on the native loop (which ignores it) or it
1171
1186
  # can't be resolved.
1172
1187
  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
1188
+ model_setting(nil) { |model| model.reasoning_history_summary(own: reasoning_history_override) }
1181
1189
  end
1182
1190
 
1183
1191
  # The effective chat model's reasoning_history level with where it came
@@ -1189,12 +1197,7 @@ module Samagotchi
1189
1197
  # +own+: the session's own level to resolve with (the current one by
1190
1198
  # default; a command asks what a change would resolve to).
1191
1199
  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
1200
+ model_setting(nil) { |model| model.reasoning_history_explained(own: own, native: native) }
1198
1201
  end
1199
1202
 
1200
1203
  # What a reasoning_history change from level +from+ to level +to+ costs
@@ -1203,10 +1206,7 @@ module Samagotchi
1203
1206
  # marked as refusing it may still get :history (one needless ask).
1204
1207
  # @return [Symbol, nil]
1205
1208
  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
1209
+ model_setting(nil) { |model| model.reasoning_history_cache_cost(from, to, conversation: @session&.messages) }
1210
1210
  end
1211
1211
 
1212
1212
  # Whether the session has a model reply (a cache to restart at all).
@@ -1216,7 +1216,7 @@ module Samagotchi
1216
1216
 
1217
1217
  # The session's own reasoning_history level, nil when it has none.
1218
1218
  # @return [Symbol, nil]
1219
- def reasoning_history_override = @session&.reasoning_history
1219
+ def reasoning_history_override = SessionSetting::REASONING_HISTORY.own(@session)
1220
1220
 
1221
1221
  # Set the session's own reasoning_history level (nil unsets it); the
1222
1222
  # next turn's start resolves it. The caller saves the session.
@@ -1224,19 +1224,18 @@ module Samagotchi
1224
1224
  def reasoning_history_override=(level)
1225
1225
  raise ArgumentError, "no session yet" unless @session
1226
1226
 
1227
- @session.reasoning_history = level
1227
+ SessionSetting::REASONING_HISTORY.set(@session, level)
1228
1228
  end
1229
1229
 
1230
1230
  # The session's own thinking level, nil when it has none.
1231
1231
  # @return [Symbol, nil]
1232
- def thinking_override = @session&.thinking
1232
+ def thinking_override = SessionSetting::THINKING.own(@session)
1233
1233
 
1234
1234
  # What a thinking level change costs the effective model's prompt
1235
1235
  # cache (Thinking.cache_cost): :full, :provider or :tail.
1236
1236
  # @return [Symbol]
1237
1237
  def thinking_cache_cost
1238
- target = @host_registry.resolve(@effective_model_name)
1239
- Thinking.cache_cost(target, profile: target.entry&.chat? ? nil : profile&.name)
1238
+ effective_model.thinking_cache_cost { profile }
1240
1239
  end
1241
1240
 
1242
1241
  # Set the session's own thinking level (nil unsets it); the next turn's
@@ -1246,7 +1245,7 @@ module Samagotchi
1246
1245
  def thinking_override=(level)
1247
1246
  raise ArgumentError, "no session yet" unless @session
1248
1247
 
1249
- @session.thinking = level
1248
+ SessionSetting::THINKING.set(@session, level)
1250
1249
  end
1251
1250
 
1252
1251
  # The model the server serves for the current model, and the name asked
@@ -1255,17 +1254,15 @@ module Samagotchi
1255
1254
  # else [nil, nil].
1256
1255
  # @return [Array(String, String), Array(nil, nil)]
1257
1256
  def served_model(probe: true)
1258
- served_model_for(@metrics.served_report, target: probe ? @host_registry.resolve(@effective_model_name) : nil)
1257
+ served_model_for(@metrics.served_report, model: probe ? effective_model : nil)
1259
1258
  end
1260
1259
 
1261
1260
  # The host whose hosts.<name>.models.<id>.served names +served+ for the
1262
1261
  # current model (a gateway's round-robin targets): not a mismatch to
1263
1262
  # warn about. nil when none does.
1264
1263
  # @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
1264
+ def served_expected_by(served)
1265
+ model_setting(nil) { |model| model.served_expected_by(served) }
1269
1266
  end
1270
1267
 
1271
1268
  def served_expected?(served) = !served_expected_by(served).nil?
@@ -1297,6 +1294,10 @@ module Samagotchi
1297
1294
  def session_state_snapshot
1298
1295
  served_pair = served_model(probe: false)
1299
1296
  metrics = @metrics.snapshot
1297
+ # One resolve for the model's fields; nil when it doesn't resolve (the
1298
+ # Bridge's /state and reset frames rely on this never raising): those
1299
+ # fields are nil then, as their own rescues always gave.
1300
+ model = model_setting(nil) { |resolved| resolved }
1300
1301
  {
1301
1302
  status: @session&.status,
1302
1303
  message_count: (@session&.messages || []).size,
@@ -1312,12 +1313,9 @@ module Samagotchi
1312
1313
  model_name: effective_model_ref,
1313
1314
  served_model: served_pair[0],
1314
1315
  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,
1316
+ served_expected_by: model&.served_expected_by(served_pair[0]),
1317
+ context_status: @last_context_status&.dup || saved_context_status(metrics[:context], model: model),
1318
+ **setting_chips(SessionSetting::ALL, model),
1321
1319
  recap_enabled: !@recap.nil?,
1322
1320
  recap_min_user_turns: @recap&.min_user_turns,
1323
1321
  recap_inactivity_seconds: @recap&.inactivity&.to_i
@@ -1673,15 +1671,17 @@ module Samagotchi
1673
1671
  # the chat loop's leaves out the raw-prompt tool text, since its tools go
1674
1672
  # as schemas. Built once per loop (and again after a model switch) so the
1675
1673
  # prompt prefix, and the server's KV cache for it, stay stable.
1676
- # @param target [HostRegistry::ModelTarget, nil]
1674
+ # @param model [EffectiveModel, nil]
1677
1675
  # @return [String]
1678
1676
  # 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))
1677
+ # Qwen's turn preamble): a level change builds it again. A build that
1678
+ # misses the cache names the model and the level through its own
1679
+ # lambdas (#model_identity, #turn_thinking), which resolve again.
1680
+ def system_prompt(model = nil)
1681
+ model ||= effective_model
1682
+ chat = model.chat?
1683
+ level = chat ? nil : thinking_level(model)
1684
+ prompt = @prompt_builder.build(chat: chat, thinking: level, layers: model.llm_context_layers(llm_context_session_args))
1685
1685
  # The memory indexes this prompt holds: /stats, the snapshot, analytics.json.
1686
1686
  @metrics.memory_index = @prompt_builder.memory_index
1687
1687
  # The model notes it carries: the session records them (/stats, /model, the web).
@@ -1689,16 +1689,6 @@ module Samagotchi
1689
1689
  prompt
1690
1690
  end
1691
1691
 
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
1692
  # The base prompt (specs, plugins' declarations).
1703
1693
  def assist_system_prompt(chat: false, thinking: nil)
1704
1694
  @prompt_builder.base(chat: chat, thinking: thinking)
@@ -2029,14 +2019,16 @@ module Samagotchi
2029
2019
  # sampling and thinking, the thinking resolved before the hooks run.
2030
2020
  # The model name is added in #generate, after the hooks that may switch
2031
2021
  # the model.
2022
+ # One resolve for all of them: nothing runs between them that could
2023
+ # change what a resolve gives.
2032
2024
  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,
2025
+ model = effective_model
2026
+ vision = turn_vision(session, model)
2027
+ sampling = model.sampling
2028
+ level = thinking_level(model)
2029
+ @thinking_notices.turn_started(level, model.target)
2030
+ LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: level,
2031
+ reasoning_history: reasoning_history_level(model), model_name: nil,
2040
2032
  window_setting: nil, llm_context: nil, price: nil)
2041
2033
  end
2042
2034
  private :turn_settings
@@ -2058,12 +2050,12 @@ module Samagotchi
2058
2050
  def warm_up_next_turn(turn, result)
2059
2051
  return if result.canceled? || result.resumable?
2060
2052
 
2061
- target = @host_registry.resolve(@effective_model_name)
2062
- client = target.client
2063
- return unless warmup_target?(target, client)
2053
+ model = effective_model
2054
+ client = model.client
2055
+ return unless warmup_target?(model, client)
2064
2056
  return if reminders_due? || @turn_state.carried_steers? || @next_turn_waiting&.call
2065
2057
 
2066
- messages = ContextNote.with_system_head(turn.session.messages.dup, { role: "system", content: system_prompt(target) })
2058
+ messages = ContextNote.with_system_head(turn.session.messages.dup, { role: "system", content: system_prompt(model) })
2067
2059
  # Under the next turn's strategy (a /model switch may have changed it);
2068
2060
  # the last turn's when it can't be resolved.
2069
2061
  prompt, images = @kernel.warmup_prompt(messages, llm_context: turn_llm_context || @kernel.turn_settings&.llm_context)
@@ -2080,8 +2072,8 @@ module Samagotchi
2080
2072
  # Never a paid or remote host (cache.warmup auto means local only), nor
2081
2073
  # the chat loop (Splash keeps its own cache; llama.cpp's chat path is
2082
2074
  # not measured), nor a non-llama.cpp transport.
2083
- def warmup_target?(target, client)
2084
- return false if target.entry.chat? || target.entry.remote?
2075
+ def warmup_target?(model, client)
2076
+ return false if model.chat? || model.entry.remote?
2085
2077
  return false unless client.respond_to?(:warm_up) && client.respond_to?(:transport) && client.transport.native?
2086
2078
 
2087
2079
  PromptWarmup.enabled?
@@ -2122,16 +2114,19 @@ module Samagotchi
2122
2114
 
2123
2115
  # Ask the effective model's backend.
2124
2116
  def generate(turn, max_iterations:, max_tool_output_chars:, pending_input:)
2125
- sync_kernel_client!
2117
+ # One resolve for the request: nothing runs between these reads that
2118
+ # could change what a resolve gives (the hooks ran before).
2119
+ model = effective_model
2120
+ sync_kernel_client!(model)
2126
2121
  # Route model name as bare (without host prefix) to the transport;
2127
2122
  # host selection already done via active client.
2128
- bare_for_backend = bare_model_name(@effective_model_name)
2123
+ bare_for_backend = bare_model_name(model.name)
2129
2124
  # The turn's settings, set once here. The chat loop dispatches tools
2130
2125
  # through the kernel without its #run: tag those dumps with this
2131
2126
  # 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)
2127
+ explained = model.llm_context_explained(llm_context_session_args)
2128
+ @kernel.turn_settings = turn.settings.with(model_name: bare_for_backend, window_setting: model.window_setting,
2129
+ llm_context: explained&.resolved, price: model.price)
2135
2130
  log_llm_context(explained)
2136
2131
 
2137
2132
  turn.limit = @no_interrupt ? IterationLimit.for(no_interrupt: true) : max_iterations || IterationLimit.for
@@ -2139,7 +2134,7 @@ module Samagotchi
2139
2134
  # end keeps it, so a Continue resumes this run.
2140
2135
  run_start = turn.messages.length
2141
2136
  reasoning_since = resumed_run_start(turn, run_start)
2142
- result = backend.complete(
2137
+ result = backend(model).complete(
2143
2138
  messages: turn.messages,
2144
2139
  max_iterations: turn.limit,
2145
2140
  on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller),
@@ -2353,164 +2348,30 @@ module Samagotchi
2353
2348
 
2354
2349
  # The turn's VisionContext: the session's images folder, and whether the
2355
2350
  # 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)
2351
+ def turn_vision(session, model = effective_model)
2358
2352
  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
- })
2353
+ capability: -> { model.vision_support(profile) })
2362
2354
  end
2363
2355
 
2364
2356
  # The effective model's request parameters (hosts: and models:
2365
2357
  # 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
2358
+ def turn_sampling = model_setting(SamplingSettings::EMPTY, &:sampling)
2372
2359
 
2373
2360
  # 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
2361
+ def turn_thinking = model_setting(Thinking::DEFAULT) { |model| thinking_level(model) }
2437
2362
 
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
2363
+ # +model+'s ReasoningHistory level, the session's own first; the
2489
2364
  # 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
2495
-
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
2365
+ def reasoning_history_level(model) = model.reasoning_history(own: reasoning_history_override)
2502
2366
 
2503
- def vision_adapter(target)
2504
- target.entry.chat? ? @host_registry.adapter_for(target.entry) : nil
2505
- rescue StandardError
2506
- nil
2507
- end
2367
+ # +model+'s thinking level (Thinking.resolve), the session's own first.
2368
+ def thinking_level(model) = model.thinking(own: thinking_override)
2508
2369
 
2509
2370
  # A model known not to see images fails a turn with images up front.
2510
2371
  def refuse_images!(vision)
2511
2372
  return if vision.sendable?
2512
2373
 
2513
- host = @host_registry.resolve(@effective_model_name).entry.name
2374
+ host = effective_model.host_name
2514
2375
  raise LLM::VisionUnsupported.new("#{host}: #{vision.refusal_reason}", host: host)
2515
2376
  end
2516
2377
 
@@ -2605,14 +2466,14 @@ module Samagotchi
2605
2466
  session.messages = messages
2606
2467
  end
2607
2468
 
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?
2469
+ # Pick the loop for +model+ (EffectiveModel); the chat loop is pointed
2470
+ # at its host's adapter (one per host, kept for its cached model list).
2471
+ def backend_for(model)
2472
+ return @native_backend unless model.chat?
2612
2473
 
2613
2474
  @chat_backend_mutex.synchronize do
2614
2475
  @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)
2476
+ @chat_backend.adapter = @host_registry.adapter_for(model.entry)
2616
2477
  @chat_backend.session_id = session&.id
2617
2478
  @chat_backend
2618
2479
  end
@@ -2640,8 +2501,6 @@ module Samagotchi
2640
2501
  # start) waits for the first turn, beside the load warnings: no UI
2641
2502
  # is there yet, and the Engine isn't built.
2642
2503
  @extension_load.load_plugins(registries, failures: @plugin_failures)
2643
- # Declared once, from what the sources published while they loaded.
2644
- @tool_index.declare(@tools, failures: @plugin_failures)
2645
2504
  end
2646
2505
 
2647
2506
  # Keep a plugin's new tool set (chi.replace_tools, from any thread)
@@ -2690,6 +2549,7 @@ module Samagotchi
2690
2549
  state_dir: -> { session_state_dir },
2691
2550
  can_stage: -> { !turn_running? && !history? },
2692
2551
  reset_prompt: -> { @prompt_builder&.reset! },
2552
+ refresh_index: -> { @tool_index.refresh! },
2693
2553
  emit: ->(sink, event) { emit_event(sink, event) },
2694
2554
  notify: ->(text, level, source, extra:) { hook_notify(text, level, source, extra: extra) }
2695
2555
  )
@@ -2793,7 +2653,7 @@ module Samagotchi
2793
2653
  # and bare model name, as a turn resolves them.
2794
2654
  # @return [IdleTarget]
2795
2655
  def session_model_recap_target
2796
- IdleTarget.of_model(@host_registry, @effective_model_name)
2656
+ IdleTarget.of_model(@host_registry, effective_model_name)
2797
2657
  end
2798
2658
 
2799
2659
  # Build the idle reminders job. Always created (reminders are opt-in
@@ -2840,29 +2700,26 @@ module Samagotchi
2840
2700
  @steer_cut.observe(event, progress)
2841
2701
  case event[:type]
2842
2702
  when :generation_started
2843
- @generation_thought = nil
2703
+ @thinking_notices.generation_started
2844
2704
  watch&.started(event[:iteration])
2845
2705
  when :generation_retrying
2846
2706
  # A dropped stream's step asked again: it streams from the start.
2847
2707
  if event[:restarted]
2848
- @generation_thought = nil
2708
+ @thinking_notices.generation_started
2849
2709
  watch&.started(event[:iteration], restarted: true)
2850
2710
  end
2851
- when :generation_chunk then note_generation_thought(progress[:thinking])
2711
+ when :generation_chunk then @thinking_notices.note_thought(progress[:thinking])
2852
2712
  end
2853
2713
  # The chat loop asked again without the thinking fields or the
2854
2714
  # 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
2715
+ next @thinking_notices.thinking_refused(event) if event[:type] == :thinking_refused
2716
+ next @thinking_notices.reasoning_history_refused(event) if event[:type] == :reasoning_history_refused
2717
+ next @thinking_notices.sampling_refused(event) if event[:type] == :sampling_refused
2858
2718
 
2859
2719
  event = with_generation_report(event) if event[:type] == :generation_completed
2860
2720
  emit_event(on_event, event)
2861
2721
  watch.feed(**progress) if watch && progress
2862
- if event[:type] == :generation_completed
2863
- check_thinking_honoured(event)
2864
- announce_effort_ignored
2865
- end
2722
+ @thinking_notices.generation_completed(event) if event[:type] == :generation_completed
2866
2723
  end
2867
2724
  end
2868
2725
 
@@ -2919,38 +2776,13 @@ module Samagotchi
2919
2776
  emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names, read_names: read_names })
2920
2777
  end
2921
2778
 
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
2779
  # The effective model's models:/hosts: window_tokens, read per turn
2938
2780
  # (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
2781
+ def turn_window_setting = model_setting(nil, &:window_setting)
2945
2782
 
2946
2783
  # The effective model's price on its host (hosts.<name>.models.<id>.price),
2947
2784
  # 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
2785
+ def turn_price = model_setting(nil, &:price)
2954
2786
 
2955
2787
  # The LLM context strategy the next turn runs under: the session's own
2956
2788
  # (Session#llm_context), else the effective model's (models:/hosts:
@@ -2964,57 +2796,82 @@ module Samagotchi
2964
2796
  private :llm_context_session_args
2965
2797
 
2966
2798
  # 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)
2799
+ # doesn't count. Without a model (EffectiveModel), no probe. A remote
2800
+ # chat host has no /props: nil until a turn. The name asked is never
2801
+ # resolved (HostRegistry#bare_name), so it holds on a broken resolve.
2802
+ def served_model_for(snapshot, model: nil)
2803
+ asked = bare_model_name(model ? model.name : effective_model_name)
2971
2804
  return [snapshot[:served_model], asked] if snapshot[:served_model] && snapshot[:served_model_for] == asked
2972
- return [nil, nil] unless target
2805
+ return [nil, nil] unless model
2973
2806
 
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]
2807
+ model.served_from_props(asked)
2808
+ # Only bare_model_name and the snapshot lookup are left to fail here:
2809
+ # the probe's failure is EffectiveModel#served_from_props's.
2981
2810
  rescue StandardError
2982
2811
  [nil, nil]
2983
2812
  end
2984
2813
 
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)
2814
+ # The window as +model+'s loop would see it (EffectiveModel#context_window).
2815
+ def current_context_window(model) = model.context_window
2990
2816
 
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
2817
+ # +settings+' chips (SessionSetting), the model's from +model+ (nil
2818
+ # when it doesn't resolve): /stats, the session state, a :command_ran.
2819
+ def setting_chips(settings, model)
2820
+ settings.to_h { |setting| [setting.key, setting.model? ? setting.chip_for(model, @session) : toolkit_summary] }
2995
2821
  end
2996
2822
 
2997
- # ── Prompt profile ─────────────────────────────────────────────────────────
2823
+ # The effective model now (EffectiveModel.of): resolved on each call,
2824
+ # never kept (config.yml, a model listing and another process's saved
2825
+ # lists all move a resolve). Raises as HostRegistry#resolve does.
2826
+ # @return [EffectiveModel]
2827
+ def effective_model(choice = @model_choice) = EffectiveModel.of(@host_registry, choice)
2998
2828
 
2999
- def resolve_profile
3000
- if @given_profile
3001
- return ModelProfile::Resolution.new(profile: @given_profile, source: :given, detail: nil, retry: false)
2829
+ # The block's answer for the effective model, or +fallback+ when the
2830
+ # model doesn't resolve: the readers whose rescue always covered the
2831
+ # resolve too. Each setting's own failure is the EffectiveModel's.
2832
+ def model_setting(fallback)
2833
+ # One read: the log line names the model that failed, even if a
2834
+ # /model switched it meanwhile.
2835
+ choice = @model_choice
2836
+ model = begin
2837
+ effective_model(choice)
2838
+ rescue StandardError => e
2839
+ return EffectiveModel::Unresolved.fallback(:effective_model_unresolved, choice.name, e, fallback)
3002
2840
  end
2841
+ yield model
2842
+ end
3003
2843
 
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)
2844
+ # ── Prompt profile ─────────────────────────────────────────────────────────
2845
+
2846
+ # +choice+'s profile (ModelProfile.resolve; may probe /props), or the
2847
+ # one given to .new.
2848
+ def resolve_profile(choice)
2849
+ given = @given_profile
2850
+ return ModelProfile::Resolution.new(profile: given, source: :given, detail: nil, retry: false) if given
2851
+
2852
+ EffectiveModel.of(@host_registry, choice).profile_resolution
3006
2853
  end
3007
2854
 
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)
2855
+ # Keeps +resolution+ as +choice+'s and applies it, unless the model
2856
+ # switched while it was resolved (a /stats on a Bridge thread probing
2857
+ # as a /model ran): then it is only returned, and the new model's is
2858
+ # resolved on its first need.
2859
+ # @return [ModelProfile::Resolution]
2860
+ def store_profile(choice, resolution)
2861
+ @profile_mutex.synchronize do
2862
+ return resolution unless @model_choice.equal?(choice)
2863
+
2864
+ apply_profile(resolution)
2865
+ @profile_resolution = [choice, resolution]
2866
+ end
2867
+ resolution
3012
2868
  end
3013
2869
 
3014
2870
  # Everything that holds a profile follows the resolution: the kernel's
3015
2871
  # prompt format and parser, and the system prompts built for the old one.
3016
2872
  def apply_profile(resolution)
3017
- @prompt_builder&.reset! if @profile_resolution && @profile_resolution.profile.name != resolution.profile.name
2873
+ kept = @profile_resolution&.last
2874
+ @prompt_builder&.reset! if kept && kept.profile.name != resolution.profile.name
3018
2875
  @kernel.use_profile!(resolution)
3019
2876
  resolution
3020
2877
  end
@@ -3023,11 +2880,10 @@ module Samagotchi
3023
2880
  # server probe failed (unreachable, or 503 while loading a model). A
3024
2881
  # profile that changes here drops the cached system prompts.
3025
2882
  def refresh_profile!
3026
- if @profile_resolution&.retry?
3027
- @profile_resolution = apply_profile(resolve_profile)
3028
- else
3029
- profile_resolution
3030
- end
2883
+ choice, kept = @profile_resolution
2884
+ return profile_resolution unless choice.equal?(@model_choice) && kept.retry?
2885
+
2886
+ store_profile(choice, resolve_profile(choice))
3031
2887
  end
3032
2888
  end
3033
2889
  end