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
@@ -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),
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require_relative "setting_explained"
5
+
6
+ module Samagotchi
7
+ # Which of its own reasoning a chat host's model gets back
8
+ # (LLM::ChatLoop sends it as reasoning_content on its earlier replies):
9
+ #
10
+ # turn the running turn's replies only (the tool-call rounds since the
11
+ # last user message); what Qwen's, Gemma 4's and DeepSeek's
12
+ # templates and the hosted APIs expect
13
+ # drop none
14
+ # all every reply that has reasoning, earlier turns too (a direct
15
+ # DeepSeek API with tools wants this)
16
+ #
17
+ # Order (#resolve): the session's own level (/reasoning-history), then
18
+ # SAMAGOTCHI_REASONING_HISTORY, models.<key>.reasoning_history,
19
+ # hosts.<name>.reasoning_history, then turn. Native hosts ignore it:
20
+ # KernelLoop keeps the running turn's reasoning on its own.
21
+ module ReasoningHistory
22
+ LEVELS = %i[drop turn all].freeze
23
+ DEFAULT = :turn
24
+ KEY = "reasoning_history"
25
+ ENV_NAME = "SAMAGOTCHI_REASONING_HISTORY"
26
+ # The source a session's own level reads as.
27
+ SESSION_SOURCE = "session"
28
+ # The word that unsets a session's own level.
29
+ UNSET = "default"
30
+
31
+ module_function
32
+
33
+ # A session's own level from its file or a command word: :drop, :turn
34
+ # or :all (an explicit turn is the session's own too); nil for anything
35
+ # else, default included. Never warns: a session file keeps a value it
36
+ # can't read as written.
37
+ # @return [Symbol, nil]
38
+ def session_level(value)
39
+ return value if LEVELS.include?(value)
40
+ return nil unless value.is_a?(String)
41
+
42
+ level = value.strip.downcase.to_sym
43
+ LEVELS.include?(level) ? level : nil
44
+ end
45
+
46
+ # A configured value as a level; nil when unset or not a level (that
47
+ # warns once, naming +where+).
48
+ # @return [Symbol, nil]
49
+ def level(value, where)
50
+ return nil if value.nil?
51
+
52
+ text = value.to_s.strip.downcase
53
+ return text.to_sym if LEVELS.include?(text.to_sym)
54
+
55
+ ConfigFile.warn_once "Warning: #{where}: #{KEY} must be one of #{LEVELS.join(", ")}; ignored"
56
+ nil
57
+ end
58
+
59
+ # The level for +target+ and where it came from ("session",
60
+ # "SAMAGOTCHI_REASONING_HISTORY", "models: qwen", "hosts.work"; nil for
61
+ # the default).
62
+ # @param target [HostRegistry::ModelTarget]
63
+ # @param names [Array<String>] the model's lookup names (HostRegistry#lookup_names)
64
+ # @param models [Hash{String => ModelSettings}, nil] ConfigFile.model_settings (specs)
65
+ # @param session [Symbol, nil] the session's own level (Session#reasoning_history)
66
+ # @return [Array(Symbol, String|nil)]
67
+ def resolve(target, names:, models: nil, session: nil)
68
+ return [session, SESSION_SOURCE] if session
69
+
70
+ env = env_level
71
+ return [env, ENV_NAME] if env
72
+
73
+ models ||= begin
74
+ ConfigFile.model_settings
75
+ rescue StandardError
76
+ {}
77
+ end
78
+ setting = ConfigFile.model_setting(names, :reasoning_history, models: models)
79
+ return [setting.value, "models: #{setting.key}"] if setting
80
+
81
+ host = target.entry&.reasoning_history
82
+ return [host, "hosts.#{target.entry.name}"] if host
83
+
84
+ [DEFAULT, nil]
85
+ end
86
+
87
+ # #resolve, as a SettingExplained with the session's own level beside it.
88
+ # @return [SettingExplained]
89
+ def explain(target, names:, models: nil, session: nil)
90
+ level, source = resolve(target, names: names, models: models, session: session)
91
+ SettingExplained.new(level: level, source: source, own: session)
92
+ end
93
+
94
+ # What changing the level from +from+ to +to+ costs +target+'s prompt
95
+ # cache, at a turn boundary (a change waits for the turn's end):
96
+ # :history to or from all on a chat host, with an earlier model entry
97
+ # that has thinking: every earlier reply from there gains or
98
+ # loses its reasoning_content, so a server that renders it
99
+ # (hosted APIs; llama.cpp's default) reads the conversation
100
+ # again from the first earlier thought;
101
+ # nil drop <-> turn (a turn's first request carries no reasoning
102
+ # under either), no change, nothing thought yet, or a
103
+ # native host (it ignores the level).
104
+ # @param conversation [Array<Hash>] the session's messages
105
+ # @return [Symbol, nil]
106
+ def cache_cost(from, to, target:, conversation:)
107
+ return nil unless target.entry&.chat?
108
+ return nil if from == to || ![from, to].include?(:all)
109
+
110
+ earlier_thought?(conversation) ? :history : nil
111
+ end
112
+
113
+ # Whether +conversation+ has a model entry with thinking (what all
114
+ # sends back as reasoning_content; LLM::ChatLoop#thought?).
115
+ def earlier_thought?(conversation)
116
+ Array(conversation).any? do |entry|
117
+ entry[:role].to_s == "model" && entry[:thinking].is_a?(String) && !entry[:thinking].strip.empty?
118
+ end
119
+ end
120
+
121
+ # "drop (models: qwen)", "turn (default)".
122
+ def label(level, source) = "#{level} (#{source || "default"})"
123
+
124
+ # SAMAGOTCHI_REASONING_HISTORY as a level, nil when unset or unknown.
125
+ def env_level
126
+ raw, origin = Config.get_with_origin(KEY)
127
+ origin == :env ? level(raw, ENV_NAME) : nil
128
+ rescue StandardError
129
+ nil
130
+ end
131
+ end
132
+ end