samagotchi 0.10.1 → 0.11.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -1
  3. data/docs/architecture.md +19 -0
  4. data/docs/cli.md +24 -7
  5. data/docs/configuration.md +44 -11
  6. data/docs/guardrails.md +2 -1
  7. data/docs/hooks.md +3 -2
  8. data/docs/internals/context-telemetry.md +4 -2
  9. data/docs/sessions.md +3 -3
  10. data/lib/samagotchi/bootstrap/config_writer.rb +2 -2
  11. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +5 -3
  12. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  13. data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
  14. data/lib/samagotchi/bundles/skills/plugin.rb +3 -23
  15. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  16. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  17. data/lib/samagotchi/client.rb +21 -1
  18. data/lib/samagotchi/config.rb +44 -40
  19. data/lib/samagotchi/context_status.rb +17 -16
  20. data/lib/samagotchi/empty_answer_retry.rb +67 -8
  21. data/lib/samagotchi/engine.rb +64 -20
  22. data/lib/samagotchi/host_registry.rb +50 -90
  23. data/lib/samagotchi/kernel_loop.rb +288 -305
  24. data/lib/samagotchi/llm/chat_loop.rb +78 -87
  25. data/lib/samagotchi/model_profile.rb +22 -3
  26. data/lib/samagotchi/model_ref.rb +60 -0
  27. data/lib/samagotchi/prompt_history.rb +91 -0
  28. data/lib/samagotchi/recap_setup.rb +15 -3
  29. data/lib/samagotchi/self_report.rb +11 -6
  30. data/lib/samagotchi/send_command.rb +8 -8
  31. data/lib/samagotchi/session.rb +9 -2
  32. data/lib/samagotchi/session_commands.rb +10 -12
  33. data/lib/samagotchi/session_manager.rb +20 -3
  34. data/lib/samagotchi/sessions_command.rb +57 -51
  35. data/lib/samagotchi/steer.rb +23 -0
  36. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -2
  37. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -6
  38. data/lib/samagotchi/terminal_ui/input_support.rb +78 -33
  39. data/lib/samagotchi/terminal_ui/reline_history_hook.rb +93 -0
  40. data/lib/samagotchi/terminal_ui.rb +9 -8
  41. data/lib/samagotchi/tool_response.rb +67 -0
  42. data/lib/samagotchi/tool_runner.rb +6 -1
  43. data/lib/samagotchi/tools/delegate.rb +4 -3
  44. data/lib/samagotchi/tools/memory.rb +8 -6
  45. data/lib/samagotchi/version.rb +1 -1
  46. data/lib/samagotchi/web/app.rb +28 -2
  47. data/lib/samagotchi/web/public/app.js +29 -2
  48. data/lib/samagotchi/web/public/data.js +9 -1
  49. data/lib/samagotchi/web/public/prompt_history.js +63 -0
  50. data/lib/samagotchi/worker.rb +2 -1
  51. metadata +6 -1
@@ -20,6 +20,7 @@ require_relative "tools/builtins"
20
20
  require_relative "muted_memories"
21
21
  require_relative "tool_activity"
22
22
  require_relative "tool_runner"
23
+ require_relative "tool_response"
23
24
  require_relative "answer_display"
24
25
 
25
26
  module Samagotchi
@@ -78,11 +79,9 @@ module Samagotchi
78
79
 
79
80
  # @param tools [Tools::Registry, nil] the tools calls dispatch to (the
80
81
  # Engine's; nil: the built-ins alone)
81
- def initialize(client: nil, profile: nil, model_name: nil, no_interrupt: false, hooks: nil, reminder_store: nil, model_key: nil,
82
- tools: nil)
82
+ def initialize(client: nil, profile: nil, model_name: nil, hooks: nil, reminder_store: nil, model_key: nil, tools: nil)
83
83
  @client = client || Client.new
84
84
  @tools = tools || Tools::Builtins.default
85
- @no_interrupt = no_interrupt
86
85
  resolved_model_name = ModelProfile.required_model_name(model_name)
87
86
  # The resolved model id actually used for this run (per-run override wins
88
87
  # over the config alias); on every debug dump so we can see exactly
@@ -96,6 +95,7 @@ module Samagotchi
96
95
  @hooks = hooks
97
96
  @reminder_store = reminder_store
98
97
  @model_key = model_key
98
+ @model_key_fallback = nil
99
99
  end
100
100
 
101
101
  # @return [ReminderStore, nil] the reminder store for inspection (used by
@@ -157,296 +157,314 @@ module Samagotchi
157
157
  # :pending_input_merged stream event is emitted.
158
158
  # @return [Result] final visible response with continuation metadata
159
159
  def run(messages, max_iterations: 100, on_stream_event: nil, cancel_controller: nil, model_name: nil, max_tool_output_chars: nil, pending_input: nil)
160
- resolved_model_name = completion_model_name(model_name)
161
- @current_model_name = resolved_model_name
160
+ @current_model_name = completion_model_name(model_name)
161
+ turn = start_turn(messages, on_stream_event: on_stream_event, cancel_controller: cancel_controller,
162
+ pending_input: pending_input, cap: resolve_output_char_cap(max_tool_output_chars))
163
+
164
+ max_iterations.times do |iteration_index|
165
+ turn.iteration = iteration_index + 1
166
+ outcome = iterate(turn)
167
+ return outcome if outcome.is_a?(Result)
168
+ break if outcome == :answer
169
+ rescue Client::RequestCancelled => e
170
+ emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: e.reason)
171
+ return cancelled_result(turn.conversation, tool_activity: turn.tool_activity, reason: e.reason,
172
+ partial_assistant_text: turn.buffer)
173
+ end
174
+ finish(turn)
175
+ rescue StandardError => e
176
+ LLM::FailedTurn.attach(e, turn && duplicate_conversation(turn.conversation))
177
+ raise
178
+ end
179
+
180
+ # Use a resolved profile (ModelProfile::Resolution) from now on,
181
+ # whatever model name later runs carry.
182
+ def use_profile!(resolution)
183
+ @profile = resolution.profile
184
+ @profile_source = resolution.label
185
+ end
162
186
 
187
+ # @param fallback [String, nil] a key whose overlay memory_read takes
188
+ # when +key+ has none (the alias a model was typed as)
189
+ def sync_model_key!(key, fallback: nil)
190
+ @model_key = key
191
+ @model_key_fallback = fallback
192
+ end
193
+
194
+ private
195
+
196
+ # ── One turn (#run) ────────────────────────────────────────────────────────
197
+
198
+ # One run's state, threaded through its steps: the conversation, the
199
+ # context tracker and the retry budget, the tool activity, the visible
200
+ # text streamed so far (salvaged on a cancel), the Qwen recovery state,
201
+ # the prefill, and what the caller gave.
202
+ Turn = Struct.new(:conversation, :context, :empty_retry, :tool_activity, :buffer, :qwen_attempts, :qwen_partial,
203
+ :prefill, :pending_tool_calls, :model_name, :pending_input, :on_stream_event,
204
+ :cancel_controller, :cap, :emit, :iteration, keyword_init: true)
205
+ # One request: the prompt and its images as sent, the images' token
206
+ # estimate, and the window it was measured against.
207
+ Request = Struct.new(:prompt, :images, :image_tokens, :window, keyword_init: true)
208
+ # One generation: the response (nil when cut), the cut's detail, this
209
+ # generation's own server counts, the model the server named, the
210
+ # thinking it streamed, and why it stopped (the transport's finish
211
+ # reason, Client::Transport#finish_reason_from; nil when not named).
212
+ Generation = Struct.new(:response, :cut, :usage, :served_model, :streamed_thinking, :finish_reason, keyword_init: true)
213
+ private_constant :Turn, :Request, :Generation
214
+
215
+ def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:)
163
216
  conversation = prepare_conversation(messages)
164
- context = ContextStatus.new(conversation: conversation)
165
- exhausted = false
166
- pending_tool_calls = false
167
- tool_activity = []
168
- qwen_recovery_attempts = 0
169
- qwen_partial_tool_call = nil
170
- empty_retries = 0
171
- empty_retry_limit = EmptyAnswerRetry.limit
172
- @retry_generation = false
173
- # Qwen with thinking off: an empty thought after the cue, so the model
174
- # answers at once. Kept in the turn's model messages, so each tool-loop
175
- # prompt starts with what the server already has cached.
176
- prefill = Thinking.native(@thinking || Thinking::DEFAULT, @profile).prefill
177
- partial_assistant_buffer = +""
178
-
179
- effective_max_iterations = @no_interrupt ? 1000 : max_iterations
180
- effective_max_tool_output_chars = resolve_output_char_cap(max_tool_output_chars)
181
- effective_max_iterations.times do |iteration_index|
182
- inject_pending_input!(conversation, pending_input, on_stream_event, iteration_index + 1, cancel_controller)
183
- prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @vision, prefill: prefill)
184
- image_tokens = images.empty? ? 0 : ImagePlan.estimated_tokens(conversation)
185
- context_window = ContextWindow.resolve(client: @client, model: resolved_model_name)
186
- emit_context_status_event(on_stream_event, context, prompt, iteration_index: iteration_index, window: context_window,
187
- image_tokens: image_tokens)
188
- if (line = context.take_guidance)
189
- # The model's own copy, on the tail (the prompt cache keeps its
190
- # prefix), then the prompt again with it.
191
- conversation << line
192
- prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @vision, prefill: prefill)
193
- end
194
- # Each generation splits its own stream: one that ended inside a
195
- # thought or a tool call doesn't leave the next one "inside" it.
196
- stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
197
- emit_stream_event(
198
- on_stream_event,
199
- type: :generation_started,
200
- iteration: iteration_index + 1,
201
- context_window_tokens: context_window.tokens,
202
- context_window_source: context_window.source,
203
- profile: @profile.name,
204
- profile_source: @profile_source
205
- )
206
- served_model = nil
207
- # This generation's own server counts (the run-long ones in
208
- # `context` can be an earlier one's).
209
- generation_usage = nil
210
- # The thinking this generation streamed, for the log (a stuck
211
- # thinking generation shows as thinking_chars=N content_length=…).
212
- streamed_thinking = 0
213
- # Fire :before_generation hook
214
- gen_event = { type: :before_generation, iteration: iteration_index + 1 }
215
- fire_hook(:before_generation, gen_event) if @hooks
216
- # The request runs under the generation's own controller: a plugin's
217
- # stop_generation cuts it alone, and the turn goes on (a cut).
218
- buffer_mark = partial_assistant_buffer.length
219
- cut = nil
220
- response = with_generation(cancel_controller) do |generation_controller|
221
- request_generation(
222
- prompt,
223
- generation_controller: generation_controller,
224
- cancel_controller: cancel_controller,
225
- model_name: resolved_model_name,
226
- on_chunk: lambda { |chunk|
227
- generation_usage = context.capture(chunk[:payload]) || generation_usage
228
- # llama.cpp names the loaded model in the stream's last payload.
229
- named = chunk[:payload]["model"] if chunk[:payload].is_a?(Hash)
230
- served_model = named if named.is_a?(String) && !named.strip.empty?
231
- split = stream_splitter.feed(chunk[:content])
232
- partial_assistant_buffer << split[:text]
233
- streamed_thinking += split[:thinking].to_s.length
234
- if on_stream_event
235
- emit_stream_event(
236
- on_stream_event,
237
- type: :generation_chunk,
238
- iteration: iteration_index + 1,
239
- content: chunk[:content],
240
- text: split[:text],
241
- thinking: split[:thinking],
242
- payload: chunk[:payload]
243
- )
244
- end
245
- },
246
- on_retry: lambda { |retry_event|
247
- # The retry streams from the start: its counts replace these.
248
- generation_usage = nil
249
- streamed_thinking = 0
250
- next unless on_stream_event
251
-
252
- emit_stream_event(
253
- on_stream_event,
254
- {
255
- type: :generation_retrying,
256
- iteration: iteration_index + 1
257
- }.merge(retry_event)
258
- )
259
- },
260
- images: images
261
- )
262
- rescue Client::RequestCancelled
263
- raise unless cut?(generation_controller, cancel_controller)
264
-
265
- cut = generation_controller.detail || {}
266
- ""
267
- end
268
- if cut
269
- # The cut stream's visible text goes with it: the buffer as it was
270
- # before (the next generation gets a fresh splitter).
271
- partial_assistant_buffer.slice!(buffer_mark..)
272
- emit_stream_event(on_stream_event, type: :generation_completed, iteration: iteration_index + 1,
273
- content_length: 0, thinking_chars: streamed_thinking,
274
- served_model: served_model, requested_model: resolved_model_name,
275
- finish_reason: "stopped", stopped_by: cut[:by], stop_reason: cut[:reason])
276
- # A Stop that came right after the cut is a plain cancel.
277
- raise Client::RequestCancelled.new(cancel_controller.reason) if cancel_controller.cancelled?
278
-
279
- if empty_retries < empty_retry_limit
280
- # Queued input (the user's line, a plugin's steer) goes in place of the nudge.
281
- next if inject_pending_input!(conversation, pending_input, on_stream_event, iteration_index + 1, cancel_controller)
282
-
283
- empty_retries += 1
284
- @retry_generation = true
285
- emit_stream_event(on_stream_event, type: :empty_answer_retry, iteration: iteration_index + 1,
286
- attempt: empty_retries, of: empty_retry_limit,
287
- thinking_chars: streamed_thinking, stopped_by: cut[:by])
288
- conversation << TurnNote.cut_retry(cut[:by], cut[:reason])
289
- next
290
- end
291
- # No retry left: the turn ends as cancelled (hook), with nothing
292
- # salvaged and without the spent nudge.
293
- drop_last_empty_retry!(conversation)
294
- cancel_controller.cancel!(:hook, cut)
295
- emit_stream_event(on_stream_event, type: :generation_cancelled, iteration: iteration_index + 1, reason: :hook)
296
- return cancelled_result(conversation, tool_activity: tool_activity, reason: :hook, partial_assistant_text: "")
297
- end
298
- context.generation_done(generation_usage, prompt_chars: prompt.length, image_tokens: image_tokens, window: context_window)
299
- emit_stream_event(
300
- on_stream_event,
301
- type: :generation_completed,
302
- iteration: iteration_index + 1,
303
- content_length: response.to_s.length,
304
- thinking_chars: thinking_chars(response.to_s, streamed_thinking),
305
- served_model: served_model,
306
- requested_model: resolved_model_name
307
- )
308
- dump_log("response", response, iteration: iteration_index + 1)
309
- # Fire :after_generation hook (after LLM returns, before tool parse),
310
- # with a read-only copy of the conversation as sent.
311
- after_gen_event = { type: :after_generation, iteration: iteration_index + 1, response: response,
312
- messages: AnswerDisplay.strip_all(conversation).map(&:dup).freeze }
313
- fire_hook(:after_generation, after_gen_event) if @hooks
314
- conversation << { role: "model", content: prefill + response.to_s }
315
-
316
- # Profile-specific parse (incl. Qwen unterminated-block recovery); the
317
- # returned fragment (non-nil only for Qwen) is fed back on the next
318
- # iteration if the model opened a tool-call block it did not close.
319
- calls, qwen_partial_tool_call = parser.parse_with_recovery(response, qwen_partial_tool_call)
320
- calls = calls.map do |call|
321
- PromptLiteralGuard.restore_call(call, profile: @profile)
322
- end
323
- qwen_incomplete_tool_call = !qwen_partial_tool_call.nil?
324
-
325
- if calls.empty?
326
- if qwen_incomplete_tool_call && qwen_recovery_attempts < QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_LIMIT
327
- qwen_recovery_attempts += 1
328
- conversation << { role: "user", content: QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_PROMPT, preserve_literals: true }
329
- pending_tool_calls = false
330
- next
331
- end
332
-
333
- pending_tool_calls = false
334
- answer = -> { PromptLiteralGuard.restore(strip_thought_blocks(response), profile: @profile) }
335
- empty = strip_thought_blocks(response.to_s).strip.empty?
336
- retry_empty = empty && empty_retries < empty_retry_limit && !cancel_controller&.cancelled?
337
- # The empty generation goes (its thinking would be sent again and
338
- # prime the same loop); an empty answer that will be retried is no
339
- # answer site, so a plugin's steer joins the retry.
340
- conversation.pop if retry_empty
341
- unless inject_pending_input!(conversation, pending_input, on_stream_event, iteration_index + 1, cancel_controller,
342
- answer: retry_empty ? nil : answer)
343
- if retry_empty
344
- empty_retries += 1
345
- @retry_generation = true
346
- emit_stream_event(on_stream_event, type: :empty_answer_retry, iteration: iteration_index + 1,
347
- attempt: empty_retries, of: empty_retry_limit,
348
- thinking_chars: thinking_chars(response.to_s, streamed_thinking))
349
- conversation << TurnNote.empty_retry
350
- next
351
- end
352
- # The Engine's TurnNote.empty says it all: the spent nudge goes.
353
- drop_last_empty_retry!(conversation) if empty && empty_retries.positive?
354
- break
355
- end
356
- # Queued steering keeps the turn going: loop again so the model
357
- # answers the injected message instead of stopping here.
358
- next
359
- end
217
+ Turn.new(
218
+ conversation: conversation, context: ContextStatus.new(conversation: conversation),
219
+ empty_retry: EmptyAnswerRetry.new, tool_activity: [], buffer: +"", qwen_attempts: 0, qwen_partial: nil,
220
+ # Qwen with thinking off: an empty thought after the cue, so the model
221
+ # answers at once. Kept in the turn's model messages, so each tool-loop
222
+ # prompt starts with what the server already has cached.
223
+ prefill: Thinking.native(@thinking || Thinking::DEFAULT, @profile).prefill,
224
+ pending_tool_calls: false, model_name: @current_model_name, pending_input: pending_input,
225
+ on_stream_event: on_stream_event, cancel_controller: cancel_controller, cap: cap,
226
+ emit: ->(event) { emit_stream_event(on_stream_event, event) }
227
+ )
228
+ end
360
229
 
361
- qwen_recovery_attempts = 0
362
- qwen_partial_tool_call = nil
363
-
364
- emit_stream_event(on_stream_event, type: :tool_dispatch_started, iteration: iteration_index + 1, call_count: calls.length)
365
- tool_images = []
366
- image_counts = []
367
- shown_params = []
368
- shown_labels = []
369
- diffs = []
370
- results = calls.map.with_index do |call, call_index|
371
- run = tool_runner.run(call, iteration: iteration_index + 1, call_index: call_index + 1,
372
- call_count: calls.length, on_stream_event: on_stream_event,
373
- max_tool_output_chars: effective_max_tool_output_chars)
374
- tool_activity << run[:activity]
375
- tool_images.concat(Array(run[:images]))
376
- image_counts << Array(run[:images]).size
377
- shown_params << run[:shown_params]
378
- shown_labels << run[:shown_label]
379
- diffs << run[:diff]
380
- run[:output]
381
- end.join("\n\n---\n\n")
382
- emit_stream_event(on_stream_event, type: :tool_dispatch_completed, iteration: iteration_index + 1, call_count: calls.length)
383
- # The joined results carry every call's images, in call order.
384
- tool_response = { role: "tool_response", content: results }
385
- tool_response[:images] = tool_images unless tool_images.empty?
386
- # How many of them each call returned, in call order, so the web's
387
- # reload puts each on its own tool row; the prompt never reads it.
388
- tool_response[:image_counts] = image_counts unless tool_images.empty?
389
- # A plugin tool's params line, one per call in call order (nil for
390
- # a built-in), for the web's reload; the prompt never reads it.
391
- tool_response[:tool_params] = shown_params if shown_params.any?
392
- tool_response[:tool_labels] = shown_labels if shown_labels.any?
393
- # What each edit/write changed (nil for other calls), in call order,
394
- # for the web's reload; the prompt never reads it.
395
- tool_response[:tool_diffs] = diffs if diffs.any?
396
- conversation << tool_response
397
- pending_tool_calls = true
398
- rescue Client::RequestCancelled => e
399
- emit_stream_event(
400
- on_stream_event,
401
- type: :generation_cancelled,
402
- iteration: iteration_index + 1,
403
- reason: e.reason
230
+ # One iteration. Returns :next, :answer (the turn ends), or the turn's
231
+ # Result (it ended cancelled).
232
+ def iterate(turn)
233
+ inject_pending_input!(turn)
234
+ request = prepare_request(turn)
235
+ generation = generate(turn, request)
236
+ return after_cut(turn, generation) if generation.cut
237
+
238
+ turn.conversation << { role: "model", content: turn.prefill + generation.response.to_s }
239
+ # Profile-specific parse (incl. Qwen unterminated-block recovery); the
240
+ # returned fragment (non-nil only for Qwen) is fed back on the next
241
+ # iteration if the model opened a tool-call block it did not close.
242
+ calls, turn.qwen_partial = parser.parse_with_recovery(generation.response, turn.qwen_partial)
243
+ calls = calls.map { |call| PromptLiteralGuard.restore_call(call, profile: @profile) }
244
+ return after_text(turn, request, generation) if calls.empty?
245
+
246
+ dispatch_calls(turn, calls)
247
+ end
248
+
249
+ # The prompt for this iteration, after the context check: a rise into a
250
+ # bucket that asks the model for a change puts its line on the tail
251
+ # (the prompt cache keeps its prefix), and the prompt is formatted again
252
+ # with it.
253
+ def prepare_request(turn)
254
+ prompt, images = format_prompt(turn)
255
+ image_tokens = images.empty? ? 0 : ImagePlan.estimated_tokens(turn.conversation)
256
+ window = ContextWindow.resolve(client: @client, model: turn.model_name)
257
+ emit_context_status_event(turn.on_stream_event, turn.context, prompt, iteration_index: turn.iteration - 1,
258
+ window: window, image_tokens: image_tokens)
259
+ if (line = turn.context.take_guidance)
260
+ turn.conversation << line
261
+ prompt, images = format_prompt(turn)
262
+ end
263
+ Request.new(prompt: prompt, images: images, image_tokens: image_tokens, window: window)
264
+ end
265
+
266
+ def format_prompt(turn)
267
+ Prompt.format_with_images(turn.conversation, profile: @profile, vision: @vision, prefill: turn.prefill)
268
+ end
269
+
270
+ # One streamed request, under the generation's own controller: a
271
+ # plugin's stop_generation cuts it alone, and the turn goes on (a cut).
272
+ # Ends with its :generation_completed (and, unless cut, the
273
+ # after_generation hook).
274
+ def generate(turn, request)
275
+ # Each generation splits its own stream: one that ended inside a
276
+ # thought or a tool call doesn't leave the next one "inside" it.
277
+ stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
278
+ emit(turn, type: :generation_started, iteration: turn.iteration, context_window_tokens: request.window.tokens,
279
+ context_window_source: request.window.source, profile: @profile.name, profile_source: @profile_source)
280
+ # This generation's own server counts (the run-long ones in the
281
+ # context tracker can be an earlier one's), and the thinking it
282
+ # streamed, for the log (a stuck thinking generation shows as
283
+ # thinking_chars=N content_length=…).
284
+ generation = Generation.new(usage: nil, served_model: nil, streamed_thinking: 0)
285
+ fire_hook(:before_generation, { type: :before_generation, iteration: turn.iteration }) if @hooks
286
+ buffer_mark = turn.buffer.length
287
+ generation.response = with_generation(turn.cancel_controller) do |generation_controller|
288
+ request_generation(
289
+ request.prompt,
290
+ generation_controller: generation_controller,
291
+ cancel_controller: turn.cancel_controller,
292
+ model_name: turn.model_name,
293
+ sampling: turn.empty_retry.request_sampling(@sampling),
294
+ on_chunk: ->(chunk) { stream_chunk(turn, generation, stream_splitter, chunk) },
295
+ on_retry: lambda { |retry_event|
296
+ # The retry streams from the start: its counts replace these.
297
+ generation.usage = nil
298
+ generation.streamed_thinking = 0
299
+ generation.finish_reason = nil
300
+ turn.emit.call({ type: :generation_retrying, iteration: turn.iteration }.merge(retry_event)) if turn.on_stream_event
301
+ },
302
+ images: request.images
404
303
  )
405
- return cancelled_result(conversation, tool_activity: tool_activity, reason: e.reason, partial_assistant_text: partial_assistant_buffer)
304
+ rescue Client::RequestCancelled
305
+ raise unless cut?(generation_controller, turn.cancel_controller)
306
+
307
+ generation.cut = generation_controller.detail || {}
308
+ nil
406
309
  end
310
+ generation.cut ? cut_generation(turn, generation, buffer_mark) : completed_generation(turn, request, generation)
311
+ generation
312
+ end
313
+
314
+ def stream_chunk(turn, generation, stream_splitter, chunk)
315
+ generation.usage = turn.context.capture(chunk[:payload]) || generation.usage
316
+ # llama.cpp names the loaded model in the stream's last payload.
317
+ named = chunk[:payload]["model"] if chunk[:payload].is_a?(Hash)
318
+ generation.served_model = named if named.is_a?(String) && !named.strip.empty?
319
+ generation.finish_reason = chunk[:finish_reason] if chunk[:finish_reason]
320
+ split = stream_splitter.feed(chunk[:content])
321
+ turn.buffer << split[:text]
322
+ generation.streamed_thinking += split[:thinking].to_s.length
323
+ return unless turn.on_stream_event
324
+
325
+ emit(turn, type: :generation_chunk, iteration: turn.iteration, content: chunk[:content], text: split[:text],
326
+ thinking: split[:thinking], payload: chunk[:payload])
327
+ end
328
+
329
+ # The cut stream's visible text goes with it: the buffer as it was
330
+ # before (the next generation gets a fresh splitter).
331
+ def cut_generation(turn, generation, buffer_mark)
332
+ turn.buffer.slice!(buffer_mark..)
333
+ emit(turn, type: :generation_completed, iteration: turn.iteration, content_length: 0,
334
+ thinking_chars: generation.streamed_thinking, served_model: generation.served_model,
335
+ requested_model: turn.model_name, finish_reason: "stopped", stopped_by: generation.cut[:by],
336
+ stop_reason: generation.cut[:reason])
337
+ end
338
+
339
+ def completed_generation(turn, request, generation)
340
+ response = generation.response.to_s
341
+ turn.context.generation_done(generation.usage, prompt_chars: request.prompt.length, image_tokens: request.image_tokens,
342
+ window: request.window)
343
+ emit(turn, type: :generation_completed, iteration: turn.iteration, content_length: response.length,
344
+ thinking_chars: thinking_chars(response, generation.streamed_thinking),
345
+ served_model: generation.served_model, requested_model: turn.model_name,
346
+ finish_reason: generation.finish_reason)
347
+ dump_log("response", generation.response, iteration: turn.iteration)
348
+ # The after_generation hook (after the model returns, before the tool
349
+ # parse), with a read-only copy of the conversation as sent.
350
+ return unless @hooks
351
+
352
+ fire_hook(:after_generation, { type: :after_generation, iteration: turn.iteration, response: generation.response,
353
+ messages: AnswerDisplay.strip_all(turn.conversation).map(&:dup).freeze })
354
+ end
407
355
 
408
- if pending_tool_calls && tool_response_turn?(conversation.last)
409
- exhausted = true
356
+ # A generation a plugin cut is an empty answer made early: asked again
357
+ # with its own nudge while the retry budget lasts (queued input goes in
358
+ # place of the nudge), else the turn ends as cancelled (hook), with
359
+ # nothing salvaged and without the spent nudge. A Stop that came right
360
+ # after the cut is a plain cancel. Returns :next or the turn's Result.
361
+ def after_cut(turn, generation)
362
+ cut = generation.cut
363
+ raise Client::RequestCancelled.new(turn.cancel_controller.reason) if turn.cancel_controller.cancelled?
364
+
365
+ if turn.empty_retry.left?
366
+ return :next if inject_pending_input!(turn)
367
+
368
+ turn.empty_retry.nudge!(turn.conversation, TurnNote.cut_retry(cut[:by], cut[:reason]),
369
+ emit: turn.emit, iteration: turn.iteration, finish_reason: "stopped",
370
+ thinking_chars: generation.streamed_thinking, stopped_by: cut[:by])
371
+ return :next
372
+ end
373
+ turn.empty_retry.drop_nudge!(turn.conversation)
374
+ turn.cancel_controller.cancel!(:hook, cut)
375
+ emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: :hook)
376
+ cancelled_result(turn.conversation, tool_activity: turn.tool_activity, reason: :hook, partial_assistant_text: "")
377
+ end
378
+
379
+ # A generation with no tool calls: a Qwen call left open is asked to be
380
+ # finished; an empty answer is asked again (EmptyAnswerRetry: not when
381
+ # a length stop came with the context full); queued input keeps the
382
+ # turn going (the model answers it); else it is the answer. Returns
383
+ # :next or :answer.
384
+ def after_text(turn, request, generation)
385
+ turn.pending_tool_calls = false
386
+ if turn.qwen_partial && turn.qwen_attempts < QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_LIMIT
387
+ turn.qwen_attempts += 1
388
+ turn.conversation << { role: "user", content: QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_PROMPT, preserve_literals: true }
389
+ return :next
410
390
  end
411
391
 
412
- output = strip_thought_blocks(last_model_content(conversation))
413
- # A turn stopped at the limit ends on a call it never ran: show only its text.
392
+ response = generation.response
393
+ answer = -> { PromptLiteralGuard.restore(strip_thought_blocks(response), profile: @profile) }
394
+ empty = strip_thought_blocks(response.to_s).strip.empty?
395
+ retry_empty = empty && turn.empty_retry.retry_empty?(iteration: turn.iteration,
396
+ cancelled: turn.cancel_controller&.cancelled?,
397
+ finish_reason: generation.finish_reason,
398
+ used_tokens: generation.usage&.dig(:total_tokens),
399
+ window_tokens: request.window.tokens)
400
+ # The empty generation goes (its thinking would be sent again and
401
+ # prime the same loop); an empty answer that will be retried is no
402
+ # answer site, so a plugin's steer joins the retry.
403
+ turn.conversation.pop if retry_empty
404
+ # Queued steering keeps the turn going: the model answers the
405
+ # injected message instead of stopping here.
406
+ return :next if inject_pending_input!(turn, answer: retry_empty ? nil : answer)
407
+
408
+ if retry_empty
409
+ turn.empty_retry.nudge!(turn.conversation, TurnNote.empty_retry,
410
+ emit: turn.emit, iteration: turn.iteration, finish_reason: generation.finish_reason,
411
+ thinking_chars: thinking_chars(response.to_s, generation.streamed_thinking))
412
+ return :next
413
+ end
414
+ # The Engine's TurnNote.empty says it all: the spent nudge goes.
415
+ turn.empty_retry.drop_nudge!(turn.conversation) if empty && turn.empty_retry.used?
416
+ :answer
417
+ end
418
+
419
+ # The batch runs (ToolResponse) and its results go back as one entry.
420
+ def dispatch_calls(turn, calls)
421
+ turn.qwen_attempts = 0
422
+ turn.qwen_partial = nil
423
+ runs = ToolResponse.run_batch(tool_runner, calls, iteration: turn.iteration, emit: turn.emit,
424
+ on_stream_event: turn.on_stream_event, cap: turn.cap)
425
+ turn.tool_activity.concat(ToolResponse.activities(runs))
426
+ # One entry for the batch: the full outputs joined (ToolResponse.joined).
427
+ turn.conversation << ToolResponse.joined(runs)
428
+ turn.pending_tool_calls = true
429
+ :next
430
+ end
431
+
432
+ # The turn's Result. It is exhausted only when it stopped on tool
433
+ # results it never answered; then the answer shows only its text.
434
+ def finish(turn)
435
+ exhausted = turn.pending_tool_calls && tool_response_turn?(turn.conversation.last)
436
+ output = strip_thought_blocks(last_model_content(turn.conversation))
414
437
  output = parser.strip_tool_calls(output) if exhausted
415
438
  Result.new(
416
439
  output: PromptLiteralGuard.restore(output, profile: @profile),
417
- conversation: duplicate_conversation(conversation),
440
+ conversation: duplicate_conversation(turn.conversation),
418
441
  exhausted: exhausted,
419
- pending_tool_calls: pending_tool_calls,
420
- tool_activity: tool_activity,
442
+ pending_tool_calls: turn.pending_tool_calls,
443
+ tool_activity: turn.tool_activity,
421
444
  canceled: false,
422
445
  cancellation_reason: nil,
423
- context_status: context.display
446
+ context_status: turn.context.display
424
447
  )
425
- rescue StandardError => e
426
- LLM::FailedTurn.attach(e, conversation && duplicate_conversation(conversation))
427
- raise
428
448
  end
429
449
 
430
- # Use a resolved profile (ModelProfile::Resolution) from now on,
431
- # whatever model name later runs carry.
432
- def use_profile!(resolution)
433
- @profile = resolution.profile
434
- @profile_source = resolution.label
450
+ def emit(turn, **event)
451
+ emit_stream_event(turn.on_stream_event, event)
435
452
  end
436
453
 
437
- def sync_model_key!(key)
438
- @model_key = key
454
+ # Queued input at an iteration boundary (Steer.inject!); +answer+ is a
455
+ # proc, built only on a merge. Returns true when anything was injected.
456
+ def inject_pending_input!(turn, answer: nil)
457
+ Steer.inject!(turn.conversation, turn.pending_input, iteration: turn.iteration, emit: turn.emit,
458
+ cancel_controller: turn.cancel_controller, answer: answer)
439
459
  end
440
460
 
441
- private
442
-
443
461
  # memory_read with the session's mutes applied: a blank name (the index)
444
462
  # loses the muted memories' lines; a muted name in a comma list is
445
463
  # refused with its own error line and the rest is read as usual.
446
464
  def muted_memory_read(tool, call)
447
465
  muted = Array(@muted_memory_names)
448
466
  content = call[:content].to_s
449
- read = ->(names) { tool.call(names, scope: call[:scope], model_key: @model_key) }
467
+ read = ->(names) { tool.call(names, scope: call[:scope], model_key: @model_key, fallback_model_key: @model_key_fallback) }
450
468
  return read.call(content) if muted.empty?
451
469
  return MutedMemories.filter_index(read.call(content), muted) if content.strip.empty?
452
470
 
@@ -466,34 +484,6 @@ module Samagotchi
466
484
  nil
467
485
  end
468
486
 
469
- # Drain the pending input queue (if any) and, when messages are waiting,
470
- # append them as ONE merged user message at the conversation tail and emit
471
- # :pending_input_merged. Tail-append only: head mutation would invalidate
472
- # the server-side prefix KV cache. Returns true when a message was injected.
473
- # A plugin's steers (Steer) follow the user's message, each its own
474
- # message; after an answer the Engine's drain has already dropped them.
475
- # After a cancel the input stays queued: it runs as the next turn instead
476
- # of dying with this one. +answer+ (a proc, called only on a merge) is the
477
- # answer the merge follows: the UIs show it, the turn summary has only the
478
- # last one.
479
- def inject_pending_input!(conversation, pending_input, on_stream_event, iteration, cancel_controller = nil, answer: nil)
480
- return false unless pending_input
481
- return false if cancel_controller&.cancelled?
482
-
483
- merge = Steer.merge(Steer.drain(pending_input, at_answer: !answer.nil?))
484
- return false if merge.empty?
485
-
486
- answer = answer.call.to_s if answer
487
- conversation.concat(merge.messages)
488
- emit_stream_event(
489
- on_stream_event,
490
- type: :pending_input_merged,
491
- iteration: iteration,
492
- **merge.event_fields,
493
- answer: answer.to_s.strip.empty? ? nil : answer
494
- )
495
- true
496
- end
497
487
 
498
488
  # ── Hook dispatch helper ───────────────────────────────────────────────────
499
489
 
@@ -553,7 +543,8 @@ module Samagotchi
553
543
  generation_controller&.cancelled? && !cancel_controller.cancelled?
554
544
  end
555
545
 
556
- def complete_kwargs(cancel_controller:, model_name: nil, on_chunk: nil, on_retry: nil, images: [])
546
+ # +sampling+: this request's (EmptyAnswerRetry#request_sampling).
547
+ def complete_kwargs(cancel_controller:, model_name: nil, on_chunk: nil, on_retry: nil, images: [], sampling: nil)
557
548
  kwargs = {}
558
549
  # Only a request with images names them: a text-only call is unchanged.
559
550
  kwargs[:images] = images unless images.empty?
@@ -565,8 +556,6 @@ module Samagotchi
565
556
  kwargs[:n_predict] = n_predict if n_predict && client_supports_keyword?(:n_predict)
566
557
  resolved_model_name = completion_model_name(model_name)
567
558
  kwargs[:model] = resolved_model_name if resolved_model_name && client_supports_keyword?(:model)
568
- sampling = @retry_generation ? EmptyAnswerRetry.sampling(@sampling) : @sampling
569
- @retry_generation = false
570
559
  kwargs[:sampling] = sampling if sampling && !sampling.empty? && client_supports_keyword?(:sampling)
571
560
  kwargs
572
561
  end
@@ -627,7 +616,7 @@ module Samagotchi
627
616
  # :context_status event. The telemetry is not for the model (it used to
628
617
  # be injected as a synthetic system message).
629
618
  def emit_context_status_event(on_stream_event, context, prompt, iteration_index:, window:, image_tokens: 0)
630
- event = context.observe(prompt, iteration_index: iteration_index, window: window, image_tokens: image_tokens)
619
+ event = context.observe(prompt.length, iteration_index: iteration_index, window: window, image_tokens: image_tokens)
631
620
  return unless event
632
621
 
633
622
  emit_stream_event(on_stream_event, type: :context_status, iteration: iteration_index + 1, **event)
@@ -645,8 +634,7 @@ module Samagotchi
645
634
  end
646
635
 
647
636
  # The status line's context value ({est_pct:, bucket:}) for +used_tokens+
648
- # of +window_tokens+; nil without both, or with context.status off. The
649
- # chat loop builds its value with it too.
637
+ # of +window_tokens+; nil without both, or with context.status off.
650
638
  def context_display(used_tokens:, window_tokens:)
651
639
  ContextStatus.new.display_for(used_tokens: used_tokens, window_tokens: window_tokens)
652
640
  end
@@ -696,11 +684,6 @@ module Samagotchi
696
684
  messages.map(&:dup)
697
685
  end
698
686
 
699
- def drop_last_empty_retry!(conversation)
700
- index = conversation.rindex { |entry| TurnNote.retry_nudge?(entry) }
701
- conversation.delete_at(index) if index
702
- end
703
-
704
687
  def last_model_content(conversation)
705
688
  message = conversation.reverse.find { |entry| entry[:role] == "model" }
706
689
  message ? message[:content].to_s : ""