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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -1
  3. data/docs/cli.md +4 -4
  4. data/docs/configuration.md +4 -0
  5. data/docs/hooks.md +1 -1
  6. data/docs/internals/engine.md +1 -1
  7. data/docs/plugins.md +1 -1
  8. data/docs/sessions.md +11 -4
  9. data/lib/samagotchi/bridge/card_store.rb +1 -1
  10. data/lib/samagotchi/bridge/turn_notice.rb +1 -1
  11. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/mcp/plugin.rb +4 -3
  13. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +17 -4
  14. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/system/delegated.md +1 -0
  16. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  17. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  18. data/lib/samagotchi/child_reports.rb +214 -0
  19. data/lib/samagotchi/client.rb +35 -4
  20. data/lib/samagotchi/command_steps.rb +35 -15
  21. data/lib/samagotchi/config.rb +5 -0
  22. data/lib/samagotchi/engine.rb +2 -0
  23. data/lib/samagotchi/host_registry.rb +4 -0
  24. data/lib/samagotchi/idle_recap.rb +4 -0
  25. data/lib/samagotchi/kernel_loop.rb +63 -11
  26. data/lib/samagotchi/llm/chat_loop.rb +14 -7
  27. data/lib/samagotchi/llm/errors.rb +9 -0
  28. data/lib/samagotchi/log_subscriber.rb +1 -1
  29. data/lib/samagotchi/model_list_store.rb +117 -0
  30. data/lib/samagotchi/model_profile.rb +43 -0
  31. data/lib/samagotchi/prompt_warmup.rb +30 -5
  32. data/lib/samagotchi/reply_wait.rb +39 -8
  33. data/lib/samagotchi/send_command.rb +5 -3
  34. data/lib/samagotchi/session.rb +31 -5
  35. data/lib/samagotchi/session_inbox.rb +34 -1
  36. data/lib/samagotchi/session_manager.rb +41 -5
  37. data/lib/samagotchi/session_metrics.rb +6 -3
  38. data/lib/samagotchi/steer.rb +39 -7
  39. data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
  40. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  41. data/lib/samagotchi/terminal_ui/formatting.rb +38 -8
  42. data/lib/samagotchi/terminal_ui.rb +44 -5
  43. data/lib/samagotchi/tool_activity.rb +6 -2
  44. data/lib/samagotchi/tool_call_parser.rb +40 -0
  45. data/lib/samagotchi/tool_declarations.rb +7 -7
  46. data/lib/samagotchi/tools/delegate.rb +26 -13
  47. data/lib/samagotchi/tools/delegate_cursor.rb +91 -0
  48. data/lib/samagotchi/tools/delegate_wait.rb +50 -31
  49. data/lib/samagotchi/tools/execute.rb +3 -1
  50. data/lib/samagotchi/tools/task_runtime.rb +21 -2
  51. data/lib/samagotchi/tools/task_wait.rb +2 -2
  52. data/lib/samagotchi/version.rb +1 -1
  53. data/lib/samagotchi/web/app.rb +8 -2
  54. data/lib/samagotchi/web/public/activity.js +2 -1
  55. data/lib/samagotchi/web/public/app.js +21 -11
  56. data/lib/samagotchi/web/public/command_view.js +19 -5
  57. data/lib/samagotchi/web/public/format.js +8 -1
  58. data/lib/samagotchi/web/public/index.html +14 -6
  59. data/lib/samagotchi/web/public/notify.js +4 -2
  60. data/lib/samagotchi/web/public/stage_model.js +18 -0
  61. data/lib/samagotchi/web/public/stage_view.js +16 -11
  62. data/lib/samagotchi/web/public/task_stop.js +11 -2
  63. data/lib/samagotchi/web/public/timing.js +12 -7
  64. data/lib/samagotchi/web/public/turn_events.js +29 -9
  65. data/lib/samagotchi/web/public/turn_model.js +16 -11
  66. data/lib/samagotchi/web/public/turn_view.js +49 -7
  67. data/lib/samagotchi/worker.rb +240 -23
  68. metadata +4 -1
@@ -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
- payload[:cache_prompt] = true
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, heredoc = inner_heredoc(slice) unless heredoc
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
- # A heredoc inside a $(…) in +slice+ (git commit -m "$(cat <<'EOF' …)"):
130
- # the slice with the body cut out, and the heredoc; or the slice as is.
131
- def inner_heredoc(slice)
132
- return [slice, nil] unless slice.include?("<<")
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
- if doc
140
- from = open + 2 + doc[:span].begin - 1 # the newline before the body
141
- to = open + 2 + doc[:span].end
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, nil]
167
+ [slice, heredocs]
148
168
  end
149
169
 
150
170
  # The body's line count, its terminator line not counted.
@@ -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
@@ -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, :qwen_attempts, :qwen_partial,
243
- :prefill, :pending_tool_calls, :model_name, :pending_input, :on_stream_event,
244
- :cancel_controller, :cap, :emit, :iteration, :empty_steps, :ended_empty, keyword_init: true)
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: +"", qwen_attempts: 0, qwen_partial: nil,
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
- def complete_kwargs(cancel_controller:, model_name: nil, on_chunk: nil, on_retry: nil, images: [], sampling: nil)
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
- conversation << { role: "model", content: "#{partial}\n[interrupted]", interrupted: true }
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
- def canceled(iteration, reason, partial = "")
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
- @conversation << { role: "model", content: "#{visible}\n[interrupted]", interrupted: true } unless visible.empty?
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)