samagotchi 0.38.0 → 0.40.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 +64 -1
- data/docs/cli.md +2 -1
- data/docs/configuration.md +58 -2
- data/docs/hooks.md +1 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/context-telemetry.md +16 -0
- data/docs/internals/llm-context-bench.md +133 -0
- data/docs/internals/llm-context-forget.md +45 -0
- data/docs/internals/prompt-caching.md +3 -2
- data/docs/plugins.md +4 -3
- data/docs/testing.md +8 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +10 -10
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +4 -0
- data/lib/samagotchi/config.rb +43 -4
- data/lib/samagotchi/context_note.rb +7 -3
- data/lib/samagotchi/context_status.rb +112 -11
- data/lib/samagotchi/engine.rb +36 -9
- data/lib/samagotchi/guardrails/targets.rb +15 -3
- data/lib/samagotchi/host_registry.rb +8 -3
- data/lib/samagotchi/kernel_loop.rb +142 -14
- data/lib/samagotchi/llm/chat_loop.rb +57 -18
- data/lib/samagotchi/llm/turn_settings.rb +5 -4
- data/lib/samagotchi/llm_context_apply.rb +165 -0
- data/lib/samagotchi/llm_context_edit.rb +102 -0
- data/lib/samagotchi/llm_context_forget.rb +258 -0
- data/lib/samagotchi/llm_context_stale.rb +271 -0
- data/lib/samagotchi/llm_context_strategy.rb +177 -0
- data/lib/samagotchi/llm_context_view.rb +201 -0
- data/lib/samagotchi/log_subscriber.rb +2 -1
- data/lib/samagotchi/prompt.rb +4 -11
- data/lib/samagotchi/session_manager.rb +9 -1
- data/lib/samagotchi/session_metrics.rb +18 -1
- data/lib/samagotchi/system_prompt.rb +20 -15
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
- data/lib/samagotchi/token_usage.rb +3 -0
- data/lib/samagotchi/tool_activity.rb +18 -0
- data/lib/samagotchi/tool_declarations.rb +60 -4
- data/lib/samagotchi/tool_ids.rb +84 -0
- data/lib/samagotchi/tool_response.rb +67 -6
- data/lib/samagotchi/tools/builtin_calls.rb +3 -2
- data/lib/samagotchi/tools/builtins.rb +15 -5
- data/lib/samagotchi/tools/context_read.rb +27 -10
- data/lib/samagotchi/tools/forget_outputs.rb +62 -0
- data/lib/samagotchi/tools/registry.rb +25 -8
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +1 -1
- data/lib/samagotchi/worker.rb +4 -0
- metadata +11 -1
|
@@ -24,6 +24,10 @@ require_relative "muted_memories"
|
|
|
24
24
|
require_relative "tool_activity"
|
|
25
25
|
require_relative "tool_runner"
|
|
26
26
|
require_relative "tool_response"
|
|
27
|
+
require_relative "tool_ids"
|
|
28
|
+
require_relative "llm_context_view"
|
|
29
|
+
require_relative "llm_context_apply"
|
|
30
|
+
require_relative "llm_context_forget"
|
|
27
31
|
require_relative "answer_display"
|
|
28
32
|
require_relative "llm/model_result"
|
|
29
33
|
require_relative "llm/turn_settings"
|
|
@@ -94,6 +98,10 @@ module Samagotchi
|
|
|
94
98
|
rescue StandardError => e
|
|
95
99
|
"Error: ask_user_question handler failed: #{e.message}"
|
|
96
100
|
end
|
|
101
|
+
|
|
102
|
+
# forget_outputs: the forget layer's call on the running turn's
|
|
103
|
+
# conversation (KernelLoop#forget_outputs).
|
|
104
|
+
def forget_outputs(call) = @kernel.forget_outputs(call)
|
|
97
105
|
end
|
|
98
106
|
|
|
99
107
|
DEFAULT_MAX_TOOL_OUTPUT_CHARS = 10_000
|
|
@@ -162,6 +170,9 @@ module Samagotchi
|
|
|
162
170
|
# The Engine's PromptWarmup: a request asks it for a slot pin
|
|
163
171
|
# (#take_pin) while the turn-end warm-up still runs; nil: never pinned.
|
|
164
172
|
attr_accessor :warmup
|
|
173
|
+
# The turn a forget_outputs call works on (LLMContextForget::Turn: its
|
|
174
|
+
# conversation and ContextStatus); both loops set it as a turn starts.
|
|
175
|
+
attr_accessor :llm_context_turn
|
|
165
176
|
# @return [Integer, nil] the llama.cpp slot (`id_slot`) the last
|
|
166
177
|
# request of the last run streamed from; nil when the server named
|
|
167
178
|
# none (other transports) or no request was made
|
|
@@ -196,7 +207,10 @@ module Samagotchi
|
|
|
196
207
|
turn.iteration = iteration_index + 1
|
|
197
208
|
outcome = iterate(turn)
|
|
198
209
|
return outcome if outcome.is_a?(LLM::ModelResult)
|
|
199
|
-
|
|
210
|
+
next unless outcome == :answer
|
|
211
|
+
|
|
212
|
+
turn.answered = true
|
|
213
|
+
break
|
|
200
214
|
rescue Client::RequestCancelled => e
|
|
201
215
|
emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: e.reason,
|
|
202
216
|
stopped_by: turn.cancel_controller&.stopped_by)
|
|
@@ -248,7 +262,7 @@ module Samagotchi
|
|
|
248
262
|
Turn = Struct.new(:conversation, :context, :empty_retry, :tool_activity, :buffer, :streamed_thinking, :qwen_attempts,
|
|
249
263
|
:qwen_partial, :prefill, :pending_tool_calls, :model_name, :pending_input, :on_stream_event,
|
|
250
264
|
:cancel_controller, :cap, :emit, :iteration, :empty_steps, :ended_empty, :malformed_retried,
|
|
251
|
-
:uncached_next, keyword_init: true)
|
|
265
|
+
:uncached_next, :answered, keyword_init: true)
|
|
252
266
|
# One request: the prompt and its images as sent, the images' token
|
|
253
267
|
# estimate, and the window it was measured against.
|
|
254
268
|
Request = Struct.new(:prompt, :images, :image_tokens, :window, :prefill, keyword_init: true)
|
|
@@ -264,8 +278,10 @@ module Samagotchi
|
|
|
264
278
|
|
|
265
279
|
def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:)
|
|
266
280
|
conversation = prepare_conversation(messages)
|
|
281
|
+
context = ContextStatus.new(conversation: conversation, llm_context: @turn_settings.llm_context)
|
|
282
|
+
@llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: context)
|
|
267
283
|
Turn.new(
|
|
268
|
-
conversation: conversation, context:
|
|
284
|
+
conversation: conversation, context: context,
|
|
269
285
|
empty_retry: EmptyAnswerRetry.new, empty_steps: [], tool_activity: [], buffer: +"", streamed_thinking: +"",
|
|
270
286
|
qwen_attempts: 0, qwen_partial: nil,
|
|
271
287
|
# Qwen with thinking off: an empty thought after the cue, so the model
|
|
@@ -305,6 +321,7 @@ module Samagotchi
|
|
|
305
321
|
# (the prompt cache keeps its prefix), and the prompt is formatted again
|
|
306
322
|
# with it.
|
|
307
323
|
def prepare_request(turn)
|
|
324
|
+
apply_llm_context!(turn.conversation, context: turn.context)
|
|
308
325
|
prompt, images = format_prompt(turn)
|
|
309
326
|
image_tokens = images.empty? ? 0 : ImagePlan.estimated_tokens(turn.conversation)
|
|
310
327
|
window = ContextWindow.resolve(client: @client, model: turn.model_name, setting: @turn_settings.window_setting)
|
|
@@ -321,7 +338,8 @@ module Samagotchi
|
|
|
321
338
|
end
|
|
322
339
|
|
|
323
340
|
def format_prompt(turn)
|
|
324
|
-
Prompt.format_with_images(turn.conversation, profile: @profile,
|
|
341
|
+
Prompt.format_with_images(llm_context_view.messages(turn.conversation), profile: @profile,
|
|
342
|
+
vision: @turn_settings.vision, prefill: turn.prefill)
|
|
325
343
|
end
|
|
326
344
|
|
|
327
345
|
# One streamed request, under the generation's own controller: a
|
|
@@ -420,6 +438,11 @@ module Samagotchi
|
|
|
420
438
|
|
|
421
439
|
def cache_fields(cache) = cache ? TokenUsage.cache_fields(**cache) : {}
|
|
422
440
|
|
|
441
|
+
def reprefill_field(model, cache)
|
|
442
|
+
tokens = cache && reprefilled_tokens(model, prompt: cache[:prompt], cached: cache[:cached])
|
|
443
|
+
tokens ? { reprefill_tokens: tokens } : {}
|
|
444
|
+
end
|
|
445
|
+
|
|
423
446
|
# The cut stream's visible text goes with it: the buffer as it was
|
|
424
447
|
# before (the next generation gets a fresh splitter).
|
|
425
448
|
def cut_generation(turn, generation, buffer_mark)
|
|
@@ -437,7 +460,8 @@ module Samagotchi
|
|
|
437
460
|
emit(turn, type: :generation_completed, iteration: turn.iteration, content_length: response.length,
|
|
438
461
|
thinking_chars: thinking_chars(response, generation.streamed_thinking),
|
|
439
462
|
served_model: generation.served_model, requested_model: turn.model_name,
|
|
440
|
-
finish_reason: generation.finish_reason, **cache_fields(generation.cache)
|
|
463
|
+
finish_reason: generation.finish_reason, **cache_fields(generation.cache),
|
|
464
|
+
**reprefill_field(turn.model_name, generation.cache))
|
|
441
465
|
dump_log("response", generation.response, iteration: turn.iteration)
|
|
442
466
|
# The after_generation hook (after the model returns, before the tool
|
|
443
467
|
# parse), with a read-only copy of the conversation as sent.
|
|
@@ -541,8 +565,9 @@ module Samagotchi
|
|
|
541
565
|
runs = ToolResponse.run_batch(tool_runner, calls, iteration: turn.iteration, emit: turn.emit,
|
|
542
566
|
on_stream_event: turn.on_stream_event, cap: turn.cap)
|
|
543
567
|
turn.tool_activity.concat(ToolResponse.activities(runs))
|
|
544
|
-
# One entry for the batch: the capped outputs joined (ToolResponse.joined)
|
|
545
|
-
|
|
568
|
+
# One entry for the batch: the capped outputs joined (ToolResponse.joined),
|
|
569
|
+
# an id per run (ToolIds).
|
|
570
|
+
turn.conversation << ToolResponse.joined(runs, ids: ToolIds.next_ids(turn.conversation, runs.size))
|
|
546
571
|
turn.pending_tool_calls = true
|
|
547
572
|
:next
|
|
548
573
|
end
|
|
@@ -552,6 +577,10 @@ module Samagotchi
|
|
|
552
577
|
# then the answer shows only its text.
|
|
553
578
|
def finish(turn)
|
|
554
579
|
exhausted = turn.pending_tool_calls && tool_response_turn?(turn.conversation.last)
|
|
580
|
+
# The model answered: the staged edits reach the session now (a turn
|
|
581
|
+
# that ran out of steps, on tool results or on a retry's nudge, is
|
|
582
|
+
# mid-task; they wait).
|
|
583
|
+
apply_llm_context!(turn.conversation, moment: :turn_end) if turn.answered
|
|
555
584
|
# An empty answer left the conversation: an earlier model message
|
|
556
585
|
# isn't this turn's answer.
|
|
557
586
|
output = turn.ended_empty ? "" : strip_thought_blocks(last_model_content(turn.conversation))
|
|
@@ -710,11 +739,18 @@ module Samagotchi
|
|
|
710
739
|
# The next turn's prompt up to where its user message starts, for the
|
|
711
740
|
# turn-end warm-up (PromptWarmup): +messages+ as the next turn sends
|
|
712
741
|
# them before its prompt (the history under the system head), formatted
|
|
713
|
-
# as #run formats them
|
|
714
|
-
#
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
742
|
+
# as #run formats them (through the LLMContextView too), with a
|
|
743
|
+
# stand-in user message cut off at its opener. +llm_context+: the next
|
|
744
|
+
# turn's strategy (LLMContextStrategy::Resolved; the Engine resolves it
|
|
745
|
+
# for the model the next turn runs on, after a /model switch too),
|
|
746
|
+
# else the last turn's. Returns [prompt, images], or nil when the cut
|
|
747
|
+
# isn't found.
|
|
748
|
+
def warmup_prompt(messages, llm_context: @turn_settings&.llm_context)
|
|
749
|
+
conversation = prepare_conversation(messages)
|
|
750
|
+
apply_llm_context!(conversation, llm_context, warmup: true)
|
|
751
|
+
conversation << { role: "user", content: WARMUP_CUT }
|
|
752
|
+
prompt, images = Prompt.format_with_images(llm_context_view(llm_context).messages(conversation),
|
|
753
|
+
profile: @profile, vision: @turn_settings.vision)
|
|
718
754
|
head = prompt[0, prompt.index(WARMUP_CUT) || 0]
|
|
719
755
|
cut = head.rindex(user_opener)
|
|
720
756
|
cut&.positive? ? [head[0, cut], images] : nil
|
|
@@ -723,6 +759,98 @@ module Samagotchi
|
|
|
723
759
|
WARMUP_CUT = "chi-warmup-cut"
|
|
724
760
|
private_constant :WARMUP_CUT
|
|
725
761
|
|
|
762
|
+
# What both loops send of the conversation (LLMContextView), under the
|
|
763
|
+
# turn's strategy (TurnSettings#llm_context, none without one), or the
|
|
764
|
+
# one given (the warm-up's, the next turn's): the prompt, the warm-up
|
|
765
|
+
# and the chat messages all format through it, so the warm-up warms
|
|
766
|
+
# the prompt the next turn sends.
|
|
767
|
+
def llm_context_view(llm_context = @turn_settings&.llm_context)
|
|
768
|
+
LLMContextView.new(strategy: llm_context&.strategy || LLMContextView::NONE)
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
# The turn's LLM context layers ([] under none): the tools they add
|
|
772
|
+
# (Tools::Registry#entries) are the turn's to offer and dispatch.
|
|
773
|
+
def llm_context_layers = @turn_settings&.llm_context&.active_layers || []
|
|
774
|
+
|
|
775
|
+
# The model's forget_outputs call (LLMContextForget) on the running
|
|
776
|
+
# turn, under its strategy; the apply rule runs on the turn's
|
|
777
|
+
# conversation at once, as at a request, so the result says whether the
|
|
778
|
+
# stubs go with the next request or wait for the turn's end.
|
|
779
|
+
# @return [String] the tool result
|
|
780
|
+
def forget_outputs(call)
|
|
781
|
+
llm_context = @turn_settings&.llm_context
|
|
782
|
+
turn = @llm_context_turn
|
|
783
|
+
unless turn && llm_context&.active_layers&.include?(:forget)
|
|
784
|
+
return "Error: forget_outputs is off (the llm_context strategy has no forget layer)"
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
LLMContextForget.call(turn, call, llm_context: llm_context,
|
|
788
|
+
apply: -> { apply_llm_context!(turn.conversation, llm_context, context: turn.context) })
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
# Before each request of both loops (+moment+ :request; the warm-up
|
|
792
|
+
# too, on its own copy, as the next turn's first request) and at the
|
|
793
|
+
# end of a turn the model answered (:turn_end): the strategy's layers
|
|
794
|
+
# stage their edits on +conversation+ and the apply rule
|
|
795
|
+
# (LLMContextApply, llm_context.apply) saves the batch it lets through
|
|
796
|
+
# as applied, so the request about to be sent (or the next turn's
|
|
797
|
+
# first, and the warm-up) is the first with the stubs. +context+: the
|
|
798
|
+
# turn's ContextStatus (payoff applies in its top bucket; an applied
|
|
799
|
+
# batch rebases its estimate, ContextStatus#edited!). none changes
|
|
800
|
+
# nothing. Logged as llm_context_apply when a batch is applied.
|
|
801
|
+
# @return [LLMContextApply::Outcome]
|
|
802
|
+
def apply_llm_context!(conversation, llm_context = @turn_settings&.llm_context, moment: :request, context: nil,
|
|
803
|
+
warmup: false)
|
|
804
|
+
view = llm_context_view(llm_context)
|
|
805
|
+
return LLMContextApply::Outcome.none if view.none?
|
|
806
|
+
|
|
807
|
+
outcome = LLMContextApply.run!(conversation, layers: view.layers, rule: llm_context.apply, moment: moment,
|
|
808
|
+
protect_steps: llm_context.protect_steps,
|
|
809
|
+
top_bucket: context&.top_bucket? || false,
|
|
810
|
+
changes: llm_context.stale_edits)
|
|
811
|
+
if outcome.applied?
|
|
812
|
+
# The prompt just shrank: the context estimate counts from it.
|
|
813
|
+
context&.edited!
|
|
814
|
+
log_llm_context(outcome, llm_context, moment) unless warmup
|
|
815
|
+
end
|
|
816
|
+
outcome
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
def log_llm_context(outcome, llm_context, moment)
|
|
820
|
+
Log.info(:model, "llm_context_apply", rule: llm_context.apply, at: moment, why: outcome.why,
|
|
821
|
+
applied: outcome.applied.size, ids: outcome.applied.map(&:id).join(","),
|
|
822
|
+
staged: outcome.staged, freed: (outcome.freed_chars / TokenUsage::CHARS_PER_TOKEN).ceil,
|
|
823
|
+
tail: (outcome.tail_chars / TokenUsage::CHARS_PER_TOKEN).ceil,
|
|
824
|
+
model: @turn_settings&.model_name)
|
|
825
|
+
end
|
|
826
|
+
private :log_llm_context
|
|
827
|
+
|
|
828
|
+
# A request's re-prefilled tokens, for both loops: of the prompt the
|
|
829
|
+
# last request on +model+ sent (this kernel's, any turn), what the
|
|
830
|
+
# server didn't reuse from its cache this time (that prompt less
|
|
831
|
+
# +cached+), so an LLM context edit's cache break shows, and so does
|
|
832
|
+
# anything else that rewrote the prompt's middle or lost the cache. nil
|
|
833
|
+
# under TokenUsage::REPREFILL_MIN_TOKENS (a cache block's rounding), for
|
|
834
|
+
# a server that reports no cached count (or 0: a cold cache isn't told
|
|
835
|
+
# from no cache), the first request, a model switch, and the first
|
|
836
|
+
# request after a warm-up (#warmed_up!).
|
|
837
|
+
# @return [Integer, nil]
|
|
838
|
+
def reprefilled_tokens(model, prompt:, cached:)
|
|
839
|
+
previous = @reprefill_baseline
|
|
840
|
+
@reprefill_baseline = { model: model, prompt: prompt.to_i } if prompt.to_i.positive?
|
|
841
|
+
return nil unless previous && previous[:model] == model && cached.to_i.positive?
|
|
842
|
+
|
|
843
|
+
tokens = previous[:prompt] - cached.to_i
|
|
844
|
+
tokens >= TokenUsage::REPREFILL_MIN_TOKENS ? tokens : nil
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
# The Engine started a turn-end warm-up: it prefills the next prompt
|
|
848
|
+
# off the clock, so the next request's cache counts are against it, not
|
|
849
|
+
# the last request (#reprefilled_tokens starts over).
|
|
850
|
+
def warmed_up!
|
|
851
|
+
@reprefill_baseline = nil
|
|
852
|
+
end
|
|
853
|
+
|
|
726
854
|
# Public wrapper so other loops (e.g. the chat loop) can strip
|
|
727
855
|
# per-profile thought blocks from finished model text without duplicating the
|
|
728
856
|
# Gemma 4 / Qwen 3.6 logic. Mirrors the native loop's "strip before deciding
|
|
@@ -809,9 +937,9 @@ module Samagotchi
|
|
|
809
937
|
private
|
|
810
938
|
|
|
811
939
|
def dispatch(call)
|
|
812
|
-
entry = @tools
|
|
940
|
+
entry = @tools.offered(call[:name], layers: llm_context_layers)
|
|
813
941
|
unless entry
|
|
814
|
-
available = @tools.names.join(", ")
|
|
942
|
+
available = @tools.names(layers: llm_context_layers).join(", ")
|
|
815
943
|
result = "Error: unknown tool '#{call[:name]}'. Available: #{available}"
|
|
816
944
|
return {
|
|
817
945
|
output: result,
|
|
@@ -16,6 +16,7 @@ require_relative "../context_window"
|
|
|
16
16
|
require_relative "../context_note"
|
|
17
17
|
require_relative "../tool_runner"
|
|
18
18
|
require_relative "../tool_response"
|
|
19
|
+
require_relative "../tool_ids"
|
|
19
20
|
require_relative "../tool_declarations"
|
|
20
21
|
require_relative "../vision_context"
|
|
21
22
|
require_relative "../log"
|
|
@@ -74,10 +75,11 @@ module Samagotchi
|
|
|
74
75
|
end
|
|
75
76
|
|
|
76
77
|
# Tool definitions for the request: the schemas the native prompts are
|
|
77
|
-
# rendered from (the kernel's registry
|
|
78
|
-
# closed
|
|
78
|
+
# rendered from (the kernel's registry, the tools the turn's LLM
|
|
79
|
+
# context layers offer), with the chat-only enums and closed
|
|
80
|
+
# parameters.
|
|
79
81
|
def tool_definitions
|
|
80
|
-
ToolDeclarations.chat_schemas(tools.schemas).map do |schema|
|
|
82
|
+
ToolDeclarations.chat_schemas(tools.schemas(layers: llm_context_layers)).map do |schema|
|
|
81
83
|
{ type: "function", function: schema.slice(:name, :description, :parameters) }
|
|
82
84
|
end
|
|
83
85
|
end
|
|
@@ -85,6 +87,28 @@ module Samagotchi
|
|
|
85
87
|
# The kernel's tools.
|
|
86
88
|
def tools = @kernel.tools
|
|
87
89
|
|
|
90
|
+
# The turn's LLM context layers (KernelLoop#llm_context_layers).
|
|
91
|
+
def llm_context_layers = @kernel.llm_context_layers
|
|
92
|
+
|
|
93
|
+
# The turn's LLM context strategy (LLMContextStrategy::Resolved), nil
|
|
94
|
+
# when none was resolved.
|
|
95
|
+
def llm_context = @kernel.turn_settings&.llm_context
|
|
96
|
+
|
|
97
|
+
# KernelLoop#reprefilled_tokens, the kernel's request history.
|
|
98
|
+
def reprefilled_tokens(model, **) = @kernel.reprefilled_tokens(model, **)
|
|
99
|
+
|
|
100
|
+
# The kernel's LLMContextView, the turn's strategy.
|
|
101
|
+
def llm_context_view = @kernel.llm_context_view
|
|
102
|
+
|
|
103
|
+
# The strategy's own edits before a request and at turn end
|
|
104
|
+
# (KernelLoop#apply_llm_context!).
|
|
105
|
+
def apply_llm_context!(conversation, **) = @kernel.apply_llm_context!(conversation, **)
|
|
106
|
+
|
|
107
|
+
# The running turn, for forget_outputs (KernelLoop#llm_context_turn).
|
|
108
|
+
def llm_context_turn=(turn)
|
|
109
|
+
@kernel.llm_context_turn = turn
|
|
110
|
+
end
|
|
111
|
+
|
|
88
112
|
# engine-format conversation -> OpenAI wire messages. Model turns are
|
|
89
113
|
# thought-stripped and carry their tool_calls; tool responses go as tool
|
|
90
114
|
# messages with their tool_call_id; user content goes as parts (a String
|
|
@@ -100,7 +124,11 @@ module Samagotchi
|
|
|
100
124
|
# message takes text only, so a run of tool results' images follows it
|
|
101
125
|
# as one user message "[images from tool results]". Images the request
|
|
102
126
|
# leaves out (ImagePlan) become placeholder lines in the text.
|
|
127
|
+
#
|
|
128
|
+
# The conversation goes through the kernel's LLMContextView first,
|
|
129
|
+
# as the native prompt does.
|
|
103
130
|
def wire_messages(conversation)
|
|
131
|
+
conversation = llm_context_view.messages(conversation)
|
|
104
132
|
paired = paired_call_ids(conversation)
|
|
105
133
|
plan = ImagePlan.new(conversation, vision)
|
|
106
134
|
tool_images = []
|
|
@@ -329,7 +357,8 @@ module Samagotchi
|
|
|
329
357
|
# The empty generations, never saved: the UIs draw the turn's
|
|
330
358
|
# steps from them (ModelResult#empty_steps).
|
|
331
359
|
@empty_steps = []
|
|
332
|
-
@context = ContextStatus.new(conversation: conversation)
|
|
360
|
+
@context = ContextStatus.new(conversation: conversation, llm_context: loop.llm_context)
|
|
361
|
+
loop.llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: @context)
|
|
333
362
|
end
|
|
334
363
|
|
|
335
364
|
def call(max_iterations:, cap:)
|
|
@@ -424,6 +453,9 @@ module Samagotchi
|
|
|
424
453
|
# One streamed request. Returns [response, nil], or [reason, partial
|
|
425
454
|
# text] when it was cancelled.
|
|
426
455
|
def generate(iteration)
|
|
456
|
+
# The strategy's edits the apply rule lets through first (stale):
|
|
457
|
+
# this request sends them.
|
|
458
|
+
@loop.apply_llm_context!(@conversation, context: @context)
|
|
427
459
|
window = @window = @loop.context_window(@model_name)
|
|
428
460
|
observe_context(iteration, window)
|
|
429
461
|
retry_generation = @empty_retry.take_sampling!
|
|
@@ -452,7 +484,7 @@ module Samagotchi
|
|
|
452
484
|
emit(type: :generation_completed, iteration: iteration, content_length: response.text.length,
|
|
453
485
|
thinking_chars: response.reasoning.to_s.length, served_model: response.model,
|
|
454
486
|
served_provider: response.provider, requested_model: @model_name, finish_reason: response.finish_reason,
|
|
455
|
-
**(response.usage&.cache_fields || {}))
|
|
487
|
+
**(response.usage&.cache_fields || {}), **reprefill_field(response.usage))
|
|
456
488
|
dump_response(response, iteration)
|
|
457
489
|
@loop.fire_hook(:after_generation, { type: :after_generation, iteration: iteration, response: response.text,
|
|
458
490
|
messages: AnswerDisplay.strip_all(@conversation).map(&:dup).freeze })
|
|
@@ -461,6 +493,14 @@ module Samagotchi
|
|
|
461
493
|
[e.reason, streamed, thought]
|
|
462
494
|
end
|
|
463
495
|
|
|
496
|
+
# The request's re-prefilled tokens (KernelLoop#reprefilled_tokens).
|
|
497
|
+
def reprefill_field(usage)
|
|
498
|
+
return {} unless usage&.source == :server
|
|
499
|
+
|
|
500
|
+
tokens = @loop.reprefilled_tokens(@model_name, prompt: usage.prompt_tokens, cached: usage.cached_tokens)
|
|
501
|
+
tokens ? { reprefill_tokens: tokens } : {}
|
|
502
|
+
end
|
|
503
|
+
|
|
464
504
|
# The request runs under the generation's own controller (the turn
|
|
465
505
|
# controller's child), so a plugin's stop_generation cuts it alone.
|
|
466
506
|
def with_generation(&)
|
|
@@ -525,18 +565,12 @@ module Samagotchi
|
|
|
525
565
|
@request_chars = prompt_chars
|
|
526
566
|
end
|
|
527
567
|
|
|
528
|
-
# The conversation's text as the request carries it
|
|
529
|
-
#
|
|
568
|
+
# The conversation's text as the request carries it, through the
|
|
569
|
+
# kernel's LLMContextView (a stub counts, not the output it stands
|
|
570
|
+
# for): contents (a parts list's text parts) and the tool calls'
|
|
571
|
+
# names and arguments.
|
|
530
572
|
def prompt_chars
|
|
531
|
-
@conversation
|
|
532
|
-
content = entry[:content]
|
|
533
|
-
chars = if content.is_a?(Array)
|
|
534
|
-
content.sum { |part| part.is_a?(Hash) ? (part[:text] || part["text"]).to_s.length : 0 }
|
|
535
|
-
else
|
|
536
|
-
content.to_s.length
|
|
537
|
-
end
|
|
538
|
-
chars + Array(entry[:tool_calls]).sum { |call| call[:name].to_s.length + call[:arguments].to_json.length }
|
|
539
|
-
end
|
|
573
|
+
LLMContextView.chars(@loop.llm_context_view.messages(@conversation))
|
|
540
574
|
end
|
|
541
575
|
|
|
542
576
|
# The server's counts for this request (prompt + answer): the status
|
|
@@ -565,12 +599,14 @@ module Samagotchi
|
|
|
565
599
|
end
|
|
566
600
|
|
|
567
601
|
# Each call's result goes on the conversation as it finishes, paired
|
|
568
|
-
# with its call (ToolResponse.single: the capped output)
|
|
602
|
+
# with its call (ToolResponse.single: the capped output), with the
|
|
603
|
+
# next id (ToolIds).
|
|
569
604
|
def dispatch(tool_calls, iteration, cap)
|
|
570
605
|
calls = tool_calls.map { |tool_call| NativeToolNormalizer.normalize(tool_call) }
|
|
571
606
|
runs = ToolResponse.run_batch(@loop.tool_runner, calls, iteration: iteration, emit: method(:emit),
|
|
572
607
|
on_stream_event: @on_stream_event, cap: cap) do |run, index|
|
|
573
|
-
@conversation << ToolResponse.single(run, tool_call_id: tool_calls[index].id
|
|
608
|
+
@conversation << ToolResponse.single(run, tool_call_id: tool_calls[index].id,
|
|
609
|
+
ids: ToolIds.next_ids(@conversation, 1))
|
|
574
610
|
end
|
|
575
611
|
@tool_activity.concat(ToolResponse.activities(runs))
|
|
576
612
|
end
|
|
@@ -602,6 +638,9 @@ module Samagotchi
|
|
|
602
638
|
end
|
|
603
639
|
|
|
604
640
|
def result(text, exhausted:)
|
|
641
|
+
# The model answered: the staged edits reach the session now (a
|
|
642
|
+
# turn that ran out of steps is mid-task; they wait).
|
|
643
|
+
@loop.apply_llm_context!(@conversation, moment: :turn_end) unless exhausted
|
|
605
644
|
ModelResult.new(text: text, conversation: @loop.plain(@conversation), exhausted: exhausted,
|
|
606
645
|
tool_activity: @tool_activity, empty_steps: @empty_steps, empty_retries: @empty_retry.attempts,
|
|
607
646
|
context_status: @context.display)
|
|
@@ -10,12 +10,13 @@ module Samagotchi
|
|
|
10
10
|
# (SamplingSettings.for; empty sends none), its thinking level
|
|
11
11
|
# (Thinking.resolve), the bare model id the turn runs on (every debug
|
|
12
12
|
# dump is tagged with it) and the model's or host's configured context
|
|
13
|
-
# window (ContextWindow.setting, nil for none)
|
|
14
|
-
#
|
|
15
|
-
|
|
13
|
+
# window (ContextWindow.setting, nil for none) and LLM context strategy
|
|
14
|
+
# (LLMContextStrategy.resolve, nil for none). The Engine sets them once
|
|
15
|
+
# per turn, in Engine#generate, after the model's host is synced.
|
|
16
|
+
TurnSettings = Data.define(:vision, :sampling, :thinking, :model_name, :window_setting, :llm_context) do
|
|
16
17
|
# No vision, no sampling, the default thinking, no model name.
|
|
17
18
|
def self.none = new(vision: nil, sampling: {}.freeze, thinking: Thinking::DEFAULT, model_name: nil,
|
|
18
|
-
window_setting: nil)
|
|
19
|
+
window_setting: nil, llm_context: nil)
|
|
19
20
|
end
|
|
20
21
|
end
|
|
21
22
|
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "llm_context_edit"
|
|
5
|
+
require_relative "llm_context_stale"
|
|
6
|
+
require_relative "llm_context_view"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
# When a layer's edits reach the prompt: the apply rule
|
|
10
|
+
# (llm_context.apply, LLMContextStrategy). Every edit breaks the prompt
|
|
11
|
+
# cache from the earliest entry it changes, so the edits due at one point
|
|
12
|
+
# go in one batch, under one cache break.
|
|
13
|
+
#
|
|
14
|
+
# Two points ask (#run!): each request of both loops (+moment+ :request,
|
|
15
|
+
# the warm-up too, as the next turn's first request) and the end of a
|
|
16
|
+
# turn the model answered (+moment+ :turn_end). What waits between them is
|
|
17
|
+
# staged: for the stale layer it is worked out again at each point from
|
|
18
|
+
# the conversation itself (LLMContextStale.found with changes), so
|
|
19
|
+
# nothing is kept for it and a --resume stages the same; an edit saved
|
|
20
|
+
# with no applied_at (a later layer's, forget) is staged too. Only an
|
|
21
|
+
# applied edit is saved (LLMContextEdit.store), and it stays.
|
|
22
|
+
#
|
|
23
|
+
# The rules, at a request:
|
|
24
|
+
# - next_request: a read a later read superseded is applied at once (P2's
|
|
25
|
+
# behaviour); one only an edit or write superseded waits for turn end.
|
|
26
|
+
# - turn_end: nothing; all of it at turn end. Where a warm-up follows
|
|
27
|
+
# (the native loop on a local llama.cpp) it warms the edited prompt;
|
|
28
|
+
# elsewhere the break moves to the next turn's first request.
|
|
29
|
+
# - payoff: the whole batch is applied when the tokens it frees are at
|
|
30
|
+
# least the tail it makes the server read again (from the earliest
|
|
31
|
+
# edited entry to the end, as sent after it), or the context is in the
|
|
32
|
+
# top ContextStatus bucket; otherwise it waits for turn end.
|
|
33
|
+
# At turn end every staged edit is applied, under each rule.
|
|
34
|
+
#
|
|
35
|
+
# Never applied, at any point: a stale stub of a read of a file the
|
|
36
|
+
# last +protect_steps+ steps edited (LLMContextStale.protected_ids); a
|
|
37
|
+
# forget was checked against them when the model made it.
|
|
38
|
+
module LLMContextApply
|
|
39
|
+
RULES = %i[payoff next_request turn_end].freeze
|
|
40
|
+
MOMENTS = %i[request turn_end].freeze
|
|
41
|
+
|
|
42
|
+
# One staged edit: the entry it goes on, the edit (applied_at nil) and
|
|
43
|
+
# whether only a change superseded its read (never sent mid-edit).
|
|
44
|
+
Staged = Data.define(:index, :edit, :change) do
|
|
45
|
+
def change? = change
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# What a point did: the edits it applied (saved on the entries), how
|
|
49
|
+
# many stay staged, and for the batch it weighed (the applied one, or
|
|
50
|
+
# under payoff the one it held back) the chars it frees and the tail
|
|
51
|
+
# it makes the server read again; +why+: :next_request, :turn_end,
|
|
52
|
+
# :payoff or :top_bucket when it applied, :held when payoff held a
|
|
53
|
+
# batch back, nil when there was nothing to weigh.
|
|
54
|
+
Outcome = Data.define(:applied, :staged, :freed_chars, :tail_chars, :why) do
|
|
55
|
+
def self.none = new(applied: [], staged: 0, freed_chars: 0, tail_chars: 0, why: nil)
|
|
56
|
+
|
|
57
|
+
def applied? = !applied.empty?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
module_function
|
|
61
|
+
|
|
62
|
+
# Stages what +layers+ find on +conversation+ and applies what +rule+
|
|
63
|
+
# lets through at +moment+.
|
|
64
|
+
# @param top_bucket [Boolean] the context is in the top ContextStatus bucket
|
|
65
|
+
# @param changes [Boolean] an edit or write makes a read stale too
|
|
66
|
+
# (llm_context.stale_edits; off: later reads only, P2's rule)
|
|
67
|
+
# @return [Outcome]
|
|
68
|
+
def run!(conversation, layers:, rule:, moment:, protect_steps:, top_bucket: false, root: Dir.pwd,
|
|
69
|
+
now: Time.now.utc.iso8601(3), changes: false)
|
|
70
|
+
layers = Array(layers)
|
|
71
|
+
return Outcome.none if layers.empty?
|
|
72
|
+
|
|
73
|
+
staged = stage(conversation, layers, protect_steps, root, now, changes)
|
|
74
|
+
return Outcome.none if staged.empty?
|
|
75
|
+
|
|
76
|
+
batch, why, measured = choose(conversation, layers, staged, rule, moment, top_bucket)
|
|
77
|
+
freed, tail = measured || measure(conversation, layers, batch, now)
|
|
78
|
+
applied = batch.map do |item|
|
|
79
|
+
edit = item.edit.with(applied_at: now)
|
|
80
|
+
LLMContextEdit.store(conversation[item.index], edit)
|
|
81
|
+
edit
|
|
82
|
+
end
|
|
83
|
+
Outcome.new(applied: applied, staged: staged.size - batch.size, freed_chars: freed, tail_chars: tail, why: why)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The edits waiting to reach the prompt.
|
|
87
|
+
# @return [Array<Staged>]
|
|
88
|
+
def stage(conversation, layers, protect_steps, root, now, changes)
|
|
89
|
+
staged = []
|
|
90
|
+
kept = LLMContextStale.protected_ids(conversation, steps: protect_steps, root: root)
|
|
91
|
+
if layers.include?(:stale)
|
|
92
|
+
LLMContextStale.found(conversation, root: root, changes: changes).each do |stale|
|
|
93
|
+
edit = LLMContextEdit.new(id: stale.run.ref.id, kind: :stale, note: stale.note, by: LLMContextStale::BY,
|
|
94
|
+
staged_at: now, applied_at: nil)
|
|
95
|
+
staged << Staged.new(index: stale.run.index, edit: edit, change: stale.change?)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
# A forget passed its own check when the model made it
|
|
99
|
+
# (LLMContextForget: a read the model edits against goes only with
|
|
100
|
+
# lines kept); the rule holds back only stale's.
|
|
101
|
+
staged.concat(saved_staged(conversation, layers))
|
|
102
|
+
.reject { |item| item.edit.kind == :stale && kept.include?(item.edit.id) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# [the batch to apply, why, its [freed, tail] when payoff measured it]
|
|
106
|
+
# at +moment+ under +rule+.
|
|
107
|
+
def choose(conversation, layers, staged, rule, moment, top_bucket)
|
|
108
|
+
return [staged, :turn_end] if moment == :turn_end
|
|
109
|
+
|
|
110
|
+
case rule
|
|
111
|
+
when :next_request
|
|
112
|
+
batch = staged.reject(&:change?)
|
|
113
|
+
[batch, batch.empty? ? nil : :next_request]
|
|
114
|
+
when :payoff
|
|
115
|
+
return [staged, :top_bucket] if top_bucket
|
|
116
|
+
|
|
117
|
+
measured = measure(conversation, layers, staged)
|
|
118
|
+
freed, tail = measured
|
|
119
|
+
freed >= tail ? [staged, :payoff, measured] : [[], :held, measured]
|
|
120
|
+
else
|
|
121
|
+
[[], nil]
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The chars +batch+ frees from what +conversation+ sends now, and the
|
|
126
|
+
# chars sent after it from its earliest entry on (the tail the server
|
|
127
|
+
# reads again).
|
|
128
|
+
# @return [Array(Integer, Integer)]
|
|
129
|
+
def measure(conversation, layers, batch, now = "now")
|
|
130
|
+
return [0, 0] if batch.empty?
|
|
131
|
+
|
|
132
|
+
view = LLMContextView.new(strategy: layers)
|
|
133
|
+
trial = conversation.dup
|
|
134
|
+
batch.each do |item|
|
|
135
|
+
trial[item.index] = trial[item.index].dup if trial[item.index].equal?(conversation[item.index])
|
|
136
|
+
LLMContextEdit.store(trial[item.index], item.edit.with(applied_at: now))
|
|
137
|
+
end
|
|
138
|
+
after = view.messages(trial)
|
|
139
|
+
first = batch.map(&:index).min
|
|
140
|
+
[sent_chars(view.messages(conversation)) - sent_chars(after), sent_chars(after[first..])]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def sent_chars(entries) = LLMContextView.chars(entries)
|
|
144
|
+
|
|
145
|
+
# What applying +edits+ ([entry index, LLMContextEdit] pairs) would
|
|
146
|
+
# free under +layers+, and the tail after them, in chars (#measure).
|
|
147
|
+
# @return [Array(Integer, Integer)]
|
|
148
|
+
def weigh(conversation, layers:, edits:)
|
|
149
|
+
measure(conversation, Array(layers), edits.map { |index, edit| Staged.new(index: index, edit: edit, change: false) })
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Edits saved unapplied on the entries, of +layers+ (forget saves its
|
|
153
|
+
# edits so, LLMContextForget).
|
|
154
|
+
def saved_staged(conversation, layers)
|
|
155
|
+
conversation.each_with_index.flat_map do |entry, index|
|
|
156
|
+
next [] unless entry[:role].to_s == "tool_response"
|
|
157
|
+
|
|
158
|
+
LLMContextEdit.on(entry).values.reject(&:applied?).select { |edit| layers.include?(edit.kind) }
|
|
159
|
+
.map { |edit| Staged.new(index: index, edit: edit, change: false) }
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private_class_method :stage, :choose, :measure, :saved_staged
|
|
164
|
+
end
|
|
165
|
+
end
|