samagotchi 0.38.0 → 0.40.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -1
  3. data/docs/cli.md +2 -1
  4. data/docs/configuration.md +58 -2
  5. data/docs/hooks.md +1 -0
  6. data/docs/internals/background-tasks.md +1 -1
  7. data/docs/internals/context-telemetry.md +16 -0
  8. data/docs/internals/llm-context-bench.md +133 -0
  9. data/docs/internals/llm-context-forget.md +45 -0
  10. data/docs/internals/prompt-caching.md +3 -2
  11. data/docs/plugins.md +4 -3
  12. data/docs/testing.md +8 -0
  13. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
  15. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/mcp/plugin.rb +10 -10
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/system/self_map.md +4 -0
  19. data/lib/samagotchi/config.rb +43 -4
  20. data/lib/samagotchi/context_note.rb +7 -3
  21. data/lib/samagotchi/context_status.rb +112 -11
  22. data/lib/samagotchi/engine.rb +36 -9
  23. data/lib/samagotchi/guardrails/targets.rb +15 -3
  24. data/lib/samagotchi/host_registry.rb +8 -3
  25. data/lib/samagotchi/kernel_loop.rb +142 -14
  26. data/lib/samagotchi/llm/chat_loop.rb +57 -18
  27. data/lib/samagotchi/llm/turn_settings.rb +5 -4
  28. data/lib/samagotchi/llm_context_apply.rb +165 -0
  29. data/lib/samagotchi/llm_context_edit.rb +102 -0
  30. data/lib/samagotchi/llm_context_forget.rb +258 -0
  31. data/lib/samagotchi/llm_context_stale.rb +271 -0
  32. data/lib/samagotchi/llm_context_strategy.rb +177 -0
  33. data/lib/samagotchi/llm_context_view.rb +201 -0
  34. data/lib/samagotchi/log_subscriber.rb +2 -1
  35. data/lib/samagotchi/prompt.rb +4 -11
  36. data/lib/samagotchi/session_manager.rb +9 -1
  37. data/lib/samagotchi/session_metrics.rb +18 -1
  38. data/lib/samagotchi/system_prompt.rb +20 -15
  39. data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
  40. data/lib/samagotchi/token_usage.rb +3 -0
  41. data/lib/samagotchi/tool_activity.rb +18 -0
  42. data/lib/samagotchi/tool_declarations.rb +60 -4
  43. data/lib/samagotchi/tool_ids.rb +84 -0
  44. data/lib/samagotchi/tool_response.rb +67 -6
  45. data/lib/samagotchi/tools/builtin_calls.rb +3 -2
  46. data/lib/samagotchi/tools/builtins.rb +15 -5
  47. data/lib/samagotchi/tools/context_read.rb +27 -10
  48. data/lib/samagotchi/tools/forget_outputs.rb +62 -0
  49. data/lib/samagotchi/tools/registry.rb +25 -8
  50. data/lib/samagotchi/version.rb +1 -1
  51. data/lib/samagotchi/web/public/app.js +1 -1
  52. data/lib/samagotchi/worker.rb +4 -0
  53. metadata +11 -1
@@ -9,6 +9,7 @@ require_relative "config_text_edit"
9
9
  require_relative "log"
10
10
  require_relative "model_ref"
11
11
  require_relative "paths"
12
+ require_relative "llm_context_strategy"
12
13
 
13
14
  module Samagotchi
14
15
  # Parses the thinking: levels of host and model entries (it needs Config).
@@ -154,6 +155,24 @@ module Samagotchi
154
155
  # 10 minutes, within session.max_wakes); false: it waits as a note for the next turn.
155
156
  Entry.new(key: "context.wake", yaml_path: %w[context wake], type: :bool, default: true, expose: %i[env config cli]),
156
157
 
158
+ # What the model is sent of the conversation (LLMContextStrategy, LLMContextView): none, or a list of layers
159
+ # (stale, forget; "|"-separated or a YAML list); forget (the model's forget_outputs) is experimental.
160
+ # models.<key>.llm_context_strategy and hosts.<name>.llm_context_strategy come first.
161
+ Entry.new(key: "llm_context.strategy", yaml_path: %w[llm_context strategy], type: :string, default: "none", expose: %i[env config]),
162
+ # When a layer's edits reach the prompt (LLMContextApply): payoff, next_request or turn_end;
163
+ # models.<key>.llm_context_apply and hosts.<name>.llm_context_apply come first.
164
+ Entry.new(key: "llm_context.apply", yaml_path: %w[llm_context apply], type: :string, default: "payoff", expose: %i[env config]),
165
+ # Whether an edit or write of a file makes its earlier reads stale too (opt-in, experimental).
166
+ Entry.new(key: "llm_context.stale_edits", yaml_path: %w[llm_context stale_edits], type: :bool, default: false, expose: %i[env config]),
167
+ # The steps (tool batches) back whose edited files' reads stale never stubs.
168
+ Entry.new(key: "llm_context.protect_steps", yaml_path: %w[llm_context protect_steps], type: :integer, default: 3, expose: %i[env config]),
169
+ # A soft context budget in tokens: the context status bands (and the forget layer's offers) count against it
170
+ # instead of the window; unset or 0: off. models.<key>.llm_context_budget_tokens and
171
+ # hosts.<name>.llm_context_budget_tokens come first.
172
+ Entry.new(key: "llm_context.budget_tokens", yaml_path: %w[llm_context budget_tokens], type: :integer, default: nil, expose: %i[env config]),
173
+ # The policy sentence forget_outputs' description carries (the forget layer); blank: none.
174
+ Entry.new(key: "llm_context.policy", yaml_path: %w[llm_context policy], type: :string, default: LLMContextStrategy::DEFAULT_POLICY, expose: %i[env config]),
175
+
157
176
  Entry.new(key: "thinking.turn_preamble", yaml_path: %w[thinking turn_preamble], type: :bool, default: true, expose: %i[env config cli]),
158
177
  # How much models think: off|low|medium|high|default, parsed by Thinking
159
178
  # (a :string, since YAML reads an unquoted off as false). The CLI flag
@@ -241,8 +260,9 @@ module Samagotchi
241
260
  # (ConfigFile.hosts_config, ConfigFile.model_settings).
242
261
  MAP_ENTRY_KEYS = {
243
262
  "hosts" => %w[host port url transport api api_key_env profile first_token_timeout vision sampling thinking enabled
244
- remote window_tokens].freeze,
245
- "models" => %w[profile vision sampling thinking window_tokens].freeze
263
+ remote window_tokens llm_context_strategy llm_context_apply llm_context_budget_tokens].freeze,
264
+ "models" => %w[profile vision sampling thinking window_tokens llm_context_strategy llm_context_apply
265
+ llm_context_budget_tokens].freeze
246
266
  }.freeze
247
267
  # Section keys beyond the registry's: guardrails' YAML rules (Engine#guardrail_rules).
248
268
  SECTION_EXTRA_KEYS = { "guardrails" => %w[rules disable].freeze }.freeze
@@ -826,6 +846,12 @@ module Samagotchi
826
846
  "hosts entry '#{name}'")
827
847
  sampling = ConfigFile.sampling_map(raw_cfg.key?("sampling") ? raw_cfg["sampling"] : raw_cfg[:sampling], "hosts entry '#{name}'")
828
848
  thinking = Thinking.level(raw_cfg.key?("thinking") ? raw_cfg["thinking"] : raw_cfg[:thinking], "hosts entry '#{name}'")
849
+ llm_context = LLMContextStrategy.parse(raw_cfg.key?(LLMContextStrategy::KEY) ? raw_cfg[LLMContextStrategy::KEY] : raw_cfg[:llm_context_strategy],
850
+ "hosts entry '#{name}'")
851
+ llm_context_apply = LLMContextStrategy.parse_apply(raw_cfg.key?(LLMContextStrategy::APPLY_KEY) ? raw_cfg[LLMContextStrategy::APPLY_KEY] : raw_cfg[:llm_context_apply],
852
+ "hosts entry '#{name}'")
853
+ llm_context_budget = LLMContextStrategy.parse_budget(raw_cfg.key?(LLMContextStrategy::BUDGET_KEY) ? raw_cfg[LLMContextStrategy::BUDGET_KEY] : raw_cfg[:llm_context_budget_tokens],
854
+ "hosts entry '#{name}'")
829
855
  unless first_token_timeout.nil? || (first_token_timeout.is_a?(Numeric) && !first_token_timeout.negative?)
830
856
  warn_once "Warning: hosts entry '#{name}': first_token_timeout must be seconds (0 = off); using the default"
831
857
  first_token_timeout = nil
@@ -890,7 +916,8 @@ module Samagotchi
890
916
  url: url.empty? ? nil : url, api_key_env: api_key_env.empty? ? nil : api_key_env,
891
917
  profile: profile.empty? ? nil : profile, first_token_timeout: first_token_timeout,
892
918
  vision: vision, sampling: sampling, thinking: thinking, remote: remote,
893
- window_tokens: window }
919
+ window_tokens: window, llm_context_strategy: llm_context,
920
+ llm_context_apply: llm_context_apply, llm_context_budget_tokens: llm_context_budget }
894
921
  end
895
922
  end
896
923
 
@@ -1037,7 +1064,10 @@ module Samagotchi
1037
1064
  location.merge("transport" => v[:transport]&.to_s, "api" => v[:api]&.to_s, "api_key_env" => v[:api_key_env],
1038
1065
  "profile" => v[:profile], "first_token_timeout" => v[:first_token_timeout],
1039
1066
  "vision" => v[:vision], "sampling" => v[:sampling], "thinking" => v[:thinking]&.to_s,
1040
- "remote" => v[:remote], "window_tokens" => v[:window_tokens]).compact
1067
+ "remote" => v[:remote], "window_tokens" => v[:window_tokens],
1068
+ "llm_context_strategy" => v[:llm_context_strategy]&.map(&:to_s),
1069
+ "llm_context_apply" => v[:llm_context_apply]&.to_s,
1070
+ "llm_context_budget_tokens" => v[:llm_context_budget_tokens]).compact
1041
1071
  end
1042
1072
  # Disabled hosts travel as just that, so a worker refuses "box:x"
1043
1073
  # the way its parent does instead of sending it to the default host.
@@ -1129,6 +1159,15 @@ module Samagotchi
1129
1159
  result[key][:thinking] = thinking if thinking
1130
1160
  window = window_tokens(v.key?("window_tokens") ? v["window_tokens"] : v[:window_tokens], "models: #{key}")
1131
1161
  result[key][:window_tokens] = window if window
1162
+ layers = LLMContextStrategy.parse(v.key?(LLMContextStrategy::KEY) ? v[LLMContextStrategy::KEY] : v[:llm_context_strategy],
1163
+ "models: #{key}")
1164
+ result[key][:llm_context_strategy] = layers if layers
1165
+ apply = LLMContextStrategy.parse_apply(v.key?(LLMContextStrategy::APPLY_KEY) ? v[LLMContextStrategy::APPLY_KEY] : v[:llm_context_apply],
1166
+ "models: #{key}")
1167
+ result[key][:llm_context_apply] = apply if apply
1168
+ budget = LLMContextStrategy.parse_budget(v.key?(LLMContextStrategy::BUDGET_KEY) ? v[LLMContextStrategy::BUDGET_KEY] : v[:llm_context_budget_tokens],
1169
+ "models: #{key}")
1170
+ result[key][:llm_context_budget_tokens] = budget if budget
1132
1171
  end
1133
1172
  rescue StandardError
1134
1173
  {}
@@ -64,9 +64,13 @@ module Samagotchi
64
64
  # (a note that arrived before the first turn, a turn note left by a
65
65
  # failed first turn) stays and the prompt goes before it.
66
66
  def with_system_head(messages, system_message)
67
- first = messages.first
68
- replace = first && fetch(first, :role).to_s == "system" && fetch(first, :kind).to_s.empty?
69
- [system_message] + (replace ? messages.drop(1) : messages)
67
+ [system_message] + (system_head?(messages.first) ? messages.drop(1) : messages)
68
+ end
69
+
70
+ # A system prompt at a conversation's head: a system message of no kind
71
+ # (#with_system_head replaces it).
72
+ def system_head?(message)
73
+ message.is_a?(Hash) && fetch(message, :role).to_s == "system" && fetch(message, :kind).to_s.empty?
70
74
  end
71
75
 
72
76
  def clock(created_at)
@@ -19,6 +19,26 @@ module Samagotchi
19
19
  #
20
20
  # The context.* settings are read once, when the tracker is built (one per
21
21
  # run, so a changed setting counts from the next turn).
22
+ #
23
+ # The turn's LLM context strategy (LLMContextStrategy::Resolved) may set
24
+ # a soft budget (llm_context.budget_tokens): the buckets then count
25
+ # against it instead of the window (the smaller of the two).
26
+ #
27
+ # Under the forget layer the model's lines offer forget_outputs in
28
+ # tiers, never "forget now" (the spike: told to free context, models
29
+ # forget nearly everything), each with a "~N/M tokens" readout:
30
+ # - the first bucket with guidance, below the top two: the readout alone
31
+ # (CLM: how-to at low pressure triggers wholesale deletion);
32
+ # - the one under the top: finish the unit of work in flight, then tidy
33
+ # once;
34
+ # - the top, or over the budget: compact settled outputs now, keep what
35
+ # will still be edited against, don't wipe.
36
+ # When: at a turn's first request (the turn before it answered, its
37
+ # outputs are settled), on a rise as ever, and again when the last line
38
+ # was the readout alone (a mid-turn rise) or the context is over the
39
+ # budget (every turn then). Mid-turn only the top tier offers; a lower
40
+ # rise gets the readout alone (the layout check: pushed mid-task, models
41
+ # forget at the base rate).
22
42
  class ContextStatus
23
43
  STATUS_PREFIX = "CONTEXT_STATUS"
24
44
  # The model's own line about its context (a tail system message, kind
@@ -62,19 +82,35 @@ module Samagotchi
62
82
  # The bucket of the last status line +conversation+ holds: the model's
63
83
  # own line, or a legacy session's injected telemetry.
64
84
  def self.last_bucket(conversation)
85
+ content = last_line(conversation)
86
+ match = content&.match(/\bbucket=([a-z0-9_]+)/)
87
+ match && match[1]
88
+ end
89
+
90
+ # The text of the last status line +conversation+ holds, nil for none.
91
+ def self.last_line(conversation)
65
92
  message = conversation.reverse.find do |entry|
66
93
  content = entry[:content].to_s
67
94
  entry[:role] == "system" && content.start_with?(STATUS_PREFIX, LINE_PREFIX)
68
95
  end
69
- return nil unless message
70
-
71
- match = message[:content].match(/\bbucket=([a-z0-9_]+)/)
72
- match && match[1]
96
+ message && message[:content].to_s
73
97
  end
74
98
 
99
+ # What a forget-layer line names when it offers the tool.
100
+ OFFER_TOOL = "forget_outputs"
101
+ # The forget layer's tiers' words (#forget_line).
102
+ TIDY = "Finish the unit of work in flight, then tidy once with #{OFFER_TOOL}: settled outputs, with a note " \
103
+ "that carries what they established."
104
+ COMPACT = "Compact settled outputs now with #{OFFER_TOOL}: keep what you'll still edit against; don't wipe."
105
+
75
106
  # @param conversation [Array<Hash>] the turn's conversation so far (its
76
107
  # last status line's bucket is where rises count from)
77
- def initialize(conversation: [])
108
+ # @param llm_context [LLMContextStrategy::Resolved, nil] the turn's
109
+ # strategy (its budget)
110
+ def initialize(conversation: [], llm_context: nil)
111
+ @budget = llm_context&.budget_tokens
112
+ @forget = llm_context&.forget? || false
113
+ @offered = self.class.last_line(conversation).to_s.include?(OFFER_TOOL)
78
114
  @enabled = self.class.enabled?
79
115
  chars_per_token = Config.get("context.chars_per_token").to_f
80
116
  @chars_per_token = chars_per_token.positive? ? chars_per_token : DEFAULT_CHARS_PER_TOKEN
@@ -85,6 +121,7 @@ module Samagotchi
85
121
  @last_bucket = self.class.last_bucket(conversation)
86
122
  @server_usage = nil
87
123
  @counted = nil
124
+ @edited = false
88
125
  @display = nil
89
126
  @guidance = nil
90
127
  end
@@ -95,6 +132,12 @@ module Samagotchi
95
132
 
96
133
  def enabled? = @enabled
97
134
 
135
+ # The status line's value is in the top configured bucket (the apply
136
+ # rule's payoff applies staged edits there at once).
137
+ def top_bucket?
138
+ !@display.nil? && bucket_rank(@display[:bucket]) == @thresholds.size
139
+ end
140
+
98
141
  # Before a request: estimate the usage of a prompt of +prompt_chars+
99
142
  # against +window+ (this request's ContextWindow::Resolved) and update
100
143
  # the status line's value. A rise into a bucket that asks the model for
@@ -114,12 +157,25 @@ module Samagotchi
114
157
  emit = emit?(bucket: bucket, iteration_index: iteration_index)
115
158
  previous_bucket = @last_bucket
116
159
  @last_bucket = bucket
160
+ line = if @forget
161
+ forget_line(usage, bucket, previous_bucket, iteration_index)
162
+ elsif guidance_due?(previous_bucket, bucket)
163
+ guidance_message(usage: usage, bucket: bucket)
164
+ end
165
+ @guidance = line if line
117
166
  return nil unless emit
118
167
 
119
- @guidance = guidance_message(usage: usage, bucket: bucket) if guidance_due?(previous_bucket, bucket)
120
168
  { status: status_message(usage: usage, bucket: bucket), usage: usage, bucket: bucket, source: usage[:source] }
121
169
  end
122
170
 
171
+ # An LLM context edit was applied (LLMContextApply): the prompt is
172
+ # shorter than the one the server's count was for. Until the next
173
+ # count the estimate takes what the prompt lost off that count too,
174
+ # instead of holding it (a shorter prompt counts as trimmed otherwise).
175
+ def edited!
176
+ @edited = true
177
+ end
178
+
123
179
  # @return [Hash, nil] the model's line from the last #observe (once)
124
180
  def take_guidance
125
181
  line = @guidance
@@ -145,6 +201,7 @@ module Samagotchi
145
201
  return unless usage
146
202
 
147
203
  @counted = { chars: prompt_chars, image_tokens: image_tokens }
204
+ @edited = false
148
205
  value = display_for(used_tokens: usage[:total_tokens], window_tokens: usage[:context_window_tokens] || window&.tokens)
149
206
  @display = value if value
150
207
  end
@@ -155,7 +212,7 @@ module Samagotchi
155
212
  return nil unless @enabled
156
213
  return nil unless ContextWindow.positive_integer?(used_tokens) && ContextWindow.positive_integer?(window_tokens)
157
214
 
158
- pct = (used_tokens.to_f / window_tokens) * 100.0
215
+ pct = (used_tokens.to_f / counted_against(window_tokens)) * 100.0
159
216
  { est_pct: pct, bucket: bucket_for(pct) }
160
217
  end
161
218
 
@@ -171,11 +228,11 @@ module Samagotchi
171
228
  window_source = :server if server_usage && server_usage[:context_window_tokens]
172
229
 
173
230
  if server_usage && server_usage[:prompt_tokens]
174
- window_tokens = server_usage[:context_window_tokens] || window.tokens
175
- used_tokens = server_usage[:prompt_tokens] + appended_tokens(prompt_chars, image_tokens, counted)
231
+ window_tokens = counted_against(server_usage[:context_window_tokens] || window.tokens)
232
+ used_tokens = [server_usage[:prompt_tokens] + appended_tokens(prompt_chars, image_tokens, counted), 0].max
176
233
  source = "server"
177
234
  else
178
- window_tokens = window.tokens
235
+ window_tokens = counted_against(window.tokens)
179
236
  used_tokens = (prompt_chars / @chars_per_token).ceil + image_tokens
180
237
  source = "estimate"
181
238
  end
@@ -192,11 +249,19 @@ module Samagotchi
192
249
 
193
250
  private
194
251
 
195
- # The estimate for what the prompt added since the +counted+ one.
252
+ # What the buckets count against: the window, or the budget when one is
253
+ # set and smaller.
254
+ def counted_against(window_tokens)
255
+ @budget ? [@budget, window_tokens].min : window_tokens
256
+ end
257
+
258
+ # The estimate for what the prompt added since the +counted+ one;
259
+ # after an edit (#edited!) what it lost too, as a negative.
196
260
  def appended_tokens(prompt_chars, image_tokens, counted)
197
261
  return 0 unless counted
198
262
 
199
263
  chars = prompt_chars - counted[:chars]
264
+ return -(-chars / @chars_per_token).ceil if @edited && chars.negative?
200
265
  return 0 unless chars.positive?
201
266
 
202
267
  (chars / @chars_per_token).ceil + [image_tokens - counted[:image_tokens].to_i, 0].max
@@ -230,6 +295,42 @@ module Samagotchi
230
295
  bucket_changed || cadence_due
231
296
  end
232
297
 
298
+ # The forget layer's line for this request, or nil (the tiers and when
299
+ # they come: the class comment).
300
+ def forget_line(usage, bucket, previous, iteration_index)
301
+ over = over_budget?(usage)
302
+ tier = over ? :compact : tier_for(bucket)
303
+ return nil unless tier
304
+
305
+ rise = guidance_due?(previous, bucket)
306
+ if iteration_index.zero?
307
+ return nil unless rise || over || (tier != :info && !@offered)
308
+ else
309
+ return nil unless rise
310
+
311
+ tier = :info unless tier == :compact
312
+ end
313
+ @offered = tier != :info
314
+ readout = "~#{k(usage[:estimated_used_tokens])}/#{k(usage[:window_tokens])} tokens in use"
315
+ readout += ", over the budget" if over
316
+ words = { info: nil, tidy: TIDY, compact: COMPACT }.fetch(tier)
317
+ { role: "system", kind: LINE_KIND, content: "#{LINE_PREFIX}#{readout} (bucket=#{bucket}).#{" #{words}" if words}]" }
318
+ end
319
+
320
+ # :info, :tidy or :compact for a bucket with guidance (the top one
321
+ # compacts, the one under it tidies, lower ones inform); nil below.
322
+ def tier_for(bucket)
323
+ rank = bucket_rank(bucket)
324
+ return nil if rank < GUIDANCE_FROM_RANK
325
+ return :compact if rank == @thresholds.size
326
+
327
+ rank == @thresholds.size - 1 ? :tidy : :info
328
+ end
329
+
330
+ def over_budget?(usage) = !@budget.nil? && usage[:estimated_used_tokens] >= @budget
331
+
332
+ def k(tokens) = tokens >= 1000 ? "#{(tokens / 1000.0).round}k" : tokens.to_s
333
+
233
334
  def guidance_message(usage:, bucket:)
234
335
  how = usage[:source].to_s == "server" ? "as the server reports" : "estimated"
235
336
  { role: "system", kind: LINE_KIND,
@@ -176,7 +176,8 @@ module Samagotchi
176
176
  # The tools this session offers (the prompts' declarations and the
177
177
  # kernel's dispatch): the built-ins, per Engine. execute.description
178
178
  # off: execute offers no description parameter.
179
- @tools = Tools::Builtins.registry(command_description: Config.get("execute.description") != false)
179
+ @tools = Tools::Builtins.registry(command_description: Config.get("execute.description") != false,
180
+ policy: Config.get(LLMContextStrategy::POLICY_SETTING))
180
181
  if @scratch
181
182
  # A scratch session's children would outlive it, and so would a
182
183
  # note it left in a peer; its memories would too (write and edit
@@ -930,11 +931,13 @@ module Samagotchi
930
931
 
931
932
  # The status line's ctx from the saved context, for a worker that has run
932
933
  # no turn yet (it woke after an idle exit): nil when either count is
933
- # unknown or context.status is off.
934
+ # unknown or context.status is off. Counted as the turns count it:
935
+ # against the effective model's llm_context budget when it has one.
934
936
  def saved_context_status(context)
935
937
  return nil unless context
936
938
 
937
- ContextStatus.new.display_for(used_tokens: context[:used_tokens], window_tokens: context[:window_tokens])
939
+ ContextStatus.new(llm_context: turn_llm_context)
940
+ .display_for(used_tokens: context[:used_tokens], window_tokens: context[:window_tokens])
938
941
  end
939
942
 
940
943
  # The effective model is on a chat host (api: openai), whose loop uses
@@ -1410,9 +1413,19 @@ module Samagotchi
1410
1413
  target ||= @host_registry.resolve(@effective_model_name)
1411
1414
  chat = target.entry.chat?
1412
1415
  level = chat ? nil : thinking_level(target)
1413
- @prompt_builder.build(chat: chat, thinking: level)
1416
+ @prompt_builder.build(chat: chat, thinking: level, layers: llm_context_layers(target))
1414
1417
  end
1415
1418
 
1419
+ # The LLM context layers +target+'s turns run under: the native prompt
1420
+ # declares the tools they add (forget_outputs under forget), so a
1421
+ # model switch may change the tool list. [] under none.
1422
+ def llm_context_layers(target)
1423
+ LLMContextStrategy.resolve(target, names: model_lookup_names(target)).active_layers
1424
+ rescue StandardError
1425
+ []
1426
+ end
1427
+ private :llm_context_layers
1428
+
1416
1429
  # The base prompt (specs, plugins' declarations).
1417
1430
  def assist_system_prompt(chat: false, thinking: nil)
1418
1431
  @prompt_builder.base(chat: chat, thinking: thinking)
@@ -1727,7 +1740,7 @@ module Samagotchi
1727
1740
  @turn_thinking = [thinking_level(thinking_target), thinking_target]
1728
1741
  announce_thinking_level(*@turn_thinking)
1729
1742
  LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: @turn_thinking.first, model_name: nil,
1730
- window_setting: nil)
1743
+ window_setting: nil, llm_context: nil)
1731
1744
  end
1732
1745
  private :turn_settings
1733
1746
 
@@ -1754,11 +1767,14 @@ module Samagotchi
1754
1767
  return if reminders_due? || @turn_state.carried_steers? || @next_turn_waiting&.call
1755
1768
 
1756
1769
  messages = ContextNote.with_system_head(turn.session.messages.dup, { role: "system", content: system_prompt(target) })
1757
- prompt, images = @kernel.warmup_prompt(messages)
1770
+ # Under the next turn's strategy (a /model switch may have changed it);
1771
+ # the last turn's when it can't be resolved.
1772
+ prompt, images = @kernel.warmup_prompt(messages, llm_context: turn_llm_context || @kernel.turn_settings&.llm_context)
1758
1773
  return unless prompt
1759
1774
 
1760
- @prompt_warmup.start(client: client, prompt: prompt, model: @kernel.turn_settings.model_name,
1761
- slot: @kernel.last_slot, images: images)
1775
+ started = @prompt_warmup.start(client: client, prompt: prompt, model: @kernel.turn_settings.model_name,
1776
+ slot: @kernel.last_slot, images: images)
1777
+ @kernel.warmed_up! if started
1762
1778
  rescue StandardError => e
1763
1779
  Log.warn(:model, "warmup_skipped", error: e.class.name, msg: e.message.to_s[0, 200])
1764
1780
  end
@@ -1816,7 +1832,8 @@ module Samagotchi
1816
1832
  # The turn's settings, set once here. The chat loop dispatches tools
1817
1833
  # through the kernel without its #run: tag those dumps with this
1818
1834
  # turn's model, not the last native one.
1819
- @kernel.turn_settings = turn.settings.with(model_name: bare_for_backend, window_setting: turn_window_setting)
1835
+ @kernel.turn_settings = turn.settings.with(model_name: bare_for_backend, window_setting: turn_window_setting,
1836
+ llm_context: turn_llm_context)
1820
1837
 
1821
1838
  turn.limit = @no_interrupt ? IterationLimit.for(no_interrupt: true) : max_iterations || IterationLimit.for
1822
1839
  backend.complete(
@@ -2512,6 +2529,16 @@ module Samagotchi
2512
2529
  nil
2513
2530
  end
2514
2531
 
2532
+ # The effective model's LLM context strategy (models:/hosts:
2533
+ # llm_context_strategy, else llm_context.strategy), read per turn as
2534
+ # the window is.
2535
+ def turn_llm_context
2536
+ target = @host_registry.resolve(@effective_model_name)
2537
+ LLMContextStrategy.resolve(target, names: model_lookup_names(target))
2538
+ rescue StandardError
2539
+ nil
2540
+ end
2541
+
2515
2542
  # See #served_model. A report for another name (before a /model switch)
2516
2543
  # doesn't count. Without a target, no probe. A remote chat host has no
2517
2544
  # /props: nil until a turn.
@@ -23,7 +23,7 @@ module Samagotchi
23
23
  PATH_TOOLS = %w[write edit read memory_write].freeze
24
24
 
25
25
  # chi's own tools, by name, whether this session has them or not.
26
- CORE_TOOLS = ToolDeclarations::TOOL_SCHEMAS.map { |schema| schema[:name] }.freeze
26
+ CORE_TOOLS = ToolDeclarations::BUILTIN_SCHEMAS.map { |schema| schema[:name] }.freeze
27
27
 
28
28
  attr_reader :tool, :command, :paths, :cwd, :repo_root
29
29
 
@@ -149,11 +149,23 @@ module Samagotchi
149
149
  end
150
150
  return name unless whose
151
151
 
152
- Log.warn(:plugins, "plugin_acts_as_dropped", tool: entry.name, acts_as: name,
153
- msg: "#{entry.name} targets: acts_as #{name} dropped: it is #{whose}")
152
+ if first_drop?(registry, entry.name, name)
153
+ Log.warn(:plugins, "plugin_acts_as_dropped", tool: entry.name, acts_as: name,
154
+ msg: "#{entry.name} targets: acts_as #{name} dropped: it is #{whose}")
155
+ end
154
156
  nil
155
157
  end
156
158
 
159
+ # The drops logged, per registry (a session's): each tool's acts_as
160
+ # once, not at every call (targets run several times a call).
161
+ DROPS_LOGGED = ObjectSpace::WeakKeyMap.new
162
+ DROPS_LOCK = Mutex.new
163
+
164
+ def self.first_drop?(registry, tool, acts_as)
165
+ DROPS_LOCK.synchronize { !(DROPS_LOGGED[registry] ||= Set.new).add?([tool, acts_as]).nil? }
166
+ end
167
+ private_class_method :first_drop?
168
+
157
169
  # The longest label: the question takes from targets:.
158
170
  LABEL_CHARS = 80
159
171
 
@@ -36,10 +36,13 @@ module Samagotchi
36
36
  # sampling: the configured request parameters (SamplingSettings), nil when unset;
37
37
  # thinking: the configured level (Thinking), nil when unset;
38
38
  # remote: the configured true/false (#remote?), nil when unset;
39
- # window_tokens: the configured context window (ContextWindow.setting), nil when unset.
39
+ # window_tokens: the configured context window (ContextWindow.setting), nil when unset;
40
+ # llm_context_strategy: the configured layers (LLMContextStrategy), nil when unset;
41
+ # llm_context_apply: the configured apply rule (LLMContextStrategy), nil when unset;
42
+ # llm_context_budget_tokens: the configured context budget (LLMContextStrategy), nil when unset.
40
43
  HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :profile,
41
44
  :first_token_timeout, :vision, :sampling, :thinking, :remote, :window_tokens,
42
- keyword_init: true) do
45
+ :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens, keyword_init: true) do
43
46
  # Talks the OpenAI chat API (the chat loop); nil and raw apis use the
44
47
  # raw-prompt loop.
45
48
  def chat? = api == :openai
@@ -99,7 +102,9 @@ module Samagotchi
99
102
  api: cfg[:api]&.to_sym, scheme: cfg[:scheme], url: cfg[:url], api_key_env: cfg[:api_key_env],
100
103
  profile: cfg[:profile], first_token_timeout: cfg[:first_token_timeout],
101
104
  vision: cfg[:vision], sampling: cfg[:sampling], thinking: cfg[:thinking],
102
- remote: cfg[:remote], window_tokens: cfg[:window_tokens])
105
+ remote: cfg[:remote], window_tokens: cfg[:window_tokens],
106
+ llm_context_strategy: cfg[:llm_context_strategy], llm_context_apply: cfg[:llm_context_apply],
107
+ llm_context_budget_tokens: cfg[:llm_context_budget_tokens])
103
108
  entry.client = Client.new(host: cfg[:host], port: cfg[:port], transport: transport, scheme: cfg[:scheme],
104
109
  first_token_timeout: entry.first_token_limit, name: entry.name,
105
110
  api_key_env: entry.api_key_env, env: env)