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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -1
- data/README.md +7 -7
- data/bin/chi +6 -2
- data/docs/architecture.md +4 -5
- data/docs/cli.md +3 -3
- data/docs/configuration.md +49 -86
- data/docs/hooks.md +1 -1
- data/docs/internals/context-telemetry.md +1 -1
- data/docs/internals/engine.md +7 -4
- data/docs/testing.md +6 -9
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -5
- data/lib/samagotchi/bundle_command/try/sandbox.rb +8 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/client.rb +59 -196
- data/lib/samagotchi/config.rb +74 -15
- data/lib/samagotchi/engine.rb +7 -7
- data/lib/samagotchi/host_config.rb +20 -27
- data/lib/samagotchi/host_registry.rb +8 -8
- data/lib/samagotchi/kernel_loop.rb +46 -8
- data/lib/samagotchi/model_profile.rb +2 -3
- data/lib/samagotchi/self_report.rb +0 -2
- data/lib/samagotchi/system_prompt.rb +7 -21
- data/lib/samagotchi/terminal_ui/thinking_line.rb +2 -4
- data/lib/samagotchi/thinking.rb +35 -2
- data/lib/samagotchi/thinking_notices.rb +11 -4
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +0 -3
- metadata +1 -1
data/lib/samagotchi/config.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
914
|
-
#
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
|
1648
|
-
@prompt_builder.base(chat: chat
|
|
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
|
|
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)
|
|
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
|
|
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;
|
|
17
|
-
#
|
|
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, :
|
|
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
|
-
|
|
83
|
-
location = parse_location(name, value, api:
|
|
84
|
-
fields[:dialect] = LLM::Dialect.parse(value.call(:dialect), where, api:
|
|
85
|
-
new(name: name.downcase, **location, **
|
|
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
|
-
#
|
|
147
|
-
#
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
return invalid(name, "
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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 :
|
|
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
|
|
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, :
|
|
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,
|
|
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,
|
|
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:,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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 (
|
|
298
|
-
# reason, Client
|
|
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
|
|
316
|
-
#
|
|
317
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
155
|
-
with_tail_declarations(assist_system_prompt(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
|
|
212
|
-
# syntax
|
|
213
|
-
|
|
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
|
-
|
|
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:
|
|
69
|
-
def sentence = strip_markdown(@ticker.line
|
|
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)
|
data/lib/samagotchi/thinking.rb
CHANGED
|
@@ -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"
|
|
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
|