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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -1
  3. data/docs/cli.md +2 -1
  4. data/docs/configuration.md +58 -2
  5. data/docs/hooks.md +1 -0
  6. data/docs/internals/background-tasks.md +1 -1
  7. data/docs/internals/context-telemetry.md +16 -0
  8. data/docs/internals/llm-context-bench.md +133 -0
  9. data/docs/internals/llm-context-forget.md +45 -0
  10. data/docs/internals/prompt-caching.md +3 -2
  11. data/docs/plugins.md +4 -3
  12. data/docs/testing.md +8 -0
  13. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
  15. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/mcp/plugin.rb +10 -10
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/system/self_map.md +4 -0
  19. data/lib/samagotchi/config.rb +43 -4
  20. data/lib/samagotchi/context_note.rb +7 -3
  21. data/lib/samagotchi/context_status.rb +112 -11
  22. data/lib/samagotchi/engine.rb +36 -9
  23. data/lib/samagotchi/guardrails/targets.rb +15 -3
  24. data/lib/samagotchi/host_registry.rb +8 -3
  25. data/lib/samagotchi/kernel_loop.rb +142 -14
  26. data/lib/samagotchi/llm/chat_loop.rb +57 -18
  27. data/lib/samagotchi/llm/turn_settings.rb +5 -4
  28. data/lib/samagotchi/llm_context_apply.rb +165 -0
  29. data/lib/samagotchi/llm_context_edit.rb +102 -0
  30. data/lib/samagotchi/llm_context_forget.rb +258 -0
  31. data/lib/samagotchi/llm_context_stale.rb +271 -0
  32. data/lib/samagotchi/llm_context_strategy.rb +177 -0
  33. data/lib/samagotchi/llm_context_view.rb +201 -0
  34. data/lib/samagotchi/log_subscriber.rb +2 -1
  35. data/lib/samagotchi/prompt.rb +4 -11
  36. data/lib/samagotchi/session_manager.rb +9 -1
  37. data/lib/samagotchi/session_metrics.rb +18 -1
  38. data/lib/samagotchi/system_prompt.rb +20 -15
  39. data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
  40. data/lib/samagotchi/token_usage.rb +3 -0
  41. data/lib/samagotchi/tool_activity.rb +18 -0
  42. data/lib/samagotchi/tool_declarations.rb +60 -4
  43. data/lib/samagotchi/tool_ids.rb +84 -0
  44. data/lib/samagotchi/tool_response.rb +67 -6
  45. data/lib/samagotchi/tools/builtin_calls.rb +3 -2
  46. data/lib/samagotchi/tools/builtins.rb +15 -5
  47. data/lib/samagotchi/tools/context_read.rb +27 -10
  48. data/lib/samagotchi/tools/forget_outputs.rb +62 -0
  49. data/lib/samagotchi/tools/registry.rb +25 -8
  50. data/lib/samagotchi/version.rb +1 -1
  51. data/lib/samagotchi/web/public/app.js +1 -1
  52. data/lib/samagotchi/worker.rb +4 -0
  53. 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
- break if outcome == :answer
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: ContextStatus.new(conversation: conversation),
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, vision: @turn_settings.vision, prefill: turn.prefill)
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
- turn.conversation << ToolResponse.joined(runs)
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, with a stand-in user message cut off at its
714
- # opener. Returns [prompt, images], or nil when the cut isn't found.
715
- def warmup_prompt(messages)
716
- conversation = prepare_conversation(messages) << { role: "user", content: WARMUP_CUT }
717
- prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @turn_settings.vision)
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[call[:name]]
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), with the chat-only enums and
78
- # closed parameters.
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: contents (a
529
- # parts list's text parts) and the tool calls' names and arguments.
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.sum do |entry|
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). The Engine sets them
14
- # once per turn, in Engine#generate, after the model's host is synced.
15
- TurnSettings = Data.define(:vision, :sampling, :thinking, :model_name, :window_setting) do
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