samagotchi 0.29.0 → 0.31.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 +99 -1
- data/docs/cli.md +4 -4
- data/docs/configuration.md +4 -0
- data/docs/hooks.md +1 -1
- data/docs/internals/engine.md +1 -1
- data/docs/plugins.md +1 -1
- data/docs/sessions.md +11 -4
- data/lib/samagotchi/bridge/card_store.rb +1 -1
- data/lib/samagotchi/bridge/turn_notice.rb +1 -1
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +4 -3
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +17 -4
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/delegated.md +1 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/child_reports.rb +214 -0
- data/lib/samagotchi/client.rb +35 -4
- data/lib/samagotchi/command_steps.rb +35 -15
- data/lib/samagotchi/config.rb +5 -0
- data/lib/samagotchi/engine.rb +2 -0
- data/lib/samagotchi/host_registry.rb +4 -0
- data/lib/samagotchi/idle_recap.rb +4 -0
- data/lib/samagotchi/kernel_loop.rb +63 -11
- data/lib/samagotchi/llm/chat_loop.rb +14 -7
- data/lib/samagotchi/llm/errors.rb +9 -0
- data/lib/samagotchi/log_subscriber.rb +1 -1
- data/lib/samagotchi/model_list_store.rb +117 -0
- data/lib/samagotchi/model_profile.rb +43 -0
- data/lib/samagotchi/prompt_warmup.rb +30 -5
- data/lib/samagotchi/reply_wait.rb +39 -8
- data/lib/samagotchi/send_command.rb +5 -3
- data/lib/samagotchi/session.rb +31 -5
- data/lib/samagotchi/session_inbox.rb +34 -1
- data/lib/samagotchi/session_manager.rb +41 -5
- data/lib/samagotchi/session_metrics.rb +6 -3
- data/lib/samagotchi/steer.rb +39 -7
- data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +38 -8
- data/lib/samagotchi/terminal_ui.rb +44 -5
- data/lib/samagotchi/tool_activity.rb +6 -2
- data/lib/samagotchi/tool_call_parser.rb +40 -0
- data/lib/samagotchi/tool_declarations.rb +7 -7
- data/lib/samagotchi/tools/delegate.rb +26 -13
- data/lib/samagotchi/tools/delegate_cursor.rb +91 -0
- data/lib/samagotchi/tools/delegate_wait.rb +50 -31
- data/lib/samagotchi/tools/execute.rb +3 -1
- data/lib/samagotchi/tools/task_runtime.rb +21 -2
- data/lib/samagotchi/tools/task_wait.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +8 -2
- data/lib/samagotchi/web/public/activity.js +2 -1
- data/lib/samagotchi/web/public/app.js +21 -11
- data/lib/samagotchi/web/public/command_view.js +19 -5
- data/lib/samagotchi/web/public/format.js +8 -1
- data/lib/samagotchi/web/public/index.html +14 -6
- data/lib/samagotchi/web/public/notify.js +4 -2
- data/lib/samagotchi/web/public/stage_model.js +18 -0
- data/lib/samagotchi/web/public/stage_view.js +16 -11
- data/lib/samagotchi/web/public/task_stop.js +11 -2
- data/lib/samagotchi/web/public/timing.js +12 -7
- data/lib/samagotchi/web/public/turn_events.js +29 -9
- data/lib/samagotchi/web/public/turn_model.js +16 -11
- data/lib/samagotchi/web/public/turn_view.js +49 -7
- data/lib/samagotchi/worker.rb +240 -23
- metadata +4 -1
data/lib/samagotchi/client.rb
CHANGED
|
@@ -250,11 +250,13 @@ module Samagotchi
|
|
|
250
250
|
# this request runs on. PromptWarmup's pin, and only while a warm-up
|
|
251
251
|
# still runs there: a request pinned to a slot the server has since
|
|
252
252
|
# cleared skips its prompt cache and prefills everything again
|
|
253
|
+
# @param cache_prompt [Boolean] llama.cpp only: false prefills the whole
|
|
254
|
+
# prompt, reusing nothing cached (a malformed generation's retry)
|
|
253
255
|
# @return [String] the generated text
|
|
254
256
|
def complete(prompt, stop: [], n_predict: nil, model: nil, on_chunk: nil, cancel_controller: nil, on_retry: nil,
|
|
255
|
-
images: [], sampling: {}, slot: nil)
|
|
257
|
+
images: [], sampling: {}, slot: nil, cache_prompt: true)
|
|
256
258
|
images = Array(images)
|
|
257
|
-
request = { stop: stop, n_predict: n_predict, model: model, sampling: sampling, slot: slot }
|
|
259
|
+
request = { stop: stop, n_predict: n_predict, model: model, sampling: sampling, slot: slot, cache_prompt: cache_prompt }
|
|
258
260
|
return stream_completion(LLM::Utf8Scrub.call(prompt), request, on_chunk, cancel_controller, on_retry) if images.empty?
|
|
259
261
|
|
|
260
262
|
# The media marker is random per server process: a restart between the
|
|
@@ -311,6 +313,33 @@ module Samagotchi
|
|
|
311
313
|
prompt_ms: timings["prompt_ms"]&.round)
|
|
312
314
|
end
|
|
313
315
|
|
|
316
|
+
# Whether llama.cpp's slot +slot+ is processing a request now (GET
|
|
317
|
+
# /slots, its is_processing): :busy or :idle. nil when unknown: no slot,
|
|
318
|
+
# another transport, the endpoint off (--no-slots), a slot it doesn't
|
|
319
|
+
# list, a bad body or a network error. One quick attempt (PromptWarmup:
|
|
320
|
+
# a warm-up pinned to a busy slot waits behind that request, and the
|
|
321
|
+
# next turn behind the warm-up).
|
|
322
|
+
# @return [Symbol, nil]
|
|
323
|
+
def slot_status(slot, model: nil)
|
|
324
|
+
return nil unless @transport.native? && slot.is_a?(Integer)
|
|
325
|
+
|
|
326
|
+
model = model.to_s.strip
|
|
327
|
+
query = model.empty? ? "" : "?#{URI.encode_www_form(model: model)}"
|
|
328
|
+
uri = URI("#{@scheme}://#{@host}:#{@port}/slots#{query}")
|
|
329
|
+
response = @http.fetch(uri, Net::HTTP::Get.new(uri), retries: false, check_status: false,
|
|
330
|
+
log_fields: { purpose: "slots" },
|
|
331
|
+
open_timeout: CONTEXT_WINDOW_PROBE_OPEN_TIMEOUT,
|
|
332
|
+
read_timeout: CONTEXT_WINDOW_PROBE_READ_TIMEOUT)
|
|
333
|
+
return nil unless response.code.to_s == "200"
|
|
334
|
+
|
|
335
|
+
entry = Array(JSON.parse(response.body.to_s)).find { |s| s.is_a?(Hash) && s["id"] == slot }
|
|
336
|
+
return nil unless entry&.key?("is_processing")
|
|
337
|
+
|
|
338
|
+
entry["is_processing"] == true ? :busy : :idle
|
|
339
|
+
rescue StandardError
|
|
340
|
+
nil
|
|
341
|
+
end
|
|
342
|
+
|
|
314
343
|
private def stream_completion(prompt, fields, on_chunk, cancel_controller, on_retry)
|
|
315
344
|
uri = completion_uri
|
|
316
345
|
request = Net::HTTP::Post.new(uri)
|
|
@@ -498,7 +527,7 @@ module Samagotchi
|
|
|
498
527
|
URI("#{@scheme}://#{@host}:#{@port}#{@transport.completion_path}")
|
|
499
528
|
end
|
|
500
529
|
|
|
501
|
-
def completion_payload(prompt, stop:, n_predict:, model:, sampling: {}, slot: nil)
|
|
530
|
+
def completion_payload(prompt, stop:, n_predict:, model:, sampling: {}, slot: nil, cache_prompt: true)
|
|
502
531
|
payload = { prompt: prompt, stop: stop, stream: true }
|
|
503
532
|
# A non-positive cap is dropped, which leaves the length to the server
|
|
504
533
|
# (unbounded): 0 here never means "only process the prompt". The
|
|
@@ -508,7 +537,9 @@ module Samagotchi
|
|
|
508
537
|
payload[:model] = model_name if model_name
|
|
509
538
|
if @transport.native?
|
|
510
539
|
# llama.cpp's default today, sent anyway: chi's next turn relies on it.
|
|
511
|
-
|
|
540
|
+
# false only for the retry of a malformed generation (KernelLoop),
|
|
541
|
+
# which a poisoned cache must not produce again.
|
|
542
|
+
payload[:cache_prompt] = cache_prompt != false
|
|
512
543
|
payload[:id_slot] = slot if slot.is_a?(Integer)
|
|
513
544
|
end
|
|
514
545
|
sendable_sampling(sampling).merge(payload)
|
|
@@ -9,7 +9,9 @@ module Samagotchi
|
|
|
9
9
|
# command as the model wrote it. Rules, in order:
|
|
10
10
|
#
|
|
11
11
|
# - a heredoc's body leaves its step's text and becomes its +heredoc+
|
|
12
|
-
# ({tag:, lines:}), also one inside a "$(cat <<'EOF' … EOF)" argument;
|
|
12
|
+
# ({tag:, lines:}), also one inside a "$(cat <<'EOF' … EOF)" argument; a
|
|
13
|
+
# step with more than one is cut of them all, and lists them as
|
|
14
|
+
# +heredocs+ (+heredoc+ stays the first; the web's chip names it, +N);
|
|
13
15
|
# - steps are split at && || ; | |& & and newlines; a ( … ) subshell stays
|
|
14
16
|
# one step; +op+ is the operator before a step (nil for the first);
|
|
15
17
|
# - a leading "cd X &&" / "cd X;" becomes +cd+ (only the first);
|
|
@@ -23,8 +25,8 @@ module Samagotchi
|
|
|
23
25
|
# parentheses) gives nil: the UI then shows the title and the raw
|
|
24
26
|
# command.
|
|
25
27
|
module CommandSteps
|
|
26
|
-
Step = Data.define(:text, :op, :label, :limit, :heredoc) do
|
|
27
|
-
def initialize(text:, op: nil, label: nil, limit: nil, heredoc: nil) = super
|
|
28
|
+
Step = Data.define(:text, :op, :label, :limit, :heredoc, :heredocs) do
|
|
29
|
+
def initialize(text:, op: nil, label: nil, limit: nil, heredoc: nil, heredocs: nil) = super
|
|
28
30
|
|
|
29
31
|
def to_h = super.compact
|
|
30
32
|
end
|
|
@@ -122,29 +124,47 @@ module Samagotchi
|
|
|
122
124
|
|
|
123
125
|
slice = text[from...to]
|
|
124
126
|
heredoc = body && { tag: bodies[body.last], lines: body_lines(text[body.last]) }
|
|
125
|
-
slice,
|
|
126
|
-
Step.new(text: slice.strip, op: op, heredoc: heredoc
|
|
127
|
+
slice, heredocs = inner_heredocs(slice) if heredoc.nil?
|
|
128
|
+
Step.new(text: slice.strip, op: op, heredoc: heredoc || heredocs&.first,
|
|
129
|
+
heredocs: heredocs && heredocs.size > 1 ? heredocs : nil)
|
|
127
130
|
end
|
|
128
131
|
|
|
129
|
-
#
|
|
130
|
-
# the slice with
|
|
131
|
-
|
|
132
|
-
|
|
132
|
+
# Every heredoc inside a $(…) in +slice+ (git commit -m "$(cat <<'EOF'
|
|
133
|
+
# …)"): the slice with all their bodies cut out, and the heredocs ([] for
|
|
134
|
+
# none). One step can hold several ("-f body=$(cat <<EOF …)" and
|
|
135
|
+
# "-f title=$(cat <<EOF …)"), and the rest of the step is text the reader
|
|
136
|
+
# would otherwise see.
|
|
137
|
+
def inner_heredocs(slice)
|
|
138
|
+
heredocs = []
|
|
139
|
+
return [slice, heredocs] unless slice.include?("<<")
|
|
133
140
|
|
|
134
141
|
at = 0
|
|
135
142
|
while (open = slice.index("$(", at))
|
|
136
143
|
lexer = Lex::Lexer.new(slice[(open + 2)..])
|
|
137
144
|
lexer.tokens
|
|
138
145
|
doc = lexer.heredoc_spans.first
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
to += 1 if slice[to] == "\n"
|
|
143
|
-
return [slice[0...from] + slice[to..], { tag: doc[:tag], lines: body_lines(slice[(from + 1)...to]) }]
|
|
146
|
+
unless doc
|
|
147
|
+
at = open + 2
|
|
148
|
+
next
|
|
144
149
|
end
|
|
150
|
+
|
|
151
|
+
from = open + 2 + doc[:span].begin - 1 # the newline before the body
|
|
152
|
+
to = open + 2 + doc[:span].end
|
|
153
|
+
to += 1 if slice[to] == "\n"
|
|
154
|
+
heredocs << { tag: doc[:tag], lines: body_lines(slice[(from + 1)...to]) }
|
|
155
|
+
cut = slice[0...from] + slice[to..]
|
|
156
|
+
# A span that cut nothing (it can't) would loop here: move past it.
|
|
157
|
+
if cut.length >= slice.length
|
|
158
|
+
at = open + 2
|
|
159
|
+
next
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
slice = cut
|
|
163
|
+
# The cut moved everything after it left; keep looking from the same
|
|
164
|
+
# spot, past this "$(".
|
|
145
165
|
at = open + 2
|
|
146
166
|
end
|
|
147
|
-
[slice,
|
|
167
|
+
[slice, heredocs]
|
|
148
168
|
end
|
|
149
169
|
|
|
150
170
|
# The body's line count, its terminator line not counted.
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -95,6 +95,11 @@ module Samagotchi
|
|
|
95
95
|
Entry.new(key: "session.keep_empty", yaml_path: %w[session keep_empty], type: :bool, default: false, expose: %i[env config]),
|
|
96
96
|
# The most sessions one session may have delegated and still running (the delegate tool); a guard against a runaway model.
|
|
97
97
|
Entry.new(key: "session.max_children", yaml_path: %w[session max_children], type: :integer, default: 4, expose: %i[env config]),
|
|
98
|
+
# A delegate child's turn end reaches its parent (ChildReports): wake runs an idle parent's turn for it, queue waits
|
|
99
|
+
# for the parent's next turn, off is the old behaviour (only delegate_result asks).
|
|
100
|
+
Entry.new(key: "session.delegate_reports", yaml_path: %w[session delegate_reports], type: :enum, default: "wake", expose: %i[env config], enum_values: %w[wake queue off]),
|
|
101
|
+
# Turns a parent runs for delegate reports in a row with no human input; past it reports wait for the next turn.
|
|
102
|
+
Entry.new(key: "session.max_wakes", yaml_path: %w[session max_wakes], type: :integer, default: 10, expose: %i[env config]),
|
|
98
103
|
|
|
99
104
|
# Images sent to a model (ImageStore): the long side they are downscaled
|
|
100
105
|
# to, the most bytes one may take (bigger → re-encoded as jpeg), and how
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -61,6 +61,7 @@ require_relative "thinking"
|
|
|
61
61
|
require_relative "answer_display"
|
|
62
62
|
require_relative "edit_preview"
|
|
63
63
|
require_relative "question_desk"
|
|
64
|
+
require_relative "child_reports"
|
|
64
65
|
require_relative "relay_desk"
|
|
65
66
|
require_relative "guardrail_wiring"
|
|
66
67
|
require_relative "plugin_tasks"
|
|
@@ -1190,6 +1191,7 @@ module Samagotchi
|
|
|
1190
1191
|
def record_last_turn(session, outcome, seconds, origin, event, limit: nil)
|
|
1191
1192
|
client_id = origin.is_a?(Hash) ? origin[:client_id].to_s : ""
|
|
1192
1193
|
source = if client_id.start_with?("#{Tools::Delegate::CLIENT_PREFIX}:") then "delegate"
|
|
1194
|
+
elsif client_id.start_with?(ChildReports::CLIENT_PREFIX) then "delegate_report"
|
|
1193
1195
|
elsif client_id == SessionManager::REMINDER_CLIENT_ID then "reminder"
|
|
1194
1196
|
else "client"
|
|
1195
1197
|
end
|
|
@@ -5,6 +5,7 @@ require_relative "config"
|
|
|
5
5
|
require_relative "log"
|
|
6
6
|
require_relative "model_ref"
|
|
7
7
|
require_relative "client"
|
|
8
|
+
require_relative "model_list_store"
|
|
8
9
|
require_relative "llm/openai_chat"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
@@ -287,6 +288,9 @@ module Samagotchi
|
|
|
287
288
|
models = list_models_for(entry)
|
|
288
289
|
data = { host: entry.host, port: entry.port, transport: entry.transport, models: models, error: nil }
|
|
289
290
|
@mutex.synchronize { @host_lists[name] = { data: data, at: @clock.call } }
|
|
291
|
+
# On disk too: a process that spawns a worker without listing
|
|
292
|
+
# (`chi send --new --model`, delegate) checks an id against it.
|
|
293
|
+
ModelListStore.save(name, models.map(&:id))
|
|
290
294
|
rescue StandardError => e
|
|
291
295
|
Log.warn(:model, "list_failed", host: name, error: e.class.name, msg: e.message.to_s[0, 500])
|
|
292
296
|
data = { host: entry.host, port: entry.port, transport: entry.transport, models: [], error: e.message }
|
|
@@ -54,6 +54,7 @@ module Samagotchi
|
|
|
54
54
|
TOOL_OUTPUT_HEADER_RE = /\A\[([\w.:-]+)\]/
|
|
55
55
|
# The text the web sends for a turn of images alone (images.js turnText).
|
|
56
56
|
IMAGE_ONLY_RE = /\A\s*(?:\[image: [^\]\n]*\]\s*)+\z/
|
|
57
|
+
DELEGATE_REPORT_LINE = "(a delegate session's report, delivered by chi; not the user's words)"
|
|
57
58
|
|
|
58
59
|
module_function
|
|
59
60
|
|
|
@@ -71,6 +72,9 @@ module Samagotchi
|
|
|
71
72
|
# An image is a line naming it (refs only, never its bytes), unless
|
|
72
73
|
# the text is only the web's placeholder naming it already.
|
|
73
74
|
content = message["content"].to_s
|
|
75
|
+
# A delegate child's report (ChildReports) is chi's news, not
|
|
76
|
+
# what the user said.
|
|
77
|
+
content = "#{DELEGATE_REPORT_LINE}\n#{content}" if message["source"].to_s == Steer::DELEGATE_REPORT
|
|
74
78
|
images = IMAGE_ONLY_RE.match?(content) ? [] : image_lines(message["images"])
|
|
75
79
|
[content, *images].reject(&:empty?).join("\n")
|
|
76
80
|
when "model", "assistant"
|
|
@@ -97,6 +97,8 @@ module Samagotchi
|
|
|
97
97
|
|
|
98
98
|
DEFAULT_MAX_TOOL_OUTPUT_CHARS = 10_000
|
|
99
99
|
QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_LIMIT = 2
|
|
100
|
+
# How much of a malformed generation's end the warning keeps.
|
|
101
|
+
MALFORMED_TAIL_CHARS = 400
|
|
100
102
|
QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_PROMPT = "Continue the previous assistant message by finishing the open <tool_call> XML block. Output only the remaining XML needed to complete the tool call."
|
|
101
103
|
|
|
102
104
|
# @param tools [Tools::Registry, nil] the tools calls dispatch to (the
|
|
@@ -197,7 +199,8 @@ module Samagotchi
|
|
|
197
199
|
emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: e.reason,
|
|
198
200
|
stopped_by: turn.cancel_controller&.stopped_by)
|
|
199
201
|
return cancelled_result(turn.conversation, tool_activity: turn.tool_activity, reason: e.reason,
|
|
200
|
-
partial_assistant_text: turn.buffer
|
|
202
|
+
partial_assistant_text: turn.buffer,
|
|
203
|
+
streamed_thinking: turn.streamed_thinking)
|
|
201
204
|
end
|
|
202
205
|
finish(turn)
|
|
203
206
|
rescue StandardError => e
|
|
@@ -239,9 +242,10 @@ module Samagotchi
|
|
|
239
242
|
# context tracker and the retry budget, the tool activity, the visible
|
|
240
243
|
# text streamed so far (salvaged on a cancel), the Qwen recovery state,
|
|
241
244
|
# the prefill, and what the caller gave.
|
|
242
|
-
Turn = Struct.new(:conversation, :context, :empty_retry, :tool_activity, :buffer, :
|
|
243
|
-
:prefill, :pending_tool_calls, :model_name, :pending_input, :on_stream_event,
|
|
244
|
-
:cancel_controller, :cap, :emit, :iteration, :empty_steps, :ended_empty,
|
|
245
|
+
Turn = Struct.new(:conversation, :context, :empty_retry, :tool_activity, :buffer, :streamed_thinking, :qwen_attempts,
|
|
246
|
+
:qwen_partial, :prefill, :pending_tool_calls, :model_name, :pending_input, :on_stream_event,
|
|
247
|
+
:cancel_controller, :cap, :emit, :iteration, :empty_steps, :ended_empty, :malformed_retried,
|
|
248
|
+
:uncached_next, keyword_init: true)
|
|
245
249
|
# One request: the prompt and its images as sent, the images' token
|
|
246
250
|
# estimate, and the window it was measured against.
|
|
247
251
|
Request = Struct.new(:prompt, :images, :image_tokens, :window, :prefill, keyword_init: true)
|
|
@@ -249,16 +253,18 @@ module Samagotchi
|
|
|
249
253
|
# generation's own server counts, the model the server named, the
|
|
250
254
|
# thinking it streamed, and why it stopped (the transport's finish
|
|
251
255
|
# reason, Client::Transport#finish_reason_from; nil when not named),
|
|
252
|
-
# and its prompt-cache counts (the largest seen: they come cumulative)
|
|
256
|
+
# and its prompt-cache counts (the largest seen: they come cumulative),
|
|
257
|
+
# and where its visible text starts in the turn's buffer.
|
|
253
258
|
Generation = Struct.new(:response, :cut, :usage, :served_model, :streamed_thinking, :finish_reason, :cache,
|
|
254
|
-
keyword_init: true)
|
|
259
|
+
:buffer_mark, keyword_init: true)
|
|
255
260
|
private_constant :Turn, :Request, :Generation
|
|
256
261
|
|
|
257
262
|
def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:)
|
|
258
263
|
conversation = prepare_conversation(messages)
|
|
259
264
|
Turn.new(
|
|
260
265
|
conversation: conversation, context: ContextStatus.new(conversation: conversation),
|
|
261
|
-
empty_retry: EmptyAnswerRetry.new, empty_steps: [], tool_activity: [], buffer: +"",
|
|
266
|
+
empty_retry: EmptyAnswerRetry.new, empty_steps: [], tool_activity: [], buffer: +"", streamed_thinking: +"",
|
|
267
|
+
qwen_attempts: 0, qwen_partial: nil,
|
|
262
268
|
# Qwen with thinking off: an empty thought after the cue, so the model
|
|
263
269
|
# answers at once. Kept in the turn's model messages, so each tool-loop
|
|
264
270
|
# prompt starts with what the server already has cached.
|
|
@@ -276,6 +282,9 @@ module Samagotchi
|
|
|
276
282
|
request = prepare_request(turn)
|
|
277
283
|
generation = generate(turn, request)
|
|
278
284
|
return after_cut(turn, generation) if generation.cut
|
|
285
|
+
if (reason = parser.malformed(generation.response))
|
|
286
|
+
return after_malformed(turn, generation, reason)
|
|
287
|
+
end
|
|
279
288
|
|
|
280
289
|
turn.conversation << { role: "model", content: request.prefill + generation.response.to_s }
|
|
281
290
|
# Profile-specific parse (incl. Qwen unterminated-block recovery); the
|
|
@@ -328,7 +337,13 @@ module Samagotchi
|
|
|
328
337
|
# thinking_chars=N content_length=…).
|
|
329
338
|
generation = Generation.new(usage: nil, served_model: nil, streamed_thinking: 0)
|
|
330
339
|
fire_hook(:before_generation, { type: :before_generation, iteration: turn.iteration }) if @hooks
|
|
331
|
-
buffer_mark = turn.buffer.length
|
|
340
|
+
buffer_mark = generation.buffer_mark = turn.buffer.length
|
|
341
|
+
# A cancel salvages only this generation's thinking: the earlier
|
|
342
|
+
# steps' is on their own messages already.
|
|
343
|
+
turn.streamed_thinking = +""
|
|
344
|
+
# Only the retry of a malformed generation skips the prompt cache.
|
|
345
|
+
uncached = turn.uncached_next
|
|
346
|
+
turn.uncached_next = false
|
|
332
347
|
generation.response = with_generation(turn.cancel_controller) do |generation_controller|
|
|
333
348
|
request_generation(
|
|
334
349
|
request.prompt,
|
|
@@ -336,12 +351,14 @@ module Samagotchi
|
|
|
336
351
|
cancel_controller: turn.cancel_controller,
|
|
337
352
|
model_name: turn.model_name,
|
|
338
353
|
sampling: turn.empty_retry.request_sampling(@turn_settings.sampling),
|
|
354
|
+
cache_prompt: !uncached,
|
|
339
355
|
on_chunk: ->(chunk) { stream_chunk(turn, generation, stream_splitter, chunk) },
|
|
340
356
|
on_retry: lambda { |retry_event|
|
|
341
357
|
# The retry streams from the start: its counts replace these.
|
|
342
358
|
generation.usage = nil
|
|
343
359
|
generation.cache = nil
|
|
344
360
|
generation.streamed_thinking = 0
|
|
361
|
+
turn.streamed_thinking = +""
|
|
345
362
|
generation.finish_reason = nil
|
|
346
363
|
turn.emit.call({ type: :generation_retrying, iteration: turn.iteration }.merge(retry_event)) if turn.on_stream_event
|
|
347
364
|
},
|
|
@@ -368,6 +385,7 @@ module Samagotchi
|
|
|
368
385
|
generation.finish_reason = chunk[:finish_reason] if chunk[:finish_reason]
|
|
369
386
|
split = stream_splitter.feed(chunk[:content])
|
|
370
387
|
turn.buffer << split[:text]
|
|
388
|
+
turn.streamed_thinking << split[:thinking].to_s
|
|
371
389
|
generation.streamed_thinking += split[:thinking].to_s.length
|
|
372
390
|
return unless turn.on_stream_event
|
|
373
391
|
|
|
@@ -455,6 +473,31 @@ module Samagotchi
|
|
|
455
473
|
cancelled_result(turn.conversation, tool_activity: turn.tool_activity, reason: :hook, partial_assistant_text: "")
|
|
456
474
|
end
|
|
457
475
|
|
|
476
|
+
# A corrupt generation (ToolCallParser#malformed) is no answer and runs
|
|
477
|
+
# no tool: it is logged and dropped, with its streamed text, and the
|
|
478
|
+
# same prompt goes once more without the server's prompt cache (a
|
|
479
|
+
# poisoned cache made it, ggml-org/llama.cpp#27148), shown as the loop
|
|
480
|
+
# asking again. A second one fails the turn. Returns :next.
|
|
481
|
+
def after_malformed(turn, generation, reason)
|
|
482
|
+
response = generation.response.to_s
|
|
483
|
+
Log.warn(:model, "generation_malformed", iteration: turn.iteration, reason: reason, id_slot: @last_slot,
|
|
484
|
+
cache_n: generation.cache&.dig(:cached), finish_reason: generation.finish_reason,
|
|
485
|
+
retried: turn.malformed_retried ? true : false,
|
|
486
|
+
payload: response[-MALFORMED_TAIL_CHARS..] || response)
|
|
487
|
+
turn.buffer.slice!(generation.buffer_mark..)
|
|
488
|
+
if turn.malformed_retried
|
|
489
|
+
label = @client.respond_to?(:host_name) && @client.host_name ? @client.host_name : "llama.cpp"
|
|
490
|
+
raise LLM::MalformedGeneration.new("#{label}: #{reason}, again without the prompt cache; nothing was saved",
|
|
491
|
+
host: label)
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
turn.malformed_retried = true
|
|
495
|
+
turn.uncached_next = true
|
|
496
|
+
emit(turn, type: :empty_answer_retry, iteration: turn.iteration, attempt: 1, of: 1,
|
|
497
|
+
finish_reason: generation.finish_reason, malformed: true)
|
|
498
|
+
:next
|
|
499
|
+
end
|
|
500
|
+
|
|
458
501
|
# A generation with no tool calls: a Qwen call left open is asked to be
|
|
459
502
|
# finished; an empty answer is asked again (EmptyAnswerRetry: not when
|
|
460
503
|
# a length stop came with the context full); queued input keeps the
|
|
@@ -605,8 +648,11 @@ module Samagotchi
|
|
|
605
648
|
end
|
|
606
649
|
|
|
607
650
|
# +sampling+: this request's (EmptyAnswerRetry#request_sampling).
|
|
608
|
-
|
|
651
|
+
# +cache_prompt+: false for a malformed generation's retry (only then sent).
|
|
652
|
+
def complete_kwargs(cancel_controller:, model_name: nil, on_chunk: nil, on_retry: nil, images: [], sampling: nil,
|
|
653
|
+
cache_prompt: true)
|
|
609
654
|
kwargs = {}
|
|
655
|
+
kwargs[:cache_prompt] = false unless cache_prompt
|
|
610
656
|
# Only a request with images names them: a text-only call is unchanged.
|
|
611
657
|
kwargs[:images] = images unless images.empty?
|
|
612
658
|
kwargs[:on_chunk] = on_chunk if on_chunk
|
|
@@ -625,7 +671,7 @@ module Samagotchi
|
|
|
625
671
|
ModelProfile.required_model_name(override)
|
|
626
672
|
end
|
|
627
673
|
|
|
628
|
-
def cancelled_result(conversation, tool_activity:, reason:, partial_assistant_text: "")
|
|
674
|
+
def cancelled_result(conversation, tool_activity:, reason:, partial_assistant_text: "", streamed_thinking: "")
|
|
629
675
|
partial = partial_assistant_text.to_s.strip
|
|
630
676
|
conversation = duplicate_conversation(conversation)
|
|
631
677
|
# Salvage the already-streamed visible reply (thought/tool_call lanes
|
|
@@ -633,7 +679,13 @@ module Samagotchi
|
|
|
633
679
|
# cannot leak) so a follow-up steering message continues with the model's
|
|
634
680
|
# half-finished work in context instead of losing it.
|
|
635
681
|
unless partial.empty?
|
|
636
|
-
|
|
682
|
+
message = { role: "model", content: "#{partial}\n[interrupted]", interrupted: true }
|
|
683
|
+
# The thinking this generation streamed goes with it (the chat loop's
|
|
684
|
+
# with_thinking key, which MessageParts reads and the prompt doesn't):
|
|
685
|
+
# without it, reloading the cut step shows the empty answer it isn't.
|
|
686
|
+
thinking = streamed_thinking.to_s
|
|
687
|
+
message[:thinking] = thinking unless thinking.strip.empty?
|
|
688
|
+
conversation << message
|
|
637
689
|
end
|
|
638
690
|
LLM::ModelResult.new(
|
|
639
691
|
text: "",
|
|
@@ -213,7 +213,7 @@ module Samagotchi
|
|
|
213
213
|
# Array), plus a model turn's tool_calls and thinking (the host's
|
|
214
214
|
# reasoning, never sent back), a result's tool_call_id, the image
|
|
215
215
|
# refs of a user message or a tool result, a prompt's turn_id (Engine,
|
|
216
|
-
# never sent), and a plugin tool result's tool_params and tool_labels
|
|
216
|
+
# never sent) and a wake turn's report's turn_start (Steer, never sent), and a plugin tool result's tool_params and tool_labels
|
|
217
217
|
# (the live row's params line and label, never sent back), an
|
|
218
218
|
# edit/write result's tool_diffs (never sent), and an empty-answer
|
|
219
219
|
# note's marker (TurnNote.empty, never sent).
|
|
@@ -225,6 +225,7 @@ module Samagotchi
|
|
|
225
225
|
message[:tool_call_id] = entry[:tool_call_id] if entry[:tool_call_id]
|
|
226
226
|
message[:images] = entry[:images] if entry[:images].is_a?(Array) && !entry[:images].empty?
|
|
227
227
|
message[:turn_id] = entry[:turn_id] if entry[:turn_id]
|
|
228
|
+
message[:turn_start] = true if entry[:turn_start] == true
|
|
228
229
|
message[:thinking] = entry[:thinking] if entry[:thinking].is_a?(String) && !entry[:thinking].empty?
|
|
229
230
|
ToolResponse::SAVED_KEYS.each { |key| message[key] = entry[key] if entry[key] }
|
|
230
231
|
message[TurnNote::RETRY_NUDGE] = true if TurnNote.retry_nudge?(entry)
|
|
@@ -338,8 +339,8 @@ module Samagotchi
|
|
|
338
339
|
return canceled(iteration, @cancel_controller.reason) if @cancel_controller&.cancelled?
|
|
339
340
|
|
|
340
341
|
inject_pending_input(iteration)
|
|
341
|
-
response, partial = generate(iteration)
|
|
342
|
-
return canceled(iteration, response, partial) if partial
|
|
342
|
+
response, partial, thought = generate(iteration)
|
|
343
|
+
return canceled(iteration, response, partial, thought) if partial
|
|
343
344
|
|
|
344
345
|
if response.cut
|
|
345
346
|
outcome = after_cut(iteration, response)
|
|
@@ -468,7 +469,7 @@ module Samagotchi
|
|
|
468
469
|
messages: AnswerDisplay.strip_all(@conversation).map(&:dup).freeze })
|
|
469
470
|
[response, nil]
|
|
470
471
|
rescue RequestCancelled => e
|
|
471
|
-
[e.reason, streamed]
|
|
472
|
+
[e.reason, streamed, thought]
|
|
472
473
|
end
|
|
473
474
|
|
|
474
475
|
# The request runs under the generation's own controller (the turn
|
|
@@ -593,11 +594,17 @@ module Samagotchi
|
|
|
593
594
|
end
|
|
594
595
|
|
|
595
596
|
# The text streamed before the cancel stays, marked [interrupted],
|
|
596
|
-
# as the native loop's salvage does
|
|
597
|
-
|
|
597
|
+
# as the native loop's salvage does; the reasoning this generation
|
|
598
|
+
# streamed rides along on the message (with_thinking's key).
|
|
599
|
+
def canceled(iteration, reason, partial = "", thinking = "")
|
|
598
600
|
emit(type: :generation_cancelled, iteration: iteration, reason: reason, stopped_by: @cancel_controller&.stopped_by)
|
|
599
601
|
visible = @loop.strip_model_thought(partial.to_s).strip
|
|
600
|
-
|
|
602
|
+
unless visible.empty?
|
|
603
|
+
message = { role: "model", content: "#{visible}\n[interrupted]", interrupted: true }
|
|
604
|
+
thought = thinking.to_s
|
|
605
|
+
message[:thinking] = thought unless thought.strip.empty?
|
|
606
|
+
@conversation << message
|
|
607
|
+
end
|
|
601
608
|
conversation = @loop.plain(@conversation)
|
|
602
609
|
conversation.last[:interrupted] = true unless visible.empty?
|
|
603
610
|
ModelResult.new(text: "", conversation: conversation, canceled: true,
|
|
@@ -207,6 +207,15 @@ module Samagotchi
|
|
|
207
207
|
def summary = "unexpected response from host #{host}: #{detail}"
|
|
208
208
|
end
|
|
209
209
|
|
|
210
|
+
# A native generation that came out corrupt twice (KernelLoop: a tool
|
|
211
|
+
# call never closed, or a fresh thought header after the answer), the
|
|
212
|
+
# second time without the server's prompt cache. A poisoned llama.cpp
|
|
213
|
+
# prompt cache does this (ggml-org/llama.cpp#27148); the text is not
|
|
214
|
+
# an answer.
|
|
215
|
+
class MalformedGeneration < ProtocolError
|
|
216
|
+
def summary = "malformed generation from host #{host}: #{detail}"
|
|
217
|
+
end
|
|
218
|
+
|
|
210
219
|
# A request that kept failing on network errors until the retry budget
|
|
211
220
|
# ran out (or failed mid-stream, where a retry would repeat output).
|
|
212
221
|
class RetryExhausted < ConnectionError
|
|
@@ -29,7 +29,7 @@ module Samagotchi
|
|
|
29
29
|
question_cancelled: %i[id reason],
|
|
30
30
|
question_relay: %i[id reason],
|
|
31
31
|
generation_cancelled: %i[iteration],
|
|
32
|
-
empty_answer_retry: %i[iteration attempt of finish_reason thinking_chars stopped_by]
|
|
32
|
+
empty_answer_retry: %i[iteration attempt of finish_reason thinking_chars stopped_by malformed]
|
|
33
33
|
}.freeze
|
|
34
34
|
|
|
35
35
|
# @param session_id [#call] the session the events are about (the
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
require_relative "atomic_file"
|
|
7
|
+
require_relative "log"
|
|
8
|
+
require_relative "paths"
|
|
9
|
+
|
|
10
|
+
module Samagotchi
|
|
11
|
+
# The model ids each host last listed, saved in
|
|
12
|
+
# <state dir>/model_lists.json for the processes that spawn a worker but
|
|
13
|
+
# never list a host themselves: `chi send --new --model` and the
|
|
14
|
+
# `delegate` tool warn about an unknown model id up front from it
|
|
15
|
+
# (ModelProfile.model_warning) instead of only the worker's first turn
|
|
16
|
+
# failing. It is a cache of the hosts' own lists, so it never raises: no
|
|
17
|
+
# file, an unreadable one or bad JSON reads as no list at all. Only a
|
|
18
|
+
# successful listing writes (a host that is down keeps the ids we know),
|
|
19
|
+
# and a list older than TTL_SECONDS is no evidence about the host any
|
|
20
|
+
# more.
|
|
21
|
+
#
|
|
22
|
+
# { "<host name>" => { "ids" => ["…"], "at" => <epoch seconds> } }
|
|
23
|
+
module ModelListStore
|
|
24
|
+
FILE = "model_lists.json"
|
|
25
|
+
# When a saved list is too old to say what a host serves: a model may
|
|
26
|
+
# have been added or removed since.
|
|
27
|
+
TTL_SECONDS = 7 * 24 * 60 * 60
|
|
28
|
+
|
|
29
|
+
# One host's saved list: the ids it listed and when (+at+, epoch
|
|
30
|
+
# seconds).
|
|
31
|
+
Saved = Data.define(:host, :ids, :at) do
|
|
32
|
+
def age(now: Time.now.to_i) = now.to_i - at.to_i
|
|
33
|
+
|
|
34
|
+
# Too old to check an id against (ModelProfile.model_warning): a model
|
|
35
|
+
# may have been added or removed since.
|
|
36
|
+
def stale?(now: Time.now.to_i) = age(now: now) > ModelListStore::TTL_SECONDS
|
|
37
|
+
|
|
38
|
+
# A host's own list spells its ids, and resolution matches them by
|
|
39
|
+
# case (the registry's model index is downcased): so does this.
|
|
40
|
+
def known?(id) = ids.any? { |known| known.casecmp?(id.to_s.strip) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
# @return [String] <state home>/samagotchi/model_lists.json
|
|
46
|
+
def path(env: ENV)
|
|
47
|
+
File.join(Paths.state_dir(env: env), FILE)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Every saved list by host name, the lists as [Saved] (empty without a
|
|
51
|
+
# file, with bad JSON, or with nothing readable in it).
|
|
52
|
+
# @return [Hash{String => Saved}]
|
|
53
|
+
def read(env: ENV)
|
|
54
|
+
all(env: env).each_with_object({}) do |(name, entry), acc|
|
|
55
|
+
list = saved_from(name, entry)
|
|
56
|
+
acc[name] = list if list
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# One host's saved list.
|
|
61
|
+
# @return [Saved, nil] nil without a file, with bad JSON, or with no
|
|
62
|
+
# entry for the host
|
|
63
|
+
def find(host, env: ENV)
|
|
64
|
+
read(env: env)[host.to_s.strip.downcase]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Save the ids +host+ just listed, keeping every other host's entry.
|
|
68
|
+
# Best effort: a store that can't be written never fails the listing
|
|
69
|
+
# that found the ids. An empty list is not saved (it is no evidence the
|
|
70
|
+
# host serves nothing: keep the ids we know).
|
|
71
|
+
# @return [Saved, nil] what was saved, nil when nothing was
|
|
72
|
+
def save(host, ids, at: Time.now.to_i, env: ENV)
|
|
73
|
+
name = host.to_s.strip.downcase
|
|
74
|
+
list = Array(ids).map { |id| id.to_s.strip }.reject(&:empty?).uniq
|
|
75
|
+
return nil if name.empty? || list.empty?
|
|
76
|
+
|
|
77
|
+
file = path(env: env)
|
|
78
|
+
FileUtils.mkdir_p(File.dirname(file))
|
|
79
|
+
# Under the lock, so two workers listing a host at once (or two
|
|
80
|
+
# threads of one registry) never drop each other's entries.
|
|
81
|
+
File.open("#{file}.lock", File::RDWR | File::CREAT, 0o600) do |lock|
|
|
82
|
+
lock.flock(File::LOCK_EX)
|
|
83
|
+
entries = all(env: env)
|
|
84
|
+
entries[name] = { "ids" => list, "at" => at.to_i }
|
|
85
|
+
AtomicFile.write(file, JSON.pretty_generate(entries) + "\n")
|
|
86
|
+
end
|
|
87
|
+
Saved.new(host: name, ids: list, at: at.to_i)
|
|
88
|
+
rescue StandardError => e
|
|
89
|
+
Log.warn(:model, "model_list_save_failed", host: host.to_s, error: e.class.name, msg: e.message.to_s[0, 200])
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The whole file, host names downcased: {name => entry}.
|
|
94
|
+
# @return [Hash]
|
|
95
|
+
def all(env: ENV)
|
|
96
|
+
data = JSON.parse(File.read(path(env: env)))
|
|
97
|
+
return {} unless data.is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
data.each_with_object({}) do |(name, entry), acc|
|
|
100
|
+
acc[name.to_s.strip.downcase] = entry if entry.is_a?(Hash)
|
|
101
|
+
end
|
|
102
|
+
rescue StandardError
|
|
103
|
+
{}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# One +entry+ of the file as a Saved, or nil when it holds no ids or no
|
|
107
|
+
# time.
|
|
108
|
+
def saved_from(name, entry)
|
|
109
|
+
ids = Array(entry["ids"]).map { |id| id.to_s.strip }.reject(&:empty?)
|
|
110
|
+
at = entry["at"].to_i
|
|
111
|
+
return nil if ids.empty? || at <= 0
|
|
112
|
+
|
|
113
|
+
Saved.new(host: name, ids: ids, at: at)
|
|
114
|
+
end
|
|
115
|
+
private_class_method :saved_from
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -179,6 +179,49 @@ module Samagotchi
|
|
|
179
179
|
raise UnknownHost, "unknown host '#{host}' in model '#{ref}'#{hint}; the configured hosts are #{names.join(", ")}"
|
|
180
180
|
end
|
|
181
181
|
|
|
182
|
+
# A warning when +model_name+ resolves to a model id a host's saved
|
|
183
|
+
# list doesn't have (ModelListStore: what a `chi models`, a /models or
|
|
184
|
+
# the web's GET /api/models last listed for that host), else nil.
|
|
185
|
+
# Called where a session is spawned without listing the hosts itself
|
|
186
|
+
# (`chi send --new --model`, the delegate tool), so a typo is
|
|
187
|
+
# named before the worker's first turn fails on it. Only a warning: some
|
|
188
|
+
# hosts serve ids they don't list (a one-model llama.cpp server takes any
|
|
189
|
+
# name, OpenRouter's :nitro variants), so the session starts anyway.
|
|
190
|
+
# Never asks a host.
|
|
191
|
+
#
|
|
192
|
+
# Only a ref that names a host is checked (its alias resolved first, as
|
|
193
|
+
# ModelRef.parse does), and only against that host's own list: a bare id
|
|
194
|
+
# goes to whichever host lists it (HostRegistry#host_for_model), so no
|
|
195
|
+
# single list can judge it. Nothing is checked either without a saved
|
|
196
|
+
# list for the host or with one older than ModelListStore::TTL_SECONDS
|
|
197
|
+
# (a week: the host may serve different models by now).
|
|
198
|
+
# @param hosts [Hash, nil] the hosts a prefix may name (a HostRegistry's
|
|
199
|
+
# entries); config.yml's by default
|
|
200
|
+
# @param lists [#read] ModelListStore by default
|
|
201
|
+
# @return [String, nil]
|
|
202
|
+
def self.model_warning(model_name, env: ENV, hosts: nil, lists: nil)
|
|
203
|
+
require_relative "config"
|
|
204
|
+
require_relative "model_list_store"
|
|
205
|
+
hosts ||= Samagotchi::ConfigFile.hosts_config(env: env)
|
|
206
|
+
lists ||= Samagotchi::ModelListStore
|
|
207
|
+
parsed = Samagotchi::ConfigFile.model_ref(model_name, env: env, hosts: hosts)
|
|
208
|
+
host = parsed.host_name
|
|
209
|
+
return nil unless host
|
|
210
|
+
|
|
211
|
+
list = lists.read(env: env)[host.to_s.strip.downcase]
|
|
212
|
+
return nil if list.nil? || list.stale? || list.known?(parsed.id)
|
|
213
|
+
|
|
214
|
+
unknown_model_message(parsed.id, host, list.ids)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# The warning for an id the host doesn't list, with up to three close
|
|
218
|
+
# ids (Config.near_names) when there are any.
|
|
219
|
+
def self.unknown_model_message(id, host, ids)
|
|
220
|
+
near = Samagotchi::Config.near_names(id, ids).first(3)
|
|
221
|
+
hint = near.empty? ? "" : " (did you mean: #{near.join(", ")}?)"
|
|
222
|
+
"host '#{host}' doesn't list model '#{id}'#{hint}; started it anyway; `chi models` lists what the hosts serve"
|
|
223
|
+
end
|
|
224
|
+
|
|
182
225
|
# The prefix of "box:x" when box is a host config.yml has with
|
|
183
226
|
# enabled: false (left out of +hosts+), else nil.
|
|
184
227
|
def self.disabled_host_prefix(ref, hosts, env)
|