samagotchi 0.50.0 → 0.52.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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -1
  3. data/README.md +1 -1
  4. data/bin/chi +11 -3
  5. data/docs/architecture.md +1 -1
  6. data/docs/cli.md +40 -11
  7. data/docs/configuration.md +63 -21
  8. data/docs/hooks.md +3 -2
  9. data/docs/internals/engine.md +4 -2
  10. data/docs/memory.md +11 -0
  11. data/docs/plugins.md +2 -1
  12. data/docs/sessions.md +10 -5
  13. data/docs/testing.md +6 -1
  14. data/lib/samagotchi/bootstrap/config_writer.rb +6 -37
  15. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  17. data/lib/samagotchi/bridge.rb +29 -17
  18. data/lib/samagotchi/bridge_client.rb +3 -1
  19. data/lib/samagotchi/broadcast/recipients.rb +1 -2
  20. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  21. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  22. data/lib/samagotchi/children_status.rb +1 -1
  23. data/lib/samagotchi/commands/registry.rb +2 -1
  24. data/lib/samagotchi/config.rb +50 -182
  25. data/lib/samagotchi/config_text_edit.rb +8 -32
  26. data/lib/samagotchi/context_sources.rb +0 -7
  27. data/lib/samagotchi/context_window.rb +2 -2
  28. data/lib/samagotchi/continue_offer.rb +5 -4
  29. data/lib/samagotchi/cut_policy.rb +0 -1
  30. data/lib/samagotchi/delivery.rb +56 -0
  31. data/lib/samagotchi/engine.rb +48 -17
  32. data/lib/samagotchi/engine_peers.rb +1 -0
  33. data/lib/samagotchi/host_config.rb +183 -0
  34. data/lib/samagotchi/host_registry.rb +21 -13
  35. data/lib/samagotchi/kernel_loop.rb +28 -2
  36. data/lib/samagotchi/last_turn.rb +48 -0
  37. data/lib/samagotchi/live_versions.rb +0 -6
  38. data/lib/samagotchi/llm/native_tool_normalizer.rb +2 -7
  39. data/lib/samagotchi/llm_context_override.rb +3 -22
  40. data/lib/samagotchi/llm_context_strategy.rb +50 -16
  41. data/lib/samagotchi/memory_bundle/placeholder.rb +0 -5
  42. data/lib/samagotchi/model_profile.rb +4 -4
  43. data/lib/samagotchi/model_settings.rb +46 -0
  44. data/lib/samagotchi/plugin/context.rb +3 -3
  45. data/lib/samagotchi/plugin/sessions.rb +2 -2
  46. data/lib/samagotchi/queued_command.rb +35 -0
  47. data/lib/samagotchi/reminder_store.rb +0 -27
  48. data/lib/samagotchi/reply_wait.rb +11 -13
  49. data/lib/samagotchi/sampling_settings.rb +4 -4
  50. data/lib/samagotchi/self_report.rb +8 -5
  51. data/lib/samagotchi/send_command.rb +92 -21
  52. data/lib/samagotchi/session.rb +45 -24
  53. data/lib/samagotchi/session_commands.rb +41 -3
  54. data/lib/samagotchi/session_inbox.rb +36 -7
  55. data/lib/samagotchi/session_manager.rb +21 -10
  56. data/lib/samagotchi/session_metrics.rb +25 -6
  57. data/lib/samagotchi/session_setup.rb +30 -0
  58. data/lib/samagotchi/sessions_command.rb +1 -1
  59. data/lib/samagotchi/steer_cut.rb +21 -10
  60. data/lib/samagotchi/terminal_ui/attach_launcher.rb +10 -4
  61. data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -1
  62. data/lib/samagotchi/terminal_ui/formatting.rb +69 -4
  63. data/lib/samagotchi/terminal_ui.rb +56 -8
  64. data/lib/samagotchi/thinking.rb +67 -7
  65. data/lib/samagotchi/thinking_command.rb +72 -0
  66. data/lib/samagotchi/tool_activity.rb +3 -0
  67. data/lib/samagotchi/tool_runner.rb +2 -0
  68. data/lib/samagotchi/tools/builtin_calls.rb +33 -0
  69. data/lib/samagotchi/tools/builtins.rb +7 -2
  70. data/lib/samagotchi/tools/peers.rb +3 -2
  71. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  72. data/lib/samagotchi/version.rb +1 -1
  73. data/lib/samagotchi/vision_support.rb +4 -3
  74. data/lib/samagotchi/web/app.rb +59 -7
  75. data/lib/samagotchi/web/message_parts.rb +3 -0
  76. data/lib/samagotchi/web/public/activity.js +6 -0
  77. data/lib/samagotchi/web/public/app.js +199 -48
  78. data/lib/samagotchi/web/public/data.js +8 -4
  79. data/lib/samagotchi/web/public/format.js +8 -4
  80. data/lib/samagotchi/web/public/index.html +25 -20
  81. data/lib/samagotchi/web/public/kept_commands.js +52 -0
  82. data/lib/samagotchi/web/public/model_pick.js +3 -1
  83. data/lib/samagotchi/web/public/session_title.js +26 -0
  84. data/lib/samagotchi/web/public/strip.js +4 -6
  85. data/lib/samagotchi/web/public/thinking_chip.js +169 -0
  86. data/lib/samagotchi/web/public/timing.js +15 -0
  87. data/lib/samagotchi/web/public/turn_events.js +26 -2
  88. data/lib/samagotchi/web/public/turn_html.js +3 -1
  89. data/lib/samagotchi/web/public/turn_view.js +328 -310
  90. data/lib/samagotchi/web/public/vendor/lit-html.js +8 -0
  91. data/lib/samagotchi/web/session_summary.rb +1 -1
  92. data/lib/samagotchi/worker.rb +44 -43
  93. data/lib/samagotchi/worker_inbound.rb +10 -7
  94. data/lib/samagotchi/worker_wakes.rb +0 -2
  95. data/lib/samagotchi/yaml_lines.rb +72 -0
  96. data/lib/samagotchi.rb +1 -0
  97. metadata +13 -1
@@ -17,6 +17,7 @@ module Samagotchi
17
17
  def cancelled? = !!engine.active_cancel_controller&.cancelled?
18
18
  def relay = engine.relay_peer
19
19
  def model_ref = engine.effective_model_ref
20
+ def thinking = engine.thinking_override
20
21
  end
21
22
 
22
23
  # What the approval relay needs from the parent's Engine (Peers#relay):
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "config"
5
+ require_relative "host_model"
6
+ require_relative "llm_context_strategy"
7
+
8
+ module Samagotchi
9
+ # One config.yml hosts: entry, parsed and checked (ConfigFile.hosts_config
10
+ # holds them by lowercased name; HostRegistry builds its HostEntry from
11
+ # one). A worker gets its parent's hosts back through
12
+ # SAMAGOTCHI_HOSTS_JSON: #to_env_h writes every field, and .parse reads
13
+ # it back to an equal HostConfig.
14
+ #
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; the rest as HostRegistry::HostEntry
18
+ # describes them, nil when unset; models: {downcased id => HostModel}.
19
+ HostConfig = Data.define(:name, :host, :port, :scheme, :url, :transport, :api, :api_key_env, :profile,
20
+ :first_token_timeout, :vision, :sampling, :thinking, :remote, :window_tokens,
21
+ :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens, :models) do
22
+ # Every field but name may be left out: nil, models {}.
23
+ def initialize(**fields)
24
+ super(**members.to_h { |m| [m, nil] }, models: {}, **fields)
25
+ end
26
+
27
+ # A hosts: entry as written (YAML, or a worker's SAMAGOTCHI_HOSTS_JSON),
28
+ # or nil when it is disabled (enabled: false) or invalid. An invalid
29
+ # one warns once, naming the entry, the way each reader of a setting
30
+ # does for a bad value (that value is then unset).
31
+ # @param raw_name [String, Symbol]
32
+ # @param raw [Hash] string or symbol keys
33
+ # @return [HostConfig, nil]
34
+ def self.parse(raw_name, raw)
35
+ name = raw_name.to_s.strip
36
+ return nil if name.empty?
37
+ return invalid(name, "must match /[a-z0-9][a-z0-9._-]*/i") unless name.match?(ConfigFile::HOST_NAME_RE)
38
+ return invalid(name, "expected mapping") unless raw.is_a?(Hash)
39
+ return nil if disabled?(raw)
40
+
41
+ value = ->(key) { raw.key?(key.to_s) ? raw[key.to_s] : raw[key.to_sym] }
42
+ where = "hosts entry '#{name}'"
43
+ fields = {
44
+ api_key_env: value.call(:api_key_env).to_s.strip.then { |v| v.empty? ? nil : v },
45
+ # Kept as written; ModelProfile.resolve warns about an unknown one.
46
+ profile: value.call(:profile).to_s.strip.downcase.then { |v| v.empty? ? nil : v },
47
+ first_token_timeout: value.call(:first_token_timeout),
48
+ vision: ConfigFile.vision_flag(value.call(:vision), where),
49
+ remote: ConfigFile.bool_flag(value.call(:remote), where, "remote"),
50
+ window_tokens: ConfigFile.window_tokens(value.call(:window_tokens), where),
51
+ sampling: ConfigFile.sampling_map(value.call(:sampling), where),
52
+ thinking: Thinking.level(value.call(:thinking), where),
53
+ llm_context_strategy: LLMContextStrategy.parse(value.call(LLMContextStrategy::KEY), where),
54
+ llm_context_apply: LLMContextStrategy.parse_apply(value.call(LLMContextStrategy::APPLY_KEY), where),
55
+ models: HostModel.parse_map(value.call(:models), name),
56
+ llm_context_budget_tokens: LLMContextStrategy.parse_budget(value.call(LLMContextStrategy::BUDGET_KEY), where)
57
+ }
58
+ timeout = fields[:first_token_timeout]
59
+ unless timeout.nil? || (timeout.is_a?(Numeric) && !timeout.negative?)
60
+ ConfigFile.warn_once "Warning: #{where}: first_token_timeout must be seconds (0 = off); using the default"
61
+ fields[:first_token_timeout] = nil
62
+ end
63
+ api_key_env = fields[:api_key_env]
64
+ unless api_key_env.nil? || api_key_env.match?(ConfigFile::ENV_NAME_RE)
65
+ return invalid(name, "api_key_env must be an environment variable name")
66
+ end
67
+
68
+ location = parse_location(name, value) or return nil
69
+ kinds = parse_kinds(name, value.call(:transport), value.call(:api)) or return nil
70
+ new(name: name.downcase, **location, **kinds, **fields)
71
+ rescue StandardError => e
72
+ # One entry that trips a reader is dropped on its own, and said so;
73
+ # the other hosts stay (the worker's copy too).
74
+ invalid(name || raw_name, "#{e.class}: #{e.message}")
75
+ end
76
+
77
+ # A literal Hash of fields (specs and in-memory callers building a
78
+ # HostRegistry) as a HostConfig, unchecked; a HostConfig as it is.
79
+ # @param name [String] the entry's name
80
+ # @param cfg [HostConfig, Hash] symbol keys, as #to_h gives them
81
+ def self.coerce(name, cfg)
82
+ return cfg if cfg.is_a?(HostConfig)
83
+
84
+ new(**cfg.to_h.slice(*members), name: name.to_s.downcase)
85
+ end
86
+
87
+ # enabled: false (or "false", any case) in a hosts entry as written.
88
+ def self.disabled?(raw)
89
+ enabled = if raw.key?("enabled")
90
+ raw["enabled"]
91
+ else
92
+ (raw.key?(:enabled) ? raw[:enabled] : true)
93
+ end
94
+ enabled == false || enabled.to_s.strip.downcase == "false"
95
+ end
96
+
97
+ # host/port/scheme/url from url:, or from host: and port:; nil (warned)
98
+ # when they are wrong.
99
+ def self.parse_location(name, value)
100
+ host = value.call(:host)
101
+ port = value.call(:port)
102
+ url = value.call(:url).to_s.strip
103
+ scheme = "http"
104
+ unless url.empty?
105
+ return invalid(name, "give url or host/port, not both") unless host.to_s.strip.empty? && port.to_s.strip.empty?
106
+
107
+ uri = begin
108
+ URI.parse(url)
109
+ rescue URI::InvalidURIError
110
+ nil
111
+ end
112
+ return invalid(name, "url must be an http(s) URL") unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
113
+
114
+ host = uri.host
115
+ port = uri.port
116
+ scheme = uri.scheme
117
+ url = url.chomp("/")
118
+ end
119
+ host = host.to_s.strip
120
+ return invalid(name, "host is required") if host.empty?
121
+
122
+ port = port.to_s.strip.empty? ? 8080 : port.to_i
123
+ return invalid(name, "invalid port") if port <= 0 || port > 65_535
124
+
125
+ { host: host, port: port, scheme: scheme, url: url.empty? ? nil : url }
126
+ end
127
+ private_class_method :parse_location
128
+
129
+ # transport: and api: as symbols; a raw-prompt api is also the
130
+ # transport. nil (warned) when unknown or contradicting each other.
131
+ def self.parse_kinds(name, transport, api)
132
+ transport = transport.to_s.strip.downcase
133
+ if transport.empty?
134
+ transport = nil
135
+ elsif !ConfigFile::VALID_TRANSPORTS_FOR_CONFIG.include?(transport)
136
+ return invalid(name, "unknown transport '#{transport}'")
137
+ end
138
+ api = api.to_s.strip.downcase
139
+ if api.empty?
140
+ api = nil
141
+ elsif !ConfigFile::VALID_APIS_FOR_CONFIG.include?(api)
142
+ return invalid(name, "unknown api '#{api}'")
143
+ elsif ConfigFile::VALID_TRANSPORTS_FOR_CONFIG.include?(api)
144
+ # A raw-prompt api is the transport; a different transport contradicts it.
145
+ return invalid(name, "api '#{api}' conflicts with transport '#{transport}'") if transport && transport != api
146
+
147
+ transport = api
148
+ end
149
+ { transport: transport&.to_sym, api: api&.to_sym }
150
+ end
151
+ private_class_method :parse_kinds
152
+
153
+ def self.invalid(name, reason)
154
+ ConfigFile.warn_once "Warning: ignoring hosts entry '#{name}': #{reason}"
155
+ nil
156
+ end
157
+ private_class_method :invalid
158
+
159
+ # The entry as a worker's SAMAGOTCHI_HOSTS_JSON carries it (string keys,
160
+ # unset fields left out); .parse(name, to_env_h) gives it back. A url
161
+ # entry travels as its url (host/port come from it); the API key stays
162
+ # in the environment, which workers inherit.
163
+ # @return [Hash{String => Object}]
164
+ def to_env_h
165
+ location = url ? { "url" => url } : { "host" => host, "port" => port }
166
+ location.merge("transport" => transport&.to_s, "api" => api&.to_s, "api_key_env" => api_key_env,
167
+ "profile" => profile, "first_token_timeout" => first_token_timeout,
168
+ "vision" => vision, "sampling" => sampling, "thinking" => thinking&.to_s,
169
+ "remote" => remote, "window_tokens" => window_tokens,
170
+ "llm_context_strategy" => llm_context_strategy&.map(&:to_s),
171
+ "llm_context_apply" => llm_context_apply&.to_s,
172
+ "llm_context_budget_tokens" => llm_context_budget_tokens,
173
+ "models" => models_config).compact
174
+ end
175
+
176
+ private
177
+
178
+ # hosts.<name>.models as written back (HostModel#to_config), nil when none.
179
+ def models_config
180
+ models.values.to_h { |m| [m.id, m.to_config] } unless models.nil? || models.empty?
181
+ end
182
+ end
183
+ end
@@ -98,16 +98,11 @@ module Samagotchi
98
98
  raw = hosts_config || ConfigFile.hosts_config(env: env)
99
99
  @entries = {}
100
100
  raw.each do |key, cfg|
101
- # cfg: {name:, host:, port:, transport:, original_name:}
102
- transport = cfg[:transport]
103
- entry = HostEntry.new(name: key.to_s.downcase, host: cfg[:host], port: cfg[:port].to_i, transport: transport,
104
- api: cfg[:api]&.to_sym, scheme: cfg[:scheme], url: cfg[:url], api_key_env: cfg[:api_key_env],
105
- profile: cfg[:profile], first_token_timeout: cfg[:first_token_timeout],
106
- vision: cfg[:vision], sampling: cfg[:sampling], thinking: cfg[:thinking],
107
- remote: cfg[:remote], window_tokens: cfg[:window_tokens],
108
- llm_context_strategy: cfg[:llm_context_strategy], llm_context_apply: cfg[:llm_context_apply],
109
- llm_context_budget_tokens: cfg[:llm_context_budget_tokens], models: cfg[:models] || {})
110
- entry.client = Client.new(host: cfg[:host], port: cfg[:port], transport: transport, scheme: cfg[:scheme],
101
+ cfg = HostConfig.coerce(key, cfg)
102
+ # Every HostConfig field is a HostEntry member (a new one must be added there too).
103
+ fields = cfg.to_h.merge(name: key.to_s.downcase, port: cfg.port.to_i, api: cfg.api&.to_sym, models: cfg.models || {})
104
+ entry = HostEntry.new(**fields)
105
+ entry.client = Client.new(host: cfg.host, port: cfg.port, transport: cfg.transport, scheme: cfg.scheme,
111
106
  first_token_timeout: entry.first_token_limit, name: entry.name,
112
107
  api_key_env: entry.api_key_env, env: env)
113
108
  @entries[entry.name] = entry
@@ -200,9 +195,11 @@ module Samagotchi
200
195
  # id it is sent as (ModelRef: one alias pass). A ref that names a host
201
196
  # goes there. A bare id goes to the hosts whose cached list has it or
202
197
  # that declare it (hosts.<name>.models, known before any /models): the
203
- # default host when it is one of them, else the first in hosts: order,
204
- # else the default host (before /models nothing is listed: no
205
- # discovery here, the latency budget). Never by a substring.
198
+ # default host when it is one of them, else the first in hosts: order.
199
+ # When none does, the hosts whose saved list (ModelListStore, another
200
+ # process's listing: a worker spawned with --model never lists) has
201
+ # it, the same way; else the default host (no discovery here, the
202
+ # latency budget). Never by a substring.
206
203
  def host_for_model(raw_model)
207
204
  ref = model_ref(raw_model)
208
205
  # ModelRef names a host only when it is one of ours
@@ -211,6 +208,7 @@ module Samagotchi
211
208
  down = ref.id.to_s.strip.downcase
212
209
  listed = @mutex.synchronize { @model_index }&.fetch(down, nil) || []
213
210
  candidates = @entries.values.select { |e| listed.include?(e.name) || e.models&.key?(down) }.map(&:name)
211
+ candidates = saved_hosts_for(down) if candidates.empty?
214
212
  default = default_entry
215
213
  name = candidates.include?(default.name) ? default.name : candidates.first
216
214
  [(name && find_entry(name)) || default, ref.id]
@@ -386,6 +384,16 @@ module Samagotchi
386
384
  ModelListStore.save(name, Array(models).map(&:id), env: @env)
387
385
  end
388
386
 
387
+ # The configured hosts, in hosts: order, whose fresh saved list
388
+ # (ModelListStore) has +down+ (a downcased id). The store never raises.
389
+ def saved_hosts_for(down)
390
+ saved = ModelListStore.read(env: @env)
391
+ @entries.each_key.select do |name|
392
+ list = saved[name]
393
+ list && !list.stale? && list.known?(down)
394
+ end
395
+ end
396
+
389
397
  def fresh_list(name, entry)
390
398
  @mutex.synchronize do
391
399
  cached = @host_lists[name]
@@ -952,11 +952,30 @@ module Samagotchi
952
952
 
953
953
  private
954
954
 
955
+ # Shell-like names (the harness's is `execute`). The unknown-tool error
956
+ # never echoes the name the model used — a model that sees its own wrong
957
+ # name again calls it again (a steer that named `bash` raised one model's
958
+ # bash rate from 9% to 30%); the activity event and the logs keep it.
959
+ SHELL_LIKE_TOOL_NAMES = %w[
960
+ bash sh zsh shell terminal cmd command run run_command run_shell exec execute_command
961
+ ].freeze
962
+
963
+ # The error an unknown tool name gets back. A shell-like name with
964
+ # `execute` offered this turn points at `execute`; every other unknown
965
+ # name lists what is offered. Neither echoes the wrong name.
966
+ def unknown_tool_error(name, layers)
967
+ names = @tools.names(layers: layers)
968
+ if SHELL_LIKE_TOOL_NAMES.include?(name.to_s.downcase) && names.include?("execute")
969
+ "Error: no such tool. Shell commands run with `execute` (same arguments)."
970
+ else
971
+ "Error: no such tool. Available: #{names.join(", ")}"
972
+ end
973
+ end
974
+
955
975
  def dispatch(call)
956
976
  entry = @tools.offered(call[:name], layers: llm_context_layers)
957
977
  unless entry
958
- available = @tools.names(layers: llm_context_layers).join(", ")
959
- result = "Error: unknown tool '#{call[:name]}'. Available: #{available}"
978
+ result = unknown_tool_error(call[:name], llm_context_layers)
960
979
  return {
961
980
  output: result,
962
981
  activity: ToolActivity.tool_activity_event(call[:name], call, result, registry: @tools)
@@ -965,6 +984,13 @@ module Samagotchi
965
984
 
966
985
  dump_log("tool_call", call[:content], tool: call[:name], path: call[:path], scope: call[:scope])
967
986
 
987
+ if call[:called_as]
988
+ # The model's spelling for a tool an alias ran (bash → execute); its
989
+ # rate is what the alias exists for, so it goes in the log. Tag :turn
990
+ # like the other per-call tool logs (there is no :tool tag).
991
+ Log.info(:turn, "tool_alias", name: call[:called_as], as: call[:name])
992
+ end
993
+
968
994
  result = entry.handler.call(call, tool_context)
969
995
 
970
996
  dump_log("tool_result", result, tool: call[:name])
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # How a session's last turn ended (Session#last_turn), saved as
5
+ # session.json's "last_turn" and sent in the web's session summary.
6
+ # Engine#record_last_turn writes it at every turn's end, ContinueOffer
7
+ # when a Stop answered the step-limit question (no turn ran).
8
+ #
9
+ # outcome: "completed", "failed", "canceled" or "not_continued";
10
+ # ended_at: iso8601; seconds: Float; origin: "client", "reminder",
11
+ # "delegate", "delegate_report" (a turn a parent ran for its delegate
12
+ # children's reports) or "context" (one an attached context source's
13
+ # change started); exhausted/limit: the turn ran out of iterations at
14
+ # limit. The rest are its stop facts (LastTurn::STOP_FACTS), only what is
15
+ # known. A field may be nil (unset; left out of the file).
16
+ LastTurn = Data.define(:outcome, :ended_at, :seconds, :origin, :exhausted, :limit,
17
+ :error_kind, :retryable, :kept_steps, :cancel_reason, :stopped_by) do
18
+ def initialize(**fields)
19
+ super(**members.to_h { |m| [m, nil] }, **fields)
20
+ end
21
+
22
+ # A saved record (string keys; symbol keys read too, a key we don't
23
+ # know dropped), a LastTurn as it is; nil for anything else.
24
+ # @return [LastTurn, nil]
25
+ def self.from_file(raw)
26
+ return raw if raw.is_a?(LastTurn)
27
+ return nil unless raw.is_a?(Hash)
28
+
29
+ new(**members.to_h { |m| [m, raw.key?(m.to_s) ? raw[m.to_s] : raw[m]] })
30
+ end
31
+
32
+ # The session file's record: string keys, in member order, unset
33
+ # fields left out.
34
+ # @return [Hash{String => Object}]
35
+ def to_file = to_h.compact.transform_keys(&:to_s)
36
+
37
+ # The stop facts this turn has (LastTurn::STOP_FACTS), symbol keys.
38
+ # @return [Hash{Symbol => Object}]
39
+ def stop_facts = to_h.slice(*self.class::STOP_FACTS).compact
40
+ end
41
+
42
+ # Why a turn stopped, for a parent agent's wait (ReplyWait::Result takes
43
+ # them by these names): a failed turn's provider error kind, whether a
44
+ # retry may help and whether its work stayed (kept_steps: a parent must
45
+ # not send the task again); a canceled one's reason and the hook that
46
+ # stopped it.
47
+ LastTurn::STOP_FACTS = %i[error_kind retryable kept_steps cancel_reason stopped_by].freeze
48
+ end
@@ -36,12 +36,6 @@ module Samagotchi
36
36
  workers(state_dir: state_dir).reject { |w| w.version == version }
37
37
  end
38
38
 
39
- # The version a chi web on host:port runs, or nil when nothing (or not
40
- # chi web) answers.
41
- def web_version(host, port, timeout: WEB_TIMEOUT)
42
- web_info(host, port, timeout: timeout)&.fetch("version", nil).to_s.then { |v| v.empty? ? nil : v }
43
- end
44
-
45
39
  # The /api/info of a chi web on host:port, or nil when nothing (or not
46
40
  # chi web) answers.
47
41
  def web_info(host, port, timeout: WEB_TIMEOUT)
@@ -11,8 +11,8 @@ module Samagotchi
11
11
  # the call, as it does for the Gemma and Qwen parsers.
12
12
  #
13
13
  # Unknown tool names pass through with their arguments on args:, so
14
- # `dispatch` renders its standard "unknown tool … available: …" error —
15
- # the loop feeds that back like any tool result.
14
+ # `dispatch` renders its standard "no such tool …" error (which never
15
+ # echoes the name) — the loop feeds that back like any tool result.
16
16
  class NativeToolNormalizer
17
17
  class << self
18
18
  # Map a single native tool call (LLM::ToolCall) to the internal call hash.
@@ -26,11 +26,6 @@ module Samagotchi
26
26
  Tools::BuiltinCalls.build(call.name.to_s, args_for(call))
27
27
  end
28
28
 
29
- # Map an Array of tool calls to internal call hashes (nils dropped).
30
- def normalize_all(calls)
31
- Array(calls).map { |call| normalize(call) }.compact
32
- end
33
-
34
29
  private
35
30
 
36
31
  # args_for: arguments is normally a Hash. Defensively handle nil /
@@ -48,7 +48,6 @@ module Samagotchi
48
48
  FIELDS = %i[strategy apply budget_tokens].freeze
49
49
  # A field's word for "unset: follow the model" (/llm-context strategy default).
50
50
  DEFAULT_WORD = "default"
51
- OFF_WORDS = %w[off 0].freeze
52
51
  # The start flags (chi, chi send --new), by field.
53
52
  FLAGS = { strategy: "--llm-context", apply: "--llm-context-apply", budget_tokens: "--llm-context-budget" }.freeze
54
53
  # The session command, and its words by field.
@@ -111,7 +110,7 @@ module Samagotchi
111
110
  end
112
111
 
113
112
  def self.file_budget(raw)
114
- raw.is_a?(Integer) ? check_budget(raw) : nil
113
+ raw.is_a?(Integer) ? LLMContextStrategy.check_budget(raw) : nil
115
114
  end
116
115
 
117
116
  # +current+ with +words+ (field => what the user typed) applied: each
@@ -153,27 +152,9 @@ module Samagotchi
153
152
  rule || raise(ArgumentError, "unknown llm_context apply #{text} (#{LLMContextStrategy::APPLIES.join(", ")})")
154
153
  end
155
154
 
156
- # The smallest and largest budget a session may set.
157
- MIN_BUDGET = 4_000
158
- MAX_BUDGET = 10_000_000
159
-
160
- # "64000", "64k" or "off" (0).
155
+ # "64000", "64k" or "off" (0): LLMContextStrategy.budget_tokens.
161
156
  # @return [Integer] 0 for off
162
- def self.parse_budget_tokens(text)
163
- return 0 if OFF_WORDS.include?(text.downcase)
164
-
165
- match = /\A(\d[\d_]*)(k)?\z/i.match(text)
166
- raise ArgumentError, "a budget is a number of tokens (64000 or 64k) or off" unless match
167
-
168
- check_budget(match[1].delete("_").to_i * (match[2] ? 1000 : 1))
169
- end
170
-
171
- # +tokens+, when 0 (off) or between MIN_BUDGET and MAX_BUDGET.
172
- def self.check_budget(tokens)
173
- return tokens if tokens.zero? || tokens.between?(MIN_BUDGET, MAX_BUDGET)
174
-
175
- raise ArgumentError, "a budget of #{tokens} tokens is out of range: #{MIN_BUDGET} (4k) to #{MAX_BUDGET} (10000k), or off"
176
- end
157
+ def self.parse_budget_tokens(text) = LLMContextStrategy.budget_tokens(text)
177
158
 
178
159
  private_class_method :file_value, :file_strategy, :file_budget
179
160
  end
@@ -23,7 +23,7 @@ module Samagotchi
23
23
  # (false: edit-driven stale stubs are opt-in, experimental) are global only.
24
24
  #
25
25
  # A soft context budget, llm_context.budget_tokens, and per model or host
26
- # the flat key llm_context_budget_tokens (a positive number of tokens),
26
+ # the flat key llm_context_budget_tokens (tokens or k, 4k to 10M, or off),
27
27
  # resolved as the apply rule is, each on its own: off (nil) by default.
28
28
  # When set, ContextStatus counts its bands against it instead of the
29
29
  # window (the smaller of the two), so the forget layer's offers come
@@ -103,21 +103,52 @@ module Samagotchi
103
103
  DEFAULT_APPLY
104
104
  end
105
105
 
106
- # +raw+ as written, a context budget in tokens; nil for unset or 0
107
- # (off). Anything but a number of tokens warns, naming +where+, and is
108
- # unset.
106
+ # +raw+ as written in config (llm_context.budget_tokens, a models: or
107
+ # hosts: entry's llm_context_budget_tokens), read as /llm-context
108
+ # budget reads it (.budget_tokens); nil for unset, 0 or off. Anything
109
+ # else warns, naming +where+, and is unset.
109
110
  # @return [Integer, nil]
110
111
  def parse_budget(raw, where)
111
112
  return nil if raw.nil? || raw.to_s.strip.empty?
112
113
 
113
- tokens = Integer(raw.to_s.strip, exception: false)
114
- return nil if tokens&.zero?
115
- return tokens if tokens&.positive?
116
-
117
- warn_once "Warning: #{where}: llm_context budget_tokens must be a positive number of tokens; ignored"
114
+ tokens = budget_tokens(raw)
115
+ tokens.zero? ? nil : tokens
116
+ rescue ArgumentError => e
117
+ warn_once "Warning: #{where}: #{e.message}; ignored"
118
118
  nil
119
119
  end
120
120
 
121
+ # The smallest and largest budget a session or the config may set.
122
+ MIN_BUDGET = 4_000
123
+ MAX_BUDGET = 10_000_000
124
+ BUDGET_OFF_WORDS = %w[off 0].freeze
125
+
126
+ # A budget in tokens: an Integer, "64000", "64_000", "64k", or "off"
127
+ # (0); the one grammar for /llm-context budget, chi
128
+ # --llm-context-budget and the config.
129
+ # @return [Integer] 0 for off
130
+ # @raise [ArgumentError] not a budget, or one out of MIN_BUDGET..MAX_BUDGET
131
+ def budget_tokens(raw)
132
+ return check_budget(raw) if raw.is_a?(Integer)
133
+
134
+ text = raw.to_s.strip
135
+ return 0 if BUDGET_OFF_WORDS.include?(text.downcase)
136
+
137
+ match = /\A(\d[\d_]*)(k)?\z/i.match(text)
138
+ raise ArgumentError, "a budget is a number of tokens (64000 or 64k) or off" unless match
139
+
140
+ check_budget(match[1].delete("_").to_i * (match[2] ? 1000 : 1))
141
+ end
142
+
143
+ # +tokens+, when 0 (off) or between MIN_BUDGET and MAX_BUDGET.
144
+ # @return [Integer]
145
+ # @raise [ArgumentError]
146
+ def check_budget(tokens)
147
+ return tokens if tokens.zero? || tokens.between?(MIN_BUDGET, MAX_BUDGET)
148
+
149
+ raise ArgumentError, "a budget of #{tokens} tokens is out of range: #{MIN_BUDGET} (4k) to #{MAX_BUDGET} (10000k), or off"
150
+ end
151
+
121
152
  # Where one value came from: :session, :model_setting, :host_setting
122
153
  # or :config, and the place as a warning names it ("the session",
123
154
  # "models: deepseek", "hosts entry 'box'", "llm_context.apply").
@@ -143,7 +174,7 @@ module Samagotchi
143
174
  # The strategy for +target+'s turn.
144
175
  # @param target [HostRegistry::ModelTarget, nil]
145
176
  # @param names [Array<String>] HostRegistry#lookup_names
146
- # @param models [Hash, nil] ConfigFile.model_settings (specs)
177
+ # @param models [Hash{String => ModelSettings}, nil] ConfigFile.model_settings (specs)
147
178
  # @param session [Array<Symbol>, nil] the session's own layers ([]: none)
148
179
  # @param session_apply [Symbol, nil] the session's own apply rule
149
180
  # @param session_budget [Integer, nil] the session's own budget (0: off)
@@ -210,8 +241,9 @@ module Samagotchi
210
241
  def budget_for(target, names, models, session_budget)
211
242
  return [session_budget.positive? ? session_budget : nil, SESSION_ORIGIN] unless session_budget.nil?
212
243
 
213
- key, budget = ConfigFile.model_setting(names, :llm_context_budget_tokens, models: models)
214
- return [budget, Origin.new(source: :model_setting, where: "models: #{key}")] if budget
244
+ if (setting = ConfigFile.model_setting(names, :llm_context_budget_tokens, models: models))
245
+ return [setting.value, Origin.new(source: :model_setting, where: "models: #{setting.key}")]
246
+ end
215
247
 
216
248
  budget = target&.entry&.llm_context_budget_tokens
217
249
  return [budget, Origin.new(source: :host_setting, where: "hosts entry '#{target.entry.name}'")] if budget
@@ -222,8 +254,9 @@ module Samagotchi
222
254
  def layers_for(target, names, models, session)
223
255
  return [session, :session, SESSION_ORIGIN.where] if session
224
256
 
225
- key, layers = ConfigFile.model_setting(names, :llm_context_strategy, models: models)
226
- return [layers, :model_setting, "models: #{key}"] if layers
257
+ if (setting = ConfigFile.model_setting(names, :llm_context_strategy, models: models))
258
+ return [setting.value, :model_setting, "models: #{setting.key}"]
259
+ end
227
260
 
228
261
  layers = target&.entry&.llm_context_strategy
229
262
  return [layers, :host_setting, "hosts entry '#{target.entry.name}'"] if layers
@@ -235,8 +268,9 @@ module Samagotchi
235
268
  def apply_for(target, names, models, session_apply)
236
269
  return [session_apply, SESSION_ORIGIN] if session_apply
237
270
 
238
- key, apply = ConfigFile.model_setting(names, :llm_context_apply, models: models)
239
- return [apply, Origin.new(source: :model_setting, where: "models: #{key}")] if apply
271
+ if (setting = ConfigFile.model_setting(names, :llm_context_apply, models: models))
272
+ return [setting.value, Origin.new(source: :model_setting, where: "models: #{setting.key}")]
273
+ end
240
274
 
241
275
  apply = target&.entry&.llm_context_apply
242
276
  return [apply, Origin.new(source: :host_setting, where: "hosts entry '#{target.entry.name}'")] if apply
@@ -18,11 +18,6 @@ module Samagotchi
18
18
  new(content: content).placeholders
19
19
  end
20
20
 
21
- # Returns a sorted list of unique placeholder names found.
22
- def unique_names
23
- @placeholders.map(&:strip).uniq.sort
24
- end
25
-
26
21
  def any?
27
22
  !@placeholders.empty?
28
23
  end
@@ -227,7 +227,7 @@ module Samagotchi
227
227
  host = parsed.host_name
228
228
  return nil unless host
229
229
  # Declared under hosts.<name>.models: served whatever the host lists.
230
- return nil if hosts[host.to_s.strip.downcase]&.dig(:models)&.key?(parsed.id.to_s.strip.downcase)
230
+ return nil if hosts[host.to_s.strip.downcase]&.models&.key?(parsed.id.to_s.strip.downcase)
231
231
 
232
232
  list = lists.read(env: env)[host.to_s.strip.downcase]
233
233
  return nil if list.nil? || list.stale? || list.known?(parsed.id)
@@ -433,10 +433,10 @@ module Samagotchi
433
433
  end
434
434
 
435
435
  names.map(&:downcase).uniq.each do |key|
436
- setting = models[key]
437
- next unless setting && setting[:profile]
436
+ configured_profile = models[key]&.profile
437
+ next unless configured_profile
438
438
 
439
- profile = configured(setting[:profile], "models: #{key}")
439
+ profile = configured(configured_profile, "models: #{key}")
440
440
  return Resolution.new(profile: profile, source: :config, detail: "models: #{key}", retry: false) if profile
441
441
  end
442
442
 
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require_relative "llm_context_strategy"
5
+
6
+ module Samagotchi
7
+ # One config.yml models: entry (per-model settings keyed by model id or
8
+ # alias), parsed and checked; ConfigFile.model_settings holds them by
9
+ # downcased key. Each field is nil when unset: profile kept as written
10
+ # (downcased; ModelProfile.resolve validates it), vision true/false,
11
+ # sampling a request-field map, thinking a level, window_tokens, and the
12
+ # LLM context strategy, apply rule and budget (LLMContextStrategy). The
13
+ # same per-model fields a hosts: entry carries (HostConfig).
14
+ ModelSettings = Data.define(:profile, :vision, :sampling, :thinking, :window_tokens,
15
+ :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens) do
16
+ # Every field may be left out (nil).
17
+ def initialize(**fields)
18
+ super(**members.to_h { |m| [m, nil] }, **fields)
19
+ end
20
+
21
+ # A models: entry as written, or nil when it is not a map (or the key is
22
+ # blank). A bad value warns, naming the entry, and is left unset.
23
+ # @param key [String] the downcased model id or alias
24
+ # @param raw [Hash] string or symbol keys
25
+ # @return [ModelSettings, nil]
26
+ def self.parse(key, raw)
27
+ return nil if key.to_s.empty? || !raw.is_a?(Hash)
28
+
29
+ value = ->(name) { raw.key?(name.to_s) ? raw[name.to_s] : raw[name.to_sym] }
30
+ where = "models: #{key}"
31
+ profile = value.call(:profile).to_s.strip.downcase
32
+ new(profile: profile.empty? ? nil : profile,
33
+ vision: ConfigFile.vision_flag(value.call(:vision), where),
34
+ sampling: ConfigFile.sampling_map(value.call(:sampling), where),
35
+ thinking: Thinking.level(value.call(:thinking), where),
36
+ window_tokens: ConfigFile.window_tokens(value.call(:window_tokens), where),
37
+ llm_context_strategy: LLMContextStrategy.parse(value.call(LLMContextStrategy::KEY), where),
38
+ llm_context_apply: LLMContextStrategy.parse_apply(value.call(LLMContextStrategy::APPLY_KEY), where),
39
+ llm_context_budget_tokens: LLMContextStrategy.parse_budget(value.call(LLMContextStrategy::BUDGET_KEY), where))
40
+ end
41
+ end
42
+
43
+ # One field ConfigFile.model_setting found: the models: key that set it
44
+ # and its value.
45
+ ModelSetting = Data.define(:key, :value)
46
+ end
@@ -20,15 +20,15 @@ module Samagotchi
20
20
  # the answer text.
21
21
  # +messages_partial+ says whether +messages+ leaves out a running turn;
22
22
  # +model_name+ (the session's resolved model ref, live after /model) and
23
- # +state_dir+ are what ctx.sessions forks with, and +llm_context+ (the
24
- # session's own LLMContextOverride, nil without one), which a fork copies; +model_key+ is the
23
+ # +state_dir+ are what ctx.sessions forks with, and +setup+ (the
24
+ # session's own SessionSetup), which a fork copies; +model_key+ is the
25
25
  # model's memory overlay key (ctx.model, ctx.model_key).
26
26
  # +steer+ takes (text, label), +stop_turn+ and +stop_generation+
27
27
  # (reason, label), each true when it acted on a running turn.
28
28
  # +frontend+ returns the Engine's interface (:repl, :worker,
29
29
  # :non_interactive), which ctx.frontend names for plugins.
30
30
  Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
31
- :ask_model, :model_name, :model_key, :state_dir, :scratch, :llm_context, :steer, :stop_turn, :stop_generation,
31
+ :ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :steer, :stop_turn, :stop_generation,
32
32
  :frontend, keyword_init: true)
33
33
 
34
34
  # ctx.ask_model failed: the model couldn't be reached, timed out, or