samagotchi 0.66.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 (151) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/docs/architecture.md +55 -30
  4. data/docs/cli.md +3 -1
  5. data/docs/configuration.md +50 -9
  6. data/docs/context.md +35 -1
  7. data/docs/desktop.md +90 -22
  8. data/docs/guardrails.md +8 -5
  9. data/docs/hooks.md +2 -2
  10. data/docs/internals/anthropic.md +156 -0
  11. data/docs/internals/background-tasks.md +1 -1
  12. data/docs/internals/bundles.md +2 -0
  13. data/docs/internals/engine.md +129 -38
  14. data/docs/internals/gemma4-contract.md +1 -1
  15. data/docs/internals/llm-context-bench.md +5 -4
  16. data/docs/internals/prompt-caching.md +26 -11
  17. data/docs/plugins.md +126 -23
  18. data/docs/sessions.md +6 -6
  19. data/docs/sub-agent.md +4 -1
  20. data/docs/testing.md +18 -4
  21. data/lib/samagotchi/bootstrap/probe.rb +4 -3
  22. data/lib/samagotchi/bootstrap_command.rb +13 -3
  23. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  24. data/lib/samagotchi/bridge.rb +6 -3
  25. data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
  26. data/lib/samagotchi/bundles/claude-peer/plugin.rb +34 -313
  27. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
  28. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
  29. data/lib/samagotchi/bundles/codex-peer/manifest.yml +7 -6
  30. data/lib/samagotchi/bundles/codex-peer/plugin.rb +37 -312
  31. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +8 -3
  32. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +14 -6
  33. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
  34. data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
  36. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  37. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  38. data/lib/samagotchi/config.rb +15 -7
  39. data/lib/samagotchi/context_absorber.rb +66 -13
  40. data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
  41. data/lib/samagotchi/desktop/macos/App.swift +3 -3
  42. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
  43. data/lib/samagotchi/desktop/macos/Images.swift +2 -2
  44. data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
  45. data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
  46. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
  47. data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
  48. data/lib/samagotchi/desktop/macos.rb +84 -13
  49. data/lib/samagotchi/desktop_command.rb +14 -3
  50. data/lib/samagotchi/effective_model.rb +283 -0
  51. data/lib/samagotchi/engine.rb +307 -549
  52. data/lib/samagotchi/engine_peers.rb +3 -0
  53. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  54. data/lib/samagotchi/guardrails/approval.rb +1 -1
  55. data/lib/samagotchi/guardrails/approvals.rb +1 -1
  56. data/lib/samagotchi/guardrails/rules.rb +1 -1
  57. data/lib/samagotchi/guardrails/targets.rb +1 -1
  58. data/lib/samagotchi/hook_routes.rb +151 -0
  59. data/lib/samagotchi/hooks/registry.rb +2 -2
  60. data/lib/samagotchi/host_keys.rb +147 -0
  61. data/lib/samagotchi/level_setting_command.rb +99 -0
  62. data/lib/samagotchi/llm/adapter_common.rb +138 -0
  63. data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
  64. data/lib/samagotchi/llm/api_key.rb +23 -7
  65. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  66. data/lib/samagotchi/llm/openai_chat.rb +9 -92
  67. data/lib/samagotchi/llm/refusal_memo.rb +29 -0
  68. data/lib/samagotchi/llm_context_override.rb +1 -0
  69. data/lib/samagotchi/llm_context_strategy.rb +5 -10
  70. data/lib/samagotchi/peer_sources.rb +58 -0
  71. data/lib/samagotchi/plugin/api.rb +64 -155
  72. data/lib/samagotchi/plugin/loader.rb +4 -2
  73. data/lib/samagotchi/plugin/peer.rb +394 -0
  74. data/lib/samagotchi/plugin/tool_spec.rb +166 -0
  75. data/lib/samagotchi/plugin_tasks.rb +2 -2
  76. data/lib/samagotchi/reasoning_history_command.rb +29 -90
  77. data/lib/samagotchi/row_refs.rb +1 -1
  78. data/lib/samagotchi/self_report.rb +5 -3
  79. data/lib/samagotchi/send_command.rb +21 -11
  80. data/lib/samagotchi/session.rb +22 -8
  81. data/lib/samagotchi/session_continue.rb +58 -15
  82. data/lib/samagotchi/session_inbox.rb +0 -13
  83. data/lib/samagotchi/session_setting.rb +86 -0
  84. data/lib/samagotchi/thinking_command.rb +24 -76
  85. data/lib/samagotchi/thinking_notices.rb +169 -0
  86. data/lib/samagotchi/tool_call_parser.rb +1 -1
  87. data/lib/samagotchi/toolkit_binding.rb +138 -0
  88. data/lib/samagotchi/tools/context_read.rb +14 -0
  89. data/lib/samagotchi/tools/execute.rb +12 -12
  90. data/lib/samagotchi/tools/registry.rb +1 -1
  91. data/lib/samagotchi/tools/task_create.rb +6 -6
  92. data/lib/samagotchi/tools/task_get.rb +13 -13
  93. data/lib/samagotchi/tools/task_list.rb +6 -6
  94. data/lib/samagotchi/tools/task_record.rb +52 -0
  95. data/lib/samagotchi/tools/task_runtime.rb +55 -91
  96. data/lib/samagotchi/tools/task_stop.rb +6 -6
  97. data/lib/samagotchi/tools/task_wait.rb +12 -13
  98. data/lib/samagotchi/tools/tool_index.rb +454 -0
  99. data/lib/samagotchi/update_command.rb +9 -6
  100. data/lib/samagotchi/version.rb +1 -1
  101. data/lib/samagotchi/web/app.rb +71 -93
  102. data/lib/samagotchi/web/capabilities.rb +1 -1
  103. data/lib/samagotchi/web/public/activity.js +2 -2
  104. data/lib/samagotchi/web/public/annotate_actions.js +1 -1
  105. data/lib/samagotchi/web/public/app.js +103 -539
  106. data/lib/samagotchi/web/public/bubbles.js +291 -0
  107. data/lib/samagotchi/web/public/cards_place.js +184 -0
  108. data/lib/samagotchi/web/public/chains.js +1 -1
  109. data/lib/samagotchi/web/public/chip_popover.js +111 -0
  110. data/lib/samagotchi/web/public/command_list.js +12 -10
  111. data/lib/samagotchi/web/public/commands.js +2 -2
  112. data/lib/samagotchi/web/public/composer_size.js +2 -2
  113. data/lib/samagotchi/web/public/copy.js +1 -1
  114. data/lib/samagotchi/web/public/ctx.js +1 -1
  115. data/lib/samagotchi/web/public/delivery_line.js +4 -4
  116. data/lib/samagotchi/web/public/families_view.js +1 -1
  117. data/lib/samagotchi/web/public/format.js +11 -7
  118. data/lib/samagotchi/web/public/images.js +25 -2
  119. data/lib/samagotchi/web/public/index.html +15 -7
  120. data/lib/samagotchi/web/public/info_bar.js +3 -3
  121. data/lib/samagotchi/web/public/info_bar_model.js +110 -0
  122. data/lib/samagotchi/web/public/init_row.js +21 -10
  123. data/lib/samagotchi/web/public/level_chip.js +29 -80
  124. data/lib/samagotchi/web/public/lists_view.js +64 -9
  125. data/lib/samagotchi/web/public/llm_context_chip.js +38 -107
  126. data/lib/samagotchi/web/public/model_picker.js +26 -22
  127. data/lib/samagotchi/web/public/notify.js +6 -4
  128. data/lib/samagotchi/web/public/question_card.js +1 -1
  129. data/lib/samagotchi/web/public/ref_scan.js +2 -8
  130. data/lib/samagotchi/web/public/refs.js +13 -0
  131. data/lib/samagotchi/web/public/session_state.js +14 -0
  132. data/lib/samagotchi/web/public/session_title.js +2 -3
  133. data/lib/samagotchi/web/public/sessions_list.js +0 -8
  134. data/lib/samagotchi/web/public/stage_frame.js +179 -0
  135. data/lib/samagotchi/web/public/stage_model.js +35 -2
  136. data/lib/samagotchi/web/public/stage_slots.js +130 -0
  137. data/lib/samagotchi/web/public/stage_view.js +56 -335
  138. data/lib/samagotchi/web/public/stage_watch.js +65 -0
  139. data/lib/samagotchi/web/public/strip.js +3 -3
  140. data/lib/samagotchi/web/public/task_stop.js +3 -2
  141. data/lib/samagotchi/web/public/turn_events.js +1 -1
  142. data/lib/samagotchi/web/public/turn_html.js +2 -2
  143. data/lib/samagotchi/web/public/turn_model.js +2 -2
  144. data/lib/samagotchi/web/public/turn_view.js +1 -1
  145. data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
  146. data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
  147. data/lib/samagotchi/web/server.rb +0 -4
  148. data/lib/samagotchi/worker.rb +22 -24
  149. data/lib/samagotchi/worker_inbound.rb +36 -4
  150. data/lib/samagotchi/worker_wakes.rb +67 -7
  151. metadata +27 -1
@@ -14,11 +14,16 @@ 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"
20
23
  require_relative "tools/builtins"
21
24
  require_relative "tools/task_runtime"
25
+ require_relative "tools/tool_index"
26
+ require_relative "peer_sources"
22
27
  require_relative "session_commands"
23
28
  require_relative "plugin/loader"
24
29
  require_relative "extension_load"
@@ -31,7 +36,7 @@ require_relative "llm/backend"
31
36
  require_relative "llm/openai_chat"
32
37
  require_relative "llm/turn_settings"
33
38
  require_relative "session"
34
- require_relative "toolkit"
39
+ require_relative "toolkit_binding"
35
40
  require_relative "answer_tail"
36
41
  require_relative "archive_store"
37
42
  require_relative "session_observer"
@@ -44,6 +49,7 @@ require_relative "recap_setup"
44
49
  require_relative "idle_reminders"
45
50
  require_relative "idle_scheduler"
46
51
  require_relative "hooks"
52
+ require_relative "hook_routes"
47
53
  require_relative "guardrails"
48
54
  require_relative "reminder_store"
49
55
  require_relative "reminder_queue"
@@ -131,24 +137,30 @@ module Samagotchi
131
137
  # is used (the REPL checks the model it starts on, #switch_model!).
132
138
  @default_model_name = ModelProfile.required_model_name(model_name)
133
139
  ModelProfile.check_host!(model_name, hosts: @host_registry.entries) unless model_name.to_s.strip.empty?
134
- @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)
135
144
  # An injected client (specs) stands in for every host's client.
136
145
  @host_registry.client_override = client if client
137
- @client = @host_registry.resolve(@effective_model_name).client
146
+ @client = effective_model.client
138
147
  # A caller's profile pins it (until a model switch); otherwise
139
148
  # #profile_resolution decides on first need (see there), so building an
140
149
  # Engine makes no network call.
141
150
  @given_profile = profile ? ModelProfile.normalize(profile) : nil
142
- @typed_model_name = model_typed.to_s.strip.empty? ? @default_model_name : model_typed.to_s.strip
143
151
  @profile_resolution = nil
144
152
  # Ensure the built-in system bundle is installed (lazy, warn-only).
145
153
  # This is the single seam for both TUI and non-TUI (web/worker) paths.
146
154
  begin
147
155
  require_relative "memory_bundle/system_bundle"
148
156
  MemoryBundle::SystemBundle.ensure!
149
- rescue StandardError
150
- nil
157
+ rescue StandardError => e
158
+ Log.warn(:memory, "system_bundle_ensure_failed", error: e.class.name, msg: e.message.to_s[0, 200])
151
159
  end
160
+ # What hooks and plugins do beyond reading their event (a notice, a
161
+ # question, a stop, a steer), routed to the UIs. Built before anything
162
+ # that may notify.
163
+ @hook_routes = build_hook_routes
152
164
  # Load hooks from config (plugins) and create the registry; what fails
153
165
  # to load is announced, and a required guardrail's failure denies
154
166
  # every tool call. Rules load now too, so their errors are announced.
@@ -185,9 +197,18 @@ module Samagotchi
185
197
  # plugins' tool sets from chi.replace_tools until the turn thread
186
198
  # applies them (#apply_staged_tools!). Built before the plugins load.
187
199
  @plugin_tasks = build_plugin_tasks
188
- # The toolkits plugins declare (chi.toolkit), beside the built-in and
189
- # config ones (#toolkit_kits).
190
- @plugin_kits = []
200
+ # The session's toolkit: the kits (the plugins' too, chi.toolkit),
201
+ # what its next turn declares, the turn-start apply.
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) })
206
+ # The tools plugins publish without declaring (chi.tool_source), and
207
+ # the search and call tools that reach them (Tools::ToolIndex).
208
+ @tool_index = Tools::ToolIndex.new
209
+ # The context sources the peer bundles declare as a peer agent's
210
+ # answers (chi.peer_source); the worker's ContextAbsorber asks it.
211
+ @peer_sources = PeerSources.new
191
212
  @lifecycle_mutex = Mutex.new
192
213
  @shut_down = false
193
214
  load_plugins if plugins
@@ -275,7 +296,7 @@ module Samagotchi
275
296
  def wire_kernel(kernel, muted_memories)
276
297
  # A given kernel (specs) gets the Engine's store, hooks and tools, as
277
298
  # the one built here does.
278
- @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,
279
300
  hooks: @hooks, reminder_store: @reminder_store, tools: @tools)
280
301
  @kernel.reminder_store = @reminder_store
281
302
  @kernel.hooks = @hooks
@@ -360,8 +381,8 @@ module Samagotchi
360
381
  end
361
382
 
362
383
  # The backend for the next turn: the loop the effective model's host speaks.
363
- def backend
364
- backend_for(@host_registry.resolve(@effective_model_name))
384
+ def backend(model = effective_model)
385
+ backend_for(model)
365
386
  end
366
387
 
367
388
  # Record that activity happened (user input or a completed turn): the
@@ -451,7 +472,8 @@ module Samagotchi
451
472
 
452
473
  log_dropped_steers(steers, "answered")
453
474
  []
454
- rescue StandardError
475
+ rescue StandardError => e
476
+ Log.warn(:turn, "take_steers_failed", error: e.class.name, msg: e.message.to_s[0, 200])
455
477
  []
456
478
  end
457
479
  private :take_steers
@@ -508,14 +530,17 @@ module Samagotchi
508
530
 
509
531
  # @return [SessionMetrics] the per-session analytics collector
510
532
  attr_reader :metrics
511
- # effective_model_name: the model as given (maybe an alias; HostRegistry#resolve
512
- # applies it); typed_model_name: as typed at start or at the last /model.
513
- 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
514
539
 
515
540
  # The resolved ref a session stores for the effective model (ModelRef#ref:
516
541
  # the alias applied, "host:id" when it names a host).
517
542
  def effective_model_ref
518
- model_ref_for(@effective_model_name)
543
+ model_ref_for(effective_model_name)
519
544
  end
520
545
 
521
546
  # The effective model as the system prompt names it (SystemPrompt::ModelIdentity):
@@ -523,11 +548,11 @@ module Samagotchi
523
548
  # only, the model its /props names when that differs. Never a new
524
549
  # request: only the /props answer profile resolution already cached.
525
550
  def model_identity
526
- target = @host_registry.resolve(@effective_model_name)
527
- entry = target.entry
551
+ model = effective_model
552
+ entry = model.entry
528
553
  host = entry && "host #{entry.name}, #{entry.url || "#{entry.host}:#{entry.port}"}"
529
- SystemPrompt::ModelIdentity.new(ref: effective_model_ref, host: host, key: @model_key,
530
- 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)
531
556
  end
532
557
 
533
558
  # The model notes for the effective model (ModelNotes): matched on its
@@ -552,7 +577,8 @@ module Samagotchi
552
577
  return saved.dup if !@prompt_builder.prompt_notes.nil? || !saved.empty? || @session.messages.to_a.any?
553
578
 
554
579
  model_notes.map { |note| PromptNote.from_note(note) }
555
- rescue StandardError
580
+ rescue StandardError => e
581
+ Log.warn(:model, "prompt_notes_failed", error: e.class.name, msg: e.message.to_s[0, 200])
556
582
  []
557
583
  end
558
584
 
@@ -571,10 +597,10 @@ module Samagotchi
571
597
  summary = error.respond_to?(:summary) ? error.summary : error.message
572
598
  return summary unless error.is_a?(LLM::ProviderError) && error.host
573
599
 
574
- note = @host_registry.alias_note(@effective_model_name)
600
+ note = @host_registry.alias_note(effective_model_name)
575
601
  note && note.end_with?("on host #{error.host}") ? "#{summary} (#{note})" : summary
576
- rescue StandardError
577
- summary
602
+ rescue StandardError => e
603
+ EffectiveModel::Unresolved.fallback(:alias_note_failed, effective_model_name, e, summary)
578
604
  end
579
605
 
580
606
  # +name+'s resolved ref (ModelRef#ref).
@@ -588,7 +614,8 @@ module Samagotchi
588
614
  def store_model!(session)
589
615
  ref = effective_model_ref
590
616
  session.model_name = ref
591
- 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
592
619
  session
593
620
  end
594
621
 
@@ -607,16 +634,25 @@ module Samagotchi
607
634
  # and kept, so the system prompt and the server's KV prefix stay stable;
608
635
  # #switch_model! starts over, and a failed server probe is retried before
609
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).
610
639
  # @return [ModelProfile::Resolution]
611
640
  def profile_resolution
612
- @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))
613
646
  end
614
- attr_reader :host_registry, :client
647
+ attr_reader :default_model_name, :host_registry, :client
615
648
 
616
649
  # @return [Commands::Registry] the commands this session runs: the
617
650
  # built-ins, and the ones bundle plugins add
618
651
  attr_reader :command_registry
619
652
 
653
+ # @return [PeerSources] the sources the peer bundles declared
654
+ attr_reader :peer_sources
655
+
620
656
  # The model id sent for +full_ref+ (its alias applied, the host prefix off).
621
657
  def bare_model_name(full_ref)
622
658
  @host_registry.bare_name(full_ref)
@@ -627,8 +663,9 @@ module Samagotchi
627
663
  # (before aliases were applied there): that key is read when the
628
664
  # target's overlay is missing (KernelLoop#sync_model_key!).
629
665
  def sync_model_key!
630
- @model_key = ModelOverlay.key_for(bare_model_name(@effective_model_name))
631
- 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)
632
669
  @fallback_model_key = typed_key == @model_key ? nil : typed_key
633
670
  @kernel.sync_model_key!(@model_key, fallback: @fallback_model_key)
634
671
  end
@@ -637,17 +674,17 @@ module Samagotchi
637
674
  # The effective model's bare name for the guardrails' models: rules;
638
675
  # nil without a model name.
639
676
  def guardrail_model_name
640
- name = @effective_model_name && bare_model_name(@effective_model_name)
677
+ model = effective_model_name
678
+ name = model && bare_model_name(model)
641
679
  name.to_s.strip.empty? ? nil : name
642
680
  end
643
681
 
644
682
  # Point the kernel (and a chat backend) at the effective model's host
645
683
  # (after /model, --model, resume).
646
- def sync_kernel_client!
647
- target = @host_registry.resolve(@effective_model_name)
648
- @client = target.client
649
- @kernel.client = target.client if @kernel.client != target.client
650
- 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)
651
688
  end
652
689
 
653
690
  # Subscribe a persistent observer to engine events.
@@ -948,11 +985,14 @@ module Samagotchi
948
985
  # The alias is applied where the model is resolved (HostRegistry#resolve),
949
986
  # once; checked here: an unknown host, an alias naming another host.
950
987
  resolved = ModelProfile.check_host!(ModelProfile.required_model_name(model_name), hosts: @host_registry.entries)
951
- @effective_model_name = resolved
952
- @typed_model_name = typed.to_s.strip.empty? ? model_name : typed.to_s.strip
953
- # A profile given to .new was for the starting model.
954
- @given_profile = nil
955
- @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
956
996
  sync_model_key!
957
997
  @prompt_builder.reset!
958
998
  # The notes the new model's prompt loads at its next build, so /model,
@@ -1023,22 +1063,24 @@ module Samagotchi
1023
1063
  # @return [Hash] @metrics.snapshot, filled in
1024
1064
  def stats_snapshot
1025
1065
  snapshot = @metrics.snapshot
1026
- target = @host_registry.resolve(@effective_model_name)
1027
- 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)
1028
1070
  snapshot = snapshot.merge(served_model: served, served_model_for: served_for,
1029
- served_expected_by: served_expected_by(served, target: target), llm_context: llm_context_summary,
1030
- thinking: thinking_explained&.summary,
1031
- reasoning_history: reasoning_history_explained&.summary, prompt_notes: prompt_notes.map(&:to_h),
1032
- 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)
1033
1075
  unless snapshot.dig(:context, :window_tokens)
1034
- window = current_context_window(target)
1076
+ window = current_context_window(model)
1035
1077
  if window
1036
1078
  snapshot = snapshot.merge(context: snapshot[:context].merge(window_tokens: window.tokens,
1037
1079
  window_source: window.source.to_s))
1038
1080
  end
1039
1081
  end
1040
1082
  # The chat loop uses no prompt profile: drop one a native turn reported.
1041
- return snapshot.except(:profile, :profile_source) if target.entry.chat?
1083
+ return snapshot.except(:profile, :profile_source) if model.chat?
1042
1084
 
1043
1085
  unless snapshot[:profile]
1044
1086
  resolution = profile_resolution
@@ -1051,10 +1093,11 @@ module Samagotchi
1051
1093
  # no turn yet (it woke after an idle exit): nil when either count is
1052
1094
  # unknown or context.status is off. Counted as the turns count it:
1053
1095
  # against the effective model's llm_context budget when it has one.
1054
- def saved_context_status(context)
1096
+ def saved_context_status(context, model: :current)
1055
1097
  return nil unless context
1056
1098
 
1057
- 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)
1058
1101
  .display_for(used_tokens: context[:used_tokens], window_tokens: context[:window_tokens])
1059
1102
  end
1060
1103
 
@@ -1062,10 +1105,7 @@ module Samagotchi
1062
1105
  # (LLMContextStrategy.explain), nil when it can't be resolved.
1063
1106
  # @return [LLMContextStrategy::Explained, nil]
1064
1107
  def llm_context_explained
1065
- target = @host_registry.resolve(@effective_model_name)
1066
- LLMContextStrategy.explain(target, names: model_lookup_names(target), **llm_context_session_args)
1067
- rescue StandardError
1068
- nil
1108
+ model_setting(nil) { |model| model.llm_context_explained(llm_context_session_args) }
1069
1109
  end
1070
1110
 
1071
1111
  # #llm_context_explained's summary (Explained#summary), nil when it
@@ -1077,7 +1117,7 @@ module Samagotchi
1077
1117
 
1078
1118
  # The session's own llm_context values, nil when it has none.
1079
1119
  # @return [LLMContextOverride, nil]
1080
- def llm_context_override = @session&.llm_context
1120
+ def llm_context_override = SessionSetting::LLM_CONTEXT.own(@session)
1081
1121
 
1082
1122
  # Set the session's own llm_context values (an empty override unsets
1083
1123
  # them); the next turn's start resolves them, never a running one (its
@@ -1086,23 +1126,18 @@ module Samagotchi
1086
1126
  def llm_context_override=(override)
1087
1127
  raise ArgumentError, "no session yet" unless @session
1088
1128
 
1089
- @session.llm_context = override
1129
+ SessionSetting::LLM_CONTEXT.set(@session, override)
1090
1130
  # The status a later client reads counts against the new budget.
1091
1131
  @last_context_status = saved_context_status(@metrics.snapshot[:context]) || @last_context_status if @last_context_status
1092
1132
  end
1093
1133
 
1094
1134
  # The effective model is on a chat host (api: openai or anthropic), whose loop uses
1095
1135
  # no prompt profile.
1096
- def chat_model? = @host_registry.resolve(@effective_model_name).entry.chat?
1136
+ def chat_model? = effective_model.chat?
1097
1137
 
1098
1138
  # "temperature=0.6 (hosts.work)" for /model: the effective model's
1099
1139
  # configured sampling, nil when none.
1100
- def sampling_summary
1101
- target = @host_registry.resolve(@effective_model_name)
1102
- SamplingSettings.summary(target, names: model_lookup_names(target))
1103
- rescue StandardError
1104
- nil
1105
- end
1140
+ def sampling_summary = model_setting(nil, &:sampling_summary)
1106
1141
 
1107
1142
  # "off (models: qwen)", "low (session)" for /model: the effective
1108
1143
  # model's thinking level and where it came from, nil when none is set.
@@ -1116,10 +1151,7 @@ module Samagotchi
1116
1151
  # turn_sampling; on any error, the global and its name.
1117
1152
  # @return [Array(Integer, String|nil)]
1118
1153
  def steer_cut_after_explained
1119
- target = @host_registry.resolve(@effective_model_name)
1120
- SteerCutAfter.resolve(target, names: model_lookup_names(target))
1121
- rescue StandardError
1122
- [SteerCut.cut_after, "steer.cut_after"]
1154
+ model_setting([SteerCut.cut_after, "steer.cut_after"], &:steer_cut_after_explained)
1123
1155
  end
1124
1156
 
1125
1157
  # #steer_cut_after_explained's seconds: what SteerCut#cut_for_steer
@@ -1141,10 +1173,7 @@ module Samagotchi
1141
1173
  # +own+: the session's own level to resolve with (the current one by
1142
1174
  # default; /thinking asks what a change would resolve to).
1143
1175
  def thinking_explained(own: thinking_override)
1144
- target = @host_registry.resolve(@effective_model_name)
1145
- Thinking.explain(target, names: model_lookup_names(target), session: own)
1146
- rescue StandardError
1147
- nil
1176
+ model_setting(nil) { |model| model.thinking_explained(own: own) }
1148
1177
  end
1149
1178
 
1150
1179
  # "drop (models: qwen)" for /model: the effective chat model's
@@ -1152,14 +1181,7 @@ module Samagotchi
1152
1181
  # default, the model runs on the native loop (which ignores it) or it
1153
1182
  # can't be resolved.
1154
1183
  def reasoning_history_summary
1155
- target = @host_registry.resolve(@effective_model_name)
1156
- return nil unless target.entry&.chat?
1157
-
1158
- level, source = ReasoningHistory.resolve(target, names: model_lookup_names(target),
1159
- session: reasoning_history_override)
1160
- source ? ReasoningHistory.label(level, source) : nil
1161
- rescue StandardError
1162
- nil
1184
+ model_setting(nil) { |model| model.reasoning_history_summary(own: reasoning_history_override) }
1163
1185
  end
1164
1186
 
1165
1187
  # The effective chat model's reasoning_history level with where it came
@@ -1171,12 +1193,7 @@ module Samagotchi
1171
1193
  # +own+: the session's own level to resolve with (the current one by
1172
1194
  # default; a command asks what a change would resolve to).
1173
1195
  def reasoning_history_explained(native: false, own: reasoning_history_override)
1174
- target = @host_registry.resolve(@effective_model_name)
1175
- return nil unless native || target.entry&.chat?
1176
-
1177
- ReasoningHistory.explain(target, names: model_lookup_names(target), session: own)
1178
- rescue StandardError
1179
- nil
1196
+ model_setting(nil) { |model| model.reasoning_history_explained(own: own, native: native) }
1180
1197
  end
1181
1198
 
1182
1199
  # What a reasoning_history change from level +from+ to level +to+ costs
@@ -1185,10 +1202,7 @@ module Samagotchi
1185
1202
  # marked as refusing it may still get :history (one needless ask).
1186
1203
  # @return [Symbol, nil]
1187
1204
  def reasoning_history_cache_cost(from, to)
1188
- target = @host_registry.resolve(@effective_model_name)
1189
- ReasoningHistory.cache_cost(from, to, target: target, conversation: @session&.messages)
1190
- rescue StandardError
1191
- nil
1205
+ model_setting(nil) { |model| model.reasoning_history_cache_cost(from, to, conversation: @session&.messages) }
1192
1206
  end
1193
1207
 
1194
1208
  # Whether the session has a model reply (a cache to restart at all).
@@ -1198,7 +1212,7 @@ module Samagotchi
1198
1212
 
1199
1213
  # The session's own reasoning_history level, nil when it has none.
1200
1214
  # @return [Symbol, nil]
1201
- def reasoning_history_override = @session&.reasoning_history
1215
+ def reasoning_history_override = SessionSetting::REASONING_HISTORY.own(@session)
1202
1216
 
1203
1217
  # Set the session's own reasoning_history level (nil unsets it); the
1204
1218
  # next turn's start resolves it. The caller saves the session.
@@ -1206,19 +1220,18 @@ module Samagotchi
1206
1220
  def reasoning_history_override=(level)
1207
1221
  raise ArgumentError, "no session yet" unless @session
1208
1222
 
1209
- @session.reasoning_history = level
1223
+ SessionSetting::REASONING_HISTORY.set(@session, level)
1210
1224
  end
1211
1225
 
1212
1226
  # The session's own thinking level, nil when it has none.
1213
1227
  # @return [Symbol, nil]
1214
- def thinking_override = @session&.thinking
1228
+ def thinking_override = SessionSetting::THINKING.own(@session)
1215
1229
 
1216
1230
  # What a thinking level change costs the effective model's prompt
1217
1231
  # cache (Thinking.cache_cost): :full, :provider or :tail.
1218
1232
  # @return [Symbol]
1219
1233
  def thinking_cache_cost
1220
- target = @host_registry.resolve(@effective_model_name)
1221
- Thinking.cache_cost(target, profile: target.entry&.chat? ? nil : profile&.name)
1234
+ effective_model.thinking_cache_cost { profile }
1222
1235
  end
1223
1236
 
1224
1237
  # Set the session's own thinking level (nil unsets it); the next turn's
@@ -1228,7 +1241,7 @@ module Samagotchi
1228
1241
  def thinking_override=(level)
1229
1242
  raise ArgumentError, "no session yet" unless @session
1230
1243
 
1231
- @session.thinking = level
1244
+ SessionSetting::THINKING.set(@session, level)
1232
1245
  end
1233
1246
 
1234
1247
  # The model the server serves for the current model, and the name asked
@@ -1237,17 +1250,15 @@ module Samagotchi
1237
1250
  # else [nil, nil].
1238
1251
  # @return [Array(String, String), Array(nil, nil)]
1239
1252
  def served_model(probe: true)
1240
- 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)
1241
1254
  end
1242
1255
 
1243
1256
  # The host whose hosts.<name>.models.<id>.served names +served+ for the
1244
1257
  # current model (a gateway's round-robin targets): not a mismatch to
1245
1258
  # warn about. nil when none does.
1246
1259
  # @return [String, nil]
1247
- def served_expected_by(served, target: nil)
1248
- ServedModel.expected_by(target || @host_registry.resolve(@effective_model_name), served)
1249
- rescue StandardError
1250
- nil
1260
+ def served_expected_by(served)
1261
+ model_setting(nil) { |model| model.served_expected_by(served) }
1251
1262
  end
1252
1263
 
1253
1264
  def served_expected?(served) = !served_expected_by(served).nil?
@@ -1279,6 +1290,10 @@ module Samagotchi
1279
1290
  def session_state_snapshot
1280
1291
  served_pair = served_model(probe: false)
1281
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 }
1282
1297
  {
1283
1298
  status: @session&.status,
1284
1299
  message_count: (@session&.messages || []).size,
@@ -1294,12 +1309,9 @@ module Samagotchi
1294
1309
  model_name: effective_model_ref,
1295
1310
  served_model: served_pair[0],
1296
1311
  served_model_for: served_pair[1],
1297
- served_expected_by: served_expected_by(served_pair[0]),
1298
- context_status: @last_context_status&.dup || saved_context_status(metrics[:context]),
1299
- llm_context: llm_context_summary,
1300
- thinking: thinking_explained&.summary,
1301
- reasoning_history: reasoning_history_explained&.summary,
1302
- 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),
1303
1315
  recap_enabled: !@recap.nil?,
1304
1316
  recap_min_user_turns: @recap&.min_user_turns,
1305
1317
  recap_inactivity_seconds: @recap&.inactivity&.to_i
@@ -1494,7 +1506,8 @@ module Samagotchi
1494
1506
 
1495
1507
  def save_quietly(session)
1496
1508
  session.save(state_dir: session_state_dir)
1497
- rescue StandardError
1509
+ rescue StandardError => e
1510
+ Log.warn(:turn, "turn_end_save_failed", error: e.class.name, msg: e.message.to_s[0, 200])
1498
1511
  nil
1499
1512
  end
1500
1513
  private :save_quietly
@@ -1532,122 +1545,59 @@ module Samagotchi
1532
1545
  end
1533
1546
  private :store_answer_display
1534
1547
 
1535
- # ── The hook runtime (Hooks::Runtime) ─────────────────────────────────────
1536
-
1537
- # The three things a hook can do beyond reading its event. Each gets the
1538
- # hook's label (event[:hook]) from the registry.
1539
- def hook_runtime
1540
- Hooks::Runtime.new(
1541
- notify: ->(text:, level:, hook:, fallback_for: nil) { hook_notify(text, level, hook, fallback_for: fallback_for) },
1542
- ask_user: ->(question:, options:, header:, allow_freeform:, hook:) { hook_ask_user(question, options, header, allow_freeform, hook) },
1543
- stop_turn: ->(reason:, hook:) { hook_stop_turn(reason, hook) },
1544
- steer: ->(text:, hook:) { steer(text, source: hook_source(hook)) },
1545
- stop_generation: ->(reason:, hook:) { hook_stop_generation(reason, hook) }
1546
- )
1547
- end
1548
+ # ── The hook runtime (HookRoutes) ───────────────────────────────────────
1549
+
1550
+ # What a hook (Hooks::Runtime) and a plugin (Plugin::Host) can do beyond
1551
+ # reading its event, routed to the UIs by HookRoutes; these forward.
1552
+ def hook_runtime = @hook_routes.runtime
1548
1553
  private :hook_runtime
1549
1554
 
1550
- # What a steer is attributed to: the bundle of a bundle hook's label
1551
- # ("<file> (bundle <name>)"), the label of any other.
1552
- def hook_source(hook)
1553
- hook.to_s[/\(bundle (.+)\)\z/, 1] || hook.to_s
1554
- end
1555
+ def hook_source(hook) = @hook_routes.source(hook)
1555
1556
  private :hook_source
1556
1557
 
1557
1558
  # What a notice may stand in for (hook_notify's fallback_for:).
1558
- NOTICE_FALLBACKS = %i[display editor].freeze
1559
+ NOTICE_FALLBACKS = HookRoutes::NOTICE_FALLBACKS
1559
1560
 
1560
- # One line to the user (:hook_notice). During a turn it is a turn
1561
- # event: the turn's sink (the REPL) and the observers (bridge, log).
1562
- # Outside one (a plugin's command at the prompt) it is announced with
1563
- # between_turns: true, which every UI shows as cards are shown.
1564
- #
1565
- # +fallback_for+ names what the line stands in for (NOTICE_FALLBACKS):
1566
- # :display, the answer's display (AnswerDisplay), for a UI that doesn't
1567
- # render its links; :editor, a ref the viewer could open in its editor
1568
- # (Web::Capabilities#editor), for a UI or a viewer that can't. A UI that
1569
- # does leaves the line out; the others, the log among them, show it. An
1570
- # unknown value is dropped, so the line shows everywhere.
1571
- # +extra+: more fields for the UIs (a toolkit change's summary).
1561
+ # One line to the user (:hook_notice), HookRoutes#notify.
1572
1562
  def hook_notify(text, level, hook, fallback_for: nil, extra: {})
1573
- level = (level || :info).to_sym
1574
- level = :info unless %i[info warn].include?(level)
1575
- notice = { type: :hook_notice, hook: hook.to_s, text: text.to_s, level: level,
1576
- fallback_for: notice_fallback(fallback_for, hook) }.compact.merge(extra)
1577
- return @extension_load.hold(notice) && nil if @extension_load.loading?
1578
-
1579
- in_turn, sink = @turn_state.in_turn_sink
1580
- if anytime_thread?
1581
- announce_anytime(notice.merge(between_turns: true))
1582
- elsif current_init_task
1583
- announce(notice.merge(between_turns: true))
1584
- elsif in_turn
1585
- emit_event(sink, notice)
1586
- else
1587
- announce_or_hold(notice.merge(between_turns: true))
1588
- end
1589
- nil
1563
+ @hook_routes.notify(text, level, hook, fallback_for: fallback_for, extra: extra)
1590
1564
  end
1591
1565
  private :hook_notify
1592
1566
 
1593
- # A known fallback_for (a String is symbolized), else nil; an unknown
1594
- # one is logged once at debug.
1595
- def notice_fallback(value, hook)
1596
- return if value.nil?
1597
-
1598
- fallback = value.to_s.to_sym
1599
- return fallback if NOTICE_FALLBACKS.include?(fallback)
1600
-
1601
- @unknown_fallbacks ||= Set.new
1602
- Log.debug(:hooks, "notice_unknown_fallback", hook: hook.to_s, value: value.to_s) if @unknown_fallbacks.add?(fallback)
1603
- nil
1604
- end
1605
- private :notice_fallback
1606
-
1607
- # A guardrails line to the user after load (:guardrail_warning, as the
1608
- # load failures show): a running turn's event, else announced.
1609
- def guardrail_notify(message)
1610
- event = { type: :guardrail_warning, message: message.to_s }
1611
- in_turn, sink = @turn_state.in_turn_sink
1612
- in_turn ? emit_event(sink, event) : announce_or_hold(event)
1613
- nil
1614
- end
1567
+ def guardrail_notify(message) = @hook_routes.guardrail_notify(message)
1615
1568
  private :guardrail_notify
1616
1569
 
1617
- # A question through the question flow (REPL sync handler, attached TUI,
1618
- # web), single-select, kind "hook". A --non-interactive run has no one
1619
- # to ask: nil at once. Anything but an answer (a sync handler's text, no
1620
- # answer, cancelled) is nil too.
1621
1570
  # @return [Hash, nil] {selected:, freeform:, selected_indices:}
1622
1571
  def hook_ask_user(question, options, header, allow_freeform, hook)
1623
- @question_desk.ask_for_hook(question, options, header, allow_freeform, hook)
1572
+ @hook_routes.ask_user(question, options, header, allow_freeform, hook)
1624
1573
  end
1625
1574
  private :hook_ask_user
1626
1575
 
1627
- # Cancel the running turn (reason :hook), after a notice that says why.
1628
- # The gate denies the rest of a tool batch once the controller is
1629
- # cancelled; the next request ends the turn as :turn_canceled.
1630
1576
  # @return [Boolean] true when a running turn was cancelled now
1631
- def hook_stop_turn(reason, hook)
1632
- ctrl = active_cancel_controller
1633
- return false unless ctrl && !ctrl.cancelled?
1634
-
1635
- hook_notify("stopped the turn: #{reason}", :warn, hook)
1636
- ctrl.cancel!(:hook, { by: hook_source(hook), reason: reason.to_s })
1637
- end
1577
+ def hook_stop_turn(reason, hook) = @hook_routes.stop_turn(reason, hook)
1638
1578
  private :hook_stop_turn
1639
1579
 
1640
- # Cut the streaming generation (reason :hook); the turn goes on and asks
1641
- # the model again (the loops' cut path). No notice: the plugin posts its
1642
- # own. The bundle and +reason+ go to the log and the retry's nudge.
1643
1580
  # @return [Boolean] true when a streaming generation was cut now
1644
- def hook_stop_generation(reason, hook)
1645
- ctrl = active_cancel_controller
1646
- return false unless ctrl
1581
+ def hook_stop_generation(reason, hook) = @hook_routes.stop_generation(reason, hook)
1582
+ private :hook_stop_generation
1647
1583
 
1648
- ctrl.cancel_generation!(:hook, { by: hook_source(hook), reason: reason.to_s })
1584
+ # The hook routes (see #initialize for when).
1585
+ def build_hook_routes
1586
+ HookRoutes.new(
1587
+ extension_load: -> { @extension_load },
1588
+ in_turn_sink: -> { @turn_state.in_turn_sink },
1589
+ anytime_thread: -> { anytime_thread? },
1590
+ announce_anytime: ->(event) { announce_anytime(event) },
1591
+ init_task: -> { current_init_task },
1592
+ announce: ->(event) { announce(event) },
1593
+ announce_or_hold: ->(event) { announce_or_hold(event) },
1594
+ emit: ->(sink, event) { emit_event(sink, event) },
1595
+ question_desk: -> { @question_desk },
1596
+ cancel_controller: -> { active_cancel_controller },
1597
+ steer: ->(text, source) { steer(text, source: source) }
1598
+ )
1649
1599
  end
1650
- private :hook_stop_generation
1600
+ private :build_hook_routes
1651
1601
 
1652
1602
  # ── Ask-user-question (structured qualification) ──────────────────────────
1653
1603
 
@@ -1717,15 +1667,17 @@ module Samagotchi
1717
1667
  # the chat loop's leaves out the raw-prompt tool text, since its tools go
1718
1668
  # as schemas. Built once per loop (and again after a model switch) so the
1719
1669
  # prompt prefix, and the server's KV cache for it, stay stable.
1720
- # @param target [HostRegistry::ModelTarget, nil]
1670
+ # @param model [EffectiveModel, nil]
1721
1671
  # @return [String]
1722
1672
  # The native prompt depends on the thinking level too (Gemma's token,
1723
- # Qwen's turn preamble): a level change builds it again.
1724
- def system_prompt(target = nil)
1725
- target ||= @host_registry.resolve(@effective_model_name)
1726
- chat = target.entry.chat?
1727
- level = chat ? nil : thinking_level(target)
1728
- 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))
1729
1681
  # The memory indexes this prompt holds: /stats, the snapshot, analytics.json.
1730
1682
  @metrics.memory_index = @prompt_builder.memory_index
1731
1683
  # The model notes it carries: the session records them (/stats, /model, the web).
@@ -1733,16 +1685,6 @@ module Samagotchi
1733
1685
  prompt
1734
1686
  end
1735
1687
 
1736
- # The LLM context layers +target+'s turns run under: the native prompt
1737
- # declares the tools they add (forget_outputs under forget), so a
1738
- # model switch may change the tool list. [] under none.
1739
- def llm_context_layers(target)
1740
- LLMContextStrategy.resolve(target, names: model_lookup_names(target), **llm_context_session_args).active_layers
1741
- rescue StandardError
1742
- []
1743
- end
1744
- private :llm_context_layers
1745
-
1746
1688
  # The base prompt (specs, plugins' declarations).
1747
1689
  def assist_system_prompt(chat: false, thinking: nil)
1748
1690
  @prompt_builder.base(chat: chat, thinking: thinking)
@@ -2073,14 +2015,16 @@ module Samagotchi
2073
2015
  # sampling and thinking, the thinking resolved before the hooks run.
2074
2016
  # The model name is added in #generate, after the hooks that may switch
2075
2017
  # the model.
2018
+ # One resolve for all of them: nothing runs between them that could
2019
+ # change what a resolve gives.
2076
2020
  def turn_settings(session)
2077
- vision = turn_vision(session)
2078
- sampling = turn_sampling
2079
- thinking_target = @host_registry.resolve(@effective_model_name)
2080
- @turn_thinking = [thinking_level(thinking_target), thinking_target]
2081
- announce_thinking_level(*@turn_thinking)
2082
- LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: @turn_thinking.first,
2083
- 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,
2084
2028
  window_setting: nil, llm_context: nil, price: nil)
2085
2029
  end
2086
2030
  private :turn_settings
@@ -2102,12 +2046,12 @@ module Samagotchi
2102
2046
  def warm_up_next_turn(turn, result)
2103
2047
  return if result.canceled? || result.resumable?
2104
2048
 
2105
- target = @host_registry.resolve(@effective_model_name)
2106
- client = target.client
2107
- return unless warmup_target?(target, client)
2049
+ model = effective_model
2050
+ client = model.client
2051
+ return unless warmup_target?(model, client)
2108
2052
  return if reminders_due? || @turn_state.carried_steers? || @next_turn_waiting&.call
2109
2053
 
2110
- 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) })
2111
2055
  # Under the next turn's strategy (a /model switch may have changed it);
2112
2056
  # the last turn's when it can't be resolved.
2113
2057
  prompt, images = @kernel.warmup_prompt(messages, llm_context: turn_llm_context || @kernel.turn_settings&.llm_context)
@@ -2124,8 +2068,8 @@ module Samagotchi
2124
2068
  # Never a paid or remote host (cache.warmup auto means local only), nor
2125
2069
  # the chat loop (Splash keeps its own cache; llama.cpp's chat path is
2126
2070
  # not measured), nor a non-llama.cpp transport.
2127
- def warmup_target?(target, client)
2128
- return false if target.entry.chat? || target.entry.remote?
2071
+ def warmup_target?(model, client)
2072
+ return false if model.chat? || model.entry.remote?
2129
2073
  return false unless client.respond_to?(:warm_up) && client.respond_to?(:transport) && client.transport.native?
2130
2074
 
2131
2075
  PromptWarmup.enabled?
@@ -2166,16 +2110,19 @@ module Samagotchi
2166
2110
 
2167
2111
  # Ask the effective model's backend.
2168
2112
  def generate(turn, max_iterations:, max_tool_output_chars:, pending_input:)
2169
- 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)
2170
2117
  # Route model name as bare (without host prefix) to the transport;
2171
2118
  # host selection already done via active client.
2172
- bare_for_backend = bare_model_name(@effective_model_name)
2119
+ bare_for_backend = bare_model_name(model.name)
2173
2120
  # The turn's settings, set once here. The chat loop dispatches tools
2174
2121
  # through the kernel without its #run: tag those dumps with this
2175
2122
  # turn's model, not the last native one.
2176
- explained = llm_context_explained
2177
- @kernel.turn_settings = turn.settings.with(model_name: bare_for_backend, window_setting: turn_window_setting,
2178
- 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)
2179
2126
  log_llm_context(explained)
2180
2127
 
2181
2128
  turn.limit = @no_interrupt ? IterationLimit.for(no_interrupt: true) : max_iterations || IterationLimit.for
@@ -2183,7 +2130,7 @@ module Samagotchi
2183
2130
  # end keeps it, so a Continue resumes this run.
2184
2131
  run_start = turn.messages.length
2185
2132
  reasoning_since = resumed_run_start(turn, run_start)
2186
- result = backend.complete(
2133
+ result = backend(model).complete(
2187
2134
  messages: turn.messages,
2188
2135
  max_iterations: turn.limit,
2189
2136
  on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller),
@@ -2228,8 +2175,8 @@ module Samagotchi
2228
2175
  # Persist deduped used memories onto the session for Web + reload.
2229
2176
  begin
2230
2177
  session.used_memory_names = used_memory_names
2231
- rescue StandardError
2232
- nil
2178
+ rescue StandardError => e
2179
+ Log.warn(:memory, "used_memories_store_failed", error: e.class.name, msg: e.message.to_s[0, 200])
2233
2180
  end
2234
2181
  # Notify live observers of the updated memory list (so yellow bar refreshes
2235
2182
  # even without a tool_call event if the preload was the only addition).
@@ -2238,8 +2185,8 @@ module Samagotchi
2238
2185
 
2239
2186
  begin
2240
2187
  emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names })
2241
- rescue StandardError
2242
- nil
2188
+ rescue StandardError => e
2189
+ Log.warn(:memory, "used_memories_emit_failed", error: e.class.name, msg: e.message.to_s[0, 200])
2243
2190
  end
2244
2191
  end
2245
2192
  private :publish_used_memories
@@ -2397,164 +2344,30 @@ module Samagotchi
2397
2344
 
2398
2345
  # The turn's VisionContext: the session's images folder, and whether the
2399
2346
  # effective model can see images, asked only when a request carries one.
2400
- def turn_vision(session)
2401
- target = @host_registry.resolve(@effective_model_name)
2347
+ def turn_vision(session, model = effective_model)
2402
2348
  VisionContext.new(session_dir: Session.session_dir(session.id, state_dir: session_state_dir),
2403
- capability: lambda {
2404
- VisionSupport.for(target, profile: profile, adapter: vision_adapter(target), names: model_lookup_names(target))
2405
- })
2349
+ capability: -> { model.vision_support(profile) })
2406
2350
  end
2407
2351
 
2408
2352
  # The effective model's request parameters (hosts: and models:
2409
2353
  # sampling), read per turn: /model can change the model between turns.
2410
- def turn_sampling
2411
- target = @host_registry.resolve(@effective_model_name)
2412
- SamplingSettings.for(target, names: model_lookup_names(target))
2413
- rescue StandardError
2414
- SamplingSettings::EMPTY
2415
- end
2354
+ def turn_sampling = model_setting(SamplingSettings::EMPTY, &:sampling)
2416
2355
 
2417
2356
  # The effective model's thinking level (Thinking.resolve), read each turn.
2418
- def turn_thinking
2419
- thinking_level(@host_registry.resolve(@effective_model_name))
2420
- rescue StandardError
2421
- Thinking::DEFAULT
2422
- end
2423
-
2424
- # An effort on a native host, which has no knob for it: said once per
2425
- # session and host.
2426
- def announce_thinking_level(level, target)
2427
- return if target.entry.chat? || Thinking.native(level, profile).honoured
2428
-
2429
- thinking_notice_once(:unsupported, target, :info,
2430
- "#{level} isn't supported by native #{profile.name} on #{target.entry.name}; " \
2431
- "thinking stays as the model has it")
2432
- rescue StandardError
2433
- nil
2434
- end
2435
-
2436
- # An effort on a llama.cpp chat host whose chat template takes none
2437
- # (its /props says so): said once per session and host, as for a native
2438
- # host. Read from the /props answer the turn's window probe left in the
2439
- # cache, so it asks the server nothing.
2440
- def announce_effort_ignored
2441
- level, target = @turn_thinking
2442
- return unless target&.entry&.chat? && Thinking::EFFORTS.include?(level)
2443
-
2444
- client = target.client
2445
- props = client.cached_server_props(model: target.bare_model)
2446
- return unless Thinking.effort_ignored?(level, props)
2447
-
2448
- thinking_notice_once(:unsupported, target, :info,
2449
- "#{level} isn't supported by #{target.bare_model}'s chat template on #{target.entry.name} " \
2450
- "(/props: supports_reasoning_effort false); thinking stays as the model has it")
2451
- rescue StandardError
2452
- nil
2453
- end
2357
+ def turn_thinking = model_setting(Thinking::DEFAULT) { |model| thinking_level(model) }
2454
2358
 
2455
- # Whether this generation's thinking lane said anything: nil while it
2456
- # streamed none, false while only whitespace (Gemma 4's empty thought
2457
- # with thinking off), true once a real thought came.
2458
- def note_generation_thought(thinking)
2459
- return if thinking.empty?
2460
-
2461
- @generation_thought ||= thinking.match?(/\S/)
2462
- end
2463
-
2464
- # Thinking off, and the model thought anyway: logged each time, said
2465
- # once per session and host. A thinking lane of whitespace alone is no
2466
- # thought.
2467
- def check_thinking_honoured(event)
2468
- level, target = @turn_thinking
2469
- chars = event[:thinking_chars].to_i
2470
- return unless level == :off && chars.positive? && target
2471
- return if @generation_thought == false
2472
-
2473
- Log.warn(:model, "thinking_not_honoured", level: level, host: target.entry.name, model: target.bare_model, chars: chars)
2474
- thinking_notice_once(:not_honoured, target, :warn,
2475
- "off wasn't honoured by #{target.bare_model} on #{target.entry.name} " \
2476
- "(#{chars} chars of thinking); a sampling: override on the host or model may turn it off " \
2477
- "(see Thinking in docs/configuration.md)")
2478
- rescue StandardError
2479
- nil
2480
- end
2481
-
2482
- # The host refused the level's request fields (gpt-oss can't turn
2483
- # thinking off) and the chat loop sent the request without them: said
2484
- # once per session and host, standing in for the not-honoured notice.
2485
- def thinking_refused(event)
2486
- _level, target = @turn_thinking
2487
- return unless target
2488
-
2489
- Log.warn(:model, "thinking_refused", level: event[:level], host: target.entry.name, model: event[:model],
2490
- detail: event[:detail])
2491
- (@thinking_notices ||= Set.new) << [@session&.id, target.entry.name, :not_honoured]
2492
- thinking_notice_once(:refused, target, :warn,
2493
- "#{target.entry.name} refused thinking: #{event[:level]} for #{event[:model]} (#{event[:detail]}); " \
2494
- "sent without it, so thinking stays as the model has it")
2495
- rescue StandardError
2496
- nil
2497
- end
2498
-
2499
- # The host refused a sampling field (the empty-answer retry's
2500
- # temperature on a model that takes none) and the chat loop asked again
2501
- # without them, as it will for the model from now on: a log line.
2502
- def sampling_refused(event)
2503
- Log.info(:model, "sampling_refused", model: event[:model], detail: event[:detail])
2504
- rescue StandardError
2505
- nil
2506
- end
2507
-
2508
- # The host refused the model's reasoning sent back (reasoning_history)
2509
- # and the chat loop asked the step again without it, as it will from
2510
- # now on: logged, and said once per session and host.
2511
- def reasoning_history_refused(event)
2512
- _level, target = @turn_thinking
2513
- return unless target
2514
-
2515
- Log.warn(:model, "reasoning_history_refused", level: event[:level], host: target.entry.name, model: event[:model],
2516
- detail: event[:detail])
2517
- return unless (@thinking_notices ||= Set.new).add?([@session&.id, target.entry.name, :reasoning_history_refused])
2518
-
2519
- hook_notify("#{target.entry.name} refused reasoning sent back (reasoning_history: #{event[:level]}) for " \
2520
- "#{event[:model]} (#{event[:detail]}); sent without it from now on", :warn, "reasoning history")
2521
- rescue StandardError
2522
- nil
2523
- end
2524
-
2525
- def thinking_notice_once(kind, target, level, text)
2526
- key = [@session&.id, target.entry.name, kind]
2527
- return unless (@thinking_notices ||= Set.new).add?(key)
2528
-
2529
- hook_notify(text, level, "thinking")
2530
- end
2531
-
2532
- # +target+'s ReasoningHistory level, the session's own first; the
2359
+ # +model+'s ReasoningHistory level, the session's own first; the
2533
2360
  # default when it can't be resolved.
2534
- def reasoning_history_level(target)
2535
- ReasoningHistory.resolve(target, names: model_lookup_names(target), session: reasoning_history_override).first
2536
- rescue StandardError
2537
- ReasoningHistory::DEFAULT
2538
- end
2361
+ def reasoning_history_level(model) = model.reasoning_history(own: reasoning_history_override)
2539
2362
 
2540
- # +target+'s thinking level (Thinking.resolve), the session's own first.
2541
- def thinking_level(target)
2542
- Thinking.resolve(target, names: model_lookup_names(target), session: thinking_override).first
2543
- rescue StandardError
2544
- Thinking::DEFAULT
2545
- end
2546
-
2547
- def vision_adapter(target)
2548
- target.entry.chat? ? @host_registry.adapter_for(target.entry) : nil
2549
- rescue StandardError
2550
- nil
2551
- end
2363
+ # +model+'s thinking level (Thinking.resolve), the session's own first.
2364
+ def thinking_level(model) = model.thinking(own: thinking_override)
2552
2365
 
2553
2366
  # A model known not to see images fails a turn with images up front.
2554
2367
  def refuse_images!(vision)
2555
2368
  return if vision.sendable?
2556
2369
 
2557
- host = @host_registry.resolve(@effective_model_name).entry.name
2370
+ host = effective_model.host_name
2558
2371
  raise LLM::VisionUnsupported.new("#{host}: #{vision.refusal_reason}", host: host)
2559
2372
  end
2560
2373
 
@@ -2649,14 +2462,14 @@ module Samagotchi
2649
2462
  session.messages = messages
2650
2463
  end
2651
2464
 
2652
- # Pick the loop for a target; the chat loop is pointed at the target
2653
- # host's adapter (one per host, kept for its cached model list).
2654
- def backend_for(target)
2655
- 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?
2656
2469
 
2657
2470
  @chat_backend_mutex.synchronize do
2658
2471
  @chat_backend ||= LLM::ChatLoop.new(kernel: @kernel, stable_length: ->(text) { @prompt_builder.stable_length(text) })
2659
- @chat_backend.adapter = @host_registry.adapter_for(target.entry)
2472
+ @chat_backend.adapter = @host_registry.adapter_for(model.entry)
2660
2473
  @chat_backend.session_id = session&.id
2661
2474
  @chat_backend
2662
2475
  end
@@ -2676,12 +2489,16 @@ module Samagotchi
2676
2489
  add_init_task(bundle: bundle, label: label, plugin_label: plugin_label, provides_tools: provides_tools,
2677
2490
  quiet: quiet, timeout: timeout, failed: failed, &block)
2678
2491
  },
2679
- toolkits: @plugin_kits
2492
+ toolkits: @toolkit_binding.plugin_kits,
2493
+ tool_index: @tool_index,
2494
+ peer_sources: @peer_sources
2680
2495
  )
2681
2496
  # What plugins show while they load (an MCP server that didn't
2682
2497
  # start) waits for the first turn, beside the load warnings: no UI
2683
2498
  # is there yet, and the Engine isn't built.
2684
2499
  @extension_load.load_plugins(registries, failures: @plugin_failures)
2500
+ # Declared once, from what the sources published while they loaded.
2501
+ @tool_index.declare(@tools, failures: @plugin_failures)
2685
2502
  end
2686
2503
 
2687
2504
  # Keep a plugin's new tool set (chi.replace_tools, from any thread)
@@ -2699,100 +2516,42 @@ module Samagotchi
2699
2516
  end
2700
2517
  public :apply_staged_tools!
2701
2518
 
2702
- # The kits a session's toolkit resolves against: the built-ins, config's
2703
- # toolkit.kits, the plugins' (Toolkit.kits). Config's are read once per
2704
- # process, like the plugins': an edit to toolkit.kits never changes a
2705
- # live session's tools mid-session (a new worker reads it).
2706
- # @param errors [Array<String>] gets a line per config kit left out
2707
- # @return [Hash{String => Toolkit::Kit}]
2708
- def toolkit_kits(errors = [])
2709
- @config_kits = Toolkit.config_kits unless defined?(@config_kits)
2710
- Toolkit.kits(config: @config_kits, plugins: @plugin_kits, errors: errors)
2711
- end
2712
-
2713
- # The session's toolkit resolved (Toolkit::Resolved): what its next
2714
- # turn declares. A plain full toolkit is nil, the Registry's "every tool".
2715
- def resolved_toolkit(session, errors = [])
2716
- resolved = Toolkit.of(session&.toolkit).resolve(toolkit_kits(errors))
2717
- resolved.everything? ? nil : resolved
2718
- end
2519
+ # The session's toolkit (ToolkitBinding): the kits, what its next turn
2520
+ # declares, ctx.use_toolkit and the turn-start apply.
2521
+ def toolkit_kits(errors = []) = @toolkit_binding.kits(errors)
2522
+ def resolved_toolkit(session, errors = []) = @toolkit_binding.resolved(session, errors)
2523
+ def toolkit_explained(toolkit = nil) = @toolkit_binding.explained(toolkit)
2524
+ def toolkit_summary = @toolkit_binding.summary
2719
2525
 
2720
2526
  # The tools this session registered (Tools::Registry), declared or not.
2721
2527
  attr_reader :tools
2722
2528
 
2723
- # +toolkit+ (the session's by default) as its next turn would declare
2724
- # it (/toolkit, the UIs' chip).
2725
- # @return [Toolkit::Explained]
2726
- def toolkit_explained(toolkit = nil)
2727
- toolkit ||= Toolkit.of(@session&.toolkit)
2728
- resolved = toolkit.resolve(toolkit_kits)
2729
- filter = resolved.everything? ? nil : resolved
2730
- Toolkit::Explained.new(toolkit: toolkit, resolved: resolved, declared: @tools.entries(toolkit: filter),
2731
- left_out: @tools.left_out_names(toolkit: filter), pending: filter != @tools.toolkit)
2732
- end
2733
-
2734
- # The session's toolkit for the UIs (the snapshot, command_ran): nil on
2735
- # full, or with no session.
2736
- def toolkit_summary
2737
- @session && toolkit_explained.summary
2738
- end
2739
2529
  public :tools, :toolkit_kits, :resolved_toolkit, :toolkit_explained, :toolkit_summary
2740
2530
 
2741
- # A plugin's ctx.use_toolkit: put the session on toolkit +name+, saved,
2742
- # when it has no model reply yet and no turn runs (D3: no confirm from
2743
- # plugins), and tell the user as /toolkit would; it applies at the next
2744
- # turn's start.
2531
+ # A plugin's ctx.use_toolkit (ToolkitBinding#use).
2745
2532
  # @return [Boolean] whether it was staged
2746
2533
  # @raise [ArgumentError] an unknown toolkit
2747
- def use_toolkit(name)
2748
- raise ArgumentError, "no toolkit named #{name}" unless toolkit_kits.key?(name)
2749
- return false if @session.nil? || turn_running? || history?
2750
-
2751
- toolkit = Toolkit.new(name: name)
2752
- return true if @session.toolkit == toolkit
2753
-
2754
- @session.toolkit = toolkit
2755
- @session.save(state_dir: session_state_dir)
2756
- hook_notify("toolkit: #{name} from the next turn's start", :info, "toolkit", extra: { toolkit: toolkit_summary })
2757
- true
2758
- end
2534
+ def use_toolkit(name) = @toolkit_binding.use(name)
2759
2535
  public :use_toolkit
2760
2536
 
2761
- # Resolve the session's toolkit into the Registry, on the turn thread at
2762
- # a turn's start (before its prompt is built), and build the prompts
2763
- # again when what it declares changed. A name that doesn't resolve (a
2764
- # typo, an uninstalled bundle) declares everything for now and says so
2765
- # once; the saved toolkit stays as it is.
2537
+ # At a turn's start (ToolkitBinding#apply!).
2766
2538
  # @return [Boolean] whether the declared tools changed
2767
- def apply_toolkit!(session, on_event = nil)
2768
- errors = []
2769
- resolved = Toolkit.of(session&.toolkit).resolve(toolkit_kits(errors))
2770
- warn_toolkit(session, resolved, errors, on_event)
2771
- resolved = nil if resolved.everything?
2772
- return false if resolved == @tools.toolkit
2773
-
2774
- @tools.toolkit = resolved
2775
- @prompt_builder&.reset!
2776
- true
2777
- end
2539
+ def apply_toolkit!(session, on_event = nil) = @toolkit_binding.apply!(session, on_event)
2778
2540
  private :apply_toolkit!
2779
2541
 
2780
- # The toolkit's load warnings, each once per process: a name that
2781
- # didn't resolve, config kits left out.
2782
- def warn_toolkit(session, resolved, errors, on_event)
2783
- lines = errors.dup
2784
- if resolved.fallback
2785
- lines << "#{Toolkit.of(session&.toolkit).label}: #{resolved.fallback}; this session declares every tool (full) " \
2786
- "until it resolves"
2787
- end
2788
- @toolkit_warned ||= []
2789
- (lines - @toolkit_warned).each do |line|
2790
- @toolkit_warned << line
2791
- Log.warn(:config, "toolkit_unresolved", message: line)
2792
- emit_event(on_event, { type: :guardrail_warning, message: line, label: "toolkit" })
2793
- end
2542
+ # The session's toolkit (see #initialize for when).
2543
+ def build_toolkit_binding
2544
+ ToolkitBinding.new(
2545
+ tools: -> { @tools },
2546
+ session: -> { @session },
2547
+ state_dir: -> { session_state_dir },
2548
+ can_stage: -> { !turn_running? && !history? },
2549
+ reset_prompt: -> { @prompt_builder&.reset! },
2550
+ emit: ->(sink, event) { emit_event(sink, event) },
2551
+ notify: ->(text, level, source, extra:) { hook_notify(text, level, source, extra: extra) }
2552
+ )
2794
2553
  end
2795
- private :warn_toolkit
2554
+ private :build_toolkit_binding
2796
2555
 
2797
2556
  # The tools changed (a plugin's chi.tools_changed!): the system prompts,
2798
2557
  # which declare them, are built again on the next turn.
@@ -2820,7 +2579,7 @@ module Samagotchi
2820
2579
  steer: ->(text, label) { steer(text, source: hook_source(label)) },
2821
2580
  stop_turn: ->(reason, label) { hook_stop_turn(reason, label) },
2822
2581
  stop_generation: ->(reason, label) { hook_stop_generation(reason, label) },
2823
- toolkit: -> { @session && Toolkit.of(@session.toolkit).name },
2582
+ toolkit: -> { @toolkit_binding.name },
2824
2583
  declared_tools: -> { toolkit_explained.declared.map(&:name) },
2825
2584
  use_toolkit: ->(name) { use_toolkit(name) },
2826
2585
  ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:|
@@ -2891,7 +2650,7 @@ module Samagotchi
2891
2650
  # and bare model name, as a turn resolves them.
2892
2651
  # @return [IdleTarget]
2893
2652
  def session_model_recap_target
2894
- IdleTarget.of_model(@host_registry, @effective_model_name)
2653
+ IdleTarget.of_model(@host_registry, effective_model_name)
2895
2654
  end
2896
2655
 
2897
2656
  # Build the idle reminders job. Always created (reminders are opt-in
@@ -2938,29 +2697,26 @@ module Samagotchi
2938
2697
  @steer_cut.observe(event, progress)
2939
2698
  case event[:type]
2940
2699
  when :generation_started
2941
- @generation_thought = nil
2700
+ @thinking_notices.generation_started
2942
2701
  watch&.started(event[:iteration])
2943
2702
  when :generation_retrying
2944
2703
  # A dropped stream's step asked again: it streams from the start.
2945
2704
  if event[:restarted]
2946
- @generation_thought = nil
2705
+ @thinking_notices.generation_started
2947
2706
  watch&.started(event[:iteration], restarted: true)
2948
2707
  end
2949
- when :generation_chunk then note_generation_thought(progress[:thinking])
2708
+ when :generation_chunk then @thinking_notices.note_thought(progress[:thinking])
2950
2709
  end
2951
2710
  # The chat loop asked again without the thinking fields or the
2952
2711
  # reasoning sent back: a notice, not an event of its own.
2953
- next thinking_refused(event) if event[:type] == :thinking_refused
2954
- next reasoning_history_refused(event) if event[:type] == :reasoning_history_refused
2955
- 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
2956
2715
 
2957
2716
  event = with_generation_report(event) if event[:type] == :generation_completed
2958
2717
  emit_event(on_event, event)
2959
2718
  watch.feed(**progress) if watch && progress
2960
- if event[:type] == :generation_completed
2961
- check_thinking_honoured(event)
2962
- announce_effort_ignored
2963
- end
2719
+ @thinking_notices.generation_completed(event) if event[:type] == :generation_completed
2964
2720
  end
2965
2721
  end
2966
2722
 
@@ -2973,7 +2729,8 @@ module Samagotchi
2973
2729
  def with_generation_report(event)
2974
2730
  report = @metrics.finish_generation
2975
2731
  event.merge(speed: report.speed&.to_h, tokens: report.tokens, served_expected_by: served_expected_by(event[:served_model]))
2976
- rescue StandardError
2732
+ rescue StandardError => e
2733
+ Log.warn(:turn, "generation_report_failed", error: e.class.name, msg: e.message.to_s[0, 200])
2977
2734
  event
2978
2735
  end
2979
2736
  private :with_generation_report
@@ -2990,7 +2747,8 @@ module Samagotchi
2990
2747
  # Capture used memories synchronously in the turn thread.
2991
2748
  read_names = begin
2992
2749
  @used_memories.capture(event, muted: @muted_memory_names)
2993
- rescue StandardError
2750
+ rescue StandardError => e
2751
+ Log.warn(:memory, "used_memories_capture_failed", error: e.class.name, msg: e.message.to_s[0, 200])
2994
2752
  nil
2995
2753
  end
2996
2754
  # Turn-scoped sink: receives the event hash as the stream handler
@@ -2999,8 +2757,9 @@ module Samagotchi
2999
2757
  if on_event
3000
2758
  begin
3001
2759
  on_event.call(event)
3002
- rescue StandardError
2760
+ rescue StandardError => e
3003
2761
  # Sink errors must not break the kernel loop (same as KernelLoop's own handling)
2762
+ Log.warn(:turn, "sink_failed", event_type: event[:type].to_s, error: e.class.name, msg: e.message.to_s[0, 200])
3004
2763
  end
3005
2764
  end
3006
2765
 
@@ -3014,38 +2773,13 @@ module Samagotchi
3014
2773
  emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names, read_names: read_names })
3015
2774
  end
3016
2775
 
3017
- # The window as the target's loop would see it (see ChatLoop#context_window:
3018
- # a remote chat host has no /props, only its model list).
3019
- def current_context_window(target)
3020
- client = target.client
3021
- adapter = nil
3022
- if target.entry.chat?
3023
- adapter = @host_registry.adapter_for(target.entry)
3024
- client = nil if adapter.remote?
3025
- end
3026
- ContextWindow.resolve(client: client, model: target.bare_model, adapter: adapter,
3027
- setting: ContextWindow.setting(target, names: model_lookup_names(target)))
3028
- rescue StandardError
3029
- nil
3030
- end
3031
-
3032
2776
  # The effective model's models:/hosts: window_tokens, read per turn
3033
2777
  # (after the hooks that may switch the model).
3034
- def turn_window_setting
3035
- target = @host_registry.resolve(@effective_model_name)
3036
- ContextWindow.setting(target, names: model_lookup_names(target))
3037
- rescue StandardError
3038
- nil
3039
- end
2778
+ def turn_window_setting = model_setting(nil, &:window_setting)
3040
2779
 
3041
2780
  # The effective model's price on its host (hosts.<name>.models.<id>.price),
3042
2781
  # by the requested id, never the served one; read per turn as the window is.
3043
- def turn_price
3044
- target = @host_registry.resolve(@effective_model_name)
3045
- target.entry.models&.dig(target.bare_model.to_s.strip.downcase)&.price
3046
- rescue StandardError
3047
- nil
3048
- end
2782
+ def turn_price = model_setting(nil, &:price)
3049
2783
 
3050
2784
  # The LLM context strategy the next turn runs under: the session's own
3051
2785
  # (Session#llm_context), else the effective model's (models:/hosts:
@@ -3059,57 +2793,82 @@ module Samagotchi
3059
2793
  private :llm_context_session_args
3060
2794
 
3061
2795
  # See #served_model. A report for another name (before a /model switch)
3062
- # doesn't count. Without a target, no probe. A remote chat host has no
3063
- # /props: nil until a turn.
3064
- def served_model_for(snapshot, target: nil)
3065
- 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)
3066
2801
  return [snapshot[:served_model], asked] if snapshot[:served_model] && snapshot[:served_model_for] == asked
3067
- return [nil, nil] unless target
2802
+ return [nil, nil] unless model
3068
2803
 
3069
- client = target.client
3070
- if target.entry.chat?
3071
- adapter = @host_registry.adapter_for(target.entry)
3072
- return [nil, nil] if adapter.remote?
3073
- end
3074
- served = ServedModel.from_props(client.server_props(model: target.bare_model))
3075
- 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.
3076
2807
  rescue StandardError
3077
2808
  [nil, nil]
3078
2809
  end
3079
2810
 
3080
- # What a local native host's cached /props names, when it isn't the
3081
- # model asked for; nil for a chat or remote host, or before any probe.
3082
- def prompt_served_model(target)
3083
- entry = target.entry
3084
- 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
3085
2813
 
3086
- served = ServedModel.from_props(target.client.cached_server_props(model: target.bare_model))
3087
- served if ServedModel.differs?(target.bare_model, served)
3088
- rescue StandardError
3089
- 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] }
3090
2818
  end
3091
2819
 
3092
- # ── 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)
3093
2825
 
3094
- def resolve_profile
3095
- if @given_profile
3096
- 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)
3097
2837
  end
2838
+ yield model
2839
+ end
3098
2840
 
3099
- target = @host_registry.resolve(@effective_model_name)
3100
- 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
3101
2850
  end
3102
2851
 
3103
- # The names a models: entry may be under (HostRegistry#lookup_names):
3104
- # the model as typed at start or at the last /model, and what it became.
3105
- def model_lookup_names(target)
3106
- @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
3107
2865
  end
3108
2866
 
3109
2867
  # Everything that holds a profile follows the resolution: the kernel's
3110
2868
  # prompt format and parser, and the system prompts built for the old one.
3111
2869
  def apply_profile(resolution)
3112
- @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
3113
2872
  @kernel.use_profile!(resolution)
3114
2873
  resolution
3115
2874
  end
@@ -3118,11 +2877,10 @@ module Samagotchi
3118
2877
  # server probe failed (unreachable, or 503 while loading a model). A
3119
2878
  # profile that changes here drops the cached system prompts.
3120
2879
  def refresh_profile!
3121
- if @profile_resolution&.retry?
3122
- @profile_resolution = apply_profile(resolve_profile)
3123
- else
3124
- profile_resolution
3125
- 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))
3126
2884
  end
3127
2885
  end
3128
2886
  end