samagotchi 0.51.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -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 +23 -8
  7. data/docs/configuration.md +59 -19
  8. data/docs/memory.md +11 -0
  9. data/docs/plugins.md +2 -1
  10. data/docs/sessions.md +5 -1
  11. data/docs/testing.md +6 -1
  12. data/lib/samagotchi/bootstrap/config_writer.rb +6 -37
  13. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  14. data/lib/samagotchi/bridge.rb +10 -13
  15. data/lib/samagotchi/broadcast/recipients.rb +1 -2
  16. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/children_status.rb +1 -1
  19. data/lib/samagotchi/commands/registry.rb +2 -1
  20. data/lib/samagotchi/config.rb +50 -182
  21. data/lib/samagotchi/config_text_edit.rb +8 -32
  22. data/lib/samagotchi/context_sources.rb +0 -7
  23. data/lib/samagotchi/context_window.rb +2 -2
  24. data/lib/samagotchi/continue_offer.rb +5 -4
  25. data/lib/samagotchi/cut_policy.rb +0 -1
  26. data/lib/samagotchi/delivery.rb +23 -0
  27. data/lib/samagotchi/engine.rb +48 -17
  28. data/lib/samagotchi/engine_peers.rb +1 -0
  29. data/lib/samagotchi/host_config.rb +183 -0
  30. data/lib/samagotchi/host_registry.rb +5 -10
  31. data/lib/samagotchi/last_turn.rb +48 -0
  32. data/lib/samagotchi/live_versions.rb +0 -6
  33. data/lib/samagotchi/llm/native_tool_normalizer.rb +0 -5
  34. data/lib/samagotchi/llm_context_override.rb +3 -22
  35. data/lib/samagotchi/llm_context_strategy.rb +50 -16
  36. data/lib/samagotchi/memory_bundle/placeholder.rb +0 -5
  37. data/lib/samagotchi/model_profile.rb +4 -4
  38. data/lib/samagotchi/model_settings.rb +46 -0
  39. data/lib/samagotchi/plugin/context.rb +3 -3
  40. data/lib/samagotchi/plugin/sessions.rb +2 -2
  41. data/lib/samagotchi/queued_command.rb +35 -0
  42. data/lib/samagotchi/reminder_store.rb +0 -27
  43. data/lib/samagotchi/reply_wait.rb +11 -13
  44. data/lib/samagotchi/sampling_settings.rb +4 -4
  45. data/lib/samagotchi/self_report.rb +8 -5
  46. data/lib/samagotchi/send_command.rb +37 -19
  47. data/lib/samagotchi/session.rb +45 -24
  48. data/lib/samagotchi/session_commands.rb +36 -7
  49. data/lib/samagotchi/session_inbox.rb +18 -9
  50. data/lib/samagotchi/session_manager.rb +8 -7
  51. data/lib/samagotchi/session_setup.rb +30 -0
  52. data/lib/samagotchi/steer_cut.rb +9 -2
  53. data/lib/samagotchi/terminal_ui/attach_launcher.rb +10 -4
  54. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
  55. data/lib/samagotchi/terminal_ui/formatting.rb +21 -3
  56. data/lib/samagotchi/terminal_ui.rb +8 -3
  57. data/lib/samagotchi/thinking.rb +67 -7
  58. data/lib/samagotchi/thinking_command.rb +72 -0
  59. data/lib/samagotchi/tools/builtins.rb +7 -2
  60. data/lib/samagotchi/tools/peers.rb +3 -2
  61. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  62. data/lib/samagotchi/version.rb +1 -1
  63. data/lib/samagotchi/vision_support.rb +4 -3
  64. data/lib/samagotchi/web/app.rb +54 -6
  65. data/lib/samagotchi/web/public/app.js +133 -20
  66. data/lib/samagotchi/web/public/data.js +8 -4
  67. data/lib/samagotchi/web/public/format.js +8 -4
  68. data/lib/samagotchi/web/public/index.html +8 -5
  69. data/lib/samagotchi/web/public/kept_commands.js +52 -0
  70. data/lib/samagotchi/web/public/model_pick.js +3 -1
  71. data/lib/samagotchi/web/public/thinking_chip.js +169 -0
  72. data/lib/samagotchi/web/public/timing.js +15 -0
  73. data/lib/samagotchi/web/public/turn_events.js +23 -0
  74. data/lib/samagotchi/web/public/turn_view.js +328 -310
  75. data/lib/samagotchi/web/public/vendor/lit-html.js +8 -0
  76. data/lib/samagotchi/web/session_summary.rb +1 -1
  77. data/lib/samagotchi/worker.rb +43 -42
  78. data/lib/samagotchi/worker_inbound.rb +5 -5
  79. data/lib/samagotchi/worker_wakes.rb +0 -2
  80. data/lib/samagotchi/yaml_lines.rb +72 -0
  81. metadata +11 -1
@@ -954,7 +954,7 @@ module Samagotchi
954
954
  served, served_for = served_model_for(snapshot, target: target)
955
955
  snapshot = snapshot.merge(served_model: served, served_model_for: served_for,
956
956
  served_expected_by: served_expected_by(served, target: target), llm_context: llm_context_summary,
957
- prompt_notes: prompt_notes.map(&:to_h), price_host: target.entry.name)
957
+ thinking: thinking_explained&.summary, prompt_notes: prompt_notes.map(&:to_h), price_host: target.entry.name)
958
958
  unless snapshot.dig(:context, :window_tokens)
959
959
  window = current_context_window(target)
960
960
  if window
@@ -1029,16 +1029,46 @@ module Samagotchi
1029
1029
  nil
1030
1030
  end
1031
1031
 
1032
- # "off (models: qwen)" for /model: the effective model's thinking level
1033
- # and where it came from, nil when none is set.
1032
+ # "off (models: qwen)", "low (session)" for /model: the effective
1033
+ # model's thinking level and where it came from, nil when none is set.
1034
1034
  def thinking_summary
1035
+ explained = thinking_explained
1036
+ explained&.source ? explained.label : nil
1037
+ end
1038
+
1039
+ # The effective model's thinking level with where it came from and the
1040
+ # session's own (Thinking::Explained), as the next turn resolves it;
1041
+ # nil when it can't be resolved.
1042
+ # @return [Thinking::Explained, nil]
1043
+ def thinking_explained
1035
1044
  target = @host_registry.resolve(@effective_model_name)
1036
- level, source = Thinking.resolve(target, names: model_lookup_names(target))
1037
- source ? "#{level} (#{source})" : nil
1045
+ Thinking.explain(target, names: model_lookup_names(target), session: thinking_override)
1038
1046
  rescue StandardError
1039
1047
  nil
1040
1048
  end
1041
1049
 
1050
+ # The session's own thinking level, nil when it has none.
1051
+ # @return [Symbol, nil]
1052
+ def thinking_override = @session&.thinking
1053
+
1054
+ # What a thinking level change costs the effective model's prompt
1055
+ # cache (Thinking.cache_cost): :full, :provider or :tail.
1056
+ # @return [Symbol]
1057
+ def thinking_cache_cost
1058
+ target = @host_registry.resolve(@effective_model_name)
1059
+ Thinking.cache_cost(target, profile: target.entry&.chat? ? nil : profile&.name)
1060
+ end
1061
+
1062
+ # Set the session's own thinking level (nil unsets it); the next turn's
1063
+ # start resolves it, never a running one (its TurnSettings are fixed).
1064
+ # The caller saves the session.
1065
+ # @raise [ArgumentError] with no session yet
1066
+ def thinking_override=(level)
1067
+ raise ArgumentError, "no session yet" unless @session
1068
+
1069
+ @session.thinking = level
1070
+ end
1071
+
1042
1072
  # The model the server serves for the current model, and the name asked
1043
1073
  # for: what the last generation of that name reported, else llama.cpp's
1044
1074
  # model_alias (/props, one short cached probe; not with probe: false),
@@ -1105,6 +1135,7 @@ module Samagotchi
1105
1135
  served_expected_by: served_expected_by(served_pair[0]),
1106
1136
  context_status: @last_context_status&.dup || saved_context_status(metrics[:context]),
1107
1137
  llm_context: llm_context_summary,
1138
+ thinking: thinking_explained&.summary,
1108
1139
  recap_enabled: !@recap.nil?,
1109
1140
  recap_min_user_turns: @recap&.min_user_turns,
1110
1141
  recap_inactivity_seconds: @recap&.inactivity&.to_i
@@ -1242,22 +1273,22 @@ module Samagotchi
1242
1273
  elsif client_id == ClientId::REMINDER then "reminder"
1243
1274
  else "client"
1244
1275
  end
1245
- session.last_turn = { "outcome" => outcome, "ended_at" => Time.now.iso8601(3),
1246
- "seconds" => seconds.round(1), "origin" => source }
1247
- session.last_turn.merge!("exhausted" => true, "limit" => limit) if limit
1248
- session.last_turn.merge!(stop_facts(event))
1276
+ session.last_turn = LastTurn.new(outcome: outcome, ended_at: Time.now.iso8601(3), seconds: seconds.round(1),
1277
+ origin: source, exhausted: limit ? true : nil, limit: limit,
1278
+ **stop_facts(event))
1249
1279
  end
1250
1280
 
1251
- # Why a turn stopped, from its end event, for a parent agent's wait
1252
- # (ReplyWait): a provider error's kind and whether a retry may help, a
1253
- # cancel's reason and the hook that stopped it. Only what is known.
1281
+ # Why a turn stopped, from its end event (LastTurn::STOP_FACTS), for a
1282
+ # parent agent's wait (ReplyWait): a provider error's kind and whether
1283
+ # a retry may help, a cancel's reason and the hook that stopped it.
1284
+ # Only what is known.
1254
1285
  def stop_facts(event)
1255
1286
  case event&.dig(:type)
1256
1287
  # kept_steps: the turn's work stayed (a parent must not send the task again).
1257
1288
  when :turn_failed
1258
- { "error_kind" => event[:error_kind]&.to_s, "retryable" => event[:retryable], "kept_steps" => event[:kept_steps] }.compact
1289
+ { error_kind: event[:error_kind]&.to_s, retryable: event[:retryable], kept_steps: event[:kept_steps] }
1259
1290
  when :turn_canceled
1260
- { "cancel_reason" => event[:cancellation_reason]&.to_s, "stopped_by" => event[:cancelled_by]&.to_s }.compact
1291
+ { cancel_reason: event[:cancellation_reason]&.to_s, stopped_by: event[:cancelled_by]&.to_s }
1261
1292
  else {}
1262
1293
  end
1263
1294
  end
@@ -2237,9 +2268,9 @@ module Samagotchi
2237
2268
  hook_notify(text, level, "thinking")
2238
2269
  end
2239
2270
 
2240
- # +target+'s thinking level (Thinking.resolve).
2271
+ # +target+'s thinking level (Thinking.resolve), the session's own first.
2241
2272
  def thinking_level(target)
2242
- Thinking.resolve(target, names: model_lookup_names(target)).first
2273
+ Thinking.resolve(target, names: model_lookup_names(target), session: thinking_override).first
2243
2274
  rescue StandardError
2244
2275
  Thinking::DEFAULT
2245
2276
  end
@@ -2470,7 +2501,7 @@ module Samagotchi
2470
2501
  model_key: -> { @model_key },
2471
2502
  state_dir: -> { session_state_dir },
2472
2503
  scratch: -> { @scratch },
2473
- llm_context: -> { @session&.llm_context },
2504
+ setup: -> { SessionSetup.of(@session) },
2474
2505
  frontend: -> { interface }
2475
2506
  )
2476
2507
  end
@@ -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
@@ -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)
@@ -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