samagotchi 0.24.0 → 0.26.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/docs/architecture.md +16 -9
  4. data/docs/cli.md +4 -3
  5. data/docs/configuration.md +22 -13
  6. data/docs/hooks.md +1 -1
  7. data/docs/internals/background-tasks.md +1 -0
  8. data/docs/internals/engine.md +478 -0
  9. data/docs/internals/prompt-caching.md +120 -0
  10. data/docs/plugins.md +18 -3
  11. data/docs/testing.md +12 -0
  12. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  13. data/lib/samagotchi/bundles/loop-guard/plugin.rb +59 -11
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/cancellation_controller.rb +8 -0
  16. data/lib/samagotchi/client.rb +53 -3
  17. data/lib/samagotchi/command_steps.rb +233 -0
  18. data/lib/samagotchi/config.rb +14 -4
  19. data/lib/samagotchi/empty_answer_retry.rb +2 -2
  20. data/lib/samagotchi/engine.rb +73 -11
  21. data/lib/samagotchi/guardrails/shell_lex.rb +49 -5
  22. data/lib/samagotchi/idle_client.rb +14 -2
  23. data/lib/samagotchi/kernel_loop.rb +60 -6
  24. data/lib/samagotchi/llm/chat_loop.rb +18 -4
  25. data/lib/samagotchi/llm/errors.rb +26 -1
  26. data/lib/samagotchi/llm/http.rb +5 -4
  27. data/lib/samagotchi/llm/openai_chat.rb +5 -4
  28. data/lib/samagotchi/llm/prompt_cache.rb +34 -9
  29. data/lib/samagotchi/llm/usage.rb +18 -3
  30. data/lib/samagotchi/log_subscriber.rb +2 -1
  31. data/lib/samagotchi/plugin/api.rb +3 -2
  32. data/lib/samagotchi/prompt_warmup.rb +95 -0
  33. data/lib/samagotchi/session_metrics.rb +38 -4
  34. data/lib/samagotchi/system_prompt.rb +30 -9
  35. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -2
  36. data/lib/samagotchi/terminal_ui/attached_view.rb +6 -2
  37. data/lib/samagotchi/terminal_ui/event_renderer.rb +14 -2
  38. data/lib/samagotchi/terminal_ui/formatting.rb +31 -9
  39. data/lib/samagotchi/terminal_ui.rb +2 -0
  40. data/lib/samagotchi/thinking_tails.rb +104 -0
  41. data/lib/samagotchi/token_usage.rb +18 -6
  42. data/lib/samagotchi/tool_activity.rb +37 -3
  43. data/lib/samagotchi/tool_declarations.rb +14 -0
  44. data/lib/samagotchi/tool_view.rb +40 -4
  45. data/lib/samagotchi/tools/builtins.rb +4 -1
  46. data/lib/samagotchi/tools/execute.rb +2 -1
  47. data/lib/samagotchi/tools/registry.rb +11 -4
  48. data/lib/samagotchi/tools/shell.rb +32 -0
  49. data/lib/samagotchi/tools/task_runtime.rb +3 -3
  50. data/lib/samagotchi/turn_note.rb +26 -2
  51. data/lib/samagotchi/turn_state.rb +6 -0
  52. data/lib/samagotchi/turn_tally.rb +17 -6
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/public/command_view.js +74 -3
  55. data/lib/samagotchi/web/public/format.js +11 -1
  56. data/lib/samagotchi/web/public/index.html +45 -13
  57. data/lib/samagotchi/web/public/stage_model.js +5 -1
  58. data/lib/samagotchi/web/public/stage_view.js +1 -1
  59. data/lib/samagotchi/web/public/turn_view.js +3 -3
  60. data/lib/samagotchi/worker.rb +3 -0
  61. metadata +7 -1
@@ -22,6 +22,7 @@ require_relative "session_commands"
22
22
  require_relative "plugin/loader"
23
23
  require_relative "log"
24
24
  require_relative "log_subscriber"
25
+ require_relative "thinking_tails"
25
26
  require_relative "client"
26
27
  require_relative "host_registry"
27
28
  require_relative "llm/backend"
@@ -63,6 +64,7 @@ require_relative "question_desk"
63
64
  require_relative "relay_desk"
64
65
  require_relative "guardrail_wiring"
65
66
  require_relative "plugin_tasks"
67
+ require_relative "prompt_warmup"
66
68
 
67
69
  module Samagotchi
68
70
  # Engine owns the core agent logic: system prompt construction, tool
@@ -172,8 +174,9 @@ module Samagotchi
172
174
  @hooks = load_hooks_from_config
173
175
  load_hooks_from_bundles
174
176
  # The tools this session offers (the prompts' declarations and the
175
- # kernel's dispatch): the built-ins, per Engine.
176
- @tools = Tools::Builtins.registry
177
+ # kernel's dispatch): the built-ins, per Engine. execute.description
178
+ # off: execute offers no description parameter.
179
+ @tools = Tools::Builtins.registry(command_description: Config.get("execute.description") != false)
177
180
  if @scratch
178
181
  # A scratch session's children would outlive it, and so would a
179
182
  # note it left in a peer; its memories would too (write and edit
@@ -240,6 +243,9 @@ module Samagotchi
240
243
  # list_sessions and send_note speak for whichever session runs now.
241
244
  @kernel.peers = PeerView.new(self)
242
245
  @kernel.guardrail_gate = @guardrail_wiring.gate
246
+ # The turn-end warm-up; the kernel asks it for a slot pin per request.
247
+ @prompt_warmup = PromptWarmup.new
248
+ @kernel.warmup = @prompt_warmup
243
249
  # What a hook can do beyond reading its event (event[:notify],
244
250
  # event[:ask_user], event[:stop_turn]): the Engine's routes to the UIs.
245
251
  @hooks.runtime = hook_runtime
@@ -282,6 +288,10 @@ module Samagotchi
282
288
  @session_observer.subscribe(observer: @metrics)
283
289
  # And the event trail in the debug log (`turn` records).
284
290
  @session_observer.subscribe(observer: LogSubscriber.new(session_id: -> { @session&.id }))
291
+ # The thinking of cut and capped generations, kept in the session dir.
292
+ @session_observer.subscribe(observer: ThinkingTails.new(session_dir: lambda {
293
+ @session&.id && Session.session_dir(@session.id, state_dir: session_state_dir)
294
+ }))
285
295
  end
286
296
 
287
297
  # A monotonically-increasing clock (wall clock can jump backwards; the idle
@@ -1609,6 +1619,7 @@ module Samagotchi
1609
1619
  pending_input: pending_input)
1610
1620
  publish_used_memories(session, on_event)
1611
1621
  complete_turn(turn, result)
1622
+ warm_up_next_turn(turn, result)
1612
1623
  result
1613
1624
  rescue Interrupt
1614
1625
  # After the end event (in the after_turn/session_end hooks) the
@@ -1745,13 +1756,7 @@ module Samagotchi
1745
1756
  # Who stopped a turn a hook stopped or cut (the controller's detail: the
1746
1757
  # bundle, or the hook's label), for the UIs' "stopped by loop-guard";
1747
1758
  # nil for any other cancel.
1748
- def cancelled_by(turn)
1749
- return nil unless turn.controller.reason == :hook
1750
-
1751
- detail = turn.controller.detail
1752
- by = detail[:by].to_s if detail.is_a?(Hash)
1753
- by unless by.nil? || by.empty?
1754
- end
1759
+ def cancelled_by(turn) = turn.controller.stopped_by
1755
1760
  private :cancelled_by
1756
1761
 
1757
1762
  # The turn's settings for the kernel (LLM::TurnSettings): vision,
@@ -1769,6 +1774,50 @@ module Samagotchi
1769
1774
  end
1770
1775
  private :turn_settings
1771
1776
 
1777
+ # @return [PromptWarmup] the turn-end warm-up (specs, measurements)
1778
+ attr_reader :prompt_warmup
1779
+
1780
+ # A caller's check that the next turn starts at once (input queued for
1781
+ # it: a worker's inbox) or that none follows (a one-shot run): no
1782
+ # warm-up then. nil: neither.
1783
+ attr_writer :next_turn_waiting
1784
+
1785
+ # After a completed turn, prefill the next turn's prompt up to its user
1786
+ # message while the user reads (PromptWarmup, cache.warmup). Only on a
1787
+ # local llama.cpp host on the native loop, and not when the next turn's
1788
+ # start differs anyway or starts at once: a reminder due, a continue
1789
+ # offer (the turn ran out of steps), a carried steer or queued input.
1790
+ # Never fails the turn.
1791
+ def warm_up_next_turn(turn, result)
1792
+ return if result.canceled? || result.resumable?
1793
+
1794
+ target = @host_registry.resolve(@effective_model_name)
1795
+ client = target.client
1796
+ return unless warmup_target?(target, client)
1797
+ return if reminders_due? || @turn_state.carried_steers? || @next_turn_waiting&.call
1798
+
1799
+ messages = ContextNote.with_system_head(turn.session.messages.dup, { role: "system", content: system_prompt(target) })
1800
+ prompt, images = @kernel.warmup_prompt(messages)
1801
+ return unless prompt
1802
+
1803
+ @prompt_warmup.start(client: client, prompt: prompt, model: @kernel.turn_settings.model_name,
1804
+ slot: @kernel.last_slot, images: images)
1805
+ rescue StandardError => e
1806
+ Log.warn(:model, "warmup_skipped", error: e.class.name, msg: e.message.to_s[0, 200])
1807
+ end
1808
+ private :warm_up_next_turn
1809
+
1810
+ # Never a paid or remote host (cache.warmup auto means local only), nor
1811
+ # the chat loop (Splash keeps its own cache; llama.cpp's chat path is
1812
+ # not measured), nor a non-llama.cpp transport.
1813
+ def warmup_target?(target, client)
1814
+ return false if target.entry.chat? || target.entry.remote?
1815
+ return false unless client.respond_to?(:warm_up) && client.respond_to?(:transport) && client.transport.native?
1816
+
1817
+ PromptWarmup.enabled?
1818
+ end
1819
+ private :warmup_target?
1820
+
1772
1821
  # The messages the turn sends: the history under the system head, due
1773
1822
  # reminders, the prompt. turn.messages is set first and grown in place,
1774
1823
  # so a Ctrl-C or a failure meanwhile keeps what is there.
@@ -2039,12 +2088,23 @@ module Samagotchi
2039
2088
  nil
2040
2089
  end
2041
2090
 
2091
+ # Whether this generation's thinking lane said anything: nil while it
2092
+ # streamed none, false while only whitespace (Gemma 4's empty thought
2093
+ # with thinking off), true once a real thought came.
2094
+ def note_generation_thought(thinking)
2095
+ return if thinking.empty?
2096
+
2097
+ @generation_thought ||= thinking.match?(/\S/)
2098
+ end
2099
+
2042
2100
  # Thinking off, and the model thought anyway: logged each time, said
2043
- # once per session and host.
2101
+ # once per session and host. A thinking lane of whitespace alone is no
2102
+ # thought.
2044
2103
  def check_thinking_honoured(event)
2045
2104
  level, target = @turn_thinking
2046
2105
  chars = event[:thinking_chars].to_i
2047
2106
  return unless level == :off && chars.positive? && target
2107
+ return if @generation_thought == false
2048
2108
 
2049
2109
  Log.warn(:model, "thinking_not_honoured", level: level, host: target.entry.name, model: target.bare_model, chars: chars)
2050
2110
  thinking_notice_once(:not_honoured, target, :warn,
@@ -2184,7 +2244,7 @@ module Samagotchi
2184
2244
  return @native_backend unless target.entry.chat?
2185
2245
 
2186
2246
  @chat_backend_mutex.synchronize do
2187
- @chat_backend ||= LLM::ChatLoop.new(kernel: @kernel)
2247
+ @chat_backend ||= LLM::ChatLoop.new(kernel: @kernel, stable_length: ->(text) { @prompt_builder.stable_length(text) })
2188
2248
  @chat_backend.adapter = @host_registry.adapter_for(target.entry)
2189
2249
  @chat_backend.session_id = session&.id
2190
2250
  @chat_backend
@@ -2406,6 +2466,7 @@ module Samagotchi
2406
2466
  case event[:type]
2407
2467
  when :generation_started
2408
2468
  @generation_phase.started!
2469
+ @generation_thought = nil
2409
2470
  watch&.started(event[:iteration])
2410
2471
  when :generation_chunk
2411
2472
  # A chunk without the lanes (no loop of ours sends one) counts as text.
@@ -2413,6 +2474,7 @@ module Samagotchi
2413
2474
  progress = { thinking: event[:thinking].to_s, text: text.to_s }
2414
2475
  @generation_phase.chunk!(**progress, tool_call: event[:tool_call])
2415
2476
  recheck_waiting_steer unless progress[:thinking].empty?
2477
+ note_generation_thought(progress[:thinking])
2416
2478
  when :generation_retrying then @generation_phase.retrying!
2417
2479
  when :generation_completed, :generation_cancelled then @generation_phase.finished!
2418
2480
  end
@@ -33,6 +33,15 @@ module Samagotchi
33
33
  Lexer.new(text).tokens
34
34
  end
35
35
 
36
+ # lex's tokens, each with the range of +text+ it was read from
37
+ # appended ([:word, "x", specials, 0...1] / [:op, "&&", 2...4]), for a
38
+ # display that slices the original text. A heredoc body's range is
39
+ # the body and its terminator line. lex itself is unchanged by this.
40
+ def self.lex_with_spans(text)
41
+ lexer = Lexer.new(text)
42
+ lexer.tokens.zip(lexer.spans).map { |token, span| [*token, span] }
43
+ end
44
+
36
45
  # The token stream split at operators into word lists; ( and ) become
37
46
  # :open / :close markers.
38
47
  def self.simple_commands(tokens)
@@ -57,18 +66,34 @@ module Samagotchi
57
66
  @s = text
58
67
  @i = 0
59
68
  @tokens = []
69
+ @spans = []
70
+ @start = 0
60
71
  @word = +""
61
72
  @specials = +""
62
73
  @in_word = false
63
74
  @heredocs = []
75
+ @heredoc_spans = []
76
+ @unterminated = false
64
77
  end
65
78
 
66
79
  def tokens
67
80
  step while @i < @s.size
68
81
  flush
82
+ @unterminated = true unless @heredocs.empty?
69
83
  @tokens
70
84
  end
71
85
 
86
+ # Each token's range in the text, in step with #tokens (after it).
87
+ attr_reader :spans
88
+
89
+ # The heredocs read ({tag:, span:}, the body and its terminator
90
+ # line), after #tokens.
91
+ attr_reader :heredoc_spans
92
+
93
+ # Whether a quote, $(…), backtick or heredoc was left open (after
94
+ # #tokens): the lexer's best effort, not the shell's reading.
95
+ def unterminated? = @unterminated
96
+
72
97
  private
73
98
 
74
99
  def step
@@ -87,6 +112,7 @@ module Samagotchi
87
112
  end
88
113
 
89
114
  def add(text, advance)
115
+ @start = @i unless @in_word
90
116
  @word << text
91
117
  @in_word = true
92
118
  @i += advance
@@ -99,7 +125,10 @@ module Samagotchi
99
125
  end
100
126
 
101
127
  def flush
102
- @tokens << [:word, @word.dup, @specials.dup] if @in_word
128
+ if @in_word
129
+ @tokens << [:word, @word.dup, @specials.dup]
130
+ @spans << (@start...[@i, @s.size].min)
131
+ end
103
132
  @word.clear
104
133
  @specials.clear
105
134
  @in_word = false
@@ -107,11 +136,14 @@ module Samagotchi
107
136
  end
108
137
 
109
138
  def single_quote
110
- close = @s.index("'", @i + 1) || @s.size
139
+ close = @s.index("'", @i + 1)
140
+ @unterminated ||= close.nil?
141
+ close ||= @s.size
111
142
  add(@s[(@i + 1)...close].to_s, close + 1 - @i)
112
143
  end
113
144
 
114
145
  def double_quote
146
+ @start = @i unless @in_word
115
147
  @i += 1
116
148
  @in_word = true
117
149
  while @i < @s.size && @s[@i] != '"'
@@ -126,11 +158,14 @@ module Samagotchi
126
158
  @i += 1
127
159
  end
128
160
  end
161
+ @unterminated = true if @i >= @s.size
129
162
  @i += 1
130
163
  end
131
164
 
132
165
  def backtick
133
- close = @s.index("`", @i + 1) || @s.size
166
+ close = @s.index("`", @i + 1)
167
+ @unterminated ||= close.nil?
168
+ close ||= @s.size
134
169
  add(SUBST, close + 1 - @i)
135
170
  end
136
171
 
@@ -143,6 +178,7 @@ module Samagotchi
143
178
  j += 1
144
179
  break if depth.zero? || j >= @s.size
145
180
  end
181
+ @unterminated = true unless depth.zero?
146
182
  add(SUBST, j - @i)
147
183
  end
148
184
 
@@ -170,6 +206,7 @@ module Samagotchi
170
206
  else
171
207
  flush
172
208
  @tokens << [:op, op]
209
+ @spans << (@i...(@i + op.size))
173
210
  @i += op.size
174
211
  bodies if op == "\n" && !@heredocs.empty?
175
212
  end
@@ -223,16 +260,23 @@ module Samagotchi
223
260
  heredocs = @heredocs
224
261
  @heredocs = []
225
262
  pos = @i
263
+ start = pos
226
264
  read = []
227
265
  heredocs.each do |doc|
228
266
  body, pos = body(doc, pos)
229
267
  break unless body
230
268
 
231
- read << [doc, body]
269
+ read << [doc, body, start...(@s[pos - 1] == "\n" ? pos - 1 : pos)]
270
+ start = pos
232
271
  end
272
+ @unterminated ||= read.size < heredocs.size
233
273
  return if read.size < heredocs.size
234
274
 
235
- read.reverse_each { |doc, body| @tokens.insert(doc[:at], [:word, body_word(doc, body), ""]) }
275
+ read.reverse_each do |doc, body, span|
276
+ @tokens.insert(doc[:at], [:word, body_word(doc, body), ""])
277
+ @spans.insert(doc[:at], span)
278
+ end
279
+ read.each { |doc, _body, span| @heredoc_spans << { tag: doc[:tag], span: span } }
236
280
  @i = pos
237
281
  end
238
282
 
@@ -93,7 +93,8 @@ module Samagotchi
93
93
  # @raise [SummarizeError] any failure but a cancel
94
94
  # @raise [LLM::RequestCancelled] +cancel_controller+ was cancelled
95
95
  def ask(messages, max_tokens: MAX_TOKENS, cancel_controller: nil)
96
- content, served = generate(messages, max_tokens: max_tokens, cancel_controller: cancel_controller, whole_sentences: false)
96
+ content, served = generate(messages, max_tokens: max_tokens, cancel_controller: cancel_controller, whole_sentences: false,
97
+ kind: "ask")
97
98
  Summary.new(text: content.to_s.strip, model: served)
98
99
  rescue SummarizeError, LLM::RequestCancelled
99
100
  raise
@@ -109,7 +110,7 @@ module Samagotchi
109
110
  # the reply has neither content nor reasoning_content. Cut off by
110
111
  # +max_tokens+, the text keeps its finished sentences (+whole_sentences+)
111
112
  # or all of it, with "…".
112
- def generate(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, whole_sentences: true)
113
+ def generate(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, whole_sentences: true, kind: "recap")
113
114
  response = begin
114
115
  request(messages, max_tokens, cancel_controller)
115
116
  rescue LLM::BadRequest => e
@@ -121,6 +122,7 @@ module Samagotchi
121
122
  Log.info(:recap, "thinking_refused", model: @model, detail: e.detail)
122
123
  request(messages, max_tokens, cancel_controller)
123
124
  end
125
+ log_usage(response, kind)
124
126
  content = response.text
125
127
  reasoning = response.reasoning
126
128
  raise SummarizeError, "server returned no parseable assistant content" if content.empty? && reasoning.empty?
@@ -141,6 +143,16 @@ module Samagotchi
141
143
  raise SummarizeError, "server returned no parseable assistant content (#{e.message})"
142
144
  end
143
145
 
146
+ # The request's prompt-cache counts (a side request on the session's
147
+ # host can evict the session's cached prompt there).
148
+ def log_usage(response, kind)
149
+ fields = response.usage&.cache_fields || {}
150
+ return if fields.empty?
151
+
152
+ Log.info(:recap, "request_usage", kind: kind, model: @model, prompt: fields[:prompt_tokens],
153
+ cached: fields[:cached_tokens], cache_write: fields[:cache_write_tokens])
154
+ end
155
+
144
156
  def request(messages, max_tokens, cancel_controller)
145
157
  @chat.chat(messages: messages, model: @model, tools: [], cancel_controller: cancel_controller,
146
158
  options: { max_tokens: max_tokens, **thinking_fields })
@@ -8,6 +8,7 @@ require_relative "context_status"
8
8
  require_relative "context_window"
9
9
  require_relative "prompt"
10
10
  require_relative "prompt_literal_guard"
11
+ require_relative "token_usage"
11
12
  require_relative "client"
12
13
  require_relative "llm/errors"
13
14
  require_relative "log"
@@ -155,6 +156,13 @@ module Samagotchi
155
156
  # Tools::Peers (or the Engine's live view of it): the session
156
157
  # list_sessions and send_note speak for; nil outside a session.
157
158
  attr_accessor :peers
159
+ # The Engine's PromptWarmup: a request asks it for a slot pin
160
+ # (#take_pin) while the turn-end warm-up still runs; nil: never pinned.
161
+ attr_accessor :warmup
162
+ # @return [Integer, nil] the llama.cpp slot (`id_slot`) the last
163
+ # request of the last run streamed from; nil when the server named
164
+ # none (other transports) or no request was made
165
+ attr_reader :last_slot
158
166
 
159
167
  # Run the conversation loop and return the final model response plus
160
168
  # resumable conversation state when execution stops at max_iterations.
@@ -176,6 +184,7 @@ module Samagotchi
176
184
  # @return [LLM::ModelResult] final visible response with continuation metadata
177
185
  def run(messages, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil, model_name: nil, max_tool_output_chars: nil, pending_input: nil)
178
186
  @turn_settings = @turn_settings.with(model_name: completion_model_name(model_name))
187
+ @last_slot = nil
179
188
  turn = start_turn(messages, on_stream_event: on_stream_event, cancel_controller: cancel_controller,
180
189
  pending_input: pending_input, cap: resolve_output_char_cap(max_tool_output_chars))
181
190
 
@@ -185,7 +194,8 @@ module Samagotchi
185
194
  return outcome if outcome.is_a?(LLM::ModelResult)
186
195
  break if outcome == :answer
187
196
  rescue Client::RequestCancelled => e
188
- emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: e.reason)
197
+ emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: e.reason,
198
+ stopped_by: turn.cancel_controller&.stopped_by)
189
199
  return cancelled_result(turn.conversation, tool_activity: turn.tool_activity, reason: e.reason,
190
200
  partial_assistant_text: turn.buffer)
191
201
  end
@@ -238,8 +248,10 @@ module Samagotchi
238
248
  # One generation: the response (nil when cut), the cut's detail, this
239
249
  # generation's own server counts, the model the server named, the
240
250
  # thinking it streamed, and why it stopped (the transport's finish
241
- # reason, Client::Transport#finish_reason_from; nil when not named).
242
- Generation = Struct.new(:response, :cut, :usage, :served_model, :streamed_thinking, :finish_reason, keyword_init: true)
251
+ # reason, Client::Transport#finish_reason_from; nil when not named),
252
+ # and its prompt-cache counts (the largest seen: they come cumulative).
253
+ Generation = Struct.new(:response, :cut, :usage, :served_model, :streamed_thinking, :finish_reason, :cache,
254
+ keyword_init: true)
243
255
  private_constant :Turn, :Request, :Generation
244
256
 
245
257
  def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:)
@@ -328,6 +340,7 @@ module Samagotchi
328
340
  on_retry: lambda { |retry_event|
329
341
  # The retry streams from the start: its counts replace these.
330
342
  generation.usage = nil
343
+ generation.cache = nil
331
344
  generation.streamed_thinking = 0
332
345
  generation.finish_reason = nil
333
346
  turn.emit.call({ type: :generation_retrying, iteration: turn.iteration }.merge(retry_event)) if turn.on_stream_event
@@ -346,9 +359,12 @@ module Samagotchi
346
359
 
347
360
  def stream_chunk(turn, generation, stream_splitter, chunk)
348
361
  generation.usage = turn.context.capture(chunk[:payload]) || generation.usage
362
+ note_cache_counts(generation, chunk[:payload])
349
363
  # llama.cpp names the loaded model in the stream's last payload.
350
364
  named = chunk[:payload]["model"] if chunk[:payload].is_a?(Hash)
351
365
  generation.served_model = named if named.is_a?(String) && !named.strip.empty?
366
+ slot = chunk[:payload]["id_slot"] if chunk[:payload].is_a?(Hash)
367
+ @last_slot = slot if slot.is_a?(Integer)
352
368
  generation.finish_reason = chunk[:finish_reason] if chunk[:finish_reason]
353
369
  split = stream_splitter.feed(chunk[:content])
354
370
  turn.buffer << split[:text]
@@ -363,6 +379,19 @@ module Samagotchi
363
379
  emit(turn, **event)
364
380
  end
365
381
 
382
+ # The payload's prompt-cache counts, kept as the largest seen.
383
+ def note_cache_counts(generation, payload)
384
+ counts = TokenUsage.from_payload(payload)
385
+ return unless counts
386
+
387
+ cache = generation.cache ||= { prompt: 0, cached: 0, cache_write: 0 }
388
+ cache[:prompt] = [cache[:prompt], counts.prompt_tokens.to_i].max
389
+ cache[:cached] = [cache[:cached], counts.cached_tokens.to_i].max
390
+ cache[:cache_write] = [cache[:cache_write], counts.cache_write_tokens.to_i].max
391
+ end
392
+
393
+ def cache_fields(cache) = cache ? TokenUsage.cache_fields(**cache) : {}
394
+
366
395
  # The cut stream's visible text goes with it: the buffer as it was
367
396
  # before (the next generation gets a fresh splitter).
368
397
  def cut_generation(turn, generation, buffer_mark)
@@ -380,7 +409,7 @@ module Samagotchi
380
409
  emit(turn, type: :generation_completed, iteration: turn.iteration, content_length: response.length,
381
410
  thinking_chars: thinking_chars(response, generation.streamed_thinking),
382
411
  served_model: generation.served_model, requested_model: turn.model_name,
383
- finish_reason: generation.finish_reason)
412
+ finish_reason: generation.finish_reason, **cache_fields(generation.cache))
384
413
  dump_log("response", generation.response, iteration: turn.iteration)
385
414
  # The after_generation hook (after the model returns, before the tool
386
415
  # parse), with a read-only copy of the conversation as sent.
@@ -415,7 +444,7 @@ module Samagotchi
415
444
  end
416
445
  turn.empty_retry.drop_nudge!(turn.conversation)
417
446
  turn.cancel_controller.cancel!(:hook, cut)
418
- emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: :hook)
447
+ emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: :hook, stopped_by: cut[:by])
419
448
  cancelled_result(turn.conversation, tool_activity: turn.tool_activity, reason: :hook, partial_assistant_text: "")
420
449
  end
421
450
 
@@ -548,7 +577,11 @@ module Samagotchi
548
577
  # One request, cancelled by +generation_controller+ (the turn's child)
549
578
  # when there is one.
550
579
  def request_generation(prompt, generation_controller:, cancel_controller:, **)
551
- @client.complete(prompt, **complete_kwargs(cancel_controller: generation_controller || cancel_controller, **))
580
+ kwargs = complete_kwargs(cancel_controller: generation_controller || cancel_controller, **)
581
+ # Behind a turn-end warm-up still running on its slot (PromptWarmup).
582
+ slot = @warmup&.take_pin(@client)
583
+ kwargs[:slot] = slot if slot
584
+ @client.complete(prompt, **kwargs)
552
585
  end
553
586
 
554
587
  # Yields the turn controller's child for one generation (nil without a
@@ -626,6 +659,22 @@ module Samagotchi
626
659
 
627
660
  public
628
661
 
662
+ # The next turn's prompt up to where its user message starts, for the
663
+ # turn-end warm-up (PromptWarmup): +messages+ as the next turn sends
664
+ # them before its prompt (the history under the system head), formatted
665
+ # as #run formats them, with a stand-in user message cut off at its
666
+ # opener. Returns [prompt, images], or nil when the cut isn't found.
667
+ def warmup_prompt(messages)
668
+ conversation = prepare_conversation(messages) << { role: "user", content: WARMUP_CUT }
669
+ prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @turn_settings.vision)
670
+ head = prompt[0, prompt.index(WARMUP_CUT) || 0]
671
+ cut = head.rindex(user_opener)
672
+ cut&.positive? ? [head[0, cut], images] : nil
673
+ end
674
+
675
+ WARMUP_CUT = "chi-warmup-cut"
676
+ private_constant :WARMUP_CUT
677
+
629
678
  # Public wrapper so other loops (e.g. the chat loop) can strip
630
679
  # per-profile thought blocks from finished model text without duplicating the
631
680
  # Gemma 4 / Qwen 3.6 logic. Mirrors the native loop's "strip before deciding
@@ -661,6 +710,11 @@ module Samagotchi
661
710
  parser.strip_thought(text)
662
711
  end
663
712
 
713
+ # What opens a user turn in the profile's format.
714
+ def user_opener
715
+ @profile.uses_role_prefixes? ? @profile.user_prefix : "#{@profile.turn_start}user\n"
716
+ end
717
+
664
718
  def sanitize_history(messages)
665
719
  messages.map do |m|
666
720
  if m[:role] == "model"
@@ -50,9 +50,12 @@ module Samagotchi
50
50
  # @param kernel [KernelLoop] tool dispatch, thought stripping, hooks
51
51
  # @param adapter [OpenAIChat, nil] the host to talk to; Engine sets it
52
52
  # per turn for the effective model's host
53
- def initialize(kernel:, adapter: nil)
53
+ # @param stable_length [#call, nil] text → the length of the system
54
+ # prompt's part every session shares (SystemPrompt#stable_length), or nil
55
+ def initialize(kernel:, adapter: nil, stable_length: nil)
54
56
  @kernel = kernel
55
57
  @adapter = adapter
58
+ @stable_length = stable_length
56
59
  end
57
60
 
58
61
  def provider = :chat
@@ -105,7 +108,7 @@ module Samagotchi
105
108
  items = plan.items(entry, index)
106
109
  case entry[:role].to_s
107
110
  when "system"
108
- wire << { role: "system", content: entry[:content].to_s }
111
+ wire << system_message(entry, index)
109
112
  when "user"
110
113
  # A steer or merged input (always a String) goes with its header.
111
114
  content = Steer.header(entry) ? Steer.wire_text(entry, entry[:content].to_s) : entry[:content]
@@ -132,6 +135,16 @@ module Samagotchi
132
135
  wire
133
136
  end
134
137
 
138
+ # The head system prompt says where its stable part ends
139
+ # (`cache_split:`, a character offset): the adapter puts a remote
140
+ # Claude model's cache breakpoint there (PromptCache) and drops it
141
+ # for every other request.
142
+ def system_message(entry, index)
143
+ text = entry[:content].to_s
144
+ split = index.zero? && @stable_length&.call(text)
145
+ split ? { role: "system", content: text, cache_split: split } : { role: "system", content: text }
146
+ end
147
+
135
148
  TOOL_IMAGES_TEXT = "[images from tool results]"
136
149
 
137
150
  # The turn's VisionContext (the Engine sets the kernel's
@@ -448,7 +461,8 @@ module Samagotchi
448
461
  record_context_status(response.usage, window)
449
462
  emit(type: :generation_completed, iteration: iteration, content_length: response.text.length,
450
463
  thinking_chars: response.reasoning.to_s.length, served_model: response.model,
451
- served_provider: response.provider, requested_model: @model_name, finish_reason: response.finish_reason)
464
+ served_provider: response.provider, requested_model: @model_name, finish_reason: response.finish_reason,
465
+ **(response.usage&.cache_fields || {}))
452
466
  dump_response(response, iteration)
453
467
  @loop.fire_hook(:after_generation, { type: :after_generation, iteration: iteration, response: response.text,
454
468
  messages: AnswerDisplay.strip_all(@conversation).map(&:dup).freeze })
@@ -581,7 +595,7 @@ module Samagotchi
581
595
  # The text streamed before the cancel stays, marked [interrupted],
582
596
  # as the native loop's salvage does.
583
597
  def canceled(iteration, reason, partial = "")
584
- emit(type: :generation_cancelled, iteration: iteration, reason: reason)
598
+ emit(type: :generation_cancelled, iteration: iteration, reason: reason, stopped_by: @cancel_controller&.stopped_by)
585
599
  visible = @loop.strip_model_thought(partial.to_s).strip
586
600
  @conversation << { role: "model", content: "#{visible}\n[interrupted]", interrupted: true } unless visible.empty?
587
601
  conversation = @loop.plain(@conversation)
@@ -25,6 +25,9 @@ module Samagotchi
25
25
  # CreditsHeld 402 "… in-flight requests": credit reserved by other
26
26
  # requests; a RateLimited, retried after 20 s
27
27
  # OutOfCredits any other 402; never retried
28
+ # OverBudget 402 metadata.reason weight_exceeds_budget: the
29
+ # request alone exceeds the key's budget; an
30
+ # OutOfCredits, never retried
28
31
  # ServerError 5xx or a server's error event mid-stream
29
32
  # AuthError 401/403, or an API key variable that is not set
30
33
  # BadRequest other 4xx (a context overflow is one, whatever status;
@@ -105,6 +108,15 @@ module Samagotchi
105
108
  def summary = "out of credits on host #{host}: #{detail}; add credits, then send again"
106
109
  end
107
110
 
111
+ # A 402 whose error.metadata.reason is weight_exceeds_budget (OpenRouter):
112
+ # this one request's credit reservation is larger than the key's whole
113
+ # budget, so no amount of waiting lets it through. Never retried, even
114
+ # when the message reads like the in-flight one.
115
+ class OverBudget < OutOfCredits
116
+ def summary = "request too large for the credit budget on host #{host}: #{detail}; " \
117
+ "lower max_tokens (default.max_tokens) or raise the key's credit limit"
118
+ end
119
+
108
120
  class ServerError < ProviderError
109
121
  def kind = :server
110
122
 
@@ -261,6 +273,9 @@ module Samagotchi
261
273
  REASONING_REFUSED_RE = /reasoning|thinking/i
262
274
  # A 402 about credit held by in-flight requests (see CreditsHeld).
263
275
  CREDITS_HELD_RE = /in-flight requests/i
276
+ # A 402's error.metadata.reason when the request can never fit the
277
+ # key's budget (see OverBudget).
278
+ OVER_BUDGET_REASON = "weight_exceeds_budget"
264
279
  # Seconds to wait before retrying a CreditsHeld without a Retry-After.
265
280
  CREDITS_SETTLE_DELAY = 20.0
266
281
  RETRYABLE_SERVER_STATUSES = [500, 502, 503, 504, 529].freeze
@@ -285,7 +300,9 @@ module Samagotchi
285
300
  when 429 then RateLimited.new(text, retry_after: parse_retry_after(retry_after), **options)
286
301
  when 408 then ServerError.new(text, retryable: true, **options)
287
302
  when 402
288
- if CREDITS_HELD_RE.match?(message) || CREDITS_HELD_RE.match?(body.to_s)
303
+ if error_reason(body) == OVER_BUDGET_REASON
304
+ OverBudget.new(text, **options)
305
+ elsif CREDITS_HELD_RE.match?(message) || CREDITS_HELD_RE.match?(body.to_s)
289
306
  CreditsHeld.new(text, retry_after: parse_retry_after(retry_after) || CREDITS_SETTLE_DELAY, **options)
290
307
  else
291
308
  OutOfCredits.new(text, **options)
@@ -356,6 +373,14 @@ module Samagotchi
356
373
  message.empty? ? "(empty body)" : message[0, 500]
357
374
  end
358
375
 
376
+ # error.metadata.reason of a JSON error body (OpenRouter), or nil.
377
+ def error_reason(body)
378
+ parsed = parse_json(body.to_s)
379
+ error = parsed["error"] if parsed.is_a?(Hash)
380
+ metadata = error["metadata"] if error.is_a?(Hash)
381
+ metadata["reason"] if metadata.is_a?(Hash)
382
+ end
383
+
359
384
  # +message+ followed by the upstream provider's name and words from
360
385
  # +metadata+ (raw may be text or a JSON error body of its own).
361
386
  def with_upstream(message, metadata)
@@ -36,9 +36,10 @@ module Samagotchi
36
36
  Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::ETIMEDOUT
37
37
  ].freeze
38
38
 
39
- # Requests of these purposes are the ones a turn waits on: INFO lines
40
- # (and ERROR when they fail). Probes and model lists are DEBUG.
41
- LOGGED_PURPOSES = %w[chat recap].freeze
39
+ # Requests of these purposes are the ones a turn waits on (or, for the
40
+ # turn-end warm-up, prepares the next one): INFO lines (and ERROR when
41
+ # they fail). Probes and model lists are DEBUG.
42
+ LOGGED_PURPOSES = %w[chat recap warmup].freeze
42
43
 
43
44
  # Exponential backoff: base_delay * 2^(attempt-1), capped at max_delay,
44
45
  # for up to +max+ retries (max + 1 attempts).
@@ -127,7 +128,7 @@ module Samagotchi
127
128
  # @raise [ProviderError] for an error status
128
129
  # @raise [FirstTokenTimeout] when nothing was shown in time
129
130
  # @param log_fields [Hash] what the log line says about the request
130
- # (model:, purpose: chat/recap/probe/models); never its body
131
+ # (model:, purpose: chat/recap/warmup/probe/models); never its body
131
132
  def stream_lines(uri, request, cancel_controller: nil, on_retry: nil, on_network_error: nil, log_fields: {}, &on_line)
132
133
  identify(request)
133
134
  current = new_attempt_state(uri, request, log_fields, stream: true)