samagotchi 0.55.0 → 0.57.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +65 -1
- data/README.md +1 -1
- data/docs/architecture.md +6 -2
- data/docs/cli.md +30 -8
- data/docs/configuration.md +84 -8
- data/docs/hooks.md +2 -2
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/engine.md +14 -0
- data/docs/internals/prompt-caching.md +8 -0
- data/docs/memory.md +1 -1
- data/docs/plugins.md +63 -0
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -0
- data/lib/samagotchi/bootstrap/probe.rb +3 -0
- data/lib/samagotchi/bootstrap_command.rb +2 -0
- data/lib/samagotchi/broadcast_command.rb +2 -0
- data/lib/samagotchi/bundles/core/manifest.yml +2 -1
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +15 -1
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +11 -0
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +320 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/cache_confirm.rb +51 -0
- data/lib/samagotchi/client_id.rb +12 -0
- data/lib/samagotchi/config.rb +7 -3
- data/lib/samagotchi/context_command.rb +2 -0
- data/lib/samagotchi/desktop/macos.rb +3 -0
- data/lib/samagotchi/engine.rb +102 -8
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/gem_update.rb +3 -0
- data/lib/samagotchi/guardrails/shell_lex.rb +8 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +3 -0
- data/lib/samagotchi/host_config.rb +6 -3
- data/lib/samagotchi/host_registry.rb +19 -3
- data/lib/samagotchi/installed_versions.rb +3 -0
- data/lib/samagotchi/kernel_loop.rb +99 -16
- data/lib/samagotchi/llm/chat_loop.rb +122 -71
- data/lib/samagotchi/llm/openai_chat.rb +2 -1
- data/lib/samagotchi/llm/stream_drops.rb +63 -0
- data/lib/samagotchi/llm/turn_settings.rb +9 -5
- data/lib/samagotchi/model_settings.rb +5 -2
- data/lib/samagotchi/plugin/context.rb +5 -0
- data/lib/samagotchi/reasoning_history.rb +132 -0
- data/lib/samagotchi/reasoning_history_command.rb +107 -0
- data/lib/samagotchi/redacted_inspect.rb +28 -0
- data/lib/samagotchi/self_report.rb +21 -0
- data/lib/samagotchi/session.rb +23 -2
- data/lib/samagotchi/session_commands.rb +55 -15
- data/lib/samagotchi/session_manager.rb +2 -1
- data/lib/samagotchi/session_setup.rb +7 -5
- data/lib/samagotchi/setting_explained.rb +20 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +23 -5
- data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
- data/lib/samagotchi/terminal_ui.rb +13 -6
- data/lib/samagotchi/thinking.rb +20 -25
- data/lib/samagotchi/thinking_command.rb +33 -11
- data/lib/samagotchi/tool_runner.rb +14 -3
- data/lib/samagotchi/tools/builtins.rb +6 -2
- data/lib/samagotchi/tools/execute.rb +3 -2
- data/lib/samagotchi/tools/peers.rb +4 -2
- data/lib/samagotchi/tools/task_runtime.rb +2 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +29 -6
- data/lib/samagotchi/web/public/app.js +44 -9
- data/lib/samagotchi/web/public/commands.js +36 -3
- data/lib/samagotchi/web/public/composer.js +5 -1
- data/lib/samagotchi/web/public/index.html +22 -5
- data/lib/samagotchi/web/public/info_bar.js +5 -3
- data/lib/samagotchi/web/public/level_chip.js +174 -0
- data/lib/samagotchi/web/public/model_pick.js +1 -1
- data/lib/samagotchi/web/public/question_card.js +5 -3
- data/lib/samagotchi/web/public/question_cards.js +21 -1
- data/lib/samagotchi/web/public/read_view.js +74 -0
- data/lib/samagotchi/web/public/reasoning_history_chip.js +49 -0
- data/lib/samagotchi/web/public/ref_scan.js +206 -0
- data/lib/samagotchi/web/public/refs.js +19 -10
- data/lib/samagotchi/web/public/session_state.js +5 -0
- data/lib/samagotchi/web/public/stream_handlers.js +10 -2
- data/lib/samagotchi/web/public/thinking_chip.js +33 -138
- data/lib/samagotchi/web/public/turn_html.js +4 -2
- data/lib/samagotchi/web/public/turn_view.js +5 -3
- data/lib/samagotchi/web/token.rb +3 -0
- data/lib/samagotchi/worker.rb +17 -5
- metadata +13 -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
|
-
|
|
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,
|
|
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)
|
|
490
|
-
#
|
|
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:
|
|
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
|
-
|
|
536
|
+
ask.call
|
|
518
537
|
rescue BadRequest => e
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
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
|
|
646
|
-
|
|
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?
|
|
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)
|
|
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),
|
|
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, :
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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,
|
|
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),
|
|
@@ -90,6 +90,11 @@ module Samagotchi
|
|
|
90
90
|
@data_dir = nil
|
|
91
91
|
end
|
|
92
92
|
|
|
93
|
+
# The bundle, never the environment it holds (every API key in it).
|
|
94
|
+
def inspect = "#<#{self.class.name} bundle=#{@bundle}>"
|
|
95
|
+
|
|
96
|
+
def pretty_print(printer) = printer.text(inspect)
|
|
97
|
+
|
|
93
98
|
# @return [String, nil] the session's id (nil before the first one)
|
|
94
99
|
def session_id = @host.session_id.call
|
|
95
100
|
|
|
@@ -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
|