samagotchi 0.54.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/bin/chi +3 -2
  4. data/docs/architecture.md +6 -2
  5. data/docs/cli.md +29 -7
  6. data/docs/configuration.md +113 -8
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/internals/context-telemetry.md +23 -5
  9. data/docs/internals/engine.md +54 -18
  10. data/docs/internals/prompt-caching.md +8 -0
  11. data/docs/sessions.md +2 -2
  12. data/docs/testing.md +23 -3
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +6 -6
  14. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  15. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  16. data/lib/samagotchi/cache_confirm.rb +51 -0
  17. data/lib/samagotchi/client_id.rb +12 -0
  18. data/lib/samagotchi/config.rb +9 -3
  19. data/lib/samagotchi/engine.rb +102 -8
  20. data/lib/samagotchi/engine_peers.rb +1 -0
  21. data/lib/samagotchi/file_ref.rb +59 -0
  22. data/lib/samagotchi/host_config.rb +6 -3
  23. data/lib/samagotchi/host_registry.rb +4 -3
  24. data/lib/samagotchi/kernel_loop.rb +99 -16
  25. data/lib/samagotchi/llm/chat_loop.rb +122 -71
  26. data/lib/samagotchi/llm/openai_chat.rb +2 -1
  27. data/lib/samagotchi/llm/stream_drops.rb +63 -0
  28. data/lib/samagotchi/llm/turn_settings.rb +9 -5
  29. data/lib/samagotchi/model_settings.rb +5 -2
  30. data/lib/samagotchi/reasoning_history.rb +132 -0
  31. data/lib/samagotchi/reasoning_history_command.rb +107 -0
  32. data/lib/samagotchi/self_report.rb +21 -0
  33. data/lib/samagotchi/session.rb +23 -2
  34. data/lib/samagotchi/session_commands.rb +55 -15
  35. data/lib/samagotchi/session_manager.rb +2 -1
  36. data/lib/samagotchi/session_setup.rb +7 -5
  37. data/lib/samagotchi/setting_explained.rb +20 -0
  38. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -1
  39. data/lib/samagotchi/terminal_ui/attached_loop.rb +11 -0
  40. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  41. data/lib/samagotchi/terminal_ui.rb +13 -6
  42. data/lib/samagotchi/thinking.rb +20 -25
  43. data/lib/samagotchi/thinking_command.rb +33 -11
  44. data/lib/samagotchi/tool_row_fields.rb +46 -0
  45. data/lib/samagotchi/tool_runner.rb +7 -11
  46. data/lib/samagotchi/tools/builtins.rb +6 -2
  47. data/lib/samagotchi/tools/peers.rb +4 -2
  48. data/lib/samagotchi/tools/task_runtime.rb +2 -1
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +48 -4
  51. data/lib/samagotchi/web/capabilities.rb +13 -9
  52. data/lib/samagotchi/web/editor.rb +38 -0
  53. data/lib/samagotchi/web/message_parts.rb +5 -10
  54. data/lib/samagotchi/web/public/activity.js +33 -18
  55. data/lib/samagotchi/web/public/app.js +256 -1133
  56. data/lib/samagotchi/web/public/commands.js +269 -0
  57. data/lib/samagotchi/web/public/composer.js +105 -0
  58. data/lib/samagotchi/web/public/diff_view.js +30 -4
  59. data/lib/samagotchi/web/public/index.html +11 -3
  60. data/lib/samagotchi/web/public/info_bar.js +80 -0
  61. data/lib/samagotchi/web/public/level_chip.js +174 -0
  62. data/lib/samagotchi/web/public/lists_view.js +725 -0
  63. data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
  64. data/lib/samagotchi/web/public/model_pick.js +1 -1
  65. data/lib/samagotchi/web/public/question_cards.js +2 -1
  66. data/lib/samagotchi/web/public/reasoning_history_chip.js +49 -0
  67. data/lib/samagotchi/web/public/refs.js +134 -0
  68. data/lib/samagotchi/web/public/restore.js +81 -0
  69. data/lib/samagotchi/web/public/session_state.js +57 -0
  70. data/lib/samagotchi/web/public/stream_handlers.js +332 -0
  71. data/lib/samagotchi/web/public/thinking_chip.js +38 -144
  72. data/lib/samagotchi/web/public/turn_events.js +17 -258
  73. data/lib/samagotchi/web/public/turn_html.js +5 -1
  74. data/lib/samagotchi/web/server.rb +4 -4
  75. data/lib/samagotchi/worker.rb +17 -5
  76. metadata +18 -2
  77. data/lib/samagotchi/web/public/stable_html.js +0 -18
@@ -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
@@ -8,6 +8,7 @@ require_relative "errors"
8
8
  require_relative "usage"
9
9
  require_relative "openai_chat"
10
10
  require_relative "native_tool_normalizer"
11
+ require_relative "stream_drops"
11
12
  require_relative "../kernel_loop"
12
13
  require_relative "../answer_display"
13
14
  require_relative "../cut_policy"
@@ -44,6 +45,8 @@ module Samagotchi
44
45
  # Cancel closes the request's socket (LLM::HTTP), so it works on any
45
46
  # thread; the text streamed so far is kept, marked [interrupted].
46
47
  class ChatLoop < ModelBackend
48
+ include LLM::StreamDrops
49
+
47
50
  attr_accessor :adapter
48
51
  # The session every request is for; Engine sets it with the adapter.
49
52
  # Goes out as a Session-Id header (OpenAIChat#chat).
@@ -63,42 +66,6 @@ module Samagotchi
63
66
  @sleeper = sleeper || ->(seconds) { sleep(seconds) }
64
67
  end
65
68
 
66
- # How many times a dropped stream's step may be asked again: +cap+, or
67
- # retry.max when that is smaller (retry.max: 0 turns it off, as it
68
- # does the transport's retries).
69
- def stream_drop_limit(cap)
70
- [cap, HTTP::RetryPolicy.from_config.max].min
71
- rescue StandardError
72
- cap
73
- end
74
-
75
- # The seconds before the +attempt+-th ask-again of a dropped stream's
76
- # step: the transport's own backoff (retry.base_delay doubling, capped
77
- # at retry.max_delay; LLM::HTTP::RetryPolicy), 0 when retry.max is
78
- # spent.
79
- def generation_retry_delay(attempt)
80
- HTTP::RetryPolicy.from_config.delay_for(attempt) || 0.0
81
- rescue StandardError
82
- 0.0
83
- end
84
-
85
- # Waits +seconds+ in short slices, raising RequestCancelled as soon as
86
- # one of +controllers+ (the turn's, the generation's) is cancelled, as
87
- # LLM::HTTP's own wait between retries does.
88
- def wait_retry(seconds, *controllers)
89
- remaining = seconds.to_f
90
- while remaining.positive?
91
- cancelled = controllers.compact.find(&:cancelled?)
92
- raise RequestCancelled, cancelled.reason if cancelled
93
-
94
- slice = [remaining, 0.05].min
95
- @sleeper.call(slice)
96
- remaining -= slice
97
- end
98
- cancelled = controllers.compact.find(&:cancelled?)
99
- raise RequestCancelled, cancelled.reason if cancelled
100
- end
101
-
102
69
  def provider = :chat
103
70
 
104
71
  def complete(messages:, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil,
@@ -164,11 +131,17 @@ module Samagotchi
164
131
  # as one user message "[images from tool results]". Images the request
165
132
  # leaves out (ImagePlan) become placeholder lines in the text.
166
133
  #
134
+ # A model turn carries its saved reasoning as reasoning_content when
135
+ # +reasoning+ (a ReasoningHistory level; the turn's by default) says
136
+ # so: under turn the running turn's only (#reasoning_boundary, with
137
+ # +since+ the Run's start), under all every one.
138
+ #
167
139
  # The conversation goes through the kernel's LLMContextView first,
168
- # as the native prompt does.
169
- def wire_messages(conversation)
140
+ # as the native prompt does (it keeps every entry in place).
141
+ def wire_messages(conversation, since: nil, reasoning: reasoning_history)
170
142
  conversation = llm_context_view.messages(conversation)
171
143
  paired = paired_call_ids(conversation)
144
+ boundary = reasoning_boundary(conversation, since: since, level: reasoning)
172
145
  plan = ImagePlan.new(conversation, vision)
173
146
  tool_images = []
174
147
  wire = []
@@ -182,7 +155,9 @@ module Samagotchi
182
155
  content = Steer.header(entry) ? Steer.wire_text(entry, entry[:content].to_s) : entry[:content]
183
156
  wire << { role: "user", content: parts(content, items) }
184
157
  when "model"
185
- wire << assistant_message(entry, paired)
158
+ message = assistant_message(entry, paired)
159
+ message[:reasoning_content] = entry[:thinking] if boundary && index >= boundary && thought?(entry)
160
+ wire << message
186
161
  when "tool_response"
187
162
  id = entry[:tool_call_id]
188
163
  if id && paired.include?(id)
@@ -215,6 +190,44 @@ module Samagotchi
215
190
 
216
191
  TOOL_IMAGES_TEXT = "[images from tool results]"
217
192
 
193
+ # The first index whose model entries carry their reasoning (nil:
194
+ # none do) under +level+. Under turn: past both the last query (a
195
+ # user message: a prompt, a steer, merged input, the chat template's
196
+ # last query; or an entry that starts a turn, turn_start) and +since+
197
+ # (the Run's start, so a continue turn -- a reminder, a context wake,
198
+ # a continue after a stop -- sends none of the run before it).
199
+ # @return [Integer, nil]
200
+ def reasoning_boundary(conversation, since: nil, level: reasoning_history)
201
+ case level
202
+ when :all then 0
203
+ when :turn
204
+ query = conversation.rindex { |entry| entry[:role].to_s == "user" || entry[:turn_start] == true }
205
+ [query ? query + 1 : 0, since.to_i].max
206
+ end
207
+ end
208
+
209
+ # The reasoning +conversation+'s wire messages carry from +since+ on
210
+ # (#reasoning_boundary), in characters.
211
+ def reasoning_chars(conversation, since: nil, reasoning: reasoning_history)
212
+ boundary = reasoning_boundary(conversation, since: since, level: reasoning) or return 0
213
+ conversation.drop(boundary).sum { |entry| entry[:role].to_s == "model" && thought?(entry) ? entry[:thinking].length : 0 }
214
+ end
215
+
216
+ # The turn's ReasoningHistory level (the kernel's turn_settings); drop
217
+ # without settings, and for a +model+ whose host refused it
218
+ # (#reasoning_history_refused!).
219
+ def reasoning_history(model = nil)
220
+ return :drop if model && @reasoning_history_refused&.include?(model)
221
+
222
+ @kernel.turn_settings&.reasoning_history || :drop
223
+ end
224
+
225
+ # The host refused +model+'s reasoning sent back: send none from now on
226
+ # (this worker's lifetime, as #thinking_refused!).
227
+ def reasoning_history_refused!(model)
228
+ (@reasoning_history_refused ||= Set.new) << model
229
+ end
230
+
218
231
  # The turn's VisionContext (the Engine sets the kernel's
219
232
  # turn_settings), or nil.
220
233
  def vision = @kernel.turn_settings.vision
@@ -282,7 +295,7 @@ module Samagotchi
282
295
 
283
296
  # The shape the engine persists: role and content (parts stay an
284
297
  # Array), plus a model turn's tool_calls and thinking (the host's
285
- # reasoning, never sent back), a result's tool_call_id, the image
298
+ # reasoning, sent back as #wire_messages says), a result's tool_call_id, the image
286
299
  # refs of a user message or a tool result, a prompt's turn_id (Engine,
287
300
  # 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
288
301
  # (the live row's params line and label, never sent back), an
@@ -380,6 +393,8 @@ module Samagotchi
380
393
  notes.empty? ? text : [text, *notes].reject(&:empty?).join("\n")
381
394
  end
382
395
 
396
+ def thought?(entry) = entry[:thinking].is_a?(String) && !entry[:thinking].strip.empty?
397
+
383
398
  def scrub(text)
384
399
  text.encoding == Encoding::UTF_8 && !text.valid_encoding? ? text.scrub("?") : text
385
400
  end
@@ -394,6 +409,9 @@ module Samagotchi
394
409
  @cancel_controller = cancel_controller
395
410
  @model_name = model_name
396
411
  @pending_input = pending_input
412
+ # Where this run's entries start: the reasoning it sends back
413
+ # (ChatLoop#reasoning_boundary) is its own.
414
+ @start = conversation.length
397
415
  @tool_activity = []
398
416
  @empty_retry = EmptyAnswerRetry.new
399
417
  # The empty generations, never saved: the UIs draw the turn's
@@ -486,8 +504,8 @@ module Samagotchi
486
504
 
487
505
  # The host's reasoning, kept on the model message as +thinking+ for
488
506
  # the web turn view's reload (the whole of it, as the live view
489
- # shows). Only saved: #assistant_message builds the wire message from
490
- # content and tool_calls, so it never goes back to the model.
507
+ # shows), and sent back to the model as ChatLoop#wire_messages says
508
+ # (reasoning_history).
491
509
  def with_thinking(message, response)
492
510
  reasoning = response.reasoning.to_s
493
511
  reasoning.strip.empty? ? message : message.merge(thinking: reasoning)
@@ -510,18 +528,17 @@ module Samagotchi
510
528
  generation_controller = nil
511
529
  response = with_generation do |controller|
512
530
  generation_controller = controller
513
- # One count for the step: the ask-again without thinking fields
514
- # doesn't get drops of its own.
515
- drops = DropCount.new(0, 0)
531
+ # One count for the step: an ask-again without reasoning or
532
+ # thinking fields doesn't get drops of its own.
533
+ drops = LLM::StreamDrops::DropCount.new(0, 0)
534
+ ask = -> { request_with_drops(iteration, retry_generation, streamed, thought, drops, controller) }
516
535
  begin
517
- request_with_drops(iteration, retry_generation, streamed, thought, drops, controller)
536
+ ask.call
518
537
  rescue BadRequest => e
519
- raise unless thinking_refused?(e)
520
-
521
- # Once per model: asked again without the thinking fields.
522
- @loop.thinking_refused!(@model_name)
523
- emit(type: :thinking_refused, iteration: iteration, model: @model_name, level: @loop.thinking, detail: e.detail)
524
- request_with_drops(iteration, retry_generation, streamed, thought, drops, controller)
538
+ if refused?(e) && reasoning_sent? then without_reasoning(iteration, e, ask)
539
+ elsif thinking_refused?(e) then without_thinking(iteration, e, ask)
540
+ else raise
541
+ end
525
542
  end
526
543
  rescue RequestCancelled
527
544
  raise unless generation_controller&.cancelled? && !@cancel_controller.cancelled?
@@ -571,7 +588,7 @@ module Samagotchi
571
588
 
572
589
  def request(iteration, retry_generation, streamed, thought, generation_controller, streamed_any = nil)
573
590
  @loop.adapter.chat(
574
- messages: @loop.wire_messages(@conversation), tools: @loop.tool_definitions, model: @model_name,
591
+ messages: @loop.wire_messages(@conversation, since: @start, reasoning: reasoning_level), tools: @loop.tool_definitions, model: @model_name,
575
592
  cancel_controller: generation_controller || @cancel_controller, session_id: @loop.session_id,
576
593
  options: @loop.request_options(retry_generation: retry_generation, model: @model_name),
577
594
  on_delta: lambda { |content:, reasoning:, payload:|
@@ -596,16 +613,8 @@ module Samagotchi
596
613
  # none with 0), after the transport's backoff: the partial reply is
597
614
  # dropped and the step restarts. A refusal or a first-token timeout
598
615
  # fails as before, and a cancel (before or during the wait) never
599
- # retries.
600
- MAX_STREAM_DROPS = 2
601
-
602
- # A step's drops asked again (+drops+) and the HTTP requests made so
603
- # far (+requests+: each failed request's transport attempts), so the
604
- # error that ends the step counts every request, not the last one's.
605
- DropCount = Struct.new(:drops, :requests)
606
-
607
- # Runs one request, asking again on a mid-stream drop; +count+ (a
608
- # DropCount) carries on across calls for one step.
616
+ # retries. +count+ (a DropCount) carries on across calls for one
617
+ # step.
609
618
  def request_with_drops(iteration, retry_generation, streamed, thought, count, generation_controller)
610
619
  streamed_any = false
611
620
  request(iteration, retry_generation, streamed, thought, generation_controller, -> { streamed_any = true })
@@ -616,7 +625,7 @@ module Samagotchi
616
625
  raise RequestCancelled, cancelled.reason if cancelled
617
626
 
618
627
  count.requests += e.attempts.to_i
619
- limit = @loop.stream_drop_limit(MAX_STREAM_DROPS)
628
+ limit = @loop.stream_drop_limit(LLM::StreamDrops::MAX_STREAM_DROPS)
620
629
  if count.drops >= limit || !streamed_any
621
630
  raise if count.requests == e.attempts.to_i
622
631
 
@@ -642,11 +651,52 @@ module Samagotchi
642
651
  raise
643
652
  end
644
653
 
645
- # A 400 about reasoning, for a request that carried thinking fields
646
- # (not a missing-tools, image or context error).
647
- def thinking_refused?(error)
654
+ # A 400 about reasoning (not a missing-tools, image or context error).
655
+ def refused?(error)
648
656
  error.reasoning_refused? && !error.is_a?(VisionUnsupported) && !error.tools_unsupported? &&
649
- !error.context_overflow? && !@loop.thinking_fields(@model_name).empty?
657
+ !error.context_overflow?
658
+ end
659
+
660
+ # #refused?, for a request that carried thinking fields.
661
+ def thinking_refused?(error) = refused?(error) && !@loop.thinking_fields(@model_name).empty?
662
+
663
+ # Whether this step's request sends reasoning back.
664
+ def reasoning_sent?
665
+ !@without_reasoning && @loop.reasoning_chars(@conversation, since: @start, reasoning: reasoning_level).positive?
666
+ end
667
+
668
+ # The level this step's request sends reasoning back under: none
669
+ # while it is asked again without (#without_reasoning).
670
+ def reasoning_level = @without_reasoning ? :drop : @loop.reasoning_history(@model_name)
671
+
672
+ # The host refused a request that sent reasoning back: ask the step
673
+ # again without it. When that goes through, the model sends none from
674
+ # now on. When it is refused too, the thinking fields may be what the
675
+ # host refuses: #without_thinking asks a third time (still without
676
+ # reasoning; a later step sends it again, so a host that refuses
677
+ # only the thinking fields keeps it).
678
+ def without_reasoning(iteration, error, ask)
679
+ @without_reasoning = true
680
+ response = begin
681
+ ask.call
682
+ rescue BadRequest => e
683
+ raise unless thinking_refused?(e)
684
+
685
+ return without_thinking(iteration, e, ask)
686
+ end
687
+ @loop.reasoning_history_refused!(@model_name)
688
+ emit(type: :reasoning_history_refused, iteration: iteration, model: @model_name,
689
+ level: @loop.reasoning_history, detail: error.detail)
690
+ response
691
+ ensure
692
+ @without_reasoning = false
693
+ end
694
+
695
+ # Once per model: asked again without the thinking fields.
696
+ def without_thinking(iteration, error, ask)
697
+ @loop.thinking_refused!(@model_name)
698
+ emit(type: :thinking_refused, iteration: iteration, model: @model_name, level: @loop.thinking, detail: error.detail)
699
+ ask.call
650
700
  end
651
701
 
652
702
  # Before a request, as the native loop: estimate how full the window
@@ -671,10 +721,11 @@ module Samagotchi
671
721
 
672
722
  # The conversation's text as the request carries it, through the
673
723
  # kernel's LLMContextView (a stub counts, not the output it stands
674
- # for): contents (a parts list's text parts) and the tool calls'
675
- # names and arguments.
724
+ # for): contents (a parts list's text parts), the tool calls'
725
+ # names and arguments, and the reasoning sent back.
676
726
  def prompt_chars
677
- LLMContextView.chars(@loop.llm_context_view.messages(@conversation))
727
+ LLMContextView.chars(@loop.llm_context_view.messages(@conversation)) +
728
+ @loop.reasoning_chars(@conversation, since: @start, reasoning: reasoning_level)
678
729
  end
679
730
 
680
731
  # The server's counts for this request (prompt + answer): the status
@@ -333,10 +333,11 @@ module Samagotchi
333
333
  end
334
334
 
335
335
  # A String stays a String, an Array of parts an Array; both scrubbed
336
- # of invalid UTF-8 (Utf8Scrub).
336
+ # of invalid UTF-8 (Utf8Scrub), as is reasoning sent back.
337
337
  def wire_message(message)
338
338
  wire = message.to_h.transform_keys(&:to_sym)
339
339
  wire[:content] = format_content(wire[:content]) if wire.key?(:content)
340
+ wire[:reasoning_content] = format_content(wire[:reasoning_content]) if wire.key?(:reasoning_content)
340
341
  wire
341
342
  end
342
343
 
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "http"
5
+
6
+ module Samagotchi
7
+ module LLM
8
+ # A stream that dropped mid-answer is asked again from the same
9
+ # conversation: the same limit, backoff and cancel behaviour for both
10
+ # the chat loop and the native loop, so a native /completion and a chat
11
+ # request react identically to a dropped stream.
12
+ #
13
+ # The helpers the ask-again loop needs: how many times a dropped
14
+ # stream's step may be asked (stream_drop_limit), the transport's own
15
+ # backoff before the ask-again (generation_retry_delay), and the
16
+ # cancel-checking wait between the asks (wait_retry).
17
+ module StreamDrops
18
+ # The most times one step's dropped stream is asked again (both loops;
19
+ # stream_drop_limit lowers it under a smaller retry.max).
20
+ MAX_STREAM_DROPS = 2
21
+
22
+ # A step's drops asked again (+drops+) and the HTTP requests made so
23
+ # far (+requests+: each failed request's transport attempts), so the
24
+ # error that ends the step counts every request, not the last one's.
25
+ DropCount = Struct.new(:drops, :requests)
26
+
27
+ # How many times a dropped stream's step may be asked again: +cap+,
28
+ # or retry.max when that is smaller (retry.max: 0 turns it off, as it
29
+ # does the transport's retries).
30
+ def stream_drop_limit(cap)
31
+ [cap, HTTP::RetryPolicy.from_config.max].min
32
+ rescue StandardError
33
+ cap
34
+ end
35
+
36
+ # The seconds before the +attempt+-th ask-again of a dropped stream's
37
+ # step: the transport's own backoff (LLM::HTTP::RetryPolicy), 0 when
38
+ # retry.max is spent.
39
+ def generation_retry_delay(attempt)
40
+ HTTP::RetryPolicy.from_config.delay_for(attempt) || 0.0
41
+ rescue StandardError
42
+ 0.0
43
+ end
44
+
45
+ # Waits +seconds+ in short slices, raising RequestCancelled as soon as
46
+ # one of +controllers+ (the turn's, the generation's) is cancelled,
47
+ # as LLM::HTTP's own wait between retries does.
48
+ def wait_retry(seconds, *controllers)
49
+ remaining = seconds.to_f
50
+ while remaining.positive?
51
+ cancelled = controllers.compact.find(&:cancelled?)
52
+ raise RequestCancelled, cancelled.reason if cancelled
53
+
54
+ slice = [remaining, 0.05].min
55
+ @sleeper.call(slice)
56
+ remaining -= slice
57
+ end
58
+ cancelled = controllers.compact.find(&:cancelled?)
59
+ raise RequestCancelled, cancelled.reason if cancelled
60
+ end
61
+ end
62
+ end
63
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../thinking"
4
+ require_relative "../reasoning_history"
4
5
 
5
6
  module Samagotchi
6
7
  module LLM
@@ -8,17 +9,20 @@ module Samagotchi
8
9
  # ChatLoop) and ToolRunner: the turn's VisionContext (nil sends no
9
10
  # images, placeholders instead), its request parameters
10
11
  # (SamplingSettings.for; empty sends none), its thinking level
11
- # (Thinking.resolve), the bare model id the turn runs on (every debug
12
+ # (Thinking.resolve), which of its reasoning ChatLoop sends back
13
+ # (ReasoningHistory.resolve; ignored on the native loop), the bare model id the turn runs on (every debug
12
14
  # dump is tagged with it) and the model's or host's configured context
13
15
  # window (ContextWindow.setting, nil for none) and LLM context strategy
14
16
  # (LLMContextStrategy.resolve, nil for none), and its price on its host
15
17
  # (hosts.<name>.models.<id>.price, a ModelPrice; nil for none), what
16
18
  # SessionMetrics estimates an unreported cost from. The Engine sets them once
17
19
  # per turn, in Engine#generate, after the model's host is synced.
18
- TurnSettings = Data.define(:vision, :sampling, :thinking, :model_name, :window_setting, :llm_context, :price) do
19
- # No vision, no sampling, the default thinking, no model name.
20
- def self.none = new(vision: nil, sampling: {}.freeze, thinking: Thinking::DEFAULT, model_name: nil,
21
- window_setting: nil, llm_context: nil, price: nil)
20
+ TurnSettings = Data.define(:vision, :sampling, :thinking, :reasoning_history, :model_name, :window_setting,
21
+ :llm_context, :price) do
22
+ # No vision, no sampling, the default thinking, no reasoning sent
23
+ # back, no model name.
24
+ def self.none = new(vision: nil, sampling: {}.freeze, thinking: Thinking::DEFAULT, reasoning_history: :drop,
25
+ model_name: nil, window_setting: nil, llm_context: nil, price: nil)
22
26
  end
23
27
  end
24
28
  end
@@ -2,16 +2,18 @@
2
2
 
3
3
  require_relative "config"
4
4
  require_relative "llm_context_strategy"
5
+ require_relative "reasoning_history"
5
6
 
6
7
  module Samagotchi
7
8
  # One config.yml models: entry (per-model settings keyed by model id or
8
9
  # alias), parsed and checked; ConfigFile.model_settings holds them by
9
10
  # downcased key. Each field is nil when unset: profile kept as written
10
11
  # (downcased; ModelProfile.resolve validates it), vision true/false,
11
- # sampling a request-field map, thinking a level, window_tokens, and the
12
+ # sampling a request-field map, thinking a level, reasoning_history a
13
+ # ReasoningHistory level, window_tokens, and the
12
14
  # LLM context strategy, apply rule and budget (LLMContextStrategy). The
13
15
  # same per-model fields a hosts: entry carries (HostConfig).
14
- ModelSettings = Data.define(:profile, :vision, :sampling, :thinking, :window_tokens,
16
+ ModelSettings = Data.define(:profile, :vision, :sampling, :thinking, :reasoning_history, :window_tokens,
15
17
  :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens) do
16
18
  # Every field may be left out (nil).
17
19
  def initialize(**fields)
@@ -33,6 +35,7 @@ module Samagotchi
33
35
  vision: ConfigFile.vision_flag(value.call(:vision), where),
34
36
  sampling: ConfigFile.sampling_map(value.call(:sampling), where),
35
37
  thinking: Thinking.level(value.call(:thinking), where),
38
+ reasoning_history: ReasoningHistory.level(value.call(:reasoning_history), where),
36
39
  window_tokens: ConfigFile.window_tokens(value.call(:window_tokens), where),
37
40
  llm_context_strategy: LLMContextStrategy.parse(value.call(LLMContextStrategy::KEY), where),
38
41
  llm_context_apply: LLMContextStrategy.parse_apply(value.call(LLMContextStrategy::APPLY_KEY), where),