samagotchi 0.25.0 → 0.27.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -1
  3. data/docs/architecture.md +6 -0
  4. data/docs/configuration.md +1 -0
  5. data/docs/hooks.md +1 -1
  6. data/docs/internals/engine.md +478 -0
  7. data/docs/internals/prompt-caching.md +33 -7
  8. data/docs/plugins.md +3 -2
  9. data/lib/samagotchi/bridge.rb +37 -1
  10. data/lib/samagotchi/bridge_client.rb +9 -0
  11. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -5
  13. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -1
  15. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  16. data/lib/samagotchi/cancellation_controller.rb +8 -0
  17. data/lib/samagotchi/client.rb +53 -3
  18. data/lib/samagotchi/config.rb +9 -2
  19. data/lib/samagotchi/engine.rb +50 -7
  20. data/lib/samagotchi/kernel_loop.rb +39 -3
  21. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  22. data/lib/samagotchi/llm/http.rb +5 -4
  23. data/lib/samagotchi/log.rb +19 -3
  24. data/lib/samagotchi/prompt_warmup.rb +95 -0
  25. data/lib/samagotchi/terminal_ui.rb +2 -0
  26. data/lib/samagotchi/thinking_tails.rb +4 -3
  27. data/lib/samagotchi/tool_activity.rb +3 -2
  28. data/lib/samagotchi/tool_declarations.rb +1 -1
  29. data/lib/samagotchi/tool_view.rb +14 -3
  30. data/lib/samagotchi/tools/task_get.rb +3 -2
  31. data/lib/samagotchi/tools/task_runtime.rb +49 -12
  32. data/lib/samagotchi/tools/task_stop.rb +1 -1
  33. data/lib/samagotchi/tools/task_wait.rb +22 -10
  34. data/lib/samagotchi/turn_note.rb +28 -2
  35. data/lib/samagotchi/turn_state.rb +6 -0
  36. data/lib/samagotchi/version.rb +1 -1
  37. data/lib/samagotchi/web/app.rb +25 -0
  38. data/lib/samagotchi/web/markdown_renderer.rb +21 -6
  39. data/lib/samagotchi/web/public/app.js +4 -0
  40. data/lib/samagotchi/web/public/command_view.js +6 -3
  41. data/lib/samagotchi/web/public/data.js +11 -0
  42. data/lib/samagotchi/web/public/index.html +39 -13
  43. data/lib/samagotchi/web/public/stage_model.js +8 -2
  44. data/lib/samagotchi/web/public/stage_view.js +14 -2
  45. data/lib/samagotchi/web/public/task_stop.js +101 -0
  46. data/lib/samagotchi/web/public/turn_view.js +24 -1
  47. data/lib/samagotchi/worker.rb +3 -0
  48. metadata +4 -1
data/docs/plugins.md CHANGED
@@ -860,8 +860,9 @@ What happens (`action: retry`, the default):
860
860
  1. The first loop in a turn: the generation is cut and the model asked
861
861
  again, with a hidden note that it was cut off. The user sees the loop's
862
862
  first sentence quoted,
863
- `loop-guard> thinking repeats itself ("Wait, the count of the letter r…", 3 sentences ×3, 4k chars, 8 s): cut`,
864
- and `↻ cut by loop-guard, asking again (1/1)`.
863
+ `loop-guard> thinking repeats itself ("Wait, the count of the letter r…", 3 sentences ×3, 4k chars, 8 s): cut`
864
+ (a short run or a window says how many different sentences it held:
865
+ "12 different sentences in 48"), and `↻ cut by loop-guard, asking again (1/1)`.
865
866
  2. If the retry loops too, the turn is stopped ("stopped the turn: …", "✕
866
867
  turn stopped by loop-guard") with a card that quotes the repeated
867
868
  sentences.
@@ -27,6 +27,8 @@ require_relative "version"
27
27
  require_relative "worker_sidecar"
28
28
  require_relative "relay_verifier"
29
29
  require_relative "relay_watcher"
30
+ require_relative "tools/task_create"
31
+ require_relative "tools/task_runtime"
30
32
 
31
33
  module Samagotchi
32
34
  # Bridge is an optional HTTP transport that lets an external web / desktop
@@ -461,7 +463,8 @@ module Samagotchi
461
463
  # sidecar (WorkerSidecar#features): a client checks a name here rather
462
464
  # than a version.
463
465
  # restart: POST /exit takes "restart": true (WorkerIdleExit#hold_for_restart).
464
- FEATURES = ["restart"].freeze
466
+ # task_stop: POST tasks/stop (#handle_task_stop), the web's stop-task button.
467
+ FEATURES = %w[restart task_stop].freeze
465
468
 
466
469
  # The routes, by method and what follows /session/:id/: a handler takes
467
470
  # the session id and the request body and returns [headers, status, body].
@@ -477,6 +480,7 @@ module Samagotchi
477
480
  %w[POST recap] => :handle_recap,
478
481
  %w[POST relay] => :handle_relay,
479
482
  %w[POST relay/status] => :handle_relay_status,
483
+ %w[POST tasks/stop] => :handle_task_stop,
480
484
  %w[GET state] => :handle_state,
481
485
  %w[GET stats] => :handle_stats,
482
486
  %w[GET snapshot] => :handle_snapshot,
@@ -539,6 +543,38 @@ module Samagotchi
539
543
  end
540
544
  end
541
545
 
546
+ # POST /session/:id/tasks/stop {task_id}: stop a background task as the
547
+ # user (TaskRuntime.stop_task by: "user"); a task_wait on it returns, and
548
+ # the turn goes on. Only a task this conversation created: records in
549
+ # tmp/tasks are shared by every session in the cwd. Those are in the
550
+ # saved conversation, or, created in the running turn, only in the
551
+ # accumulator's task_create parts. 200 {status, stop_reason, task_id},
552
+ # 404 task_not_found, 409 not_running (it already ended), 400 no id.
553
+ # Answers once the task is gone: up to STOP_GRACE_SEC.
554
+ def handle_task_stop(_session_id, body)
555
+ parsed = parse_json(body)
556
+ task_id = (fetched(parsed, "task_id") if parsed.is_a?(Hash)).to_s.strip
557
+ return [{ "Allow" => "POST" }, 400, { error: "missing_fields", detail: "task_id required" }] if task_id.empty?
558
+ return [{}, 404, { error: "task_not_found", task_id: task_id }] unless own_task_ids.include?(task_id)
559
+
560
+ record, error = Tools::TaskRuntime.get_record(task_id)
561
+ return [{}, 404, { error: "task_not_found", task_id: task_id }] if error
562
+ return [{}, 409, { error: "not_running", status: record["status"], task_id: task_id }] unless record["status"] == "running"
563
+
564
+ Log.info(:bridge, "task_stop", task_id: task_id)
565
+ record, error = Tools::TaskRuntime.stop_task(task_id, by: "user")
566
+ return [{}, 500, { error: "bridge_error", detail: error }] if error
567
+
568
+ [{}, 200, { status: record["status"], stop_reason: record["stop_reason"], task_id: task_id }]
569
+ end
570
+
571
+ def own_task_ids
572
+ running = Array(@accumulator.current_turn&.dig(:parts)).filter_map do |part|
573
+ part[:output].to_s[/\A(?:\[task_create\]\n)?task_id: (\S+)/, 1] if part[:kind] == "tool" && part[:tool] == Tools::TaskCreate::NAME
574
+ end
575
+ Tools::TaskRuntime.created_ids_in(@engine.messages_checkpoint) | running
576
+ end
577
+
542
578
  # Answer the pending question. A past +deadline+ (see #handle_post_turn):
543
579
  # 408 deadline_passed, and the question stays open. An answer takes no
544
580
  # event hold, so it is checked right before it is recorded.
@@ -187,6 +187,15 @@ module Samagotchi
187
187
  post("cancel", { reason: reason }, read_body: true)
188
188
  end
189
189
 
190
+ # POST /session/:id/tasks/stop: stop a background task this session's
191
+ # conversation created, as the user (Bridge#handle_task_stop). 200 with
192
+ # its final status, 404 task_not_found, 409 not_running. Synchronous:
193
+ # the worker answers once the task is gone (up to its 3 s grace).
194
+ # @return [Response]
195
+ def stop_task(task_id)
196
+ post("tasks/stop", { task_id: task_id }, read_body: true)
197
+ end
198
+
190
199
  # How long the approval relay's calls wait for a reply: a parent's Stop
191
200
  # isn't held for READ_TIMEOUT by a child that doesn't answer.
192
201
  RELAY_TIMEOUT = 5
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: loop-guard
3
- version: 0.3.3
3
+ version: 0.3.4
4
4
  scope: system
5
5
  description: "Breaks loops: the same tool call with the same result again and again in one turn is denied with advice (after a few denies the turn stops with a card), and thinking that repeats itself while it streams is cut and the model asked again"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:8cf255cd1cc0846a759fcf70144af53bfc634c9d5c766981ce0ef5e476749e0a
9
+ sha256: sha256:a267a325aa134e3d9c57f41d9446c4385a6a1f6bee6267ef127880f8bc4d121f
10
10
  requires_chi: ">= 0.6.0"
@@ -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,11 +1,11 @@
1
1
  ---
2
2
  name: mcp
3
- version: 0.4.2
3
+ version: 0.4.3
4
4
  scope: system
5
5
  description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session, its tools are mcp_<server>_<tool>; /mcp lists them"
6
6
  trust_level: reviewed
7
7
  files: {}
8
8
  plugin:
9
9
  file: plugin.rb
10
- sha256: sha256:d0491b038e7f111f9f8c629729a2b0525d573fd0b17d48ea8ec59c4a2b8b821b
10
+ sha256: sha256:079a42892f10822115235e1ced7ccd481f80c9ef54abb094f9a1c599bb03e8e2
11
11
  requires_chi: ">= 0.8.0"
@@ -752,7 +752,8 @@ class Plugin
752
752
 
753
753
  @servers.map do |server|
754
754
  head = "**#{server.name}**: #{state_text(server)}"
755
- tools = server.tools.map { |tool| tool["chi_name"] }.compact
755
+ # Sorted: a server lists its tools in its own (often grouped) order.
756
+ tools = server.tools.map { |tool| tool["chi_name"] }.compact.sort
756
757
  tools.empty? ? head : "#{head}\n#{tools.map { |t| "- `#{t}`" }.join("\n")}"
757
758
  end.join("\n\n")
758
759
  end
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.25.0
3
+ version: 0.27.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)
@@ -23,6 +23,8 @@ module Samagotchi
23
23
  # (once, until Config.reload!). While that runs (Config can warn itself)
24
24
  # records only echo. Nothing here raises into the caller: a record that
25
25
  # can't be formatted is dropped, an unwritable file pauses (DebugLog).
26
+ # The one exception is strict mode (SAMAGOTCHI_LOG_STRICT=1, the spec
27
+ # suite): an unknown tag or a malformed event raises, whatever the level.
26
28
  module Log
27
29
  LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }.freeze
28
30
  DEFAULT_LEVEL = :info
@@ -39,6 +41,7 @@ module Samagotchi
39
41
  @state = nil
40
42
  @resolving = false
41
43
  @session_id = nil
44
+ @strict = ENV["SAMAGOTCHI_LOG_STRICT"] == "1"
42
45
 
43
46
  class << self
44
47
  # @param path [String, nil, :auto] the file; :auto → LogPath.resolve
@@ -72,6 +75,9 @@ module Samagotchi
72
75
  # carry its first SID_LENGTH chars unless they pass their own sid:.
73
76
  attr_accessor :session_id
74
77
 
78
+ # Raise on an unknown tag or a bad event instead of dropping the record.
79
+ attr_accessor :strict
80
+
75
81
  def debug(tag, event, **kw) = log(:debug, tag, event, **kw)
76
82
  def info(tag, event, **kw) = log(:info, tag, event, **kw)
77
83
  def warn(tag, event, **kw) = log(:warn, tag, event, **kw)
@@ -96,7 +102,19 @@ module Samagotchi
96
102
  resolved_state&.dig(:writer)&.path
97
103
  end
98
104
 
99
- def log(level, tag, event, payload: nil, sid: nil, echo: nil, **fields)
105
+ def log(level, tag, event, **kw)
106
+ check_names!(tag, event) if strict
107
+ write_record(level, tag, event, **kw)
108
+ end
109
+
110
+ private
111
+
112
+ def check_names!(tag, event)
113
+ raise ArgumentError, "unknown log tag #{tag}" unless LogLine::TAGS.include?(tag.to_s)
114
+ raise ArgumentError, "bad log event #{event}" unless event.to_s.match?(LogLine::SLUG)
115
+ end
116
+
117
+ def write_record(level, tag, event, payload: nil, sid: nil, echo: nil, **fields)
100
118
  options = @options
101
119
  echo_to_stderr(echo) if echo && options.fetch(:stderr, true)
102
120
  state = resolved_state
@@ -113,8 +131,6 @@ module Samagotchi
113
131
  nil
114
132
  end
115
133
 
116
- private
117
-
118
134
  def build(level, tag, event, payload:, sid:, fields:)
119
135
  tag = tag.to_s
120
136
  event = event.to_s