samagotchi 0.25.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.
@@ -77,7 +77,18 @@ class ThinkingWatch
77
77
  MAX_DISTINCT = 5000
78
78
  SPLIT = /(?<=[.!?])\s+|\n+/
79
79
 
80
- Loop = Struct.new(:period, :times, :sentences, :chars, keyword_init: true)
80
+ # +span+: the sentences a loop that isn't a cycle (the short run, the
81
+ # window) was seen in, +period+ the different ones among them; nil for a
82
+ # cycle of +period+ sentences seen +times+ times.
83
+ Loop = Struct.new(:period, :times, :sentences, :chars, :span, keyword_init: true) do
84
+ # "one sentence ×8", "3 sentences ×3", "12 different sentences in 48".
85
+ def describe
86
+ return "one sentence ×#{times}" if period == 1
87
+ return "#{period} sentences ×#{times}" unless span
88
+
89
+ "#{period} different sentences in #{span}"
90
+ end
91
+ end
81
92
  Sentence = Struct.new(:text, :words, :bigrams, :key, :length, keyword_init: true)
82
93
 
83
94
  attr_reader :thinking_chars
@@ -197,7 +208,8 @@ class ThinkingWatch
197
208
 
198
209
  texts = @recent.to_h # key => its last text
199
210
  top = @recent_counts.sort_by { |_key, n| -n }.first(3).map { |key, _n| texts[key] }
200
- Loop.new(period: @recent_counts.size, times: @window_sentences / @recent_counts.size, sentences: top, chars: nil)
211
+ Loop.new(period: @recent_counts.size, times: @window_sentences / @recent_counts.size, sentences: top, chars: nil,
212
+ span: @window_sentences)
201
213
  end
202
214
 
203
215
  def short_loop(text, words)
@@ -216,7 +228,8 @@ class ThinkingWatch
216
228
  distinct = @short_window.uniq(&:first)
217
229
  return nil if distinct.length > @short_distinct
218
230
 
219
- Loop.new(period: distinct.length, times: @short_count / distinct.length, sentences: distinct.first(3).map(&:last), chars: nil)
231
+ Loop.new(period: distinct.length, times: @short_count / distinct.length, sentences: distinct.first(3).map(&:last), chars: nil,
232
+ span: @short_window.length)
220
233
  end
221
234
 
222
235
  def count(key)
@@ -307,8 +320,7 @@ class Plugin
307
320
  return unless found
308
321
 
309
322
  @watch_done = true
310
- what = "#{found.period == 1 ? "one sentence" : "#{found.period} sentences"} ×#{found.times}, " \
311
- "#{(@watch.thinking_chars / 1000.0).round}k chars, #{(event[:elapsed_ms].to_i / 1000.0).round} s"
323
+ what = "#{found.describe}, #{(@watch.thinking_chars / 1000.0).round}k chars, #{(event[:elapsed_ms].to_i / 1000.0).round} s"
312
324
  # The notices quote the loop's sentences on one line (a short cycle
313
325
  # whole), as the stop card does.
314
326
  quoted = "\"#{cut(found.sentences.first(3).join(" ").gsub(/\s+/, " "))}\", #{what}"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.25.0
3
+ version: 0.26.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -54,6 +54,14 @@ module Samagotchi
54
54
  @mutex.synchronize { @detail }
55
55
  end
56
56
 
57
+ # Who stopped it when a hook did: the detail's +by+ (the bundle, or the
58
+ # hook's label); nil for any other cancel.
59
+ def stopped_by
60
+ reason, detail = @mutex.synchronize { [@reason, @detail] }
61
+ by = detail[:by].to_s if reason == :hook && detail.is_a?(Hash)
62
+ by unless by.nil? || by.empty?
63
+ end
64
+
57
65
  # Yields a child controller for one generation: it is cancelled when
58
66
  # this one is, and #cancel_generation! cancels it alone. Gone after the
59
67
  # block.
@@ -148,6 +148,9 @@ module Samagotchi
148
148
  value.empty? ? nil : value
149
149
  end
150
150
 
151
+ # llama.cpp's own API: slots (`id_slot`), `cache_prompt`, /props.
152
+ def native? = @name == :llama_cpp
153
+
151
154
  private
152
155
 
153
156
  def openai_compatible?
@@ -243,11 +246,15 @@ module Samagotchi
243
246
  # ImagePlan::NATIVE_PLACEHOLDER in the prompt (llama.cpp only)
244
247
  # @param sampling [Hash] request fields to add (SamplingSettings):
245
248
  # temperature, penalties, …; they can't replace the fields above
249
+ # @param slot [Integer, nil] llama.cpp only: the slot (`id_slot`)
250
+ # this request runs on. PromptWarmup's pin, and only while a warm-up
251
+ # still runs there: a request pinned to a slot the server has since
252
+ # cleared skips its prompt cache and prefills everything again
246
253
  # @return [String] the generated text
247
254
  def complete(prompt, stop: [], n_predict: nil, model: nil, on_chunk: nil, cancel_controller: nil, on_retry: nil,
248
- images: [], sampling: {})
255
+ images: [], sampling: {}, slot: nil)
249
256
  images = Array(images)
250
- request = { stop: stop, n_predict: n_predict, model: model, sampling: sampling }
257
+ request = { stop: stop, n_predict: n_predict, model: model, sampling: sampling, slot: slot }
251
258
  return stream_completion(LLM::Utf8Scrub.call(prompt), request, on_chunk, cancel_controller, on_retry) if images.empty?
252
259
 
253
260
  # The media marker is random per server process: a restart between the
@@ -269,6 +276,41 @@ module Samagotchi
269
276
 
270
277
  OPENAI_API_HINT = "does this host speak the OpenAI API? set `api: openai` on it"
271
278
 
279
+ # What a warm-up (#warm_up) did: the slot it ran on and the server's
280
+ # counts (tokens reused from the cache, tokens prefilled, prefill ms).
281
+ Warmup = Data.define(:slot, :cache_n, :prompt_n, :prompt_ms)
282
+
283
+ # Prefill +prompt+ and keep it in the server's cache, so the next
284
+ # request that starts with it only prefills what follows (PromptWarmup:
285
+ # the turn-end warm-up). llama.cpp's native /completion only; nil on
286
+ # any other transport. One non-streamed request generating one token
287
+ # (n_predict 0 does the same on current builds, 1 on every one): no
288
+ # retries, no first-token limit, no stream events. Errors are raised.
289
+ # @param slot [Integer, nil] the slot to run on (`id_slot`): the one the
290
+ # last request used, which still holds its state
291
+ # @param images [Array<String>] as #complete's
292
+ # @return [Warmup, nil]
293
+ def warm_up(prompt, model: nil, slot: nil, images: [])
294
+ return nil unless @transport.native?
295
+
296
+ images = Array(images)
297
+ text = LLM::Utf8Scrub.call(images.empty? ? prompt : prompt.gsub(ImagePlan::NATIVE_PLACEHOLDER, media_marker!(model)))
298
+ payload = { prompt: images.empty? ? text : { prompt_string: text, multimodal_data: images },
299
+ n_predict: 1, cache_prompt: true, stream: false }
300
+ payload[:id_slot] = slot if slot.is_a?(Integer)
301
+ model_name = @transport.model_for_payload(model)
302
+ payload[:model] = model_name if model_name
303
+ uri = completion_uri
304
+ request = Net::HTTP::Post.new(uri)
305
+ request["Content-Type"] = "application/json"
306
+ request.body = payload.to_json
307
+ response = @http.fetch(uri, request, retries: false, log_fields: { model: model, purpose: "warmup" })
308
+ body = JSON.parse(response.body.to_s)
309
+ timings = body["timings"].is_a?(Hash) ? body["timings"] : {}
310
+ Warmup.new(slot: body["id_slot"], cache_n: timings["cache_n"], prompt_n: timings["prompt_n"],
311
+ prompt_ms: timings["prompt_ms"]&.round)
312
+ end
313
+
272
314
  private def stream_completion(prompt, fields, on_chunk, cancel_controller, on_retry)
273
315
  uri = completion_uri
274
316
  request = Net::HTTP::Post.new(uri)
@@ -456,11 +498,19 @@ module Samagotchi
456
498
  URI("#{@scheme}://#{@host}:#{@port}#{@transport.completion_path}")
457
499
  end
458
500
 
459
- def completion_payload(prompt, stop:, n_predict:, model:, sampling: {})
501
+ def completion_payload(prompt, stop:, n_predict:, model:, sampling: {}, slot: nil)
460
502
  payload = { prompt: prompt, stop: stop, stream: true }
503
+ # A non-positive cap is dropped, which leaves the length to the server
504
+ # (unbounded): 0 here never means "only process the prompt". The
505
+ # turn-end warm-up (#warm_up) sends its own payload for that reason.
461
506
  payload[@transport.token_limit_key] = n_predict if n_predict && n_predict.to_i.positive?
462
507
  model_name = @transport.model_for_payload(model)
463
508
  payload[:model] = model_name if model_name
509
+ if @transport.native?
510
+ # llama.cpp's default today, sent anyway: chi's next turn relies on it.
511
+ payload[:cache_prompt] = true
512
+ payload[:id_slot] = slot if slot.is_a?(Integer)
513
+ end
464
514
  sendable_sampling(sampling).merge(payload)
465
515
  end
466
516
 
@@ -138,6 +138,11 @@ module Samagotchi
138
138
  Entry.new(key: "default.max_tokens", yaml_path: %w[default max_tokens], type: :integer, default: nil, expose: %i[env config cli]),
139
139
  Entry.new(key: "max_tool_output_chars", yaml_path: %w[max_tool_output_chars], type: :integer, default: 10_000, expose: %i[env config cli]),
140
140
 
141
+ # The turn-end warm-up (PromptWarmup): auto prefills the next turn's
142
+ # prompt on a local llama.cpp host while the user reads; off never
143
+ # does. A :string, since YAML reads an unquoted off as false.
144
+ Entry.new(key: "cache.warmup", yaml_path: %w[cache warmup], type: :string, default: "auto", expose: %i[env config]),
145
+
141
146
  Entry.new(key: "retry.max", yaml_path: %w[retry max], type: :integer, default: 5, expose: %i[env config cli]),
142
147
  Entry.new(key: "retry.base_delay", yaml_path: %w[retry base_delay], type: :float, default: 0.5, expose: %i[env config cli]),
143
148
  Entry.new(key: "retry.max_delay", yaml_path: %w[retry max_delay], type: :float, default: 8.0, expose: %i[env config cli]),
@@ -661,9 +666,11 @@ module Samagotchi
661
666
  end
662
667
 
663
668
  # Request keys chi sets itself; a `sampling:` map can't override them.
664
- # (Length caps would need their own semantics.)
669
+ # (Length caps would need their own semantics.) `id_slot` too: pinning
670
+ # every request to one llama.cpp slot makes a request after an idle
671
+ # gap skip the server's prompt cache (measured: 0.5 s → 12 s).
665
672
  SAMPLING_RESERVED_KEYS = %w[model messages prompt stream stream_options tools tool_choice stop n_predict
666
- max_tokens n parallel_tool_calls response_format cache_prompt].freeze
673
+ max_tokens n parallel_tool_calls response_format cache_prompt id_slot].freeze
667
674
 
668
675
  # A `sampling:` setting (hosts entry or models: entry): request parameters
669
676
  # passed through to the provider as written (keys symbolized, nested maps
@@ -64,6 +64,7 @@ require_relative "question_desk"
64
64
  require_relative "relay_desk"
65
65
  require_relative "guardrail_wiring"
66
66
  require_relative "plugin_tasks"
67
+ require_relative "prompt_warmup"
67
68
 
68
69
  module Samagotchi
69
70
  # Engine owns the core agent logic: system prompt construction, tool
@@ -242,6 +243,9 @@ module Samagotchi
242
243
  # list_sessions and send_note speak for whichever session runs now.
243
244
  @kernel.peers = PeerView.new(self)
244
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
245
249
  # What a hook can do beyond reading its event (event[:notify],
246
250
  # event[:ask_user], event[:stop_turn]): the Engine's routes to the UIs.
247
251
  @hooks.runtime = hook_runtime
@@ -1615,6 +1619,7 @@ module Samagotchi
1615
1619
  pending_input: pending_input)
1616
1620
  publish_used_memories(session, on_event)
1617
1621
  complete_turn(turn, result)
1622
+ warm_up_next_turn(turn, result)
1618
1623
  result
1619
1624
  rescue Interrupt
1620
1625
  # After the end event (in the after_turn/session_end hooks) the
@@ -1751,13 +1756,7 @@ module Samagotchi
1751
1756
  # Who stopped a turn a hook stopped or cut (the controller's detail: the
1752
1757
  # bundle, or the hook's label), for the UIs' "stopped by loop-guard";
1753
1758
  # nil for any other cancel.
1754
- def cancelled_by(turn)
1755
- return nil unless turn.controller.reason == :hook
1756
-
1757
- detail = turn.controller.detail
1758
- by = detail[:by].to_s if detail.is_a?(Hash)
1759
- by unless by.nil? || by.empty?
1760
- end
1759
+ def cancelled_by(turn) = turn.controller.stopped_by
1761
1760
  private :cancelled_by
1762
1761
 
1763
1762
  # The turn's settings for the kernel (LLM::TurnSettings): vision,
@@ -1775,6 +1774,50 @@ module Samagotchi
1775
1774
  end
1776
1775
  private :turn_settings
1777
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
+
1778
1821
  # The messages the turn sends: the history under the system head, due
1779
1822
  # reminders, the prompt. turn.messages is set first and grown in place,
1780
1823
  # so a Ctrl-C or a failure meanwhile keeps what is there.
@@ -156,6 +156,13 @@ module Samagotchi
156
156
  # Tools::Peers (or the Engine's live view of it): the session
157
157
  # list_sessions and send_note speak for; nil outside a session.
158
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
159
166
 
160
167
  # Run the conversation loop and return the final model response plus
161
168
  # resumable conversation state when execution stops at max_iterations.
@@ -177,6 +184,7 @@ module Samagotchi
177
184
  # @return [LLM::ModelResult] final visible response with continuation metadata
178
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)
179
186
  @turn_settings = @turn_settings.with(model_name: completion_model_name(model_name))
187
+ @last_slot = nil
180
188
  turn = start_turn(messages, on_stream_event: on_stream_event, cancel_controller: cancel_controller,
181
189
  pending_input: pending_input, cap: resolve_output_char_cap(max_tool_output_chars))
182
190
 
@@ -186,7 +194,8 @@ module Samagotchi
186
194
  return outcome if outcome.is_a?(LLM::ModelResult)
187
195
  break if outcome == :answer
188
196
  rescue Client::RequestCancelled => e
189
- 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)
190
199
  return cancelled_result(turn.conversation, tool_activity: turn.tool_activity, reason: e.reason,
191
200
  partial_assistant_text: turn.buffer)
192
201
  end
@@ -354,6 +363,8 @@ module Samagotchi
354
363
  # llama.cpp names the loaded model in the stream's last payload.
355
364
  named = chunk[:payload]["model"] if chunk[:payload].is_a?(Hash)
356
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)
357
368
  generation.finish_reason = chunk[:finish_reason] if chunk[:finish_reason]
358
369
  split = stream_splitter.feed(chunk[:content])
359
370
  turn.buffer << split[:text]
@@ -433,7 +444,7 @@ module Samagotchi
433
444
  end
434
445
  turn.empty_retry.drop_nudge!(turn.conversation)
435
446
  turn.cancel_controller.cancel!(:hook, cut)
436
- 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])
437
448
  cancelled_result(turn.conversation, tool_activity: turn.tool_activity, reason: :hook, partial_assistant_text: "")
438
449
  end
439
450
 
@@ -566,7 +577,11 @@ module Samagotchi
566
577
  # One request, cancelled by +generation_controller+ (the turn's child)
567
578
  # when there is one.
568
579
  def request_generation(prompt, generation_controller:, cancel_controller:, **)
569
- @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)
570
585
  end
571
586
 
572
587
  # Yields the turn controller's child for one generation (nil without a
@@ -644,6 +659,22 @@ module Samagotchi
644
659
 
645
660
  public
646
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
+
647
678
  # Public wrapper so other loops (e.g. the chat loop) can strip
648
679
  # per-profile thought blocks from finished model text without duplicating the
649
680
  # Gemma 4 / Qwen 3.6 logic. Mirrors the native loop's "strip before deciding
@@ -679,6 +710,11 @@ module Samagotchi
679
710
  parser.strip_thought(text)
680
711
  end
681
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
+
682
718
  def sanitize_history(messages)
683
719
  messages.map do |m|
684
720
  if m[:role] == "model"
@@ -595,7 +595,7 @@ module Samagotchi
595
595
  # The text streamed before the cancel stays, marked [interrupted],
596
596
  # as the native loop's salvage does.
597
597
  def canceled(iteration, reason, partial = "")
598
- emit(type: :generation_cancelled, iteration: iteration, reason: reason)
598
+ emit(type: :generation_cancelled, iteration: iteration, reason: reason, stopped_by: @cancel_controller&.stopped_by)
599
599
  visible = @loop.strip_model_thought(partial.to_s).strip
600
600
  @conversation << { role: "model", content: "#{visible}\n[interrupted]", interrupted: true } unless visible.empty?
601
601
  conversation = @loop.plain(@conversation)
@@ -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)
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require_relative "log"
5
+
6
+ module Samagotchi
7
+ # The turn-end warm-up (cache.warmup). After a turn, the next turn's prompt
8
+ # differs from the last request at its tail: the previous answer's
9
+ # thinking is stripped (Gemma's and Qwen's specs), so a hybrid model on
10
+ # llama.cpp prefills from a restore point well before the end, longer
11
+ # with every turn. The warm-up sends that prompt, up to where the new user
12
+ # turn starts, while the user reads, so the next turn prefills only its
13
+ # own message (measured on Ornith: 0.73 s -> 0.11 s; 417 -> 26 tokens).
14
+ #
15
+ # The slot rule (R4 spike, llama.cpp with its host-memory prompt cache):
16
+ # - the warm-up runs on the slot the turn's last request used, which still
17
+ # holds its state (Client#warm_up, `id_slot`);
18
+ # - the next request is pinned to that slot only while the warm-up still
19
+ # runs, so it queues behind it (0.6 s) instead of starting cold on
20
+ # another slot (12 s);
21
+ # - once the warm-up is done, nothing is pinned: the server finds the
22
+ # state itself, and pinning to a slot it has since cleared skips its
23
+ # prompt cache (12 s again). #take_pin forgets the slot either way.
24
+ class PromptWarmup
25
+ MODES = %w[auto off].freeze
26
+
27
+ # Whether cache.warmup allows a warm-up (auto, the default; off, false,
28
+ # no or 0 turn it off; anything else warns once and is auto).
29
+ def self.enabled?
30
+ value = Config.get("cache.warmup").to_s.strip.downcase
31
+ return false if %w[off false no 0].include?(value)
32
+
33
+ unless value.empty? || value == "auto" || @warned
34
+ @warned = true
35
+ Log.warn(:config, "invalid_value", echo: "Warning: invalid value for cache.warmup: #{value.inspect} (allowed: auto, off) — using auto",
36
+ key: "cache.warmup")
37
+ end
38
+ true
39
+ rescue StandardError
40
+ true
41
+ end
42
+
43
+ def initialize
44
+ @mutex = Mutex.new
45
+ # The pin (#take_pin forgets it) and the last warm-up's thread.
46
+ @state = nil
47
+ @thread = nil
48
+ end
49
+
50
+ # Start a warm-up of +prompt+ on +client+ in its own thread.
51
+ # @param slot [Integer, nil] the last request's slot (nil: unpinned, and
52
+ # nothing to pin the next request to)
53
+ # @return [Thread]
54
+ def start(client:, prompt:, model:, slot:, images: [])
55
+ thread = Thread.new do
56
+ Thread.current.report_on_exception = false
57
+ result = client.warm_up(prompt, model: model, slot: slot, images: images)
58
+ Log.info(:model, "warmup", slot: result&.slot, cached: result&.cache_n, prefilled: result&.prompt_n,
59
+ prefill_ms: result&.prompt_ms, chars: prompt.length)
60
+ result
61
+ rescue StandardError => e
62
+ Log.warn(:model, "warmup_failed", error: e.class.name, msg: e.message.to_s[0, 200])
63
+ nil
64
+ end
65
+ @mutex.synchronize do
66
+ @thread = thread
67
+ @state = { thread: thread, client: client, slot: slot }
68
+ end
69
+ thread
70
+ end
71
+
72
+ # The slot the next request on +client+ must run on: the warm-up's,
73
+ # while it still runs there; nil otherwise (done, failed, unpinned, or
74
+ # another host). Called once per request; the first call forgets the
75
+ # warm-up, so no later request is ever pinned.
76
+ def take_pin(client)
77
+ state = @mutex.synchronize { @state.tap { @state = nil } }
78
+ return nil unless state && state[:slot].is_a?(Integer) && state[:client].equal?(client)
79
+
80
+ state[:thread].alive? ? state[:slot] : nil
81
+ end
82
+
83
+ # Whether a warm-up is running (specs, measurements).
84
+ def running?
85
+ thread = @mutex.synchronize { @thread }
86
+ thread&.alive? || false
87
+ end
88
+
89
+ # Wait up to +timeout+ seconds for the running warm-up (specs).
90
+ def wait(timeout = nil)
91
+ thread = @mutex.synchronize { @thread }
92
+ thread&.join(timeout)
93
+ end
94
+ end
95
+ end
@@ -173,6 +173,8 @@ module Samagotchi
173
173
  # -p without --non-interactive drops into the REPL, which can answer
174
174
  # an approval; --non-interactive can't, so an approval there denies.
175
175
  @engine.interface = @non_interactive ? :non_interactive : :repl
176
+ # A --non-interactive run exits after its turn: no turn-end warm-up.
177
+ @engine.next_turn_waiting = -> { true } if @non_interactive
176
178
  @turn_flow = TurnFlow.new(engine: @engine)
177
179
  # A recap written while a continue offer waits says the turn stopped
178
180
  # unfinished. (Answering at the prompt is typing: activity already.)
@@ -55,8 +55,9 @@ module Samagotchi
55
55
  when :generation_chunk then add(event[:thinking].to_s)
56
56
  when :generation_completed then completed(event)
57
57
  when :generation_cancelled
58
- # A plugin's stop_turn mid-generation; a user's Stop is not kept.
59
- keep(event, finish_reason: "cancelled", stopped_by: "hook") if event[:reason].to_s == "hook"
58
+ # A plugin's stop_turn mid-generation (the bundle that stopped it,
59
+ # "hook" when unknown); a user's Stop is not kept.
60
+ keep(event, finish_reason: "cancelled", stopped_by: (event[:stopped_by] || "hook").to_s) if event[:reason].to_s == "hook"
60
61
  end
61
62
  rescue StandardError
62
63
  nil
@@ -28,7 +28,7 @@ module Samagotchi
28
28
  },
29
29
  description: {
30
30
  type: "string",
31
- description: "Optional: what this command does, in a few words (what, not why)"
31
+ description: "What this command does, in a few words (what, not why). Shown to the user as the step's title, so include it on every call."
32
32
  },
33
33
  cwd: {
34
34
  type: "string",
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "model_profile"
4
+
3
5
  module Samagotchi
4
6
  # The one-line system note a turn leaves in the conversation when it ends
5
7
  # without an answer: failed before the model replied, cancelled, or over
@@ -19,6 +21,12 @@ module Samagotchi
19
21
  TASK_COMMAND_CHARS = 60
20
22
  RETRY_NUDGE = :retry_nudge
21
23
  EMPTY_ANSWER = :empty_answer
24
+ # An empty step's thinking kept on the marker: its tail, so a model
25
+ # that thought for minutes doesn't put megabytes in the session file.
26
+ STEP_CHARS = 20_000
27
+ # A step's native thinking starts with one of these; the cut keeps it,
28
+ # so the UIs still find the thinking (MessageParts).
29
+ THINK_OPENS = ["<think>", ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN].freeze
22
30
 
23
31
  module_function
24
32
 
@@ -75,16 +83,32 @@ module Samagotchi
75
83
  # UIs, which draw the turn's notice and steps from it after a reload:
76
84
  # +retries+ the empty-answer retries spent, +steps+ the empty
77
85
  # generations as model messages (their thinking; the loops keep them out
78
- # of the conversation, where they'd be empty assistant turns). Never
86
+ # of the conversation, where they'd be empty assistant turns), each
87
+ # text cut to its last STEP_CHARS. Never
79
88
  # sent: the payload builders send a system message's content, and the
80
89
  # copies for hooks, plugins and the recap drop it (AnswerDisplay.strip).
81
90
  def empty(retries: 0, steps: [])
82
91
  marker = { retries: retries.to_i }
83
- marker[:steps] = steps.map { |step| step.slice(:role, :content, :thinking) } unless steps.empty?
92
+ marker[:steps] = steps.map { |step| capped_step(step) } unless steps.empty?
84
93
  message("the previous turn ended with no visible answer (thinking only, or nothing). The user's last message is still unanswered.")
85
94
  .merge(EMPTY_ANSWER => marker)
86
95
  end
87
96
 
97
+ def capped_step(step)
98
+ step.slice(:role, :content, :thinking).to_h do |key, value|
99
+ [key, key == :role || !value.is_a?(String) ? value : tail_of(value)]
100
+ end
101
+ end
102
+
103
+ # The last STEP_CHARS of +text+ after a line saying how much was cut
104
+ # (and the thinking's opening tag when it had one); short text as is.
105
+ def tail_of(text)
106
+ return text if text.length <= STEP_CHARS
107
+
108
+ open = THINK_OPENS.find { |tag| text.start_with?(tag) }
109
+ "#{open}[… #{text.length - STEP_CHARS} earlier characters cut]\n#{text[-STEP_CHARS..]}"
110
+ end
111
+
88
112
  # The empty-answer marker of +entry+ (either key type), or nil.
89
113
  def empty_answer(entry)
90
114
  return nil unless note?(entry)
@@ -133,6 +133,12 @@ module Samagotchi
133
133
  true
134
134
  end
135
135
 
136
+ # @return [Boolean] whether #steer_next_turn queued anything a turn has
137
+ # yet to begin with
138
+ def carried_steers?
139
+ @lock.synchronize { !@carried_steers.empty? }
140
+ end
141
+
136
142
  # Drop what #steer_next_turn queued and no turn began with (a no-op
137
143
  # once #begin! took it).
138
144
  # @return [Array<Hash>] the steers dropped ({text:, source:})
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.25.0"
4
+ VERSION = "0.26.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -20,8 +20,9 @@ export function rowHover(row) {
20
20
  //
21
21
  // A view with steps (CommandSteps) shows them in the command's place, its
22
22
  // leading cd as one more "in" tag, and a "raw" toggle (a checkbox, CSS
23
- // only, so a live and a reloaded row behave alike) that swaps the steps
24
- // for the command as written. The copy button copies the raw command
23
+ // only, so a live and a reloaded row behave alike; beside the first step
24
+ // when there's no "in" tag) that swaps the steps for the command as
25
+ // written. The copy button copies the raw command
25
26
  // either way. A view without steps (the parser's fallback) is the raw
26
27
  // block alone: nothing hides the command.
27
28
  //
@@ -47,7 +48,9 @@ export function commandBlockHtml(view, title = null) {
47
48
  const cwd = where ? `<div class="activity-command-cwd">${where}</div>` : "";
48
49
  return `<div class="activity-command code-wrap">${desc}${cwd}${raw}${copyButtonHtml("code")}${cut}</div>`;
49
50
  }
50
- const head = `<div class="activity-command-head"><span class="activity-command-cwd">${where}</span>` +
51
+ // No "in" tag: the toggle alone, floated beside the first step (an
52
+ // empty line for it would sit above the steps).
53
+ const head = (where ? `<div class="activity-command-head"><span class="activity-command-cwd">${where}</span>` : `<div class="activity-command-head bare">`) +
51
54
  `<label class="activity-command-raw" title="Show the command as written"><input type="checkbox">raw</label></div>`;
52
55
  return `<div class="activity-command code-wrap has-steps">${desc}${head}${stepsHtml(steps)}${raw}${copyButtonHtml("code")}${cut}</div>`;
53
56
  }