samagotchi 0.155.0 → 0.156.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 (124) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -1
  3. data/bin/chi +1 -1
  4. data/docs/architecture.md +10 -6
  5. data/docs/internals/anthropic.md +1 -1
  6. data/docs/internals/attached-context.md +3 -2
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/internals/engine.md +625 -935
  9. data/docs/internals/llm-context-forget.md +8 -3
  10. data/docs/internals/objectives.md +2 -2
  11. data/docs/internals/prompt-caching.md +4 -1
  12. data/docs/internals/remote-sessions.md +3 -1
  13. data/docs/internals/working-set.md +1 -1
  14. data/docs/sessions.md +2 -2
  15. data/docs/system-one.md +3 -2
  16. data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
  17. data/lib/samagotchi/broadcast_command.rb +3 -3
  18. data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
  19. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  20. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  21. data/lib/samagotchi/child_move.rb +7 -51
  22. data/lib/samagotchi/children_status.rb +1 -1
  23. data/lib/samagotchi/cli/parent_wait.rb +1 -1
  24. data/lib/samagotchi/config.rb +17 -112
  25. data/lib/samagotchi/context_command.rb +1 -2
  26. data/lib/samagotchi/context_window.rb +3 -3
  27. data/lib/samagotchi/desktop/macos.rb +1 -1
  28. data/lib/samagotchi/engine/context_judge.rb +19 -11
  29. data/lib/samagotchi/engine/messages.rb +1 -1
  30. data/lib/samagotchi/engine/models.rb +5 -2
  31. data/lib/samagotchi/engine/objectives.rb +29 -11
  32. data/lib/samagotchi/engine/side_requests.rb +21 -11
  33. data/lib/samagotchi/engine/toolkit.rb +10 -9
  34. data/lib/samagotchi/engine/turn_end.rb +1 -1
  35. data/lib/samagotchi/engine/turn_helpers.rb +7 -7
  36. data/lib/samagotchi/engine/verify.rb +1 -1
  37. data/lib/samagotchi/engine.rb +14 -9
  38. data/lib/samagotchi/guardrails/targets.rb +24 -9
  39. data/lib/samagotchi/host_config.rb +72 -1
  40. data/lib/samagotchi/host_model.rb +2 -6
  41. data/lib/samagotchi/host_registry.rb +1 -1
  42. data/lib/samagotchi/kernel_loop.rb +46 -121
  43. data/lib/samagotchi/llm/anthropic_wire.rb +2 -2
  44. data/lib/samagotchi/llm/chat_loop.rb +34 -18
  45. data/lib/samagotchi/llm/native_backend.rb +1 -1
  46. data/lib/samagotchi/llm_context_apply.rb +54 -1
  47. data/lib/samagotchi/llm_context_notice.rb +1 -1
  48. data/lib/samagotchi/llm_context_strategy.rb +7 -9
  49. data/lib/samagotchi/llm_context_view.rb +9 -0
  50. data/lib/samagotchi/model_price.rb +4 -10
  51. data/lib/samagotchi/model_profile.rb +6 -6
  52. data/lib/samagotchi/model_settings.rb +37 -2
  53. data/lib/samagotchi/note_command.rb +1 -2
  54. data/lib/samagotchi/objectives_audit.rb +4 -3
  55. data/lib/samagotchi/participants.rb +2 -2
  56. data/lib/samagotchi/plugin/context.rb +1 -1
  57. data/lib/samagotchi/plugin/sessions.rb +2 -5
  58. data/lib/samagotchi/plugin/tool_spec.rb +5 -3
  59. data/lib/samagotchi/project_env.rb +1 -1
  60. data/lib/samagotchi/reasoning_history.rb +3 -3
  61. data/lib/samagotchi/reminder_carry.rb +1 -1
  62. data/lib/samagotchi/reprefill_meter.rb +45 -0
  63. data/lib/samagotchi/review_command.rb +1 -1
  64. data/lib/samagotchi/sampling_settings.rb +3 -3
  65. data/lib/samagotchi/self_report.rb +7 -7
  66. data/lib/samagotchi/session.rb +50 -7
  67. data/lib/samagotchi/session_access/local.rb +7 -5
  68. data/lib/samagotchi/session_access/remote.rb +17 -3
  69. data/lib/samagotchi/session_archive_command.rb +2 -2
  70. data/lib/samagotchi/session_chain.rb +7 -6
  71. data/lib/samagotchi/session_continue.rb +6 -5
  72. data/lib/samagotchi/session_delete_command.rb +1 -1
  73. data/lib/samagotchi/session_manager/delivery.rb +254 -0
  74. data/lib/samagotchi/session_manager/emptiness.rb +138 -0
  75. data/lib/samagotchi/session_manager/lifecycle.rb +274 -0
  76. data/lib/samagotchi/session_manager/listing.rb +189 -0
  77. data/lib/samagotchi/session_manager/removal.rb +190 -0
  78. data/lib/samagotchi/session_manager/spawn.rb +242 -0
  79. data/lib/samagotchi/session_manager/worker_command.rb +157 -0
  80. data/lib/samagotchi/session_manager.rb +26 -1540
  81. data/lib/samagotchi/session_metrics.rb +1 -1
  82. data/lib/samagotchi/sessions_command.rb +1 -1
  83. data/lib/samagotchi/share_command.rb +1 -1
  84. data/lib/samagotchi/starting_marker.rb +65 -0
  85. data/lib/samagotchi/steer_cut_after.rb +3 -3
  86. data/lib/samagotchi/system_one/recorder.rb +49 -0
  87. data/lib/samagotchi/terminal_ui/exit_lines.rb +1 -1
  88. data/lib/samagotchi/terminal_ui.rb +2 -6
  89. data/lib/samagotchi/testing/sessions.rb +1 -1
  90. data/lib/samagotchi/thinking.rb +3 -3
  91. data/lib/samagotchi/token_usage.rb +1 -1
  92. data/lib/samagotchi/tool_activity.rb +10 -4
  93. data/lib/samagotchi/tool_declarations.rb +2 -2
  94. data/lib/samagotchi/tool_output_cut.rb +17 -0
  95. data/lib/samagotchi/tool_runner.rb +7 -4
  96. data/lib/samagotchi/toolkit_command.rb +1 -1
  97. data/lib/samagotchi/tools/delegate.rb +10 -5
  98. data/lib/samagotchi/tools/delegate_result.rb +4 -4
  99. data/lib/samagotchi/tools/index_hint.rb +14 -0
  100. data/lib/samagotchi/tools/registry.rb +28 -11
  101. data/lib/samagotchi/tools/send_note.rb +4 -6
  102. data/lib/samagotchi/tools/tool_index.rb +52 -51
  103. data/lib/samagotchi/tools/tool_surface.rb +22 -8
  104. data/lib/samagotchi/turn_context.rb +2 -1
  105. data/lib/samagotchi/turn_stages/context_judge.rb +38 -36
  106. data/lib/samagotchi/turn_stages/objectives.rb +56 -52
  107. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +13 -17
  108. data/lib/samagotchi/turn_stages/verify_anchors.rb +9 -10
  109. data/lib/samagotchi/turn_stages/warmup.rb +14 -15
  110. data/lib/samagotchi/version.rb +1 -1
  111. data/lib/samagotchi/vision_support.rb +3 -3
  112. data/lib/samagotchi/web/app.rb +15 -8
  113. data/lib/samagotchi/web/participant_access.rb +2 -2
  114. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  115. data/lib/samagotchi/web/session_control.rb +3 -3
  116. data/lib/samagotchi/web/session_create.rb +3 -3
  117. data/lib/samagotchi/web/session_hub.rb +1 -1
  118. data/lib/samagotchi/web/session_show.rb +3 -3
  119. data/lib/samagotchi/web/session_summary.rb +3 -4
  120. data/lib/samagotchi/worker.rb +55 -27
  121. data/lib/samagotchi/worker_boot.rb +184 -0
  122. data/lib/samagotchi/worker_signals.rb +1 -1
  123. data/lib/samagotchi/worker_task_wakes.rb +2 -2
  124. metadata +13 -1
@@ -27,6 +27,8 @@ require_relative "tools/builtin_calls"
27
27
  require_relative "tools/tool_surface"
28
28
  require_relative "muted_memories"
29
29
  require_relative "tool_activity"
30
+ require_relative "reprefill_meter"
31
+ require_relative "tool_output_cut"
30
32
  require_relative "tool_runner"
31
33
  require_relative "tool_response"
32
34
  require_relative "tool_ids"
@@ -123,13 +125,12 @@ module Samagotchi
123
125
  # conversation (KernelLoop#forget_outputs).
124
126
  def forget_outputs(call) = @kernel.forget_outputs(call, @context)
125
127
 
126
- # The turn's Tools::ToolSurface (TurnContext#surface), else one
127
- # computed now from the kernel's tools (a bare kernel): what the tool
128
+ # The turn's Tools::ToolSurface (TurnContext#surface; a bare kernel's
129
+ # own context has one too, KernelLoop#own_context): what the tool
128
130
  # index's handlers (find_tools, call_tool) read.
129
- def surface = Tools::ToolSurface.of(@context, @kernel.tools)
131
+ def surface = Tools::ToolSurface.of(@context)
130
132
  end
131
133
 
132
- DEFAULT_MAX_TOOL_OUTPUT_CHARS = 10_000
133
134
  QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_LIMIT = 2
134
135
  # How much of a malformed generation's end the warning keeps.
135
136
  MALFORMED_TAIL_CHARS = 400
@@ -172,12 +173,16 @@ module Samagotchi
172
173
 
173
174
  # @param services [Services, nil] what the kernel holds of its Engine
174
175
  # (Services.none by default: the built-in tools alone)
176
+ # @param reprefill [ReprefillMeter, nil] the re-prefill baseline its
177
+ # requests count against (the Engine's, shared with its ChatLoop; its
178
+ # own by default)
175
179
  # @param sleeper [#call, nil] waits the given seconds before a dropped
176
180
  # stream's step is asked again (specs pass one that doesn't sleep)
177
- def initialize(client: nil, profile: nil, model_name: nil, model_key: nil, services: nil, sleeper: nil)
181
+ def initialize(client: nil, profile: nil, model_name: nil, model_key: nil, services: nil, reprefill: nil, sleeper: nil)
178
182
  @client = client || Client.new
179
183
  @sleeper = sleeper || ->(seconds) { sleep(seconds) }
180
184
  @services = services || Services.none
185
+ @reprefill = reprefill || ReprefillMeter.new
181
186
  resolved_model_name = ModelProfile.required_model_name(model_name)
182
187
  # The context of the run in progress, else of the last run (#context):
183
188
  # a kernel that ran none has its own (no turn, no settings but its
@@ -193,11 +198,14 @@ module Samagotchi
193
198
  # @return [Services] what the kernel holds of its Engine (#attach)
194
199
  attr_reader :services
195
200
 
196
- # Hand the kernel its Engine's services, all at once: the Engine does
197
- # for a kernel it is given (specs) what KernelLoop.new does for its own.
201
+ # Hand the kernel its Engine's services, all at once, and its
202
+ # re-prefill meter (nil keeps the one it has): the Engine does for a
203
+ # kernel it is given (specs) what KernelLoop.new does for its own.
198
204
  # @param services [Services]
205
+ # @param reprefill [ReprefillMeter, nil]
199
206
  # @return [Services]
200
- def attach(services)
207
+ def attach(services, reprefill: nil)
208
+ @reprefill = reprefill if reprefill
201
209
  @services = services
202
210
  end
203
211
 
@@ -288,16 +296,16 @@ module Samagotchi
288
296
  # their thought blocks (#prepare_conversation), as within one run. nil:
289
297
  # every earlier thought is stripped.
290
298
  # @param context [TurnContext] the run's turn (Engine#generate's); a
291
- # bare kernel's last (#context) by default. Its settings take the
299
+ # bare kernel's own (#own_context) by default. Its settings take the
292
300
  # run's model name: this run's own copy, never published.
293
301
  # @return [LLM::ModelResult] final visible response with continuation metadata
294
302
  def run(messages, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil, model_name: nil, max_tool_output_chars: nil, pending_input: nil,
295
- reasoning_since: nil, context: @context)
303
+ reasoning_since: nil, context: own_context)
296
304
  settings = context.settings || LLM::TurnSettings.none
297
305
  @context = context.with(settings: settings.with(model_name: completion_model_name(model_name)))
298
306
  @last_slot = nil
299
307
  turn = start_turn(messages, on_stream_event: on_stream_event, cancel_controller: cancel_controller,
300
- pending_input: pending_input, cap: resolve_output_char_cap(max_tool_output_chars),
308
+ pending_input: pending_input, cap: ToolOutputCut.max_chars(max_tool_output_chars),
301
309
  reasoning_since: reasoning_since)
302
310
 
303
311
  max_iterations.times do |iteration_index|
@@ -330,21 +338,6 @@ module Samagotchi
330
338
  @profile_source = resolution.label
331
339
  end
332
340
 
333
- # Resolve the per-output character cap for the tool outputs the model
334
- # gets back and the emitted tool call events.
335
- #
336
- # Precedence: an explicit override wins, then max_tool_output_chars
337
- # (Config). A non-positive value falls back to
338
- # DEFAULT_MAX_TOOL_OUTPUT_CHARS (there is intentionally no "unlimited" — live UIs get a
339
- # bounded `output:` plus a truthful `output_truncated:` flag). The one
340
- # exception is memory_read, which ToolRunner leaves out of this cap
341
- # (memory.read_max_chars, 0: no fixed cap; a memory is read whole).
342
- # Class-level so the chat loop resolves it the same way.
343
- def self.resolve_output_char_cap(override)
344
- parsed = (override || Samagotchi::Config.get("max_tool_output_chars")).to_i
345
- parsed.positive? ? parsed : DEFAULT_MAX_TOOL_OUTPUT_CHARS
346
- end
347
-
348
341
  private
349
342
 
350
343
  # ── One turn (#run) ────────────────────────────────────────────────────────
@@ -659,7 +652,7 @@ module Samagotchi
659
652
  def cache_fields(cache) = cache ? TokenUsage.cache_fields(**cache) : {}
660
653
 
661
654
  def reprefill_field(model, cache)
662
- tokens = cache && reprefilled_tokens(model, prompt: cache[:prompt], cached: cache[:cached])
655
+ tokens = cache && @reprefill.tokens(model, client: @client, prompt: cache[:prompt], cached: cache[:cached])
663
656
  tokens ? { reprefill_tokens: tokens } : {}
664
657
  end
665
658
 
@@ -877,12 +870,6 @@ module Samagotchi
877
870
  # Coerce a value to boolean — handles true/false, nil, and string "true"/"false".
878
871
  def truthy?(val) = Tools::Builtins.truthy?(val)
879
872
 
880
- # ── Output char cap resolution ─────────────────────────────────────────────
881
-
882
- def resolve_output_char_cap(override)
883
- self.class.resolve_output_char_cap(override)
884
- end
885
-
886
873
  # One request, cancelled by +generation_controller+ (the turn's child)
887
874
  # when there is one.
888
875
  def request_generation(prompt, generation_controller:, cancel_controller:, **)
@@ -1005,14 +992,10 @@ module Samagotchi
1005
992
  WARMUP_CUT = "chi-warmup-cut"
1006
993
  private_constant :WARMUP_CUT
1007
994
 
1008
- # What both loops send of the conversation (LLMContextView), under the
1009
- # turn's strategy (TurnSettings#llm_context, none without one), or the
1010
- # one given (the warm-up's, the next turn's): the prompt, the warm-up
1011
- # and the chat messages all format through it, so the warm-up warms
1012
- # the prompt the next turn sends.
1013
- def llm_context_view(llm_context = turn_settings&.llm_context)
1014
- LLMContextView.new(strategy: llm_context&.strategy || LLMContextView::NONE)
1015
- end
995
+ # What the native prompt and the warm-up send of the conversation
996
+ # (LLMContextView.for), under the turn's strategy or the one given (the
997
+ # warm-up's, the next turn's).
998
+ def llm_context_view(llm_context = turn_settings&.llm_context) = LLMContextView.for(llm_context)
1016
999
 
1017
1000
  # The model's forget_outputs call (LLMContextForget) on the running
1018
1001
  # turn (the loop's llm_context_turn), under +context+'s strategy (the
@@ -1042,84 +1025,19 @@ module Samagotchi
1042
1025
  end
1043
1026
  private :pinned_ids
1044
1027
 
1045
- # Before each request of both loops (+moment+ :request; the warm-up
1046
- # too, on its own copy, as the next turn's first request) and at the
1047
- # end of a turn the model answered (:turn_end): the strategy's layers
1048
- # stage their edits on +conversation+ and the apply rule
1049
- # (LLMContextApply, llm_context.apply) saves the batch it lets through
1050
- # as applied, so the request about to be sent (or the next turn's
1051
- # first, and the warm-up) is the first with the stubs. +context+: the
1052
- # turn's ContextStatus (payoff applies in its top bucket; an applied
1053
- # batch rebases its estimate, ContextStatus#edited!). none changes
1054
- # nothing. Logged as llm_context_apply when a batch is applied, and
1055
- # shown to the user as a ✂ row (+emit+ takes the :llm_context_edited
1056
- # event, LLMContextNotice; the warm-up's own copy has none).
1028
+ # The strategy's edits on +conversation+ (LLMContextApply.apply!), under
1029
+ # the turn's strategy and model name by default: the native run's
1030
+ # requests and turn end, the warm-up's copy, a forget_outputs call.
1057
1031
  # @return [LLMContextApply::Outcome]
1058
- # +model+: the run's model name, in the log line (the chat loop's own).
1059
- def apply_llm_context!(conversation, llm_context = turn_settings&.llm_context, moment: :request, context: nil,
1060
- warmup: false, emit: nil, model: turn_settings&.model_name)
1061
- view = llm_context_view(llm_context)
1062
- return LLMContextApply::Outcome.none if view.none?
1063
-
1064
- outcome = LLMContextApply.run!(conversation, layers: view.layers, rule: llm_context.apply, moment: moment,
1065
- protect_steps: llm_context.protect_steps,
1066
- top_bucket: context&.top_bucket? || false,
1067
- changes: llm_context.stale_edits)
1068
- if outcome.applied?
1069
- # The prompt just shrank: the context estimate counts from it.
1070
- context&.edited!
1071
- unless warmup
1072
- log_llm_context(outcome, llm_context, moment, model)
1073
- emit_llm_context(emit, conversation, outcome, moment) if emit
1074
- end
1075
- end
1076
- outcome
1077
- end
1078
-
1079
- def log_llm_context(outcome, llm_context, moment, model)
1080
- Log.info(:model, "llm_context_apply", rule: llm_context.apply, at: moment, why: outcome.why,
1081
- applied: outcome.applied.size, ids: outcome.applied.map(&:id).join(","),
1082
- staged: outcome.staged, freed: (outcome.freed_chars / TokenUsage::CHARS_PER_TOKEN).ceil,
1083
- tail: (outcome.tail_chars / TokenUsage::CHARS_PER_TOKEN).ceil,
1084
- model: model)
1085
- end
1086
- private :log_llm_context
1087
-
1088
- # The batch's ✂ row; a row that can't be built never breaks the turn.
1089
- def emit_llm_context(emit, conversation, outcome, moment)
1090
- emit.call(LLMContextNotice.event(conversation, outcome, moment: moment))
1091
- rescue StandardError => e
1092
- Log.warn(:model, "llm_context_notice_failed", error: e.class.name, message: e.message)
1093
- end
1094
- private :emit_llm_context
1095
-
1096
- # A request's re-prefilled tokens, for both loops: of the prompt the
1097
- # last request on +model+ through this client (the host) sent (this
1098
- # kernel's, any turn), what the server didn't reuse from its cache this
1099
- # time (that prompt less +cached+), so an LLM context edit's cache break
1100
- # shows, and so does anything else that rewrote the prompt's middle or
1101
- # lost the cache. nil under TokenUsage::REPREFILL_MIN_TOKENS (a cache
1102
- # block's rounding), for a server that reports no cached count (or 0: a
1103
- # cold cache isn't told from no cache), the first request, a model
1104
- # switch (another bare model, or the same one on another host: the
1105
- # Engine points #client at the new host's; SessionMetrics counts that
1106
- # prefill as the switch's), and the first request after a warm-up
1107
- # (#warmed_up!).
1108
- # @return [Integer, nil]
1109
- def reprefilled_tokens(model, prompt:, cached:)
1110
- previous = @reprefill_baseline
1111
- @reprefill_baseline = { model: model, client: @client, prompt: prompt.to_i } if prompt.to_i.positive?
1112
- return nil unless previous && previous[:model] == model && previous[:client].equal?(@client) && cached.to_i.positive?
1113
-
1114
- tokens = previous[:prompt] - cached.to_i
1115
- tokens >= TokenUsage::REPREFILL_MIN_TOKENS ? tokens : nil
1032
+ def apply_llm_context!(conversation, llm_context = turn_settings&.llm_context, model: turn_settings&.model_name, **)
1033
+ LLMContextApply.apply!(conversation, llm_context, model: model, **)
1116
1034
  end
1117
1035
 
1118
1036
  # The Engine started a turn-end warm-up: it prefills the next prompt
1119
1037
  # off the clock, so the next request's cache counts are against it, not
1120
- # the last request (#reprefilled_tokens starts over).
1038
+ # the last request (the re-prefill meter starts over, ReprefillMeter#reset!).
1121
1039
  def warmed_up!
1122
- @reprefill_baseline = nil
1040
+ @reprefill.reset!
1123
1041
  end
1124
1042
 
1125
1043
  # Public wrapper so other loops (e.g. the chat loop) can strip
@@ -1212,21 +1130,28 @@ module Samagotchi
1212
1130
  # first (see Samagotchi::LLM::NativeToolNormalizer); dispatch itself never
1213
1131
  # parses provider text. +context+: the call's turn (TurnContext: its
1214
1132
  # ToolSurface, the handler's ToolContext); a bare kernel's own
1215
- # (#context) by default.
1216
- def dispatch_tool_call(call, context = @context)
1133
+ # (#own_context) by default.
1134
+ def dispatch_tool_call(call, context = own_context)
1217
1135
  dispatch(call, context)
1218
1136
  end
1219
1137
 
1138
+ # The context a bare kernel (no Engine: a spec, a bench) runs in when
1139
+ # given none: #context, with a Tools::ToolSurface built now from the
1140
+ # kernel's tools when it has none (Tools::ToolSurface.bare). An
1141
+ # Engine's turn always hands its own down (Engine#generate's surface).
1142
+ # @return [TurnContext]
1143
+ def own_context = Tools::ToolSurface.bare(@context, tools)
1144
+
1220
1145
  private
1221
1146
 
1222
1147
  # The call runs on the entry the turn's surface gives it
1223
1148
  # (Tools::ToolSurface#entry_for: a call_tool-reached one only while the
1224
1149
  # toolkit reaches it that way, a reachable one by its name on a host
1225
1150
  # that loads tools); without one it gets ToolSurface#unknown_tool_error.
1226
- # A context without a surface (a bare kernel) computes one now.
1151
+ # A context without a surface raises Tools::MissingSurface.
1227
1152
  def dispatch(call, context)
1228
1153
  model = context.settings&.model_name
1229
- surface = Tools::ToolSurface.of(context, tools)
1154
+ surface = Tools::ToolSurface.of(context)
1230
1155
  entry = surface.entry_for(call)
1231
1156
  unless entry
1232
1157
  result = surface.unknown_tool_error(call[:name], via: call[:via])
@@ -1271,10 +1196,10 @@ module Samagotchi
1271
1196
  rescue StandardError => e
1272
1197
  dump_log("tool_error", e.message, model: context.settings&.model_name, tool: call[:name], error: e.class.name)
1273
1198
  result = "Error: #{e.message}"
1274
- {
1275
- output: "[#{call[:name]}] #{result}",
1276
- activity: ToolActivity.tool_activity_event(call[:name], call, result, registry: tools, surface: surface)
1277
- }
1199
+ # No surface (Tools::MissingSurface from ToolSurface.of): no row either,
1200
+ # it would read the surface again.
1201
+ activity = surface && ToolActivity.tool_activity_event(call[:name], call, result, registry: tools, surface: surface)
1202
+ { output: "[#{call[:name]}] #{result}", activity: activity }
1278
1203
  end
1279
1204
  end
1280
1205
  end
@@ -4,7 +4,7 @@ require "digest"
4
4
  require "json"
5
5
  require_relative "../log"
6
6
  require_relative "utf8_scrub"
7
- require_relative "../tools/tool_index"
7
+ require_relative "../tools/index_hint"
8
8
 
9
9
  module Samagotchi
10
10
  module LLM
@@ -278,7 +278,7 @@ module Samagotchi
278
278
  # don't change; only this wire says it).
279
279
  def loaded_text(text, names)
280
280
  line = "Loaded: #{names.join(", ")}. #{LOADED_HINT}"
281
- text = text.sub("#{Tools::ToolIndex::CALL_HINT}\n\n", "")
281
+ text = text.sub("#{Tools::IndexHint::CALL_HINT}\n\n", "")
282
282
  blank?(text) ? line : "#{line}\n\n#{text}"
283
283
  end
284
284
 
@@ -20,6 +20,7 @@ require_relative "../cut_policy"
20
20
  require_relative "../context_status"
21
21
  require_relative "../context_window"
22
22
  require_relative "../context_note"
23
+ require_relative "../tool_output_cut"
23
24
  require_relative "../tool_runner"
24
25
  require_relative "../tool_response"
25
26
  require_relative "../tool_ids"
@@ -27,7 +28,10 @@ require_relative "../tool_declarations"
27
28
  require_relative "../tools/tool_surface"
28
29
  require_relative "../tool_loads"
29
30
  require_relative "../vision_context"
31
+ require_relative "../llm_context_apply"
32
+ require_relative "../llm_context_view"
30
33
  require_relative "../log"
34
+ require_relative "../reprefill_meter"
31
35
  require_relative "../empty_answer_retry"
32
36
  require_relative "../steer"
33
37
  require_relative "../reasoning_history"
@@ -66,6 +70,12 @@ module Samagotchi
66
70
  # The session every request is for; Engine sets it with the adapter.
67
71
  # Goes out as a Session-Id header (OpenAIChat#chat).
68
72
  attr_accessor :session_id
73
+ # The effective model's host's client (EffectiveModel#client): what
74
+ # #context_window probes on a local host (a llama.cpp /props) and what
75
+ # a request's re-prefill is counted against. Engine sets it with the
76
+ # adapter (Engine#backend_for, under its chat backend mutex, from any
77
+ # thread a model switch runs on); nil until then (no probe).
78
+ attr_accessor :client
69
79
  # The last counted request's chars per token
70
80
  # (ContextStatus#counted_chars_per_token), carried to the next turn's
71
81
  # tracker: its first estimate counts the tool schemas the chars leave
@@ -73,6 +83,9 @@ module Samagotchi
73
83
  attr_accessor :counted_chars_per_token
74
84
 
75
85
  # @param kernel [KernelLoop] tool dispatch, thought stripping, hooks
86
+ # @param reprefill [ReprefillMeter, nil] the re-prefill baseline its
87
+ # requests count against (the Engine's, shared with its KernelLoop;
88
+ # its own by default)
76
89
  # @param adapter [OpenAIChat, nil] the host to talk to; Engine sets it
77
90
  # per turn for the effective model's host
78
91
  # @param stable_length [#call, nil] text → the length of the system
@@ -81,8 +94,9 @@ module Samagotchi
81
94
  # stream's step is asked again (specs pass one that doesn't sleep)
82
95
  # @param context [TurnContext, nil] the turn its helpers read before any
83
96
  # #complete (a bench building wire messages); a run's replaces it
84
- def initialize(kernel:, adapter: nil, stable_length: nil, sleeper: nil, context: nil)
97
+ def initialize(kernel:, reprefill: nil, adapter: nil, stable_length: nil, sleeper: nil, context: nil)
85
98
  @kernel = kernel
99
+ @reprefill = reprefill || ReprefillMeter.new
86
100
  @turn_context = context
87
101
  @adapter = adapter
88
102
  @stable_length = stable_length
@@ -106,7 +120,7 @@ module Samagotchi
106
120
  @turn_context = context if context
107
121
  conversation = Array(messages).map(&:dup)
108
122
  Run.new(self, conversation, on_stream_event, cancel_controller, model_name, pending_input, start: reasoning_since)
109
- .call(max_iterations: max_iterations || 1, cap: KernelLoop.resolve_output_char_cap(max_tool_output_chars))
123
+ .call(max_iterations: max_iterations || 1, cap: ToolOutputCut.max_chars(max_tool_output_chars))
110
124
  rescue StandardError, Interrupt => e
111
125
  # A failure, or a Ctrl-C (its calls answered: ToolResponse.run_batch),
112
126
  # carries the conversation so far for the Engine to keep.
@@ -137,10 +151,9 @@ module Samagotchi
137
151
  # (ToolSurface#deferred_for).
138
152
  def deferred_tools(conversation) = surface.deferred_for(conversation)
139
153
 
140
- # The turn's ToolSurface (#turn_context's), else one computed now from
141
- # the kernel's tools (a bare loop).
154
+ # The turn's ToolSurface (#turn_context's).
142
155
  # @return [Tools::ToolSurface]
143
- def surface = Tools::ToolSurface.of(turn_context, tools)
156
+ def surface = Tools::ToolSurface.of(turn_context)
144
157
 
145
158
  # Whether the adapter takes tools it doesn't declare up front for
146
159
  # +model+ (AnthropicMessages#loads_tools?); OpenAIChat doesn't.
@@ -159,9 +172,12 @@ module Samagotchi
159
172
 
160
173
  # The TurnContext of the run in progress, else of the last run
161
174
  # (#complete's context:); before any, the one it was built with, else a
162
- # context of the kernel's own settings (a bare loop). Read on the turn's thread only.
175
+ # context of the kernel's own settings and a surface of its tools
176
+ # built now (a bare loop, Tools::ToolSurface.bare). Read on the turn's thread only.
163
177
  # @return [TurnContext]
164
- def turn_context = @turn_context || TurnContext.none(settings: @kernel.turn_settings)
178
+ def turn_context
179
+ @turn_context || Tools::ToolSurface.bare(TurnContext.none(settings: @kernel.turn_settings), tools)
180
+ end
165
181
 
166
182
  # The turn's LLM::TurnSettings (#turn_context's).
167
183
  def settings = turn_context.settings
@@ -173,16 +189,17 @@ module Samagotchi
173
189
  # when none was resolved.
174
190
  def llm_context = settings&.llm_context
175
191
 
176
- # KernelLoop#reprefilled_tokens, the kernel's request history.
177
- def reprefilled_tokens(model, **) = @kernel.reprefilled_tokens(model, **)
192
+ # The request's re-prefilled tokens on the loop's meter, against this
193
+ # loop's host (#client): ReprefillMeter#tokens.
194
+ def reprefilled_tokens(model, **) = @reprefill.tokens(model, client: @client, **)
178
195
 
179
- # The kernel's LLMContextView under the turn's strategy.
180
- def llm_context_view = @kernel.llm_context_view(llm_context)
196
+ # The LLMContextView under the turn's strategy (LLMContextView.for).
197
+ def llm_context_view = LLMContextView.for(llm_context)
181
198
 
182
199
  # The strategy's own edits before a request and at turn end
183
- # (KernelLoop#apply_llm_context!), under the turn's strategy.
200
+ # (LLMContextApply.apply!), under the turn's strategy and model name.
184
201
  def apply_llm_context!(conversation, **)
185
- @kernel.apply_llm_context!(conversation, llm_context, model: settings&.model_name, **)
202
+ LLMContextApply.apply!(conversation, llm_context, model: settings&.model_name, **)
186
203
  end
187
204
 
188
205
  # The running turn, for forget_outputs (KernelLoop#llm_context_turn).
@@ -398,14 +415,13 @@ module Samagotchi
398
415
  @tool_runner ||= ToolRunner.new(@kernel, window: ->(run_settings) { context_window(run_settings&.model_name, run_settings) })
399
416
  end
400
417
 
401
- # The window for +model+: the kernel's client probes the server it
402
- # points at (Engine keeps it on the effective model's host; a local
403
- # llama.cpp answers /props), then the host's model list, the model's
418
+ # The window for +model+: the loop's client (#client) probes the server
419
+ # it points at (a local llama.cpp answers /props), then the host's model list, the model's
404
420
  # or host's window_tokens, config, env and the default. A remote
405
421
  # provider has no /props to probe. +run_settings+: the LLM::TurnSettings
406
422
  # whose window_setting counts (the turn's by default).
407
423
  def context_window(model, run_settings = settings)
408
- client = @adapter.remote? ? nil : @kernel.client
424
+ client = @adapter.remote? ? nil : @client
409
425
  ContextWindow.resolve(client: client, model: model, adapter: @adapter, setting: run_settings&.window_setting)
410
426
  rescue StandardError
411
427
  nil
@@ -897,7 +913,7 @@ module Samagotchi
897
913
  count
898
914
  end
899
915
 
900
- # The request's re-prefilled tokens (KernelLoop#reprefilled_tokens).
916
+ # The request's re-prefilled tokens (ChatLoop#reprefilled_tokens).
901
917
  def reprefill_field(usage)
902
918
  return {} unless usage&.source == :server
903
919
 
@@ -27,7 +27,7 @@ module Samagotchi
27
27
  max_tool_output_chars: max_tool_output_chars,
28
28
  pending_input: pending_input,
29
29
  reasoning_since: reasoning_since,
30
- context: context || @kernel.context
30
+ context: context || @kernel.own_context
31
31
  )
32
32
  end
33
33
  end
@@ -4,8 +4,14 @@ require "time"
4
4
  require_relative "llm_context_edit"
5
5
  require_relative "llm_context_stale"
6
6
  require_relative "llm_context_view"
7
+ require_relative "log"
8
+ require_relative "token_usage"
7
9
 
8
10
  module Samagotchi
11
+ # The ✂ row's builder (#apply!), loaded on first use: the test kit loads
12
+ # this file (through the strategy) and never emits a row.
13
+ autoload :LLMContextNotice, File.expand_path("llm_context_notice", __dir__)
14
+
9
15
  # When a layer's edits reach the prompt: the apply rule
10
16
  # (llm_context.apply, LLMContextStrategy). Every edit breaks the prompt
11
17
  # cache from the earliest entry it changes, so the edits due at one point
@@ -59,6 +65,53 @@ module Samagotchi
59
65
 
60
66
  module_function
61
67
 
68
+ # Before each request of both loops (+moment+ :request; the warm-up
69
+ # too, on its own copy, as the next turn's first request) and at the
70
+ # end of a turn the model answered (:turn_end): +llm_context+'s layers
71
+ # (LLMContextStrategy::Resolved, the turn's) stage their edits on
72
+ # +conversation+ and its apply rule (#run!) saves the batch it lets
73
+ # through as applied, so the request about to be sent (or the next
74
+ # turn's first, and the warm-up) is the first with the stubs.
75
+ # +context+: the turn's ContextStatus (payoff applies in its top bucket;
76
+ # an applied batch rebases its estimate, ContextStatus#edited!). none
77
+ # changes nothing. Logged as llm_context_apply when a batch is applied
78
+ # (+model+: the run's model name, in the line), and shown to the user as
79
+ # a ✂ row (+emit+ takes the :llm_context_edited event, LLMContextNotice;
80
+ # the warm-up's own copy, +warmup+, has neither).
81
+ # @return [Outcome]
82
+ def apply!(conversation, llm_context, moment: :request, context: nil, warmup: false, emit: nil, model: nil)
83
+ view = LLMContextView.for(llm_context)
84
+ return Outcome.none if view.none?
85
+
86
+ outcome = run!(conversation, layers: view.layers, rule: llm_context.apply, moment: moment,
87
+ protect_steps: llm_context.protect_steps, top_bucket: context&.top_bucket? || false,
88
+ changes: llm_context.stale_edits)
89
+ if outcome.applied?
90
+ # The prompt just shrank: the context estimate counts from it.
91
+ context&.edited!
92
+ unless warmup
93
+ log_applied(outcome, llm_context, moment, model)
94
+ emit_applied(emit, conversation, outcome, moment) if emit
95
+ end
96
+ end
97
+ outcome
98
+ end
99
+
100
+ def log_applied(outcome, llm_context, moment, model)
101
+ Log.info(:model, "llm_context_apply", rule: llm_context.apply, at: moment, why: outcome.why,
102
+ applied: outcome.applied.size, ids: outcome.applied.map(&:id).join(","),
103
+ staged: outcome.staged, freed: (outcome.freed_chars / TokenUsage::CHARS_PER_TOKEN).ceil,
104
+ tail: (outcome.tail_chars / TokenUsage::CHARS_PER_TOKEN).ceil,
105
+ model: model)
106
+ end
107
+
108
+ # The batch's ✂ row; a row that can't be built never breaks the turn.
109
+ def emit_applied(emit, conversation, outcome, moment)
110
+ emit.call(LLMContextNotice.event(conversation, outcome, moment: moment))
111
+ rescue StandardError => e
112
+ Log.warn(:model, "llm_context_notice_failed", error: e.class.name, message: e.message)
113
+ end
114
+
62
115
  # Stages what +layers+ find on +conversation+ and applies what +rule+
63
116
  # lets through at +moment+.
64
117
  # @param top_bucket [Boolean] the context is in the top ContextStatus bucket
@@ -160,6 +213,6 @@ module Samagotchi
160
213
  end
161
214
  end
162
215
 
163
- private_class_method :stage, :choose, :measure, :saved_staged
216
+ private_class_method :stage, :choose, :measure, :saved_staged, :log_applied, :emit_applied
164
217
  end
165
218
  end
@@ -7,7 +7,7 @@ require_relative "token_usage"
7
7
  module Samagotchi
8
8
  # The ✂ row a turn gets for a batch of LLM context edits the apply rule
9
9
  # let through (LLMContextApply::Outcome#applied?): the :llm_context_edited
10
- # event both loops emit (KernelLoop#apply_llm_context!, its +emit+), at a
10
+ # event both loops emit (LLMContextApply.apply!, its +emit+), at a
11
11
  # request (+moment+ :request; a forget_outputs call's batch too) or at the
12
12
  # end of a turn (:turn_end); never for the warm-up, a held or a staged
13
13
  # batch, or a restore (the forget_outputs row says it). The web and the
@@ -35,11 +35,9 @@ module Samagotchi
35
35
  LAYERS = %i[stale forget].freeze
36
36
  # The layers a turn can run under so far.
37
37
  BUILT = %i[stale forget].freeze
38
- # The sentence forget_outputs' description carries (global only):
39
- # the plan's D7 line, adapted from CLM's steering example.
38
+ # The sentence forget_outputs' description carries (global only; its
39
+ # default is Config::DEFAULT_LLM_CONTEXT_POLICY).
40
40
  POLICY_SETTING = "llm_context.policy"
41
- DEFAULT_POLICY = "Tidy at subtask boundaries: once a subtask is done, forget its tool outputs and note what it " \
42
- "established; keep anything you'll still edit against."
43
41
 
44
42
  APPLY_SETTING = "llm_context.apply"
45
43
  APPLY_KEY = "llm_context_apply"
@@ -174,7 +172,7 @@ module Samagotchi
174
172
  # The strategy for +target+'s turn.
175
173
  # @param target [HostRegistry::ModelTarget, nil]
176
174
  # @param names [Array<String>] HostRegistry#lookup_names
177
- # @param models [Hash{String => ModelSettings}, nil] ConfigFile.model_settings (specs)
175
+ # @param models [Hash{String => ModelSettings}, nil] ModelSettings.all (specs)
178
176
  # @param session [Array<Symbol>, nil] the session's own layers ([]: none)
179
177
  # @param session_apply [Symbol, nil] the session's own apply rule
180
178
  # @param session_budget [Integer, nil] the session's own budget (0: off)
@@ -187,7 +185,7 @@ module Samagotchi
187
185
  # #resolve, with where each value came from.
188
186
  # @return [Explained]
189
187
  def explain(target, names:, models: nil, session: nil, session_apply: nil, session_budget: nil)
190
- models ||= ConfigFile.model_settings
188
+ models ||= ModelSettings.all
191
189
  layers, source, where = layers_for(target, names, models, session)
192
190
  apply, apply_origin = apply_for(target, names, models, session_apply)
193
191
  budget, budget_origin = budget_for(target, names, models, session_budget)
@@ -236,7 +234,7 @@ module Samagotchi
236
234
  def budget_for(target, names, models, session_budget)
237
235
  return [session_budget.positive? ? session_budget : nil, SESSION_ORIGIN] unless session_budget.nil?
238
236
 
239
- if (setting = ConfigFile.model_setting(names, :llm_context_budget_tokens, models: models))
237
+ if (setting = ModelSettings.find(names, :llm_context_budget_tokens, models: models))
240
238
  return [setting.value, Origin.new(source: :model_setting, where: "models: #{setting.key}")]
241
239
  end
242
240
 
@@ -249,7 +247,7 @@ module Samagotchi
249
247
  def layers_for(target, names, models, session)
250
248
  return [session, :session, SESSION_ORIGIN.where] if session
251
249
 
252
- if (setting = ConfigFile.model_setting(names, :llm_context_strategy, models: models))
250
+ if (setting = ModelSettings.find(names, :llm_context_strategy, models: models))
253
251
  return [setting.value, :model_setting, "models: #{setting.key}"]
254
252
  end
255
253
 
@@ -263,7 +261,7 @@ module Samagotchi
263
261
  def apply_for(target, names, models, session_apply)
264
262
  return [session_apply, SESSION_ORIGIN] if session_apply
265
263
 
266
- if (setting = ConfigFile.model_setting(names, :llm_context_apply, models: models))
264
+ if (setting = ModelSettings.find(names, :llm_context_apply, models: models))
267
265
  return [setting.value, Origin.new(source: :model_setting, where: "models: #{setting.key}")]
268
266
  end
269
267
 
@@ -73,6 +73,15 @@ module Samagotchi
73
73
  end
74
74
  end
75
75
 
76
+ # The view under +llm_context+'s strategy (LLMContextStrategy::Resolved,
77
+ # a turn's TurnSettings#llm_context), none without one: what both loops
78
+ # format the conversation through, so the warm-up warms the prompt the
79
+ # next turn sends.
80
+ # @return [LLMContextView]
81
+ def self.for(llm_context)
82
+ new(strategy: llm_context&.strategy || NONE)
83
+ end
84
+
76
85
  # @param strategy [Symbol, Array<Symbol>] :none, or the layers
77
86
  def initialize(strategy: NONE)
78
87
  @layers = strategy == NONE ? [] : Array(strategy).map(&:to_sym) & LAYERS
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "config"
4
+
3
5
  module Samagotchi
4
6
  # A model's price on a host (hosts.<name>.models.<id>.price), in USD per
5
7
  # 1M tokens: what a generation's cost is estimated from when the provider
@@ -13,7 +15,7 @@ module Samagotchi
13
15
  def self.parse(raw, where)
14
16
  return nil if raw.nil?
15
17
 
16
- values = raw.is_a?(Hash) ? raw.transform_keys(&:to_s).slice(*ModelPrice::KEYS) : nil
18
+ values = raw.is_a?(Hash) ? raw.transform_keys(&:to_s).slice(*Config::MODEL_PRICE_KEYS) : nil
17
19
  valid = values && values.key?("input") && values.key?("output") &&
18
20
  # YAML's .inf and .nan are Floats too: an infinite price
19
21
  # isn't one, and NaN poisons the sums.
@@ -26,12 +28,7 @@ module Samagotchi
26
28
  cache_write: values.fetch("cache_write", values["input"]), output: values["output"])
27
29
  end
28
30
 
29
- # config.rb requires model_price through host_model, so config is
30
- # required here only when a warning needs it.
31
- def self.warn_once(message)
32
- require_relative "config"
33
- ConfigFile.warn_once(message)
34
- end
31
+ def self.warn_once(message) = ConfigFile.warn_once(message)
35
32
  private_class_method :warn_once
36
33
 
37
34
  # The cost rule, one place for a turn's generation (SessionMetrics) and
@@ -63,7 +60,4 @@ module Samagotchi
63
60
  (completion_tokens * output)) / 1e6
64
61
  end
65
62
  end
66
-
67
- # The keys a price may hold (config validation checks them too).
68
- ModelPrice::KEYS = %w[input cache_read cache_write output].freeze
69
63
  end