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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +6 -2
- data/docs/cli.md +29 -7
- data/docs/configuration.md +113 -8
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/context-telemetry.md +23 -5
- data/docs/internals/engine.md +54 -18
- data/docs/internals/prompt-caching.md +8 -0
- data/docs/sessions.md +2 -2
- data/docs/testing.md +23 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +6 -6
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cache_confirm.rb +51 -0
- data/lib/samagotchi/client_id.rb +12 -0
- data/lib/samagotchi/config.rb +9 -3
- data/lib/samagotchi/engine.rb +102 -8
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/file_ref.rb +59 -0
- data/lib/samagotchi/host_config.rb +6 -3
- data/lib/samagotchi/host_registry.rb +4 -3
- 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/reasoning_history.rb +132 -0
- data/lib/samagotchi/reasoning_history_command.rb +107 -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 +11 -0
- 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_row_fields.rb +46 -0
- data/lib/samagotchi/tool_runner.rb +7 -11
- data/lib/samagotchi/tools/builtins.rb +6 -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 +48 -4
- data/lib/samagotchi/web/capabilities.rb +13 -9
- data/lib/samagotchi/web/editor.rb +38 -0
- data/lib/samagotchi/web/message_parts.rb +5 -10
- data/lib/samagotchi/web/public/activity.js +33 -18
- data/lib/samagotchi/web/public/app.js +256 -1133
- data/lib/samagotchi/web/public/commands.js +269 -0
- data/lib/samagotchi/web/public/composer.js +105 -0
- data/lib/samagotchi/web/public/diff_view.js +30 -4
- data/lib/samagotchi/web/public/index.html +11 -3
- data/lib/samagotchi/web/public/info_bar.js +80 -0
- data/lib/samagotchi/web/public/level_chip.js +174 -0
- data/lib/samagotchi/web/public/lists_view.js +725 -0
- data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
- data/lib/samagotchi/web/public/model_pick.js +1 -1
- data/lib/samagotchi/web/public/question_cards.js +2 -1
- data/lib/samagotchi/web/public/reasoning_history_chip.js +49 -0
- data/lib/samagotchi/web/public/refs.js +134 -0
- data/lib/samagotchi/web/public/restore.js +81 -0
- data/lib/samagotchi/web/public/session_state.js +57 -0
- data/lib/samagotchi/web/public/stream_handlers.js +332 -0
- data/lib/samagotchi/web/public/thinking_chip.js +38 -144
- data/lib/samagotchi/web/public/turn_events.js +17 -258
- data/lib/samagotchi/web/public/turn_html.js +5 -1
- data/lib/samagotchi/web/server.rb +4 -4
- data/lib/samagotchi/worker.rb +17 -5
- metadata +18 -2
- 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
|
-
|
|
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
|
|
353
|
-
# thought or a tool call doesn't
|
|
354
|
-
|
|
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:
|
|
417
|
+
sampling: sampling,
|
|
378
418
|
cache_prompt: !uncached,
|
|
379
|
-
on_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
|
|
392
|
-
|
|
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
|
-
|
|
395
|
-
|
|
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
|
-
|
|
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),
|