samagotchi 0.10.1 → 0.11.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -1
  3. data/docs/architecture.md +19 -0
  4. data/docs/cli.md +24 -7
  5. data/docs/configuration.md +44 -11
  6. data/docs/guardrails.md +2 -1
  7. data/docs/hooks.md +3 -2
  8. data/docs/internals/context-telemetry.md +4 -2
  9. data/docs/sessions.md +3 -3
  10. data/lib/samagotchi/bootstrap/config_writer.rb +2 -2
  11. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +5 -3
  12. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  13. data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
  14. data/lib/samagotchi/bundles/skills/plugin.rb +3 -23
  15. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  16. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  17. data/lib/samagotchi/client.rb +21 -1
  18. data/lib/samagotchi/config.rb +44 -40
  19. data/lib/samagotchi/context_status.rb +17 -16
  20. data/lib/samagotchi/empty_answer_retry.rb +67 -8
  21. data/lib/samagotchi/engine.rb +64 -20
  22. data/lib/samagotchi/host_registry.rb +50 -90
  23. data/lib/samagotchi/kernel_loop.rb +288 -305
  24. data/lib/samagotchi/llm/chat_loop.rb +78 -87
  25. data/lib/samagotchi/model_profile.rb +22 -3
  26. data/lib/samagotchi/model_ref.rb +60 -0
  27. data/lib/samagotchi/prompt_history.rb +91 -0
  28. data/lib/samagotchi/recap_setup.rb +15 -3
  29. data/lib/samagotchi/self_report.rb +11 -6
  30. data/lib/samagotchi/send_command.rb +8 -8
  31. data/lib/samagotchi/session.rb +9 -2
  32. data/lib/samagotchi/session_commands.rb +10 -12
  33. data/lib/samagotchi/session_manager.rb +20 -3
  34. data/lib/samagotchi/sessions_command.rb +57 -51
  35. data/lib/samagotchi/steer.rb +23 -0
  36. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -2
  37. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -6
  38. data/lib/samagotchi/terminal_ui/input_support.rb +78 -33
  39. data/lib/samagotchi/terminal_ui/reline_history_hook.rb +93 -0
  40. data/lib/samagotchi/terminal_ui.rb +9 -8
  41. data/lib/samagotchi/tool_response.rb +67 -0
  42. data/lib/samagotchi/tool_runner.rb +6 -1
  43. data/lib/samagotchi/tools/delegate.rb +4 -3
  44. data/lib/samagotchi/tools/memory.rb +8 -6
  45. data/lib/samagotchi/version.rb +1 -1
  46. data/lib/samagotchi/web/app.rb +28 -2
  47. data/lib/samagotchi/web/public/app.js +29 -2
  48. data/lib/samagotchi/web/public/data.js +9 -1
  49. data/lib/samagotchi/web/public/prompt_history.js +63 -0
  50. data/lib/samagotchi/worker.rb +2 -1
  51. metadata +6 -1
@@ -8,6 +8,7 @@ require "set"
8
8
  require_relative "atomic_file"
9
9
  require_relative "config_text_edit"
10
10
  require_relative "log"
11
+ require_relative "model_ref"
11
12
  require_relative "paths"
12
13
 
13
14
  module Samagotchi
@@ -436,7 +437,44 @@ module Samagotchi
436
437
  else
437
438
  [key_problem(key)].compact
438
439
  end
440
+ end + model_ref_problems(data)
441
+ end
442
+
443
+ # What a model ref in config.yml won't do as written: an alias whose
444
+ # target is another alias (aliases apply once, ModelRef), a ref that
445
+ # starts with "<host>/" (only ':' names a host).
446
+ def model_ref_problems(data)
447
+ hosts = data["hosts"].is_a?(Hash) ? data["hosts"] : {}
448
+ aliases = data["model_aliases"].is_a?(Hash) ? data["model_aliases"] : {}
449
+ default = data["default"]["model"] if data["default"].is_a?(Hash)
450
+ refs = [["default.model", default], *aliases.map { |name, target| ["model_aliases.#{name}", target] }]
451
+ slash = refs.filter_map { |where, ref| host_slash_problem(where, ref, hosts) }
452
+ names = aliases.keys.map { |k| k.to_s.strip.downcase }
453
+ chains = aliases.filter_map do |name, target|
454
+ _, inner = ModelRef.split(target, hosts: hosts)
455
+ next unless names.include?(inner.downcase)
456
+
457
+ "config: model_aliases.#{name} points to the alias '#{inner}'; aliases don't chain, so '#{inner}' is sent as written"
439
458
  end
459
+ slash + chains + hosts.keys.filter_map { |name| family_host_problem(name.to_s) }
460
+ end
461
+
462
+ # Model families whose ids carry a ':' tag on Ollama and friends
463
+ # (qwen3:8b, llama3.1:70b): a host named like one takes those refs.
464
+ MODEL_FAMILY_HOST_RE = /\A(?:qwen|llama|codellama|gemma|mistral|mixtral|deepseek|phi|granite|starcoder|smollm|olmo)[\d.]*\z/i
465
+
466
+ def family_host_problem(name)
467
+ return nil unless name.match?(MODEL_FAMILY_HOST_RE)
468
+
469
+ "config: hosts.#{name} is named like a model family: a ref such as '#{name}:8b' (an Ollama tag) " \
470
+ "goes to host #{name} as '8b'; rename the host if you use such ids"
471
+ end
472
+
473
+ def host_slash_problem(where, ref, hosts)
474
+ prefix, rest = ref.to_s.strip.split("/", 2)
475
+ return nil if rest.to_s.empty? || !hosts.keys.map { |k| k.to_s.downcase }.include?(prefix.downcase)
476
+
477
+ "config: #{where} '#{ref}' starts with the host '#{prefix}' and '/': only ':' names a host now (#{prefix}:#{rest})"
440
478
  end
441
479
 
442
480
  # Dotted keys config.yml may set: config-exposed entries (snake and kebab
@@ -831,28 +869,9 @@ module Samagotchi
831
869
  end
832
870
 
833
871
  # Parse a model string that may be qualified as "host_alias:model"
834
- # Returns [host_alias_or_nil, bare_model]
872
+ # Returns [host_alias_or_nil, bare_model] (ModelRef.split)
835
873
  def parse_host_qualified_model(raw, hosts: nil)
836
- value = raw.to_s.strip
837
- return [nil, value] if value.empty?
838
- # Try split on first ':' or '/' where prefix matches a known host
839
- hosts_map = hosts || {}
840
- # Normalize keys downcase
841
- lowered_keys = hosts_map.keys.map(&:downcase)
842
- # Check ':' split
843
- if value.include?(":")
844
- prefix, rest = value.split(":", 2)
845
- if lowered_keys.include?(prefix.strip.downcase) && !rest.strip.empty?
846
- return [prefix.strip.downcase, rest.strip]
847
- end
848
- end
849
- if value.include?("/")
850
- prefix, rest = value.split("/", 2)
851
- if lowered_keys.include?(prefix.strip.downcase) && !rest.strip.empty?
852
- return [prefix.strip.downcase, rest.strip]
853
- end
854
- end
855
- [nil, value]
874
+ ModelRef.split(raw, hosts: hosts)
856
875
  end
857
876
 
858
877
  # Hosted providers' names, which no local model family uses: as a
@@ -989,25 +1008,10 @@ module Samagotchi
989
1008
  nil
990
1009
  end
991
1010
 
992
- def resolve_model_alias(raw, env: ENV, path: global_path(env: env), hosts: nil)
993
- value = raw.to_s.strip
994
- return value if value.empty?
995
-
996
- aliases = model_aliases(env: env, path: path)
997
- # Host-qualified handling: "host:alias" -> "host:resolved"
998
- # This allows /model small-box:small where "small" is an alias.
999
- if value.include?(":") || value.include?("/")
1000
- hosts_map = hosts || hosts_config(env: env, path: path)
1001
- host, bare = parse_host_qualified_model(value, hosts: hosts_map)
1002
- if host && !bare.to_s.strip.empty?
1003
- resolved_bare = aliases.fetch(bare.downcase, bare)
1004
- # Preserve the separator the user used (: or /)
1005
- sep = value.downcase.include?("#{host}:") ? ":" : (value.downcase.include?("#{host}/") ? "/" : ":")
1006
- return "#{host}#{sep}#{resolved_bare}"
1007
- end
1008
- end
1009
-
1010
- aliases.fetch(value.downcase, value)
1011
+ # +raw+ parsed against config.yml's hosts (or +hosts+) and aliases.
1012
+ # @return [ModelRef]
1013
+ def model_ref(raw, env: ENV, path: global_path(env: env), hosts: nil)
1014
+ ModelRef.parse(raw, hosts: hosts || hosts_config(env: env, path: path), aliases: model_aliases(env: env, path: path))
1011
1015
  end
1012
1016
 
1013
1017
  # Words /model takes as arguments (clear/default/none/off reset the
@@ -5,7 +5,7 @@ require_relative "context_usage"
5
5
  require_relative "context_window"
6
6
 
7
7
  module Samagotchi
8
- # One turn's context tracking for KernelLoop#run: how full the window is
8
+ # One turn's context tracking for either loop: how full the window is
9
9
  # before each request (the server's last prompt count plus an estimate for
10
10
  # what the turn appended since, else a chars-per-token estimate), the
11
11
  # bucket that puts it in, and what comes of that:
@@ -95,18 +95,18 @@ module Samagotchi
95
95
 
96
96
  def enabled? = @enabled
97
97
 
98
- # Before a request: estimate +prompt+'s usage against +window+ (this
99
- # request's ContextWindow::Resolved) and update the status line's
100
- # value. A rise into a bucket that asks the model for a change leaves
101
- # a line for #take_guidance.
102
- # +image_tokens+: the images' estimate (their base64 is not in +prompt+).
98
+ # Before a request: estimate the usage of a prompt of +prompt_chars+
99
+ # against +window+ (this request's ContextWindow::Resolved) and update
100
+ # the status line's value. A rise into a bucket that asks the model for
101
+ # a change leaves a line for #take_guidance.
102
+ # +image_tokens+: the images' estimate (their base64 is not in the chars).
103
103
  #
104
104
  # @return [Hash, nil] the :context_status event's fields (without
105
105
  # :type/:iteration) when the emit gate fires, else nil
106
- def observe(prompt, iteration_index:, window:, image_tokens: 0)
106
+ def observe(prompt_chars, iteration_index:, window:, image_tokens: 0)
107
107
  return nil unless @enabled
108
108
 
109
- usage = estimate(prompt, server_usage: @server_usage, window: window, image_tokens: image_tokens, counted: @counted)
109
+ usage = estimate(prompt_chars, server_usage: @server_usage, window: window, image_tokens: image_tokens, counted: @counted)
110
110
  bucket = bucket_for(usage[:estimated_pct])
111
111
  # The status line's value, every iteration; the gate below decides
112
112
  # only the event and the model's guidance line.
@@ -135,7 +135,8 @@ module Samagotchi
135
135
  normalized
136
136
  end
137
137
 
138
- # After a generation: +usage+ is its own server counts (nil: none), for
138
+ # After a generation: +usage+ is its own server counts ({prompt_tokens:,
139
+ # total_tokens:, context_window_tokens:}; nil: none), for
139
140
  # +prompt_chars+/+image_tokens+ as sent. The next estimate adds only
140
141
  # what the turn appended since (answer, tool results), and the status
141
142
  # line's value counts the answer. Without counts the pre-generation
@@ -161,21 +162,21 @@ module Samagotchi
161
162
  # `window` is this request's ContextWindow::Resolved. A window the
162
163
  # stream payload reports itself still wins.
163
164
  # +counted+: the prompt the server's prompt_tokens counted ({chars:,
164
- # image_tokens:}); what +prompt+ has on top of it (the answer, tool
165
+ # image_tokens:}); what the prompt has on top of it (the answer, tool
165
166
  # results since) is added as an estimate, so the value doesn't read low
166
167
  # during a long tool loop. A prompt shorter than that one (trimmed) or
167
168
  # none known: the server's count alone.
168
- def estimate(prompt, window:, server_usage: nil, image_tokens: 0, counted: nil)
169
+ def estimate(prompt_chars, window:, server_usage: nil, image_tokens: 0, counted: nil)
169
170
  window_source = window.source
170
171
  window_source = :server if server_usage && server_usage[:context_window_tokens]
171
172
 
172
173
  if server_usage && server_usage[:prompt_tokens]
173
174
  window_tokens = server_usage[:context_window_tokens] || window.tokens
174
- used_tokens = server_usage[:prompt_tokens] + appended_tokens(prompt, image_tokens, counted)
175
+ used_tokens = server_usage[:prompt_tokens] + appended_tokens(prompt_chars, image_tokens, counted)
175
176
  source = "server"
176
177
  else
177
178
  window_tokens = window.tokens
178
- used_tokens = (prompt.length / @chars_per_token).ceil + image_tokens
179
+ used_tokens = (prompt_chars / @chars_per_token).ceil + image_tokens
179
180
  source = "estimate"
180
181
  end
181
182
 
@@ -191,11 +192,11 @@ module Samagotchi
191
192
 
192
193
  private
193
194
 
194
- # The estimate for what +prompt+ added since the +counted+ one.
195
- def appended_tokens(prompt, image_tokens, counted)
195
+ # The estimate for what the prompt added since the +counted+ one.
196
+ def appended_tokens(prompt_chars, image_tokens, counted)
196
197
  return 0 unless counted
197
198
 
198
- chars = prompt.length - counted[:chars]
199
+ chars = prompt_chars - counted[:chars]
199
200
  return 0 unless chars.positive?
200
201
 
201
202
  (chars / @chars_per_token).ceil + [image_tokens - counted[:image_tokens].to_i, 0].max
@@ -1,14 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "config"
4
+ require_relative "log"
4
5
  require_relative "turn_note"
5
6
 
6
7
  module Samagotchi
7
8
  # Asking the model again, in the same turn, when a generation ends with no
8
9
  # visible text and no tool calls (thinking only, or nothing): a hidden
9
- # nudge (TurnNote.empty_retry), then another generation. Both loops use
10
- # it; the Engine's TurnNote.empty is what's left when the retries run out.
11
- module EmptyAnswerRetry
10
+ # nudge (TurnNote.empty_retry), then another generation. A generation a
11
+ # plugin cut (stop_generation) spends the same budget with its own nudge.
12
+ # Both loops keep one per turn; the Engine's TurnNote.empty is what's left
13
+ # when the retries run out.
14
+ class EmptyAnswerRetry
12
15
  MAX = 3
13
16
  # Greedy decoding on a near-identical prompt would likely loop again:
14
17
  # the retry runs at this temperature unless one is configured.
@@ -17,10 +20,8 @@ module Samagotchi
17
20
  # is retried unless the context is this full.
18
21
  CONTEXT_FULL_RATIO = 0.9
19
22
 
20
- module_function
21
-
22
23
  # retry.empty_answer, 0..MAX.
23
- def limit
24
+ def self.limit
24
25
  Config.get("retry.empty_answer").to_i.clamp(0, MAX)
25
26
  rescue StandardError
26
27
  1
@@ -28,16 +29,74 @@ module Samagotchi
28
29
 
29
30
  # The request options of a retry generation: the turn's sampling, with
30
31
  # TEMPERATURE when it sets none (a configured nil keeps "don't send").
31
- def sampling(base)
32
+ def self.sampling(base)
32
33
  base = base || {}
33
34
  base.key?(:temperature) ? base : base.merge(temperature: TEMPERATURE)
34
35
  end
35
36
 
36
37
  # The prompt filled the window (a length stop is then no loop to break).
37
- def context_full?(used_tokens, window_tokens)
38
+ def self.context_full?(used_tokens, window_tokens)
38
39
  return false unless used_tokens.to_i.positive? && window_tokens.to_i.positive?
39
40
 
40
41
  used_tokens.to_f / window_tokens >= CONTEXT_FULL_RATIO
41
42
  end
43
+
44
+ attr_reader :limit
45
+
46
+ def initialize(limit: self.class.limit)
47
+ @limit = limit
48
+ @attempts = 0
49
+ @armed = false
50
+ end
51
+
52
+ # An attempt is left.
53
+ def left? = @attempts < @limit
54
+
55
+ # A nudge went out this turn.
56
+ def used? = @attempts.positive?
57
+
58
+ # An empty answer is asked again: an attempt is left, the turn wasn't
59
+ # cancelled, and the answer wasn't cut short by a full context (a
60
+ # length stop while thinking is retried: a thinking loop cut by the
61
+ # output cap, not a full window).
62
+ def retry_empty?(iteration:, cancelled:, finish_reason: nil, used_tokens: nil, window_tokens: nil)
63
+ return false if !left? || cancelled
64
+
65
+ if finish_reason.to_s == "length" && self.class.context_full?(used_tokens, window_tokens)
66
+ Log.info(:turn, "empty_answer_not_retried", iteration: iteration, why: "context full")
67
+ return false
68
+ end
69
+ true
70
+ end
71
+
72
+ # Spends an attempt: :empty_answer_retry (with +fields+) goes out, +note+
73
+ # goes on the tail, and the next request runs at the retry sampling.
74
+ # Returns true.
75
+ def nudge!(conversation, note, emit:, iteration:, **fields)
76
+ @attempts += 1
77
+ @armed = true
78
+ emit.call({ type: :empty_answer_retry, iteration: iteration, attempt: @attempts, of: @limit, **fields })
79
+ conversation << note
80
+ true
81
+ end
82
+
83
+ # Whether this request is the retry (once per nudge).
84
+ def take_sampling!
85
+ armed = @armed
86
+ @armed = false
87
+ armed
88
+ end
89
+
90
+ # This request's sampling: the retry's once after a nudge, else +base+.
91
+ def request_sampling(base)
92
+ take_sampling! ? self.class.sampling(base) : base
93
+ end
94
+
95
+ # The last spent nudge goes (a retry that failed too: the Engine's
96
+ # TurnNote.empty says it all).
97
+ def drop_nudge!(conversation)
98
+ index = conversation.rindex { |entry| TurnNote.retry_nudge?(entry) }
99
+ conversation.delete_at(index) if index
100
+ end
42
101
  end
43
102
  end
@@ -76,20 +76,27 @@ module Samagotchi
76
76
  # @param client [Client, nil] defaults to Client.new
77
77
  # @param profile [ModelProfile, Symbol, String, nil]
78
78
  # @param session_id [String, nil] resume an existing session
79
- # @param no_interrupt [Boolean]
79
+ # @param no_interrupt [Boolean] --no-interrupt: every turn runs with
80
+ # NO_INTERRUPT_MAX_ITERATIONS, whichever loop runs it
80
81
  # @param model_name [String, nil] defaults from SAMAGOTCHI_DEFAULT_MODEL
82
+ # @param model_typed [String, nil] the name the model was given as (a session's
83
+ # model_typed: an alias), for the models: lookup; model_name by default
81
84
  # @param memories [Array<String>] explicit --memory preload list (merged with the config.yml `memories:` baseline)
82
85
  # @param muted_memories [Array<String>] --mute list: memories hidden from this session (not in the
83
86
  # prompt's index, dropped from the preloads, refused by memory_read); a mute wins over a preload
84
87
  # What memory_write answers in a scratch session.
85
88
  SCRATCH_MEMORY_WRITE = "Error: scratch session: nothing is saved"
89
+ # A turn's iteration limit with no_interrupt (the worker's own
90
+ # --no-interrupt turns pass the same).
91
+ NO_INTERRUPT_MAX_ITERATIONS = 1000
86
92
 
87
93
  # @param plugins [Boolean] false: load no bundle plugins (a throwaway Engine for a prompt)
88
94
  # @param scratch [Boolean] a `chi scratch` session: memory writes are refused, and there is no
89
95
  # delegate (a child would outlive it) nor plugin fork
90
96
  def initialize(client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, model_name: nil, memories: [], muted_memories: [], kernel: nil, recap: nil, reminders: nil,
91
- plugins: true, scratch: false)
97
+ plugins: true, scratch: false, model_typed: nil)
92
98
  @scratch = scratch
99
+ @no_interrupt = no_interrupt
93
100
  @chat_backend = nil
94
101
  @chat_backend_mutex = Mutex.new
95
102
  @host_registry = host_registry || HostRegistry.new
@@ -106,7 +113,7 @@ module Samagotchi
106
113
  # #profile_resolution decides on first need (see there), so building an
107
114
  # Engine makes no network call.
108
115
  @given_profile = profile ? ModelProfile.normalize(profile) : nil
109
- @typed_model_name = @default_model_name
116
+ @typed_model_name = model_typed.to_s.strip.empty? ? @default_model_name : model_typed.to_s.strip
110
117
  @profile_resolution = nil
111
118
  # Ensure the built-in system bundle is installed (lazy, warn-only).
112
119
  # This is the single seam for both TUI and non-TUI (web/worker) paths.
@@ -196,11 +203,10 @@ module Samagotchi
196
203
  @reminders = build_reminders(auto_turn_callback: callback)
197
204
  # Track whether this is the first turn in the session (for session_start event)
198
205
  @first_turn = true
199
- @kernel = kernel || KernelLoop.new(client: @client, profile: @given_profile, no_interrupt: no_interrupt, hooks: @hooks, reminder_store: @reminder_store,
206
+ @kernel = kernel || KernelLoop.new(client: @client, profile: @given_profile, hooks: @hooks, reminder_store: @reminder_store,
200
207
  tools: @tools)
201
208
  sync_kernel_client!
202
- @model_key = ModelOverlay.key_for(bare_model_name(@effective_model_name))
203
- @kernel.sync_model_key!(@model_key) if @kernel.respond_to?(:sync_model_key!)
209
+ sync_model_key!
204
210
  # The mutes never change during a session, so no re-sync: the kernel's
205
211
  # memory_read guard reads the same list for every turn.
206
212
  @muted_memory_names = MutedMemories.normalize_list(muted_memories)
@@ -426,7 +432,30 @@ module Samagotchi
426
432
 
427
433
  # @return [SessionMetrics] the per-session analytics collector
428
434
  attr_reader :metrics
429
- attr_reader :default_model_name, :effective_model_name
435
+ # effective_model_name: the model as given (maybe an alias; HostRegistry#resolve
436
+ # applies it); typed_model_name: as typed at start or at the last /model.
437
+ attr_reader :default_model_name, :effective_model_name, :typed_model_name
438
+
439
+ # The resolved ref a session stores for the effective model (ModelRef#ref:
440
+ # the alias applied, "host:id" when it names a host).
441
+ def effective_model_ref
442
+ model_ref_for(@effective_model_name)
443
+ end
444
+
445
+ # +name+'s resolved ref (ModelRef#ref).
446
+ def model_ref_for(name)
447
+ @host_registry.model_ref(name).ref
448
+ end
449
+
450
+ # Writes the effective model to +session+: the resolved ref, and the
451
+ # name as typed when that differs (model_typed: an alias, for the
452
+ # models: lookup after a resume).
453
+ def store_model!(session)
454
+ ref = effective_model_ref
455
+ session.model_name = ref
456
+ session.model_typed = @typed_model_name.to_s == ref ? nil : @typed_model_name if session.respond_to?(:model_typed=)
457
+ session
458
+ end
430
459
 
431
460
  # The effective model's key (ModelOverlay.key_for its bare name): memory
432
461
  # overlays, guardrail rules' models:.
@@ -453,10 +482,23 @@ module Samagotchi
453
482
  # built-ins, and the ones bundle plugins add
454
483
  attr_reader :command_registry
455
484
 
485
+ # The model id sent for +full_ref+ (its alias applied, the host prefix off).
456
486
  def bare_model_name(full_ref)
457
487
  @host_registry.bare_name(full_ref)
458
488
  end
459
489
 
490
+ # The memory overlay key follows the model sent (ModelOverlay.key_for
491
+ # its id). A model typed as an alias used to key overlays by the alias
492
+ # (before aliases were applied there): that key is read when the
493
+ # target's overlay is missing (KernelLoop#sync_model_key!).
494
+ def sync_model_key!
495
+ @model_key = ModelOverlay.key_for(bare_model_name(@effective_model_name))
496
+ typed_key = ModelOverlay.key_for(@host_registry.parse_qualified_model(@typed_model_name).last)
497
+ fallback = typed_key == @model_key ? nil : typed_key
498
+ @kernel.sync_model_key!(@model_key, fallback: fallback) if @kernel.respond_to?(:sync_model_key!)
499
+ end
500
+ private :sync_model_key!
501
+
460
502
  # The effective model's bare name for the guardrails' models: rules;
461
503
  # nil without a model name.
462
504
  def guardrail_model_name
@@ -762,27 +804,29 @@ module Samagotchi
762
804
 
763
805
  # ── Model switching ────────────────────────────────────────────────────────
764
806
 
765
- def switch_model!(model_name, persist_default: false)
766
- # Resolve alias first (alias may point to qualified ref)
767
- aliased = ConfigFile.resolve_model_alias(model_name)
768
- resolved = ModelProfile.check_host!(ModelProfile.required_model_name(aliased), hosts: @host_registry.entries)
807
+ # @param typed [String, nil] the name the model was typed as when
808
+ # +model_name+ is a stored ref (a resumed session's model_typed)
809
+ # @return [String] the resolved ref the session stores (ModelRef#ref)
810
+ def switch_model!(model_name, persist_default: false, typed: nil)
811
+ # The alias is applied where the model is resolved (HostRegistry#resolve),
812
+ # once; checked here: an unknown host, an alias naming another host.
813
+ resolved = ModelProfile.check_host!(ModelProfile.required_model_name(model_name), hosts: @host_registry.entries)
769
814
  @effective_model_name = resolved
770
- bare = bare_model_name(resolved)
771
- @typed_model_name = model_name
815
+ @typed_model_name = typed.to_s.strip.empty? ? model_name : typed.to_s.strip
772
816
  # A profile given to .new was for the starting model.
773
817
  @given_profile = nil
774
818
  @profile_resolution = nil
775
- @model_key = ModelOverlay.key_for(bare)
776
- @kernel.sync_model_key!(@model_key) if @kernel.respond_to?(:sync_model_key!)
819
+ sync_model_key!
777
820
  @prompt_builder.reset!
778
821
  sync_kernel_client!
779
822
  @client.invalidate_context_window! if @client.respond_to?(:invalidate_context_window!)
780
823
  @metrics.forget_model_reports!
824
+ ref = effective_model_ref
781
825
  if persist_default
782
- ConfigFile.write_default_model!(resolved)
783
- @default_model_name = resolved
826
+ ConfigFile.write_default_model!(ref)
827
+ @default_model_name = ref
784
828
  end
785
- resolved
829
+ ref
786
830
  end
787
831
 
788
832
  # ── Hooks API ──────────────────────────────────────────────────────────────
@@ -968,7 +1012,7 @@ module Samagotchi
968
1012
  preloaded_memory_names: preloaded_memory_names,
969
1013
  muted_memory_names: @muted_memory_names.dup,
970
1014
  parent_id: @session&.parent_id,
971
- model_name: @effective_model_name,
1015
+ model_name: effective_model_ref,
972
1016
  served_model: served_pair[0],
973
1017
  served_model_for: served_pair[1],
974
1018
  context_status: @last_context_status&.dup || saved_context_status(metrics[:context]),
@@ -1580,7 +1624,7 @@ module Samagotchi
1580
1624
 
1581
1625
  result = backend.complete(
1582
1626
  messages: messages,
1583
- max_iterations: max_iterations,
1627
+ max_iterations: @no_interrupt ? NO_INTERRUPT_MAX_ITERATIONS : max_iterations,
1584
1628
  on_stream_event: build_stream_event_handler(on_event, cancel_controller: effective_controller),
1585
1629
  cancel_controller: effective_controller,
1586
1630
  model_name: bare_for_backend,