samagotchi 0.55.0 → 0.56.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -1
  3. data/docs/architecture.md +6 -2
  4. data/docs/cli.md +29 -7
  5. data/docs/configuration.md +84 -8
  6. data/docs/internals/background-tasks.md +1 -1
  7. data/docs/internals/engine.md +14 -0
  8. data/docs/internals/prompt-caching.md +8 -0
  9. data/docs/sessions.md +1 -1
  10. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  11. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  12. data/lib/samagotchi/cache_confirm.rb +51 -0
  13. data/lib/samagotchi/client_id.rb +12 -0
  14. data/lib/samagotchi/config.rb +7 -3
  15. data/lib/samagotchi/engine.rb +102 -8
  16. data/lib/samagotchi/engine_peers.rb +1 -0
  17. data/lib/samagotchi/host_config.rb +6 -3
  18. data/lib/samagotchi/host_registry.rb +4 -3
  19. data/lib/samagotchi/kernel_loop.rb +99 -16
  20. data/lib/samagotchi/llm/chat_loop.rb +122 -71
  21. data/lib/samagotchi/llm/openai_chat.rb +2 -1
  22. data/lib/samagotchi/llm/stream_drops.rb +63 -0
  23. data/lib/samagotchi/llm/turn_settings.rb +9 -5
  24. data/lib/samagotchi/model_settings.rb +5 -2
  25. data/lib/samagotchi/reasoning_history.rb +132 -0
  26. data/lib/samagotchi/reasoning_history_command.rb +107 -0
  27. data/lib/samagotchi/self_report.rb +21 -0
  28. data/lib/samagotchi/session.rb +23 -2
  29. data/lib/samagotchi/session_commands.rb +55 -15
  30. data/lib/samagotchi/session_manager.rb +2 -1
  31. data/lib/samagotchi/session_setup.rb +7 -5
  32. data/lib/samagotchi/setting_explained.rb +20 -0
  33. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -1
  34. data/lib/samagotchi/terminal_ui/attached_loop.rb +11 -0
  35. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  36. data/lib/samagotchi/terminal_ui.rb +13 -6
  37. data/lib/samagotchi/thinking.rb +20 -25
  38. data/lib/samagotchi/thinking_command.rb +33 -11
  39. data/lib/samagotchi/tools/builtins.rb +6 -2
  40. data/lib/samagotchi/tools/peers.rb +4 -2
  41. data/lib/samagotchi/tools/task_runtime.rb +2 -1
  42. data/lib/samagotchi/version.rb +1 -1
  43. data/lib/samagotchi/web/app.rb +23 -2
  44. data/lib/samagotchi/web/public/app.js +20 -1
  45. data/lib/samagotchi/web/public/commands.js +36 -3
  46. data/lib/samagotchi/web/public/composer.js +5 -1
  47. data/lib/samagotchi/web/public/index.html +6 -3
  48. data/lib/samagotchi/web/public/info_bar.js +5 -3
  49. data/lib/samagotchi/web/public/level_chip.js +174 -0
  50. data/lib/samagotchi/web/public/model_pick.js +1 -1
  51. data/lib/samagotchi/web/public/reasoning_history_chip.js +49 -0
  52. data/lib/samagotchi/web/public/session_state.js +5 -0
  53. data/lib/samagotchi/web/public/stream_handlers.js +10 -2
  54. data/lib/samagotchi/web/public/thinking_chip.js +33 -138
  55. data/lib/samagotchi/worker.rb +17 -5
  56. metadata +8 -1
@@ -62,6 +62,7 @@ require_relative "vision_context"
62
62
  require_relative "vision_support"
63
63
  require_relative "sampling_settings"
64
64
  require_relative "thinking"
65
+ require_relative "reasoning_history"
65
66
  require_relative "answer_display"
66
67
  require_relative "edit_preview"
67
68
  require_relative "question_desk"
@@ -1010,7 +1011,8 @@ module Samagotchi
1010
1011
  served, served_for = served_model_for(snapshot, target: target)
1011
1012
  snapshot = snapshot.merge(served_model: served, served_model_for: served_for,
1012
1013
  served_expected_by: served_expected_by(served, target: target), llm_context: llm_context_summary,
1013
- thinking: thinking_explained&.summary, prompt_notes: prompt_notes.map(&:to_h), price_host: target.entry.name)
1014
+ thinking: thinking_explained&.summary,
1015
+ reasoning_history: reasoning_history_explained&.summary, prompt_notes: prompt_notes.map(&:to_h), price_host: target.entry.name)
1014
1016
  unless snapshot.dig(:context, :window_tokens)
1015
1017
  window = current_context_window(target)
1016
1018
  if window
@@ -1093,16 +1095,80 @@ module Samagotchi
1093
1095
  end
1094
1096
 
1095
1097
  # The effective model's thinking level with where it came from and the
1096
- # session's own (Thinking::Explained), as the next turn resolves it;
1098
+ # session's own (SettingExplained), as the next turn resolves it;
1097
1099
  # nil when it can't be resolved.
1098
- # @return [Thinking::Explained, nil]
1099
- def thinking_explained
1100
+ # @return [SettingExplained, nil]
1101
+ # +own+: the session's own level to resolve with (the current one by
1102
+ # default; /thinking asks what a change would resolve to).
1103
+ def thinking_explained(own: thinking_override)
1100
1104
  target = @host_registry.resolve(@effective_model_name)
1101
- Thinking.explain(target, names: model_lookup_names(target), session: thinking_override)
1105
+ Thinking.explain(target, names: model_lookup_names(target), session: own)
1102
1106
  rescue StandardError
1103
1107
  nil
1104
1108
  end
1105
1109
 
1110
+ # "drop (models: qwen)" for /model: the effective chat model's
1111
+ # reasoning_history level and where it came from; nil when it is the
1112
+ # default, the model runs on the native loop (which ignores it) or it
1113
+ # can't be resolved.
1114
+ def reasoning_history_summary
1115
+ target = @host_registry.resolve(@effective_model_name)
1116
+ return nil unless target.entry&.chat?
1117
+
1118
+ level, source = ReasoningHistory.resolve(target, names: model_lookup_names(target),
1119
+ session: reasoning_history_override)
1120
+ source ? ReasoningHistory.label(level, source) : nil
1121
+ rescue StandardError
1122
+ nil
1123
+ end
1124
+
1125
+ # The effective chat model's reasoning_history level with where it came
1126
+ # from and the session's own (SettingExplained), as the next turn
1127
+ # resolves it; nil on a native host (the native loop ignores it; native:
1128
+ # true resolves it there too, for /reasoning-history) or when it can't
1129
+ # be resolved.
1130
+ # @return [SettingExplained, nil]
1131
+ # +own+: the session's own level to resolve with (the current one by
1132
+ # default; a command asks what a change would resolve to).
1133
+ def reasoning_history_explained(native: false, own: reasoning_history_override)
1134
+ target = @host_registry.resolve(@effective_model_name)
1135
+ return nil unless native || target.entry&.chat?
1136
+
1137
+ ReasoningHistory.explain(target, names: model_lookup_names(target), session: own)
1138
+ rescue StandardError
1139
+ nil
1140
+ end
1141
+
1142
+ # What a reasoning_history change from level +from+ to level +to+ costs
1143
+ # the effective model's prompt cache, given the session's conversation
1144
+ # (ReasoningHistory.cache_cost): :history or nil. A host ChatLoop
1145
+ # marked as refusing it may still get :history (one needless ask).
1146
+ # @return [Symbol, nil]
1147
+ def reasoning_history_cache_cost(from, to)
1148
+ target = @host_registry.resolve(@effective_model_name)
1149
+ ReasoningHistory.cache_cost(from, to, target: target, conversation: @session&.messages)
1150
+ rescue StandardError
1151
+ nil
1152
+ end
1153
+
1154
+ # Whether the session has a model reply (a cache to restart at all).
1155
+ def history?
1156
+ Array(@session&.messages).any? { |entry| entry[:role].to_s == "model" }
1157
+ end
1158
+
1159
+ # The session's own reasoning_history level, nil when it has none.
1160
+ # @return [Symbol, nil]
1161
+ def reasoning_history_override = @session&.reasoning_history
1162
+
1163
+ # Set the session's own reasoning_history level (nil unsets it); the
1164
+ # next turn's start resolves it. The caller saves the session.
1165
+ # @raise [ArgumentError] with no session yet
1166
+ def reasoning_history_override=(level)
1167
+ raise ArgumentError, "no session yet" unless @session
1168
+
1169
+ @session.reasoning_history = level
1170
+ end
1171
+
1106
1172
  # The session's own thinking level, nil when it has none.
1107
1173
  # @return [Symbol, nil]
1108
1174
  def thinking_override = @session&.thinking
@@ -1192,6 +1258,7 @@ module Samagotchi
1192
1258
  context_status: @last_context_status&.dup || saved_context_status(metrics[:context]),
1193
1259
  llm_context: llm_context_summary,
1194
1260
  thinking: thinking_explained&.summary,
1261
+ reasoning_history: reasoning_history_explained&.summary,
1195
1262
  recap_enabled: !@recap.nil?,
1196
1263
  recap_min_user_turns: @recap&.min_user_turns,
1197
1264
  recap_inactivity_seconds: @recap&.inactivity&.to_i
@@ -1926,7 +1993,8 @@ module Samagotchi
1926
1993
  thinking_target = @host_registry.resolve(@effective_model_name)
1927
1994
  @turn_thinking = [thinking_level(thinking_target), thinking_target]
1928
1995
  announce_thinking_level(*@turn_thinking)
1929
- LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: @turn_thinking.first, model_name: nil,
1996
+ LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: @turn_thinking.first,
1997
+ reasoning_history: reasoning_history_level(thinking_target), model_name: nil,
1930
1998
  window_setting: nil, llm_context: nil, price: nil)
1931
1999
  end
1932
2000
  private :turn_settings
@@ -2317,6 +2385,23 @@ module Samagotchi
2317
2385
  nil
2318
2386
  end
2319
2387
 
2388
+ # The host refused the model's reasoning sent back (reasoning_history)
2389
+ # and the chat loop asked the step again without it, as it will from
2390
+ # now on: logged, and said once per session and host.
2391
+ def reasoning_history_refused(event)
2392
+ _level, target = @turn_thinking
2393
+ return unless target
2394
+
2395
+ Log.warn(:model, "reasoning_history_refused", level: event[:level], host: target.entry.name, model: event[:model],
2396
+ detail: event[:detail])
2397
+ return unless (@thinking_notices ||= Set.new).add?([@session&.id, target.entry.name, :reasoning_history_refused])
2398
+
2399
+ hook_notify("#{target.entry.name} refused reasoning sent back (reasoning_history: #{event[:level]}) for " \
2400
+ "#{event[:model]} (#{event[:detail]}); sent without it from now on", :warn, "reasoning history")
2401
+ rescue StandardError
2402
+ nil
2403
+ end
2404
+
2320
2405
  def thinking_notice_once(kind, target, level, text)
2321
2406
  key = [@session&.id, target.entry.name, kind]
2322
2407
  return unless (@thinking_notices ||= Set.new).add?(key)
@@ -2324,6 +2409,14 @@ module Samagotchi
2324
2409
  hook_notify(text, level, "thinking")
2325
2410
  end
2326
2411
 
2412
+ # +target+'s ReasoningHistory level, the session's own first; the
2413
+ # default when it can't be resolved.
2414
+ def reasoning_history_level(target)
2415
+ ReasoningHistory.resolve(target, names: model_lookup_names(target), session: reasoning_history_override).first
2416
+ rescue StandardError
2417
+ ReasoningHistory::DEFAULT
2418
+ end
2419
+
2327
2420
  # +target+'s thinking level (Thinking.resolve), the session's own first.
2328
2421
  def thinking_level(target)
2329
2422
  Thinking.resolve(target, names: model_lookup_names(target), session: thinking_override).first
@@ -2602,9 +2695,10 @@ module Samagotchi
2602
2695
  end
2603
2696
  when :generation_chunk then note_generation_thought(progress[:thinking])
2604
2697
  end
2605
- # The chat loop asked again without the thinking fields: a notice,
2606
- # not an event of its own.
2698
+ # The chat loop asked again without the thinking fields or the
2699
+ # reasoning sent back: a notice, not an event of its own.
2607
2700
  next thinking_refused(event) if event[:type] == :thinking_refused
2701
+ next reasoning_history_refused(event) if event[:type] == :reasoning_history_refused
2608
2702
 
2609
2703
  event = with_generation_report(event) if event[:type] == :generation_completed
2610
2704
  emit_event(on_event, event)
@@ -18,6 +18,7 @@ module Samagotchi
18
18
  def relay = engine.relay_peer
19
19
  def model_ref = engine.effective_model_ref
20
20
  def thinking = engine.thinking_override
21
+ def reasoning_history = engine.reasoning_history_override
21
22
  end
22
23
 
23
24
  # What the approval relay needs from the parent's Engine (Peers#relay):
@@ -4,6 +4,7 @@ require "uri"
4
4
  require_relative "config"
5
5
  require_relative "host_model"
6
6
  require_relative "llm_context_strategy"
7
+ require_relative "reasoning_history"
7
8
 
8
9
  module Samagotchi
9
10
  # One config.yml hosts: entry, parsed and checked (ConfigFile.hosts_config
@@ -17,8 +18,9 @@ module Samagotchi
17
18
  # api: symbols, nil when unset; the rest as HostRegistry::HostEntry
18
19
  # describes them, nil when unset; models: {downcased id => HostModel}.
19
20
  HostConfig = Data.define(:name, :host, :port, :scheme, :url, :transport, :api, :api_key_env, :profile,
20
- :first_token_timeout, :vision, :sampling, :thinking, :remote, :window_tokens,
21
- :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens, :models) do
21
+ :first_token_timeout, :vision, :sampling, :thinking, :reasoning_history, :remote,
22
+ :window_tokens, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
23
+ :models) do
22
24
  # Every field but name may be left out: nil, models {}.
23
25
  def initialize(**fields)
24
26
  super(**members.to_h { |m| [m, nil] }, models: {}, **fields)
@@ -50,6 +52,7 @@ module Samagotchi
50
52
  window_tokens: ConfigFile.window_tokens(value.call(:window_tokens), where),
51
53
  sampling: ConfigFile.sampling_map(value.call(:sampling), where),
52
54
  thinking: Thinking.level(value.call(:thinking), where),
55
+ reasoning_history: ReasoningHistory.level(value.call(:reasoning_history), where),
53
56
  llm_context_strategy: LLMContextStrategy.parse(value.call(LLMContextStrategy::KEY), where),
54
57
  llm_context_apply: LLMContextStrategy.parse_apply(value.call(LLMContextStrategy::APPLY_KEY), where),
55
58
  models: HostModel.parse_map(value.call(:models), name),
@@ -166,7 +169,7 @@ module Samagotchi
166
169
  location.merge("transport" => transport&.to_s, "api" => api&.to_s, "api_key_env" => api_key_env,
167
170
  "profile" => profile, "first_token_timeout" => first_token_timeout,
168
171
  "vision" => vision, "sampling" => sampling, "thinking" => thinking&.to_s,
169
- "remote" => remote, "window_tokens" => window_tokens,
172
+ "reasoning_history" => reasoning_history&.to_s, "remote" => remote, "window_tokens" => window_tokens,
170
173
  "llm_context_strategy" => llm_context_strategy&.map(&:to_s),
171
174
  "llm_context_apply" => llm_context_apply&.to_s,
172
175
  "llm_context_budget_tokens" => llm_context_budget_tokens,
@@ -35,6 +35,7 @@ module Samagotchi
35
35
  # vision: the configured true/false (VisionSupport), nil when unset;
36
36
  # sampling: the configured request parameters (SamplingSettings), nil when unset;
37
37
  # thinking: the configured level (Thinking), nil when unset;
38
+ # reasoning_history: the configured level (ReasoningHistory), nil when unset;
38
39
  # remote: the configured true/false (#remote?), nil when unset;
39
40
  # window_tokens: the configured context window (ContextWindow.setting), nil when unset;
40
41
  # llm_context_strategy: the configured layers (LLMContextStrategy), nil when unset;
@@ -42,9 +43,9 @@ module Samagotchi
42
43
  # llm_context_budget_tokens: the configured context budget (LLMContextStrategy), nil when unset;
43
44
  # models: the ids the host serves whatever it lists, {downcased id => HostModel} ({} when none).
44
45
  HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :profile,
45
- :first_token_timeout, :vision, :sampling, :thinking, :remote, :window_tokens,
46
- :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens, :models,
47
- keyword_init: true) do
46
+ :first_token_timeout, :vision, :sampling, :thinking, :reasoning_history, :remote,
47
+ :window_tokens, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
48
+ :models, keyword_init: true) do
48
49
  # Talks the OpenAI chat API (the chat loop); nil and raw apis use the
49
50
  # raw-prompt loop.
50
51
  def chat? = api == :openai
@@ -32,6 +32,7 @@ require_relative "llm_context_notice"
32
32
  require_relative "answer_display"
33
33
  require_relative "llm/model_result"
34
34
  require_relative "llm/turn_settings"
35
+ require_relative "llm/stream_drops"
35
36
 
36
37
  module Samagotchi
37
38
  # The KernelLoop drives the model ↔ tool interaction cycle.
@@ -47,6 +48,8 @@ module Samagotchi
47
48
  # - Gemma 4: <|tool_call>call:NAME{params}<tool_call|>
48
49
  # - Qwen 3.6: <tool_call><function=NAME><parameter=KEY>VALUE</parameter></function></tool_call>
49
50
  class KernelLoop
51
+ include LLM::StreamDrops
52
+
50
53
  # The built-in tool classes (Tools::Builtins registers them).
51
54
  TOOLS = Tools::Builtins::CLASSES
52
55
 
@@ -113,8 +116,12 @@ module Samagotchi
113
116
 
114
117
  # @param tools [Tools::Registry, nil] the tools calls dispatch to (the
115
118
  # Engine's; nil: the built-ins alone)
116
- def initialize(client: nil, profile: nil, model_name: nil, hooks: nil, reminder_store: nil, model_key: nil, tools: nil)
119
+ # @param sleeper [#call, nil] waits the given seconds before a dropped
120
+ # stream's step is asked again (specs pass one that doesn't sleep)
121
+ def initialize(client: nil, profile: nil, model_name: nil, hooks: nil, reminder_store: nil, model_key: nil, tools: nil,
122
+ sleeper: nil)
117
123
  @client = client || Client.new
124
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
118
125
  @tools = tools || Tools::Builtins.default
119
126
  resolved_model_name = ModelProfile.required_model_name(model_name)
120
127
  # The turn's settings (LLM::TurnSettings). Its model name is the
@@ -349,9 +356,9 @@ module Samagotchi
349
356
  # Ends with its :generation_completed (and, unless cut, the
350
357
  # after_generation hook).
351
358
  def generate(turn, request)
352
- # Each generation splits its own stream: one that ended inside a
353
- # thought or a tool call doesn't leave the next one "inside" it.
354
- stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
359
+ # Each ask splits its own stream (run_generation rebuilds the
360
+ # splitter), so one that ended inside a thought or a tool call doesn't
361
+ # leave the next one "inside" it.
355
362
  emit(turn, type: :generation_started, iteration: turn.iteration, context_window_tokens: request.window.tokens,
356
363
  context_window_source: request.window.source, profile: @profile.name, profile_source: @profile_source,
357
364
  price: @turn_settings.price&.to_h)
@@ -369,14 +376,55 @@ module Samagotchi
369
376
  uncached = turn.uncached_next
370
377
  turn.uncached_next = false
371
378
  generation.response = with_generation(turn.cancel_controller) do |generation_controller|
379
+ run_generation(turn, request, generation, generation_controller, buffer_mark, uncached)
380
+ rescue Client::RequestCancelled
381
+ raise unless cut?(generation_controller, turn.cancel_controller)
382
+
383
+ generation.cut = generation_controller.detail || {}
384
+ nil
385
+ end
386
+ generation.cut ? cut_generation(turn, generation, buffer_mark) : completed_generation(turn, request, generation)
387
+ generation
388
+ end
389
+
390
+ # One generation's request, asking again on a mid-stream drop (LLM::HTTP
391
+ # raises RetryExhausted once a stream that already showed something
392
+ # fails, where a retry would repeat output) up to MAX_STREAM_DROPS
393
+ # times after the transport's backoff: the partial reply is dropped and
394
+ # the step restarts from the same conversation, as generation_retrying
395
+ # with restarted: true. A cancel (before or during the wait) never
396
+ # retries; a refusal or a first-token timeout fails as before.
397
+ def run_generation(turn, request, generation, generation_controller, buffer_mark, uncached)
398
+ # The empty-retry sampling is taken once, before the ask-again loop:
399
+ # a drop's restart must still carry the nudge's 0.6 (as ChatLoop does
400
+ # per step), not lose it when request_sampling is asked again.
401
+ sampling = turn.empty_retry.request_sampling(@turn_settings.sampling)
402
+ count = LLM::StreamDrops::DropCount.new(0, 0)
403
+ begin
404
+ # Each ask splits its own stream: a drop mid tool call (or mid
405
+ # thinking) leaves the splitter inside that block, so rebuilding it
406
+ # keeps the restarted stream's prose in the right lane.
407
+ stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
408
+ # Each ask starts with streamed_any = false, but once any chunk of
409
+ # this ask is shown it stays shown: a held-back partial marker after
410
+ # real text must not clear it, or the drop below won't ask again.
411
+ streamed_any = false
372
412
  request_generation(
373
413
  request.prompt,
374
414
  generation_controller: generation_controller,
375
415
  cancel_controller: turn.cancel_controller,
376
416
  model_name: turn.model_name,
377
- sampling: turn.empty_retry.request_sampling(@turn_settings.sampling),
417
+ sampling: sampling,
378
418
  cache_prompt: !uncached,
379
- on_chunk: ->(chunk) { stream_chunk(turn, generation, stream_splitter, chunk) },
419
+ on_chunk: lambda { |chunk|
420
+ # Shown the way ChatLoop counts it: anything that split into
421
+ # text or thinking was shown (a tool-call delta counts too, even
422
+ # though its bytes are dropped from both lanes). stream_chunk runs
423
+ # for every chunk (it splits the stream and emits the chunk event);
424
+ # streamed_any is sticky — once shown it stays shown, so a
425
+ # held-back partial marker after real text doesn't clear it.
426
+ streamed_any = true if stream_chunk(turn, generation, stream_splitter, chunk)
427
+ },
380
428
  on_retry: lambda { |retry_event|
381
429
  # The retry streams from the start: its counts replace these.
382
430
  generation.usage = nil
@@ -388,17 +436,54 @@ module Samagotchi
388
436
  },
389
437
  images: request.images
390
438
  )
391
- rescue Client::RequestCancelled
392
- raise unless cut?(generation_controller, turn.cancel_controller)
439
+ rescue LLM::RetryExhausted => e
440
+ # A cancel that landed as the stream drops ends the turn canceled,
441
+ # not failed, whatever the drop's count.
442
+ cancelled = [turn.cancel_controller, generation_controller].compact.find(&:cancelled?)
443
+ raise Client::RequestCancelled, cancelled.reason if cancelled
444
+
445
+ count.requests += e.attempts.to_i
446
+ limit = stream_drop_limit(LLM::StreamDrops::MAX_STREAM_DROPS)
447
+ if count.drops >= limit || !streamed_any
448
+ raise if count.requests == e.attempts.to_i
449
+
450
+ raise LLM::RetryExhausted.new(attempts: count.requests, last_error: e.last_error, label: e.host)
451
+ end
393
452
 
394
- generation.cut = generation_controller.detail || {}
395
- nil
453
+ count.drops += 1
454
+ attempt = count.drops
455
+ # restarted: the step's streamed chunks so far are void (the UIs and
456
+ # the Bridge's TurnAccumulator drop them); the same prompt goes
457
+ # again from where the step started, and the failed attempt's
458
+ # counts (mirroring the transport-retry reset) don't linger onto it.
459
+ turn.buffer.slice!(buffer_mark..)
460
+ turn.streamed_thinking = +""
461
+ generation.usage = nil
462
+ generation.cache = nil
463
+ generation.served_model = nil
464
+ generation.finish_reason = nil
465
+ generation.streamed_thinking = 0
466
+ delay = generation_retry_delay(attempt)
467
+ emit(turn, type: :generation_retrying, iteration: turn.iteration, attempt: attempt, max_retries: limit,
468
+ next_delay: delay, error_class: e.class.name, error_message: e.message, status: nil, restarted: true)
469
+ wait_retry(delay, turn.cancel_controller, generation_controller)
470
+ retry
471
+ rescue LLM::ProviderError => e
472
+ # Another failure (a 503, a refusal) after earlier requests this
473
+ # step: its count takes theirs in.
474
+ count.requests += e.attempts.to_i
475
+ e.attempts = count.requests
476
+ raise
396
477
  end
397
- generation.cut ? cut_generation(turn, generation, buffer_mark) : completed_generation(turn, request, generation)
398
- generation
399
478
  end
400
479
 
480
+ # Whether the chunk showed anything: a delta that split into text or
481
+ # thinking was shown, and a tool-call delta too (its bytes are dropped
482
+ # from both lanes, but it still counts). run_generation reads this to
483
+ # decide whether a dropped stream actually streamed its partial reply.
401
484
  def stream_chunk(turn, generation, stream_splitter, chunk)
485
+ split = stream_splitter.feed(chunk[:content])
486
+ shown = !split[:text].to_s.empty? || !split[:thinking].to_s.empty? || split[:tool]
402
487
  generation.usage = turn.context.capture(chunk[:payload]) || generation.usage
403
488
  note_cache_counts(generation, chunk[:payload])
404
489
  # llama.cpp names the loaded model in the stream's last payload.
@@ -407,18 +492,16 @@ module Samagotchi
407
492
  slot = chunk[:payload]["id_slot"] if chunk[:payload].is_a?(Hash)
408
493
  @last_slot = slot if slot.is_a?(Integer)
409
494
  generation.finish_reason = chunk[:finish_reason] if chunk[:finish_reason]
410
- split = stream_splitter.feed(chunk[:content])
411
495
  turn.buffer << split[:text]
412
496
  turn.streamed_thinking << split[:thinking].to_s
413
497
  generation.streamed_thinking += split[:thinking].to_s.length
414
- return unless turn.on_stream_event
415
-
416
498
  event = { type: :generation_chunk, iteration: turn.iteration, content: chunk[:content], text: split[:text],
417
499
  thinking: split[:thinking], payload: event_payload(chunk[:payload]) }
418
500
  # A chunk of a tool call (its bytes are dropped from both lanes): a
419
501
  # steer doesn't cut this generation (Engine#cut_for_steer).
420
502
  event[:tool_call] = true if split[:tool]
421
- emit(turn, **event)
503
+ emit(turn, **event) if turn.on_stream_event
504
+ shown
422
505
  end
423
506
 
424
507
  # The payload a :generation_chunk event carries: llama.cpp's last chunk