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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +47 -1
- data/docs/architecture.md +6 -0
- data/docs/configuration.md +1 -0
- data/docs/hooks.md +1 -1
- data/docs/internals/engine.md +478 -0
- data/docs/internals/prompt-caching.md +33 -7
- data/docs/plugins.md +3 -2
- data/lib/samagotchi/bridge.rb +37 -1
- data/lib/samagotchi/bridge_client.rb +9 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -5
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +2 -1
- 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/log.rb +19 -3
- data/lib/samagotchi/prompt_warmup.rb +95 -0
- data/lib/samagotchi/terminal_ui.rb +2 -0
- data/lib/samagotchi/thinking_tails.rb +4 -3
- data/lib/samagotchi/tool_activity.rb +3 -2
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tool_view.rb +14 -3
- data/lib/samagotchi/tools/task_get.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +49 -12
- data/lib/samagotchi/tools/task_stop.rb +1 -1
- data/lib/samagotchi/tools/task_wait.rb +22 -10
- data/lib/samagotchi/turn_note.rb +28 -2
- data/lib/samagotchi/turn_state.rb +6 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +25 -0
- data/lib/samagotchi/web/markdown_renderer.rb +21 -6
- data/lib/samagotchi/web/public/app.js +4 -0
- data/lib/samagotchi/web/public/command_view.js +6 -3
- data/lib/samagotchi/web/public/data.js +11 -0
- data/lib/samagotchi/web/public/index.html +39 -13
- data/lib/samagotchi/web/public/stage_model.js +8 -2
- data/lib/samagotchi/web/public/stage_view.js +14 -2
- data/lib/samagotchi/web/public/task_stop.js +101 -0
- data/lib/samagotchi/web/public/turn_view.js +24 -1
- data/lib/samagotchi/worker.rb +3 -0
- 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
|
-
|
|
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.
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
+
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:
|
|
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
|
-
|
|
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}"
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: mcp
|
|
3
|
-
version: 0.4.
|
|
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:
|
|
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
|
-
|
|
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
|
|
@@ -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)
|
data/lib/samagotchi/log.rb
CHANGED
|
@@ -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,
|
|
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
|