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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -1
- data/docs/configuration.md +1 -0
- data/docs/internals/engine.md +478 -0
- data/docs/internals/prompt-caching.md +33 -7
- data/docs/plugins.md +3 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/cancellation_controller.rb +8 -0
- data/lib/samagotchi/client.rb +53 -3
- data/lib/samagotchi/config.rb +9 -2
- data/lib/samagotchi/engine.rb +50 -7
- data/lib/samagotchi/kernel_loop.rb +39 -3
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/llm/http.rb +5 -4
- data/lib/samagotchi/prompt_warmup.rb +95 -0
- data/lib/samagotchi/terminal_ui.rb +2 -0
- data/lib/samagotchi/thinking_tails.rb +3 -2
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/turn_note.rb +26 -2
- data/lib/samagotchi/turn_state.rb +6 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/command_view.js +6 -3
- data/lib/samagotchi/web/public/index.html +20 -13
- data/lib/samagotchi/worker.rb +3 -0
- metadata +3 -1
|
@@ -77,7 +77,18 @@ class ThinkingWatch
|
|
|
77
77
|
MAX_DISTINCT = 5000
|
|
78
78
|
SPLIT = /(?<=[.!?])\s+|\n+/
|
|
79
79
|
|
|
80
|
-
|
|
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.
|
|
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}"
|
|
@@ -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.
|
data/lib/samagotchi/client.rb
CHANGED
|
@@ -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
|
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -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
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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
|
-
|
|
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)
|
data/lib/samagotchi/llm/http.rb
CHANGED
|
@@ -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
|
|
40
|
-
#
|
|
41
|
-
|
|
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
|
|
59
|
-
|
|
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: "
|
|
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",
|
data/lib/samagotchi/turn_note.rb
CHANGED
|
@@ -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)
|
|
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
|
|
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:})
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -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
|
|
24
|
-
//
|
|
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
|
-
|
|
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
|
}
|