samagotchi 0.94.0 → 0.95.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.
@@ -84,7 +84,6 @@ module Samagotchi
84
84
  # The prompt profile for every model in this process (ModelProfile::NAMES).
85
85
  # No config key: per-model and per-host profiles live in models: and hosts:.
86
86
  Entry.new(key: "model.profile", yaml_path: %w[model profile], type: :enum, default: nil, expose: %i[env cli], enum_values: %w[qwen36 gemma4]),
87
- Entry.new(key: "server.transport", yaml_path: %w[server transport], type: :enum, default: "llama_cpp", expose: %i[env config cli], enum_values: %w[llama_cpp mlx omlx], lifecycle: :worker_start),
88
87
  Entry.new(key: "server.host", yaml_path: %w[server host], type: :string, default: "localhost", expose: %i[env config cli], lifecycle: :worker_start),
89
88
  Entry.new(key: "server.port", yaml_path: %w[server port], type: :integer, default: 8080, expose: %i[env config cli], lifecycle: :worker_start),
90
89
  Entry.new(key: "server.open_timeout", yaml_path: %w[server open_timeout], type: :integer, default: 10, expose: %i[env config cli], lifecycle: :worker_start),
@@ -205,7 +204,6 @@ module Samagotchi
205
204
  # The policy sentence forget_outputs' description carries (the forget layer); blank: none.
206
205
  Entry.new(key: "llm_context.policy", yaml_path: %w[llm_context policy], type: :string, default: LLMContextStrategy::DEFAULT_POLICY, expose: %i[env config], lifecycle: :worker_start),
207
206
 
208
- Entry.new(key: "thinking.turn_preamble", yaml_path: %w[thinking turn_preamble], type: :bool, default: true, expose: %i[env config cli], lifecycle: :prompt_rebuild),
209
207
  # How much models think: off|low|medium|high|default, parsed by Thinking
210
208
  # (a :string, since YAML reads an unquoted off as false). The CLI flag
211
209
  # is bin/chi's own --thinking; models: and hosts: entries rank below
@@ -312,7 +310,7 @@ module Samagotchi
312
310
  # Maps of named entries and the keys an entry may hold
313
311
  # (ConfigFile.hosts_config, ConfigFile.model_settings).
314
312
  MAP_ENTRY_KEYS = {
315
- "hosts" => %w[host port url transport api api_key_env auth dialect profile first_token_timeout vision sampling thinking
313
+ "hosts" => %w[host port url api api_key_env auth dialect profile first_token_timeout vision sampling thinking
316
314
  reasoning_history enabled remote window_tokens steer_cut_after llm_context_strategy llm_context_apply llm_context_budget_tokens models].freeze,
317
315
  "models" => %w[profile vision sampling thinking reasoning_history window_tokens steer_cut_after llm_context_strategy llm_context_apply
318
316
  llm_context_budget_tokens].freeze
@@ -644,6 +642,7 @@ module Samagotchi
644
642
  # nil when +dotted+ is a key the file may hold.
645
643
  def key_problem(dotted)
646
644
  return nil if known_config_keys.include?(dotted)
645
+ return "config: #{ConfigFile.removed_transport_problem("'#{dotted}'")}" if dotted == "server.transport"
647
646
  if (replaced = REPLACED_KEYS[dotted])
648
647
  return "config: unknown key '#{dotted}': replaced by '#{replaced}' (hard, standard or soft; see docs/guardrails.md)"
649
648
  end
@@ -669,6 +668,8 @@ module Samagotchi
669
668
  next [] unless entry.is_a?(Hash)
670
669
 
671
670
  (entry.keys.map(&:to_s) - allowed).map do |k|
671
+ next "config: #{ConfigFile.removed_transport_problem("'hosts.#{name}.transport'")}" if map == "hosts" && k == "transport"
672
+
672
673
  unknown_key_message("#{map}.#{name}.#{k}", k, allowed, prefix: "#{map}.#{name}.")
673
674
  end + (map == "hosts" ? host_model_problems(name, entry["models"]) : [])
674
675
  end
@@ -887,13 +888,17 @@ module Samagotchi
887
888
  end
888
889
 
889
890
  # Check config.yml once at start (warnings for keys the code doesn't
890
- # read) and clear the CLI settings. Nothing is copied into ENV: every
891
+ # read, and for SAMAGOTCHI_SERVER_TRANSPORT) and clear the CLI settings. Nothing is copied into ENV: every
891
892
  # Config.get reads the file again when it changed, so a long-lived
892
893
  # process (chi web) and the workers it spawns see edits, and a file
893
894
  # value reports origin :file. Returns true if the file existed.
894
895
  def load!(env: ENV, path: global_path(env: env))
895
896
  existed = File.file?(path)
896
897
  raw = read_yaml(env: env, path: path) || {}
898
+ check_removed_apis!(raw, env: env)
899
+ unless env[REMOVED_TRANSPORT_ENV].to_s.strip.empty?
900
+ Log.warn(:config, "config_key", echo: "Warning: #{removed_transport_problem(REMOVED_TRANSPORT_ENV)}")
901
+ end
897
902
  if existed
898
903
  Samagotchi::Config.validate_yaml_sections(raw).each { |w| Log.warn(:config, "config_key", echo: "Warning: #{w}") }
899
904
  end
@@ -901,6 +906,39 @@ module Samagotchi
901
906
  existed
902
907
  end
903
908
 
909
+ # Raises RemovedApi for the first api/transport in +data+ (config.yml)
910
+ # or the environment that names a removed one: config.yml's
911
+ # hosts.<name>.api, and the removed transport keys (server.transport,
912
+ # hosts.<name>.transport, SAMAGOTCHI_SERVER_TRANSPORT), which with
913
+ # another value only warn (#removed_transport_problem). Run once at
914
+ # start (#load!); a hosts entry read later says the same through
915
+ # HostConfig.parse and is left out.
916
+ def check_removed_apis!(data, env: ENV)
917
+ env_value = env[REMOVED_TRANSPORT_ENV]
918
+ raise RemovedApi.new(REMOVED_TRANSPORT_ENV, env_value.to_s.strip) if removed_api?(env_value)
919
+
920
+ server = data["server"] if data.is_a?(Hash)
921
+ value = server["transport"] if server.is_a?(Hash)
922
+ raise RemovedApi.new("config.yml server.transport", value) if removed_api?(value)
923
+
924
+ hosts = data[HOSTS_KEY] if data.is_a?(Hash)
925
+ return unless hosts.is_a?(Hash)
926
+
927
+ hosts.each do |name, cfg|
928
+ next unless cfg.is_a?(Hash)
929
+
930
+ %w[api transport].each do |key|
931
+ value = cfg[key]
932
+ raise RemovedApi.new("config.yml hosts.#{name}.#{key}", value) if removed_api?(value)
933
+ end
934
+ end
935
+ end
936
+
937
+ # Whether +value+ (as written) names a removed api/transport.
938
+ def removed_api?(value)
939
+ REMOVED_APIS.include?(value.to_s.strip.downcase)
940
+ end
941
+
904
942
  def config_dir(env: ENV)
905
943
  File.join(Paths.config_home(env: env), CONFIG_DIR)
906
944
  end
@@ -909,18 +947,42 @@ module Samagotchi
909
947
  File.join(config_dir(env: env), CONFIG_FILE)
910
948
  end
911
949
 
950
+ # A key chi no longer reads, set to anything but a removed api (that is
951
+ # a RemovedApi): config.yml server.transport, a hosts entry's
952
+ # transport: or the environment variable. llama.cpp is the one
953
+ # raw-prompt transport, so the key changes nothing: a warning, the
954
+ # config still loads. +where+ names it as the reader shows it.
955
+ def removed_transport_problem(where)
956
+ "#{where} was removed: llama.cpp is chi's only raw-prompt transport " \
957
+ "(a hosts entry without api: is api: llama_cpp); remove it"
958
+ end
959
+
960
+ REMOVED_TRANSPORT_ENV = "SAMAGOTCHI_SERVER_TRANSPORT"
912
961
  HOSTS_KEY = "hosts"
913
- VALID_TRANSPORTS_FOR_CONFIG = %w[llama_cpp mlx omlx].freeze
914
- # How chi talks to a host: a raw-prompt api (also the Client transport),
915
- # or a chat api the chat loop talks (LLM::Adapters): openai (Chat
916
- # Completions against <host>/v1) or anthropic (Messages).
917
- VALID_APIS_FOR_CONFIG = (VALID_TRANSPORTS_FOR_CONFIG + %w[openai anthropic]).freeze
962
+ # api/transport values chi no longer has: its own mlx_lm.server and
963
+ # oMLX transports, gone for their OpenAI chat endpoint (api: openai).
964
+ REMOVED_APIS = %w[mlx omlx].freeze
965
+
966
+ # A config naming a removed api/transport (REMOVED_APIS). The message
967
+ # is the one line the user sees: where, the value, what to write.
968
+ class RemovedApi < StandardError
969
+ # @param where [String] e.g. "config.yml hosts.mac.api"
970
+ # @param value [String] the removed value as written
971
+ def initialize(where, value)
972
+ super("#{where}: #{value} was removed; for mlx_lm.server or oMLX use url: http://HOST:PORT/v1, api: openai in a hosts: entry")
973
+ end
974
+ end
975
+ # How chi talks to a host: llama_cpp (the raw-prompt loop over
976
+ # llama.cpp's own API, also what a hosts entry without api: talks), or a
977
+ # chat api the chat loop talks (LLM::Adapters): openai (Chat Completions
978
+ # against <host>/v1) or anthropic (Messages).
979
+ VALID_APIS_FOR_CONFIG = %w[llama_cpp openai anthropic].freeze
918
980
  HOST_NAME_RE = /\A[a-z0-9][a-z0-9._-]*\z/i
919
981
  ENV_NAME_RE = /\A[A-Za-z_][A-Za-z0-9_]*\z/
920
982
 
921
983
  # config.yml's hosts: by lowercased name; an entry that is disabled or
922
984
  # invalid (warned) is left out. No entry left: "default", from
923
- # server.host/port/transport.
985
+ # server.host/port.
924
986
  # @return [Hash{String => HostConfig}]
925
987
  def hosts_config(env: ENV, path: global_path(env: env))
926
988
  data = read_yaml(env: env, path: path)
@@ -941,17 +1003,14 @@ module Samagotchi
941
1003
  {}
942
1004
  end
943
1005
 
944
- # The host chi uses when hosts: names none: server.host/port/transport.
1006
+ # The host chi uses when hosts: names none: server.host/port.
945
1007
  def default_host_config(data, env)
946
1008
  opts = { file_data: data, env: env, cli_overrides: Samagotchi::Config.cli_overrides }
947
1009
  host = Samagotchi::Config.resolve("server.host", **opts).to_s.strip
948
1010
  host = "localhost" if host.empty?
949
1011
  port = Samagotchi::Config.resolve("server.port", **opts).to_i
950
1012
  port = 8080 if port <= 0 || port > 65_535
951
- transport = Samagotchi::Config.resolve_with_origin("server.transport", **opts).then do |value, origin|
952
- origin == :default ? nil : value.to_sym
953
- end
954
- HostConfig.new(name: "default", host: host, port: port, transport: transport)
1013
+ HostConfig.new(name: "default", host: host, port: port)
955
1014
  end
956
1015
  private_class_method :default_host_config
957
1016
 
@@ -1627,8 +1627,8 @@ module Samagotchi
1627
1627
  # prompt prefix, and the server's KV cache for it, stay stable.
1628
1628
  # @param model [EffectiveModel, nil]
1629
1629
  # @return [String]
1630
- # The native prompt depends on the thinking level too (Gemma's token,
1631
- # Qwen's turn preamble): a level change builds it again. A build that
1630
+ # The native prompt depends on the thinking level too (Gemma's token):
1631
+ # a level change builds it again. A build that
1632
1632
  # misses the cache names the model and the level through its own
1633
1633
  # lambdas (#model_identity, #turn_thinking), which resolve again.
1634
1634
  def system_prompt(model = nil)
@@ -1644,8 +1644,8 @@ module Samagotchi
1644
1644
  end
1645
1645
 
1646
1646
  # The base prompt (specs, plugins' declarations).
1647
- def assist_system_prompt(chat: false, thinking: nil)
1648
- @prompt_builder.base(chat: chat, thinking: thinking)
1647
+ def assist_system_prompt(chat: false)
1648
+ @prompt_builder.base(chat: chat)
1649
1649
  end
1650
1650
 
1651
1651
  # The --memory names activated while building the prompt; the TerminalUI
@@ -2048,10 +2048,10 @@ module Samagotchi
2048
2048
 
2049
2049
  # Never a paid or remote host (cache.warmup auto means local only), nor
2050
2050
  # the chat loop (Splash keeps its own cache; llama.cpp's chat path is
2051
- # not measured), nor a non-llama.cpp transport.
2051
+ # not measured), nor a client that can't warm up (a spec's stub).
2052
2052
  def warmup_target?(model, client)
2053
2053
  return false if model.chat? || model.entry.remote?
2054
- return false unless client.respond_to?(:warm_up) && client.respond_to?(:transport) && client.transport.native?
2054
+ return false unless client.respond_to?(:warm_up)
2055
2055
 
2056
2056
  PromptWarmup.enabled?
2057
2057
  end
@@ -2095,7 +2095,7 @@ module Samagotchi
2095
2095
  # could change what a resolve gives (the hooks ran before).
2096
2096
  model = effective_model
2097
2097
  sync_kernel_client!(model)
2098
- # Route model name as bare (without host prefix) to the transport;
2098
+ # Route model name as bare (without host prefix) to the server;
2099
2099
  # host selection already done via active client.
2100
2100
  bare_for_backend = bare_model_name(model.name)
2101
2101
  # The turn's settings, set once here. The chat loop dispatches tools
@@ -13,13 +13,13 @@ module Samagotchi
13
13
  # one).
14
14
  #
15
15
  # name: the lowercased entry name; host, port, scheme: where it is (from
16
- # url: when given); url: the configured url, nil when host/port; transport,
17
- # api: symbols, nil when unset; auth: the header the API key goes in
16
+ # url: when given); url: the configured url, nil when host/port; api: a
17
+ # symbol, nil when unset (llama_cpp's raw-prompt loop); auth: the header the API key goes in
18
18
  # (LLM::ApiKey::SCHEMES), nil when unset; dialect: an api: anthropic
19
19
  # host's LLM::Dialect name, nil when unset; the rest as
20
20
  # HostRegistry::HostEntry describes them, nil when unset; models:
21
21
  # {downcased id => HostModel}.
22
- HostConfig = Data.define(:name, :host, :port, :scheme, :url, :transport, :api, :api_key_env, :auth, :dialect, :profile,
22
+ HostConfig = Data.define(:name, :host, :port, :scheme, :url, :api, :api_key_env, :auth, :dialect, :profile,
23
23
  :first_token_timeout, :vision, :sampling, :thinking, :reasoning_history, :remote,
24
24
  :window_tokens, :steer_cut_after, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
25
25
  :models) do
@@ -79,10 +79,10 @@ module Samagotchi
79
79
  ConfigFile.warn_once "Warning: #{where}: auth has no effect without api_key_env (no key is sent)"
80
80
  end
81
81
 
82
- kinds = parse_kinds(name, value.call(:transport), value.call(:api)) or return nil
83
- location = parse_location(name, value, api: kinds[:api]) or return nil
84
- fields[:dialect] = LLM::Dialect.parse(value.call(:dialect), where, api: kinds[:api])
85
- new(name: name.downcase, **location, **kinds, **fields)
82
+ kind = parse_api(name, value.call(:api), value.call(:transport)) or return nil
83
+ location = parse_location(name, value, api: kind[:api]) or return nil
84
+ fields[:dialect] = LLM::Dialect.parse(value.call(:dialect), where, api: kind[:api])
85
+ new(name: name.downcase, **location, **kind, **fields)
86
86
  rescue StandardError => e
87
87
  # One entry that trips a reader is dropped on its own, and said so;
88
88
  # the other hosts stay (the worker's copy too).
@@ -143,29 +143,22 @@ module Samagotchi
143
143
  end
144
144
  private_class_method :parse_location
145
145
 
146
- # transport: and api: as symbols; a raw-prompt api is also the
147
- # transport. nil (warned) when unknown or contradicting each other.
148
- def self.parse_kinds(name, transport, api)
149
- transport = transport.to_s.strip.downcase
150
- if transport.empty?
151
- transport = nil
152
- elsif !ConfigFile::VALID_TRANSPORTS_FOR_CONFIG.include?(transport)
153
- return invalid(name, "unknown transport '#{transport}'")
146
+ # { api: } (a symbol, nil when not given), or nil (warned) when the api
147
+ # is unknown. A removed api (RemovedApi) in api: or in the old
148
+ # transport: key leaves the entry out with its line; any other
149
+ # transport: is only warned about at start
150
+ # (ConfigFile.removed_transport_problem).
151
+ def self.parse_api(name, api, transport)
152
+ %w[api transport].zip([api, transport]).each do |key, written|
153
+ return invalid(name, ConfigFile::RemovedApi.new("hosts.#{name}.#{key}", written.to_s.strip).message) if ConfigFile.removed_api?(written)
154
154
  end
155
155
  api = api.to_s.strip.downcase
156
- if api.empty?
157
- api = nil
158
- elsif !ConfigFile::VALID_APIS_FOR_CONFIG.include?(api)
159
- return invalid(name, "unknown api '#{api}'")
160
- elsif ConfigFile::VALID_TRANSPORTS_FOR_CONFIG.include?(api)
161
- # A raw-prompt api is the transport; a different transport contradicts it.
162
- return invalid(name, "api '#{api}' conflicts with transport '#{transport}'") if transport && transport != api
163
-
164
- transport = api
165
- end
166
- { transport: transport&.to_sym, api: api&.to_sym }
156
+ return { api: nil } if api.empty?
157
+ return invalid(name, "unknown api '#{api}'") unless ConfigFile::VALID_APIS_FOR_CONFIG.include?(api)
158
+
159
+ { api: api.to_sym }
167
160
  end
168
- private_class_method :parse_kinds
161
+ private_class_method :parse_api
169
162
 
170
163
  def self.invalid(name, reason)
171
164
  ConfigFile.warn_once "Warning: ignoring hosts entry '#{name}': #{reason}"
@@ -10,7 +10,7 @@ require_relative "llm/adapters"
10
10
  require_relative "llm/dialect"
11
11
 
12
12
  module Samagotchi
13
- # HostRegistry manages multiple model hosts (llama.cpp / mlx / oMLX) and
13
+ # HostRegistry manages multiple model hosts (llama.cpp, OpenAI-compatible, Anthropic) and
14
14
  # provides lazy model discovery aggregation.
15
15
  #
16
16
  # - Hosts are defined in config.yml `hosts:` section or synthesized from
@@ -46,7 +46,7 @@ module Samagotchi
46
46
  # llm_context_apply: the configured apply rule (LLMContextStrategy), nil when unset;
47
47
  # llm_context_budget_tokens: the configured context budget (LLMContextStrategy), nil when unset;
48
48
  # models: the ids the host serves whatever it lists, {downcased id => HostModel} ({} when none).
49
- HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :auth, :dialect,
49
+ HostEntry = Struct.new(:name, :host, :port, :client, :api, :scheme, :url, :api_key_env, :auth, :dialect,
50
50
  :profile, :first_token_timeout, :vision, :sampling, :thinking, :reasoning_history, :remote,
51
51
  :window_tokens, :steer_cut_after, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
52
52
  :models, keyword_init: true) do
@@ -126,7 +126,7 @@ module Samagotchi
126
126
  # Every HostConfig field is a HostEntry member (a new one must be added there too).
127
127
  fields = cfg.to_h.merge(name: key.to_s.downcase, port: cfg.port.to_i, api: cfg.api&.to_sym, models: cfg.models || {})
128
128
  entry = HostEntry.new(**fields)
129
- entry.client = Client.new(host: cfg.host, port: cfg.port, transport: cfg.transport, scheme: cfg.scheme,
129
+ entry.client = Client.new(host: cfg.host, port: cfg.port, scheme: cfg.scheme,
130
130
  first_token_timeout: entry.first_token_limit, name: entry.name,
131
131
  api_key_env: entry.api_key_env, auth: entry.auth_scheme, env: env)
132
132
  @entries[entry.name] = entry
@@ -137,7 +137,7 @@ module Samagotchi
137
137
  host = "localhost" if host.empty?
138
138
  port = Config.get("server.port").to_i
139
139
  port = 8080 if port <= 0
140
- @entries["default"] = HostEntry.new(name: "default", host: host, port: port, transport: nil, models: {},
140
+ @entries["default"] = HostEntry.new(name: "default", host: host, port: port, models: {},
141
141
  client: Client.new(host: host, port: port, name: "default"))
142
142
  end
143
143
  @mutex = Mutex.new
@@ -341,7 +341,7 @@ module Samagotchi
341
341
  end
342
342
 
343
343
  # List models on all hosts in parallel. On error per-host, skip with error entry (no failover).
344
- # Returns { host_name => { host:, port:, transport:, models: [LLM::ModelInfo], error: nil|String } }
344
+ # Returns { host_name => { host:, port:, models: [LLM::ModelInfo], error: nil|String } }
345
345
  # Also populates the model index. Unless forced, a host's list is reused
346
346
  # for its TTL (60s; 10 minutes for a remote host).
347
347
  #
@@ -361,12 +361,12 @@ module Samagotchi
361
361
  Thread.new do
362
362
  begin
363
363
  models = list_models_for(entry)
364
- data = { host: entry.host, port: entry.port, transport: entry.transport, models: models, error: nil }
364
+ data = { host: entry.host, port: entry.port, models: models, error: nil }
365
365
  @mutex.synchronize { @host_lists[name] = { data: data, at: @clock.call } }
366
366
  save_model_list(name, models)
367
367
  rescue StandardError => e
368
368
  Log.warn(:model, "list_failed", host: name, error: e.class.name, msg: e.message.to_s[0, 500])
369
- data = { host: entry.host, port: entry.port, transport: entry.transport, models: [], error: list_error(e) }
369
+ data = { host: entry.host, port: entry.port, models: [], error: list_error(e) }
370
370
  end
371
371
  results_mutex.synchronize { results[name] = data }
372
372
  end
@@ -380,7 +380,7 @@ module Samagotchi
380
380
  @entries.each_with_object({}) do |(name, entry), acc|
381
381
  acc[name] = results.fetch(name) do
382
382
  partial = true
383
- { host: entry.host, port: entry.port, transport: entry.transport, models: [],
383
+ { host: entry.host, port: entry.port, models: [],
384
384
  error: "no answer in #{wait == wait.to_i ? wait.to_i : wait} s" }
385
385
  end
386
386
  end
@@ -193,7 +193,7 @@ module Samagotchi
193
193
  attr_accessor :llm_context_turn
194
194
  # @return [Integer, nil] the llama.cpp slot (`id_slot`) the last
195
195
  # request of the last run streamed from; nil when the server named
196
- # none (other transports) or no request was made
196
+ # none or no request was made
197
197
  attr_reader :last_slot
198
198
 
199
199
  # Run the conversation loop and return the final model response plus
@@ -294,8 +294,8 @@ module Samagotchi
294
294
  Request = Struct.new(:prompt, :images, :image_tokens, :window, :prefill, keyword_init: true)
295
295
  # One generation: the response (nil when cut), the cut's detail, this
296
296
  # generation's own server counts, the model the server named, the
297
- # thinking it streamed, and why it stopped (the transport's finish
298
- # reason, Client::Transport#finish_reason_from; nil when not named),
297
+ # thinking it streamed, and why it stopped (llama.cpp's finish
298
+ # reason, Client.finish_reason_from; nil when not named),
299
299
  # and its prompt-cache counts (the largest seen: they come cumulative),
300
300
  # and where its visible text starts in the turn's buffer.
301
301
  Generation = Struct.new(:response, :cut, :usage, :served_model, :streamed_thinking, :finish_reason, :cache,
@@ -312,15 +312,32 @@ module Samagotchi
312
312
  empty_retry: EmptyAnswerRetry.new, empty_steps: [], tool_activity: [], buffer: +"", streamed_thinking: +"",
313
313
  qwen_attempts: 0, qwen_partial: nil,
314
314
  # Qwen with thinking off: an empty thought after the cue, so the model
315
- # answers at once. Kept in the turn's model messages, so each tool-loop
316
- # prompt starts with what the server already has cached.
317
- prefill: Thinking.native(@turn_settings.thinking || Thinking::DEFAULT, @profile).prefill,
315
+ # answers at once; on, an open one where the server's template has
316
+ # it. Kept in the turn's model messages, so each tool-loop prompt
317
+ # starts with what the server already has cached.
318
+ prefill: native_prefill(@turn_settings.thinking || Thinking::DEFAULT),
318
319
  pending_tool_calls: false, model_name: @turn_settings.model_name, pending_input: pending_input,
319
320
  on_stream_event: on_stream_event, cancel_controller: cancel_controller, cap: cap,
320
321
  emit: emit
321
322
  )
322
323
  end
323
324
 
325
+ # The text after the cue for +level+ (Thinking.native), set once per
326
+ # turn. Only qwen36 with thinking on asks the server's template whether
327
+ # it opens a thought: the /props answer the profile and the window
328
+ # probes cached (no template, as a router's own /props or a failed
329
+ # probe: no open thought, the prompt as before).
330
+ def native_prefill(level)
331
+ opens = @profile&.name == "qwen36" && level != :off && Thinking.opens_thought?(model_props)
332
+ Thinking.native(level, @profile, opens_thought: opens).prefill
333
+ end
334
+
335
+ def model_props
336
+ @client.respond_to?(:server_props) ? @client.server_props(model: @turn_settings.model_name) : nil
337
+ rescue StandardError
338
+ nil
339
+ end
340
+
324
341
  # One iteration. Returns :next, :answer (the turn ends), or the turn's
325
342
  # result (it ended cancelled).
326
343
  def iterate(turn)
@@ -336,13 +353,28 @@ module Samagotchi
336
353
  # Profile-specific parse (incl. Qwen unterminated-block recovery); the
337
354
  # returned fragment (non-nil only for Qwen) is fed back on the next
338
355
  # iteration if the model opened a tool-call block it did not close.
339
- calls, turn.qwen_partial = parser.parse_with_recovery(generation.response, turn.qwen_partial)
356
+ calls, turn.qwen_partial = parser.parse_with_recovery(callable_text(request, generation), turn.qwen_partial)
340
357
  calls = calls.map { |call| PromptLiteralGuard.restore_call(call, profile: @profile) }
341
358
  return after_text(turn, request, generation) if calls.empty?
342
359
 
343
360
  dispatch_calls(turn, calls)
344
361
  end
345
362
 
363
+ # The reply's text the tool-call parse reads: with a closed thought
364
+ # (Qwen's </think>, the prefill's open thought included), what is left
365
+ # without its thought blocks, so a call the model only thought about
366
+ # neither runs nor asks for a recovery (the UIs show it as thinking).
367
+ # A thought never closed stays: a model that writes its call there
368
+ # still has it run. The model message keeps the whole reply.
369
+ def callable_text(request, generation)
370
+ response = generation.response.to_s
371
+ close = @profile.thought_close
372
+ full = request.prefill + response
373
+ return response unless close && full.include?(close)
374
+
375
+ strip_thought_blocks(full)
376
+ end
377
+
346
378
  # The prompt for this iteration, after the context check: a rise into a
347
379
  # bucket that asks the model for a change puts its line on the tail
348
380
  # (the prompt cache keeps its prefix), and the prompt is formatted again
@@ -427,6 +459,10 @@ module Samagotchi
427
459
  # thinking) leaves the splitter inside that block, so rebuilding it
428
460
  # keeps the restarted stream's prose in the right lane.
429
461
  stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
462
+ # The prompt's prefill is the reply's start: an open thought there
463
+ # streams as thinking. What the seed itself splits into is the
464
+ # prompt's, not shown.
465
+ stream_splitter.feed(request.prefill) unless request.prefill.empty?
430
466
  # Each ask starts with streamed_any = false, but once any chunk of
431
467
  # this ask is shown it stays shown: a held-back partial marker after
432
468
  # real text must not clear it, or the drop below won't ask again.
@@ -635,7 +671,9 @@ module Samagotchi
635
671
  return :next
636
672
  end
637
673
 
638
- response = generation.response
674
+ # The prefill is the reply's start: an open thought it began closes in
675
+ # the response, and the whole block goes from the answer.
676
+ response = request.prefill + generation.response.to_s
639
677
  answer = -> { PromptLiteralGuard.restore(strip_thought_blocks(response), profile: @profile) }
640
678
  empty = strip_thought_blocks(response.to_s).strip.empty?
641
679
  retry_empty = empty && turn.empty_retry.retry_empty?(iteration: turn.iteration,
@@ -484,12 +484,11 @@ module Samagotchi
484
484
  private_class_method :configured
485
485
 
486
486
  # Only a native llama.cpp host has /props with a chat template; a chat
487
- # host (api: openai or anthropic) barely uses a profile, and mlx/oMLX expose none.
487
+ # host (api: openai or anthropic) barely uses a profile.
488
488
  def self.probe?(entry, client)
489
489
  return false if client.nil? || !client.respond_to?(:server_props)
490
- return false if entry&.chat?
491
490
 
492
- client.transport.name == :llama_cpp
491
+ !entry&.chat?
493
492
  end
494
493
  private_class_method :probe?
495
494
 
@@ -345,8 +345,6 @@ module Samagotchi
345
345
  when :config then "#{name} (config #{result.detail})"
346
346
  when :cli, :env then "#{name} (#{result.source})"
347
347
  else
348
- return "#{name} (#{result.source})" unless entry.nil? || entry.transport.nil? || entry.transport == :llama_cpp
349
-
350
348
  given = result.source == :name ? "name says #{name}" : "default #{name}"
351
349
  "from the server at runtime (#{given})"
352
350
  end
@@ -96,7 +96,7 @@ module Samagotchi
96
96
  @section_notes = nil
97
97
  @section_files = []
98
98
  @built[key] = system_prompt_with_index(
99
- assist_system_prompt(chat: chat, thinking: thinking, layers: layers), chat: chat, thinking: thinking, layers: layers
99
+ assist_system_prompt(chat: chat, layers: layers), chat: chat, thinking: thinking, layers: layers
100
100
  )
101
101
  @index_sizes[key] = @measured_index
102
102
  @notes_loaded[key] = @section_notes || []
@@ -151,9 +151,8 @@ module Samagotchi
151
151
 
152
152
  # The base prompt (specs, plugins' declarations), with Gemma's tool
153
153
  # declarations at its end as #build has them.
154
- def base(chat: false, thinking: nil, layers: [])
155
- with_tail_declarations(assist_system_prompt(chat: chat, thinking: thinking, layers: layers), chat: chat,
156
- layers: layers)
154
+ def base(chat: false, layers: [])
155
+ with_tail_declarations(assist_system_prompt(chat: chat, layers: layers), chat: chat, layers: layers)
157
156
  end
158
157
 
159
158
  # Names activated via preloaded --memory entries during prompt
@@ -195,27 +194,14 @@ module Samagotchi
195
194
  end
196
195
  end
197
196
 
198
- # Only Qwen has an explicit thinking-close marker, so only Qwen can
199
- # reliably have this preamble parsed back out of its thinking block.
200
- # With thinking off there is no thinking to begin with it.
201
- def turn_preamble_instruction(thinking = nil)
202
- return "" unless profile.name == "qwen36"
203
- return "" if Samagotchi::Config.get("thinking.turn_preamble") == false
204
- return "" if (thinking || turn_thinking) == :off
205
-
206
- "\nTurn preamble: as the very first line of your thinking, write \"TURN: \" followed by a short present-tense action phrase (max 8 words) describing what you are about to do, e.g. \"TURN: reading project config\". Then continue reasoning normally.\n"
207
- end
208
-
209
197
  # ── System prompts ─────────────────────────────────────────────────────────
210
198
 
211
- # @param chat [Boolean] for the chat loop: no tool declarations, call
212
- # syntax or turn preamble (its tools go as schemas with each request)
213
- # @param thinking [Symbol, nil] the level (Thinking); nil: the effective model's
214
- def assist_system_prompt(chat: false, thinking: nil, layers: [])
199
+ # @param chat [Boolean] for the chat loop: no tool declarations or call
200
+ # syntax (its tools go as schemas with each request)
201
+ def assist_system_prompt(chat: false, layers: [])
215
202
  return chat_system_prompt if chat
216
203
 
217
204
  hint = tool_call_hint
218
- turn_preamble = turn_preamble_instruction(thinking)
219
205
  # Gemma 4's template declares the tools at the end of the system turn
220
206
  # (#system_prompt_with_index puts them there).
221
207
  tools = if gemma_tail_declarations?
@@ -228,7 +214,7 @@ module Samagotchi
228
214
  You are Chi (pronounced "chee"), the friendly name for the Samagotchi assistant harness. #{tools}
229
215
  #{hint}
230
216
  You may make multiple tool calls. After seeing tool results, continue reasoning or answer the user.
231
- #{turn_preamble}
217
+
232
218
  #{ToolDeclarations::SMALL_CONTEXT_PROTOCOL}
233
219
 
234
220
  #{assist_guidance}
@@ -20,8 +20,6 @@ module Samagotchi
20
20
 
21
21
  # Enough text to find the newest sentence in; scanning grows with it.
22
22
  BUFFER_LIMIT = 2000
23
- # The Qwen turn preamble's first line ("TURN: reading the config").
24
- TURN_PREFIX = /\ATURN:\s*/i
25
23
  # Where a raw stream's thinking ends: a boundary, so its last words
26
24
  # don't run into the answer's first.
27
25
  THOUGHT_CLOSE = %r{<channel\|>|</think>}
@@ -65,8 +63,8 @@ module Samagotchi
65
63
  # @return [Symbol, nil] :thinking or :writing
66
64
  attr_reader :label
67
65
 
68
- # The sentence as a row shows it: no TURN: prefix, markdown plain.
69
- def sentence = strip_markdown(@ticker.line.sub(TURN_PREFIX, ""))
66
+ # The sentence as a row shows it: markdown plain.
67
+ def sentence = strip_markdown(@ticker.line)
70
68
 
71
69
  # #sentence cut to +room+ columns, with an ellipsis when cut.
72
70
  def fit(room)
@@ -27,6 +27,14 @@ module Samagotchi
27
27
  # An empty thought after the Qwen assistant cue: the model answers at once
28
28
  # (what the server's own template does with enable_thinking false).
29
29
  QWEN_EMPTY_THOUGHT = "<think>\n\n</think>\n\n"
30
+ # An open thought after the Qwen assistant cue, as a Qwen3.5+ template
31
+ # writes it with thinking on: without it Qwen3.6 writes an empty thought
32
+ # at once and never thinks (#opens_thought?).
33
+ QWEN_OPEN_THOUGHT = "<think>\n"
34
+ # A Jinja string literal that ends with the open thought: '<think>\n'
35
+ # (a Qwen3.5+ generation prompt). Qwen3's writes only the empty one,
36
+ # '<think>\n\n</think>\n\n', with thinking off.
37
+ OPEN_THOUGHT_LITERAL = /<think>(?:\\n|\n)['"]/
30
38
  # Gemma 4's empty thought channel after the model cue, as its chat
31
39
  # template writes it with enable_thinking false (only where a model turn
32
40
  # starts: Prompt.prefill_for).
@@ -168,15 +176,40 @@ module Samagotchi
168
176
  caps.is_a?(Hash) && caps["supports_reasoning_effort"] == false
169
177
  end
170
178
 
179
+ # Whether a llama.cpp /props answer (+props+, Client::ServerProps)
180
+ # carries a chat template whose generation prompt opens a thought
181
+ # (Qwen3.5 and later: `assistant\n` then '<think>\n' unless
182
+ # enable_thinking is false). Read from the template's last
183
+ # add_generation_prompt branch on: the message loop before it writes
184
+ # earlier turns' thoughts. false when there is no template (a router's
185
+ # own /props, an unloaded model, a failed probe).
186
+ def opens_thought?(props)
187
+ return false unless props.respond_to?(:answered?) && props.answered? && props.body.is_a?(Hash)
188
+
189
+ template = props.body["chat_template"]
190
+ return false unless template.is_a?(String)
191
+
192
+ cue = template.rindex("add_generation_prompt")
193
+ !cue.nil? && OPEN_THOUGHT_LITERAL.match?(template[cue..])
194
+ end
195
+
171
196
  # The native prompt's switch for +level+ under +profile+.
172
197
  # @param profile [ModelProfile]
198
+ # @param opens_thought [Boolean] the server's template opens a thought
199
+ # after the cue (#opens_thought?): qwen36 then gets the open thought
200
+ # at every level but off, as the template has it
173
201
  # @return [Native]
174
- def native(level, profile)
202
+ def native(level, profile, opens_thought: false)
175
203
  off = level == :off
176
204
  honoured = !EFFORTS.include?(level)
177
205
  case profile&.name
178
206
  when "gemma4" then Native.new(system_token: off ? "" : GEMMA_THINK_TOKEN, prefill: off ? GEMMA_EMPTY_THOUGHT : "", honoured: honoured)
179
- when "qwen36" then Native.new(system_token: "", prefill: off ? QWEN_EMPTY_THOUGHT : "", honoured: honoured)
207
+ when "qwen36"
208
+ prefill = if off then QWEN_EMPTY_THOUGHT
209
+ elsif opens_thought then QWEN_OPEN_THOUGHT
210
+ else ""
211
+ end
212
+ Native.new(system_token: "", prefill: prefill, honoured: honoured)
180
213
  else Native.new(system_token: "", prefill: "", honoured: level == DEFAULT)
181
214
  end
182
215
  end