samagotchi 0.154.1 → 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 (185) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/bin/chi +1 -1
  4. data/docs/architecture.md +12 -7
  5. data/docs/bundles.md +21 -0
  6. data/docs/cli.md +1 -1
  7. data/docs/guardrails.md +2 -2
  8. data/docs/hooks.md +1 -1
  9. data/docs/internals/anthropic.md +5 -5
  10. data/docs/internals/attached-context.md +9 -6
  11. data/docs/internals/background-tasks.md +1 -1
  12. data/docs/internals/block-renderers.md +8 -1
  13. data/docs/internals/engine.md +642 -831
  14. data/docs/internals/inline-widgets.md +136 -0
  15. data/docs/internals/llm-context-forget.md +8 -3
  16. data/docs/internals/objectives.md +8 -6
  17. data/docs/internals/participants.md +5 -5
  18. data/docs/internals/prompt-caching.md +13 -6
  19. data/docs/internals/remote-sessions.md +3 -1
  20. data/docs/internals/web-frontend.md +1 -1
  21. data/docs/internals/working-set.md +2 -1
  22. data/docs/plugins.md +2 -0
  23. data/docs/releasing.md +1 -1
  24. data/docs/sessions.md +4 -4
  25. data/docs/system-one.md +3 -2
  26. data/docs/testing.md +1 -1
  27. data/lib/samagotchi/block_renderers.rb +5 -0
  28. data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
  29. data/lib/samagotchi/broadcast_command.rb +3 -3
  30. data/lib/samagotchi/bundle_command/check.rb +1 -1
  31. data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
  32. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  33. data/lib/samagotchi/bundles/inline-widgets/manifest.yml +11 -0
  34. data/lib/samagotchi/bundles/inline-widgets/plugin.rb +76 -0
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  36. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  37. data/lib/samagotchi/child_move.rb +7 -51
  38. data/lib/samagotchi/children_status.rb +1 -1
  39. data/lib/samagotchi/cli/parent_wait.rb +1 -1
  40. data/lib/samagotchi/config.rb +17 -112
  41. data/lib/samagotchi/context_command.rb +1 -2
  42. data/lib/samagotchi/context_window.rb +3 -3
  43. data/lib/samagotchi/desktop/macos.rb +1 -1
  44. data/lib/samagotchi/engine/context_judge.rb +21 -119
  45. data/lib/samagotchi/engine/guardrails.rb +4 -2
  46. data/lib/samagotchi/engine/messages.rb +9 -6
  47. data/lib/samagotchi/engine/models.rb +5 -2
  48. data/lib/samagotchi/engine/objectives.rb +30 -80
  49. data/lib/samagotchi/engine/participants.rb +15 -10
  50. data/lib/samagotchi/engine/plugins.rb +3 -1
  51. data/lib/samagotchi/engine/questions.rb +1 -1
  52. data/lib/samagotchi/engine/side_requests.rb +26 -13
  53. data/lib/samagotchi/engine/toolkit.rb +35 -191
  54. data/lib/samagotchi/engine/turn_end.rb +1 -1
  55. data/lib/samagotchi/engine/turn_helpers.rb +16 -39
  56. data/lib/samagotchi/engine/verify.rb +7 -143
  57. data/lib/samagotchi/engine.rb +166 -98
  58. data/lib/samagotchi/engine_peers.rb +1 -1
  59. data/lib/samagotchi/event_fanout.rb +18 -12
  60. data/lib/samagotchi/guardrail_wiring.rb +83 -82
  61. data/lib/samagotchi/guardrails/gate.rb +41 -40
  62. data/lib/samagotchi/guardrails/pair_boundary.rb +2 -1
  63. data/lib/samagotchi/guardrails/review_boundary.rb +2 -1
  64. data/lib/samagotchi/guardrails/targets.rb +28 -11
  65. data/lib/samagotchi/guardrails_command.rb +1 -1
  66. data/lib/samagotchi/host_config.rb +72 -1
  67. data/lib/samagotchi/host_model.rb +2 -6
  68. data/lib/samagotchi/host_registry.rb +1 -1
  69. data/lib/samagotchi/inline_widgets.rb +325 -0
  70. data/lib/samagotchi/kernel_loop.rb +215 -313
  71. data/lib/samagotchi/llm/anthropic_wire.rb +2 -2
  72. data/lib/samagotchi/llm/backend.rb +3 -1
  73. data/lib/samagotchi/llm/chat_loop.rb +87 -49
  74. data/lib/samagotchi/llm/native_backend.rb +3 -2
  75. data/lib/samagotchi/llm_context_apply.rb +54 -1
  76. data/lib/samagotchi/llm_context_forget.rb +5 -3
  77. data/lib/samagotchi/llm_context_notice.rb +1 -1
  78. data/lib/samagotchi/llm_context_strategy.rb +7 -9
  79. data/lib/samagotchi/llm_context_view.rb +9 -0
  80. data/lib/samagotchi/model_binding.rb +3 -3
  81. data/lib/samagotchi/model_price.rb +4 -10
  82. data/lib/samagotchi/model_profile.rb +6 -6
  83. data/lib/samagotchi/model_settings.rb +37 -2
  84. data/lib/samagotchi/note_command.rb +1 -2
  85. data/lib/samagotchi/objectives_audit.rb +4 -3
  86. data/lib/samagotchi/participants.rb +2 -2
  87. data/lib/samagotchi/plugin/context.rb +1 -1
  88. data/lib/samagotchi/plugin/prompt_sections.rb +7 -0
  89. data/lib/samagotchi/plugin/sessions.rb +2 -5
  90. data/lib/samagotchi/plugin/tool_spec.rb +5 -3
  91. data/lib/samagotchi/project_env.rb +1 -1
  92. data/lib/samagotchi/question_desk.rb +1 -1
  93. data/lib/samagotchi/reasoning_history.rb +3 -3
  94. data/lib/samagotchi/reminder_carry.rb +1 -1
  95. data/lib/samagotchi/reprefill_meter.rb +45 -0
  96. data/lib/samagotchi/review_command.rb +1 -1
  97. data/lib/samagotchi/revise_anchor.rb +1 -1
  98. data/lib/samagotchi/sampling_settings.rb +3 -3
  99. data/lib/samagotchi/self_report.rb +7 -7
  100. data/lib/samagotchi/session.rb +50 -7
  101. data/lib/samagotchi/session_access/local.rb +7 -5
  102. data/lib/samagotchi/session_access/remote.rb +17 -3
  103. data/lib/samagotchi/session_archive_command.rb +2 -2
  104. data/lib/samagotchi/session_chain.rb +7 -6
  105. data/lib/samagotchi/session_continue.rb +6 -5
  106. data/lib/samagotchi/session_delete_command.rb +1 -1
  107. data/lib/samagotchi/session_manager/delivery.rb +254 -0
  108. data/lib/samagotchi/session_manager/emptiness.rb +138 -0
  109. data/lib/samagotchi/session_manager/lifecycle.rb +274 -0
  110. data/lib/samagotchi/session_manager/listing.rb +189 -0
  111. data/lib/samagotchi/session_manager/removal.rb +190 -0
  112. data/lib/samagotchi/session_manager/spawn.rb +242 -0
  113. data/lib/samagotchi/session_manager/worker_command.rb +157 -0
  114. data/lib/samagotchi/session_manager.rb +26 -1540
  115. data/lib/samagotchi/session_metrics.rb +1 -1
  116. data/lib/samagotchi/sessions_command.rb +1 -1
  117. data/lib/samagotchi/share_command.rb +1 -1
  118. data/lib/samagotchi/starting_marker.rb +65 -0
  119. data/lib/samagotchi/steer_cut_after.rb +3 -3
  120. data/lib/samagotchi/system_one/recorder.rb +49 -0
  121. data/lib/samagotchi/system_prompt.rb +37 -30
  122. data/lib/samagotchi/terminal_ui/exit_lines.rb +1 -1
  123. data/lib/samagotchi/terminal_ui.rb +2 -6
  124. data/lib/samagotchi/testing/boundary.rb +7 -0
  125. data/lib/samagotchi/testing/bundle.rb +7 -5
  126. data/lib/samagotchi/testing/install.rb +3 -2
  127. data/lib/samagotchi/testing/sessions.rb +1 -1
  128. data/lib/samagotchi/thinking.rb +3 -3
  129. data/lib/samagotchi/token_usage.rb +1 -1
  130. data/lib/samagotchi/tool_activity.rb +15 -7
  131. data/lib/samagotchi/tool_declarations.rb +6 -6
  132. data/lib/samagotchi/tool_output_cut.rb +17 -0
  133. data/lib/samagotchi/tool_response.rb +4 -3
  134. data/lib/samagotchi/tool_runner.rb +96 -102
  135. data/lib/samagotchi/toolkit_binding.rb +40 -14
  136. data/lib/samagotchi/toolkit_command.rb +1 -1
  137. data/lib/samagotchi/tools/deferred_tools.rb +17 -21
  138. data/lib/samagotchi/tools/delegate.rb +10 -5
  139. data/lib/samagotchi/tools/delegate_result.rb +4 -4
  140. data/lib/samagotchi/tools/index_hint.rb +14 -0
  141. data/lib/samagotchi/tools/registry.rb +64 -44
  142. data/lib/samagotchi/tools/send_note.rb +4 -6
  143. data/lib/samagotchi/tools/tool_index.rb +150 -102
  144. data/lib/samagotchi/tools/tool_surface.rb +187 -0
  145. data/lib/samagotchi/turn_context.rb +68 -0
  146. data/lib/samagotchi/turn_stages/context_judge.rb +172 -0
  147. data/lib/samagotchi/turn_stages/objectives.rb +150 -0
  148. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +257 -0
  149. data/lib/samagotchi/turn_stages/verify_anchors.rb +168 -0
  150. data/lib/samagotchi/turn_stages/warmup.rb +82 -0
  151. data/lib/samagotchi/turn_state.rb +46 -4
  152. data/lib/samagotchi/version.rb +1 -1
  153. data/lib/samagotchi/vision_support.rb +3 -3
  154. data/lib/samagotchi/web/app.rb +15 -8
  155. data/lib/samagotchi/web/markdown_renderer.rb +39 -14
  156. data/lib/samagotchi/web/message_display.rb +1 -1
  157. data/lib/samagotchi/web/participant_access.rb +2 -2
  158. data/lib/samagotchi/web/public/app.js +5 -1
  159. data/lib/samagotchi/web/public/block_host.js +74 -199
  160. data/lib/samagotchi/web/public/block_renderers.css +2 -0
  161. data/lib/samagotchi/web/public/block_source.js +7 -4
  162. data/lib/samagotchi/web/public/block_watch.js +256 -0
  163. data/lib/samagotchi/web/public/composer_fold.js +3 -0
  164. data/lib/samagotchi/web/public/index.html +28 -9
  165. data/lib/samagotchi/web/public/inline_widgets.js +146 -0
  166. data/lib/samagotchi/web/public/owner_verify.js +1 -1
  167. data/lib/samagotchi/web/public/participant.js +1 -1
  168. data/lib/samagotchi/web/public/revise_stack.js +1 -1
  169. data/lib/samagotchi/web/public/stage_watch.js +6 -2
  170. data/lib/samagotchi/web/public/text_roots.js +1 -1
  171. data/lib/samagotchi/web/public/verify_target.js +1 -1
  172. data/lib/samagotchi/web/public/widget_picks.js +130 -0
  173. data/lib/samagotchi/web/public/widget_walk.js +230 -0
  174. data/lib/samagotchi/web/public/widgets.css +70 -0
  175. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  176. data/lib/samagotchi/web/session_control.rb +3 -3
  177. data/lib/samagotchi/web/session_create.rb +3 -3
  178. data/lib/samagotchi/web/session_hub.rb +1 -1
  179. data/lib/samagotchi/web/session_show.rb +3 -3
  180. data/lib/samagotchi/web/session_summary.rb +3 -4
  181. data/lib/samagotchi/worker.rb +55 -27
  182. data/lib/samagotchi/worker_boot.rb +184 -0
  183. data/lib/samagotchi/worker_signals.rb +1 -1
  184. data/lib/samagotchi/worker_task_wakes.rb +2 -2
  185. metadata +29 -1
@@ -24,8 +24,11 @@ require_relative "reasoning_history"
24
24
  require_relative "thought_stream_splitter"
25
25
  require_relative "tools/builtins"
26
26
  require_relative "tools/builtin_calls"
27
+ require_relative "tools/tool_surface"
27
28
  require_relative "muted_memories"
28
29
  require_relative "tool_activity"
30
+ require_relative "reprefill_meter"
31
+ require_relative "tool_output_cut"
29
32
  require_relative "tool_runner"
30
33
  require_relative "tool_response"
31
34
  require_relative "tool_ids"
@@ -38,6 +41,7 @@ require_relative "answer_display"
38
41
  require_relative "message_time"
39
42
  require_relative "llm/model_result"
40
43
  require_relative "llm/turn_settings"
44
+ require_relative "turn_context"
41
45
  require_relative "llm/stream_drops"
42
46
 
43
47
  module Samagotchi
@@ -59,30 +63,34 @@ module Samagotchi
59
63
  # The built-in tool classes (Tools::Builtins registers them).
60
64
  TOOLS = Tools::Builtins::CLASSES
61
65
 
62
- # What a tool handler (call, kctx) gets from the kernel: the reminder
63
- # store, the peers, the model key, and the memory read and ask-user
64
- # flows that need its state (read through the kernel's public readers).
66
+ # What a tool handler (call, kctx) gets: the kernel's reminder store
67
+ # and peers, the call's turn (+context+, a TurnContext: the participant's
68
+ # mode, the model key), and the memory read and ask-user flows that need
69
+ # them. One per dispatched call.
65
70
  class ToolContext
66
- def initialize(kernel)
71
+ def initialize(kernel, context)
67
72
  @kernel = kernel
73
+ @context = context
68
74
  end
69
75
 
70
76
  def reminder_store = @kernel.reminder_store
71
- def peers = @kernel.peers
77
+ def peers = @kernel.services.peers
72
78
 
73
- # The running participant turn's mode (KernelLoop#participant_mode:
74
- # :pair, :review), nil for the owner's turns.
75
- def participant_mode = @kernel.participant_mode&.call
76
- def model_key = @kernel.model_key
79
+ # The participant turn's mode (TurnContext#participant_mode: :pair,
80
+ # :review), nil for the owner's turns.
81
+ def participant_mode = @context.participant_mode
82
+ # The memory overlay key (TurnContext#model_key, frozen at the turn's
83
+ # generate).
84
+ def model_key = @context.model_key
77
85
 
78
86
  # memory_read with the session's mutes applied: a blank name (the index)
79
87
  # loses the muted memories' lines; a muted name in a comma list is
80
88
  # refused with its own error line and the rest is read as usual.
81
89
  def muted_memory_read(call)
82
- muted = Array(@kernel.muted_memory_names)
90
+ muted = Array(@kernel.services.muted_memory_names)
83
91
  content = call[:content].to_s
84
92
  read = lambda do |names|
85
- Tools::MemoryRead.call(names, scope: call[:scope], model_key: model_key, fallback_model_key: @kernel.model_key_fallback)
93
+ Tools::MemoryRead.call(names, scope: call[:scope], model_key: model_key, fallback_model_key: @context.fallback_model_key)
86
94
  end
87
95
  return read.call(content) if muted.empty?
88
96
  return MutedMemories.filter_index(read.call(content), muted) if content.strip.empty?
@@ -98,14 +106,14 @@ module Samagotchi
98
106
  end
99
107
 
100
108
  # ask_user_question: validate the call, then hand the payload to the
101
- # Engine's question flow (the kernel's question_handler, which blocks
109
+ # Engine's question flow (the kernel's Services#question_handler, which blocks
102
110
  # until the user answers). Without one (headless), the payload as JSON
103
111
  # so the model sees the options and can ask in plain text.
104
112
  def ask_user_question(call)
105
113
  payload = Tools::AskUserQuestion.validate(call)
106
114
  return payload if payload.is_a?(String)
107
115
 
108
- handler = @kernel.question_handler
116
+ handler = @kernel.services.question_handler
109
117
  return JSON.pretty_generate(payload) unless handler
110
118
 
111
119
  handler.call(payload).to_s
@@ -115,44 +123,102 @@ module Samagotchi
115
123
 
116
124
  # forget_outputs: the forget layer's call on the running turn's
117
125
  # conversation (KernelLoop#forget_outputs).
118
- def forget_outputs(call) = @kernel.forget_outputs(call)
126
+ def forget_outputs(call) = @kernel.forget_outputs(call, @context)
127
+
128
+ # The turn's Tools::ToolSurface (TurnContext#surface; a bare kernel's
129
+ # own context has one too, KernelLoop#own_context): what the tool
130
+ # index's handlers (find_tools, call_tool) read.
131
+ def surface = Tools::ToolSurface.of(@context)
119
132
  end
120
133
 
121
- DEFAULT_MAX_TOOL_OUTPUT_CHARS = 10_000
122
134
  QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_LIMIT = 2
123
135
  # How much of a malformed generation's end the warning keeps.
124
136
  MALFORMED_TAIL_CHARS = 400
125
137
  QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_PROMPT = "Continue the previous assistant message by finishing the open <tool_call> XML block. Output only the remaining XML needed to complete the tool call."
126
138
 
127
- # @param tools [Tools::Registry, nil] the tools calls dispatch to (the
128
- # Engine's; nil: the built-ins alone)
139
+ # What a kernel holds of its Engine for the Engine's life, one value
140
+ # (Engine#wire_kernel builds it; a spec's kernel takes one through
141
+ # #attach): nothing per turn (a turn's TurnContext goes down #run).
142
+ #
143
+ # - hooks: the Hooks::Registry the loop fires (nil: none); the Engine's
144
+ # - tools: the Tools::Registry #dispatch runs (the built-ins by default)
145
+ # - reminder_store: the ReminderStore the reminder tools write (nil: none)
146
+ # - muted_memory_names: the session's muted memories (normalized names,
147
+ # MutedMemories), refused by memory_read; nil: none
148
+ # - question_handler: answers ask_user_question (payload → answer
149
+ # string; the Engine's blocking request_question); nil: headless
150
+ # - peers: Tools::Peers (or the Engine's PeerView): the session
151
+ # list_sessions and send_note speak for; nil outside a session
152
+ # - gate: the Guardrails::Gate ToolRunner asks before each call; nil:
153
+ # ToolRunner's own, hooks only
154
+ # - warmup: the Engine's PromptWarmup, asked for a slot pin per request
155
+ # (#take_pin) while the turn-end warm-up runs; nil: never pinned
156
+ # - generation_guard: called before each generation with the turn's
157
+ # TurnContext, before the before_generation hook; the Engine's check
158
+ # that may stop the turn (a pair turn past its participant's cap,
159
+ # Engine#guard_participant_spend); nil: none
160
+ #
161
+ # The client is not one of them: a model switch swaps it
162
+ # (#client=, Engine#sync_kernel_client!).
163
+ Services = Data.define(:hooks, :tools, :reminder_store, :muted_memory_names, :question_handler, :peers, :gate,
164
+ :warmup, :generation_guard) do
165
+ # A bare kernel's (specs, a kernel built without its Engine): the
166
+ # built-in tools and nothing else, or the +fields+ given.
167
+ # @return [Services]
168
+ def self.none(**fields)
169
+ new(hooks: nil, tools: Tools::Builtins.default, reminder_store: nil, muted_memory_names: nil,
170
+ question_handler: nil, peers: nil, gate: nil, warmup: nil, generation_guard: nil).with(**fields)
171
+ end
172
+ end
173
+
174
+ # @param services [Services, nil] what the kernel holds of its Engine
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)
129
179
  # @param sleeper [#call, nil] waits the given seconds before a dropped
130
180
  # stream's step is asked again (specs pass one that doesn't sleep)
131
- def initialize(client: nil, profile: nil, model_name: nil, hooks: nil, reminder_store: nil, model_key: nil, tools: nil,
132
- sleeper: nil)
181
+ def initialize(client: nil, profile: nil, model_name: nil, model_key: nil, services: nil, reprefill: nil, sleeper: nil)
133
182
  @client = client || Client.new
134
183
  @sleeper = sleeper || ->(seconds) { sleep(seconds) }
135
- @tools = tools || Tools::Builtins.default
184
+ @services = services || Services.none
185
+ @reprefill = reprefill || ReprefillMeter.new
136
186
  resolved_model_name = ModelProfile.required_model_name(model_name)
137
- # The turn's settings (LLM::TurnSettings). Its model name is the
138
- # resolved model id actually used for this run (per-run override wins
139
- # over the config alias); on every debug dump so we can see exactly
140
- # which model each request went to. The Engine sets them per turn:
141
- # the chat loop dispatches tools here without going through #run.
142
- @turn_settings = LLM::TurnSettings.none.with(model_name: resolved_model_name)
187
+ # The context of the run in progress, else of the last run (#context):
188
+ # a kernel that ran none has its own (no turn, no settings but its
189
+ # model name, its model key). #run takes the one it is given.
190
+ @context = TurnContext.none(settings: LLM::TurnSettings.none.with(model_name: resolved_model_name),
191
+ model_key: model_key)
143
192
  @profile = profile ? ModelProfile.normalize(profile) : ModelProfile.from_model_name(resolved_model_name)
144
193
  # Where @profile came from, as /stats shows it (a Resolution's label
145
194
  # once the Engine resolves one, see #use_profile!).
146
195
  @profile_source = profile ? "given" : "name"
147
- @hooks = hooks
148
- @reminder_store = reminder_store
149
- @model_key = model_key
150
- @model_key_fallback = nil
151
196
  end
152
197
 
153
- # @return [ReminderStore, nil] the reminder store the reminder tools
154
- # write; the Engine's own (it hands it to a kernel it was given).
155
- attr_accessor :reminder_store
198
+ # @return [Services] what the kernel holds of its Engine (#attach)
199
+ attr_reader :services
200
+
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.
204
+ # @param services [Services]
205
+ # @param reprefill [ReprefillMeter, nil]
206
+ # @return [Services]
207
+ def attach(services, reprefill: nil)
208
+ @reprefill = reprefill if reprefill
209
+ @services = services
210
+ end
211
+
212
+ # @return [Hooks::Registry, nil] the hooks the loop fires (Services#hooks)
213
+ def hooks = @services.hooks
214
+ # @return [Tools::Registry] the tools #dispatch runs (Services#tools)
215
+ def tools = @services.tools
216
+ # @return [ReminderStore, nil] the store the reminder tools write
217
+ # (Services#reminder_store)
218
+ def reminder_store = @services.reminder_store
219
+ # @return [Guardrails::Gate, nil] the gate ToolRunner asks before each
220
+ # call (Services#gate)
221
+ def guardrail_gate = @services.gate
156
222
 
157
223
  # @return [Float, nil] the last counted request's chars per token
158
224
  # (ContextStatus#counted_chars_per_token), for the next turn's first
@@ -162,79 +228,43 @@ module Samagotchi
162
228
  # @return [ModelProfile] the active prompt profile
163
229
  attr_reader :profile
164
230
 
165
- # @return [Samagotchi::Hooks::Registry, nil] hooks registry shared with Engine.
166
- # Engine owns the registry; KernelLoop only fires events. Accessor allows
167
- # Engine to hand its registry to a kernel it is given (specs).
168
- attr_accessor :hooks
169
- # @return [Tools::Registry] the tools #dispatch runs. The Engine sets
170
- # its own on a kernel it is given, as with hooks.
171
- attr_accessor :tools
172
- attr_accessor :client, :model_key
173
- # @return [String, nil] the key whose overlay memory_read takes when
174
- # #model_key has none (#sync_model_key!)
175
- attr_reader :model_key_fallback
176
- # @return [Array<String>, nil] the session's muted memories (normalized
177
- # names, see MutedMemories); memory_read refuses them. The Engine sets it.
178
- attr_accessor :muted_memory_names
179
- # @return [Proc, nil] answers ask_user_question (payload → answer string);
180
- # Engine sets it to its blocking request_question.
181
- attr_accessor :question_handler
182
- # The Guardrails::Gate ToolRunner asks before each call; the Engine sets
183
- # it (nil: ToolRunner's own, hooks only).
184
- attr_accessor :guardrail_gate
185
- # Whether the current turn belongs to a participant, so pre-gate
186
- # refusals can defer to ReviewBoundary.
187
- attr_accessor :participant_turn
188
- # The running participant turn's mode (a callable → :pair, :review or
189
- # nil: GuardrailWiring#turn_mode): ToolRunner reads it. The Engine sets it.
190
- attr_accessor :participant_mode
191
- # Called before each generation, before the before_generation hook (a
192
- # callable, nil: none): the Engine's check that may stop the turn (a
193
- # pair turn past its participant's cap, Engine#guard_participant_spend).
194
- attr_accessor :generation_guard
195
-
196
- # The generation guard, if any; one that raises never breaks the turn.
231
+ # The model client; a model switch swaps it (Engine#sync_kernel_client!).
232
+ attr_accessor :client
233
+
234
+ # @return [String, nil] the model key a kernel was built with (its own
235
+ # #context's, for a run given none)
236
+ def model_key = @context.model_key
237
+
197
238
  # The model's context window as this loop knows it (ContextWindow.resolve
198
- # on the client and the turn's setting): ToolRunner halves it for
199
- # memory_read's overflow guard. nil when it can't be read.
239
+ # on the client and +settings+, a turn's LLM::TurnSettings): ToolRunner
240
+ # halves it for memory_read's overflow guard. nil when it can't be read.
200
241
  # @return [ContextWindow::Resolved, nil]
201
- def tool_output_window
242
+ def tool_output_window(settings = turn_settings)
202
243
  Degrade.call(:model, "context_window_unresolved") do
203
- ContextWindow.resolve(client: @client, model: @turn_settings&.model_name, setting: @turn_settings&.window_setting)
244
+ ContextWindow.resolve(client: @client, model: settings&.model_name, setting: settings&.window_setting)
204
245
  end
205
246
  end
206
247
 
207
- def guard_generation
208
- @generation_guard&.call
248
+ # The generation guard for +context+'s turn, if any; one that raises
249
+ # never breaks the turn.
250
+ def guard_generation(context = @context)
251
+ @services.generation_guard&.call(context)
209
252
  rescue StandardError => e
210
253
  Log.warn(:turn, "generation_guard_failed", error: "#{e.class}: #{e.message}")
211
254
  nil
212
255
  end
213
256
 
214
- def participant_turn? = @participant_turn&.call == true
215
- # @return [LLM::TurnSettings] the turn's vision, sampling, thinking and
216
- # model name, set by the Engine per turn (#run sets the model name
217
- # too); the thinking level is its own field, since the native path
218
- # sends the sampling as is.
219
- attr_accessor :turn_settings
220
- # The session's working directory (a callable → String or nil), which
221
- # a plugin tool's row refs resolve their files against (ToolRunner,
222
- # RowRefs); the Engine sets it. nil: the process's own (Dir.pwd).
223
- attr_accessor :session_cwd
224
- # The session's working-set pins (a callable → Array<WorkingSetPin>),
225
- # which forget_outputs refuses (LLMContextForget); the Engine sets it.
226
- # nil: none.
227
- attr_accessor :working_set_pins
228
- # The session's folder (a callable → String or nil), where ToolRunner
229
- # saves a cut output whole (ToolOutputFiles); the Engine sets it. nil:
230
- # none (a bare kernel), the cut says the output wasn't saved.
231
- attr_accessor :session_dir
232
- # Tools::Peers (or the Engine's live view of it): the session
233
- # list_sessions and send_note speak for; nil outside a session.
234
- attr_accessor :peers
235
- # The Engine's PromptWarmup: a request asks it for a slot pin
236
- # (#take_pin) while the turn-end warm-up still runs; nil: never pinned.
237
- attr_accessor :warmup
257
+ # @return [TurnContext] the context of the run in progress, else of the
258
+ # last run (the kernel's own before any): its turn's settings,
259
+ # session folders and participant mode. Read on the turn's thread
260
+ # only; another thread reads the Engine's (Engine#turn_context).
261
+ attr_reader :context
262
+
263
+ # @return [LLM::TurnSettings] the vision, sampling, thinking and model
264
+ # name of the run in progress, else of the last one (#context's; #run
265
+ # sets the model name); the thinking level is its own field, since the
266
+ # native path sends the sampling as is.
267
+ def turn_settings = @context.settings
238
268
  # The turn a forget_outputs call works on (LLMContextForget::Turn: its
239
269
  # conversation and ContextStatus); both loops set it as a turn starts.
240
270
  attr_accessor :llm_context_turn
@@ -265,13 +295,17 @@ module Samagotchi
265
295
  # run it resumes started in +messages+; that run's model messages keep
266
296
  # their thought blocks (#prepare_conversation), as within one run. nil:
267
297
  # every earlier thought is stripped.
298
+ # @param context [TurnContext] the run's turn (Engine#generate's); a
299
+ # bare kernel's own (#own_context) by default. Its settings take the
300
+ # run's model name: this run's own copy, never published.
268
301
  # @return [LLM::ModelResult] final visible response with continuation metadata
269
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,
270
- reasoning_since: nil)
271
- @turn_settings = @turn_settings.with(model_name: completion_model_name(model_name))
303
+ reasoning_since: nil, context: own_context)
304
+ settings = context.settings || LLM::TurnSettings.none
305
+ @context = context.with(settings: settings.with(model_name: completion_model_name(model_name)))
272
306
  @last_slot = nil
273
307
  turn = start_turn(messages, on_stream_event: on_stream_event, cancel_controller: cancel_controller,
274
- 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),
275
309
  reasoning_since: reasoning_since)
276
310
 
277
311
  max_iterations.times do |iteration_index|
@@ -304,28 +338,6 @@ module Samagotchi
304
338
  @profile_source = resolution.label
305
339
  end
306
340
 
307
- # @param fallback [String, nil] a key whose overlay memory_read takes
308
- # when +key+ has none (the alias a model was typed as)
309
- def sync_model_key!(key, fallback: nil)
310
- @model_key = key
311
- @model_key_fallback = fallback
312
- end
313
-
314
- # Resolve the per-output character cap for the tool outputs the model
315
- # gets back and the emitted tool call events.
316
- #
317
- # Precedence: an explicit override wins, then max_tool_output_chars
318
- # (Config). A non-positive value falls back to
319
- # DEFAULT_MAX_TOOL_OUTPUT_CHARS (there is intentionally no "unlimited" — live UIs get a
320
- # bounded `output:` plus a truthful `output_truncated:` flag). The one
321
- # exception is memory_read, which ToolRunner leaves out of this cap
322
- # (memory.read_max_chars, 0: no fixed cap; a memory is read whole).
323
- # Class-level so the chat loop resolves it the same way.
324
- def self.resolve_output_char_cap(override)
325
- parsed = (override || Samagotchi::Config.get("max_tool_output_chars")).to_i
326
- parsed.positive? ? parsed : DEFAULT_MAX_TOOL_OUTPUT_CHARS
327
- end
328
-
329
341
  private
330
342
 
331
343
  # ── One turn (#run) ────────────────────────────────────────────────────────
@@ -354,10 +366,11 @@ module Samagotchi
354
366
 
355
367
  def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:, reasoning_since: nil)
356
368
  conversation = prepare_conversation(messages, keep_thought_from: ReasoningHistory.run_start(messages, reasoning_since))
357
- context = ContextStatus.new(conversation: conversation, llm_context: @turn_settings.llm_context,
369
+ context = ContextStatus.new(conversation: conversation, llm_context: turn_settings.llm_context,
358
370
  counted_chars_per_token: @counted_chars_per_token)
359
371
  emit = ->(event) { emit_stream_event(on_stream_event, event) }
360
- @llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: context, emit: emit)
372
+ @llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: context, emit: emit,
373
+ turn_id: @context.id)
361
374
  Turn.new(
362
375
  conversation: conversation, context: context,
363
376
  empty_retry: EmptyAnswerRetry.new, empty_steps: [], tool_activity: [], buffer: +"", streamed_thinking: +"",
@@ -366,8 +379,8 @@ module Samagotchi
366
379
  # answers at once; on, an open one where the server's template has
367
380
  # it. Kept in the turn's model messages, so each tool-loop prompt
368
381
  # starts with what the server already has cached.
369
- prefill: native_prefill(@turn_settings.thinking || Thinking::DEFAULT),
370
- pending_tool_calls: false, model_name: @turn_settings.model_name, pending_input: pending_input,
382
+ prefill: native_prefill(turn_settings.thinking || Thinking::DEFAULT),
383
+ pending_tool_calls: false, model_name: turn_settings.model_name, pending_input: pending_input,
371
384
  on_stream_event: on_stream_event, cancel_controller: cancel_controller, cap: cap,
372
385
  emit: emit
373
386
  )
@@ -385,7 +398,7 @@ module Samagotchi
385
398
 
386
399
  def model_props
387
400
  Degrade.call(:model, "server_props_unreadable") do
388
- @client.respond_to?(:server_props) ? @client.server_props(model: @turn_settings.model_name) : nil
401
+ @client.respond_to?(:server_props) ? @client.server_props(model: turn_settings.model_name) : nil
389
402
  end
390
403
  end
391
404
 
@@ -435,7 +448,7 @@ module Samagotchi
435
448
  apply_llm_context!(turn.conversation, context: turn.context, emit: turn.emit)
436
449
  prompt, images = format_prompt(turn)
437
450
  image_tokens = images.empty? ? 0 : ImagePlan.estimated_tokens(turn.conversation)
438
- window = ContextWindow.resolve(client: @client, model: turn.model_name, setting: @turn_settings.window_setting)
451
+ window = ContextWindow.resolve(client: @client, model: turn.model_name, setting: turn_settings.window_setting)
439
452
  emit_context_status_event(turn.on_stream_event, turn.context, prompt, iteration_index: turn.iteration - 1,
440
453
  window: window, image_tokens: image_tokens)
441
454
  if (line = turn.context.take_guidance)
@@ -450,7 +463,7 @@ module Samagotchi
450
463
 
451
464
  def format_prompt(turn)
452
465
  Prompt.format_with_images(llm_context_view.messages(turn.conversation), profile: @profile,
453
- vision: @turn_settings.vision, prefill: turn.prefill)
466
+ vision: turn_settings.vision, prefill: turn.prefill)
454
467
  end
455
468
 
456
469
  # One streamed request, under the generation's own controller: a
@@ -460,8 +473,8 @@ module Samagotchi
460
473
  def generate(turn, request)
461
474
  # Before the generation starts: a hook that waits (a question) isn't
462
475
  # the generation's time, and a Stop there leaves none started.
463
- guard_generation
464
- fire_hook(:before_generation, { type: :before_generation, iteration: turn.iteration }) if @hooks
476
+ guard_generation(@context)
477
+ fire_hook(:before_generation, { type: :before_generation, iteration: turn.iteration }) if hooks
465
478
  raise Client::RequestCancelled, turn.cancel_controller.reason if turn.cancel_controller&.cancelled?
466
479
 
467
480
  # Each ask splits its own stream (run_generation rebuilds the
@@ -469,7 +482,7 @@ module Samagotchi
469
482
  # leave the next one "inside" it.
470
483
  emit(turn, type: :generation_started, iteration: turn.iteration, context_window_tokens: request.window.tokens,
471
484
  context_window_source: request.window.source, profile: @profile.name, profile_source: @profile_source,
472
- price: @turn_settings.price&.to_h)
485
+ price: turn_settings.price&.to_h)
473
486
  # This generation's own server counts (the run-long ones in the
474
487
  # context tracker can be an earlier one's), and the thinking it
475
488
  # streamed, for the log (a stuck thinking generation shows as
@@ -505,7 +518,7 @@ module Samagotchi
505
518
  # The empty-retry sampling is taken once, before the ask-again loop:
506
519
  # a drop's restart must still carry the nudge's 0.6 (as ChatLoop does
507
520
  # per step), not lose it when request_sampling is asked again.
508
- sampling = turn.empty_retry.request_sampling(@turn_settings.sampling)
521
+ sampling = turn.empty_retry.request_sampling(turn_settings.sampling)
509
522
  count = LLM::StreamDrops::DropCount.new(0, 0)
510
523
  begin
511
524
  # Each ask splits its own stream: a drop mid tool call (or mid
@@ -639,7 +652,7 @@ module Samagotchi
639
652
  def cache_fields(cache) = cache ? TokenUsage.cache_fields(**cache) : {}
640
653
 
641
654
  def reprefill_field(model, cache)
642
- 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])
643
656
  tokens ? { reprefill_tokens: tokens } : {}
644
657
  end
645
658
 
@@ -666,7 +679,7 @@ module Samagotchi
666
679
  dump_log("response", generation.response, iteration: turn.iteration)
667
680
  # The after_generation hook (after the model returns, before the tool
668
681
  # parse), with a read-only copy of the conversation as sent.
669
- return unless @hooks
682
+ return unless hooks
670
683
 
671
684
  fire_hook(:after_generation, { type: :after_generation, iteration: turn.iteration, response: generation.response,
672
685
  messages: AnswerDisplay.strip_all(turn.conversation).map(&:dup).freeze })
@@ -780,7 +793,7 @@ module Samagotchi
780
793
  turn.qwen_partial = nil
781
794
  runs = []
782
795
  begin
783
- ToolResponse.run_batch(tool_runner, calls, iteration: turn.iteration, emit: turn.emit,
796
+ ToolResponse.run_batch(tool_runner, calls, context: @context, iteration: turn.iteration, emit: turn.emit,
784
797
  on_stream_event: turn.on_stream_event, cap: turn.cap) { |run, _| runs << run }
785
798
  rescue Interrupt
786
799
  # A Ctrl-C mid-batch: the entry still answers every call (the
@@ -848,29 +861,21 @@ module Samagotchi
848
861
  end
849
862
 
850
863
  def fire_hook(name, event)
851
- return unless @hooks
864
+ return unless hooks
852
865
 
853
866
  # A failing hook must not break the turn.
854
- Degrade.call(:hooks, "hook_fire_failed", hook: name) { @hooks.fire(name, event) }
867
+ Degrade.call(:hooks, "hook_fire_failed", hook: name) { hooks.fire(name, event) }
855
868
  end
856
869
 
857
870
  # Coerce a value to boolean — handles true/false, nil, and string "true"/"false".
858
871
  def truthy?(val) = Tools::Builtins.truthy?(val)
859
872
 
860
- def tool_context = @tool_context ||= ToolContext.new(self)
861
-
862
- # ── Output char cap resolution ─────────────────────────────────────────────
863
-
864
- def resolve_output_char_cap(override)
865
- self.class.resolve_output_char_cap(override)
866
- end
867
-
868
873
  # One request, cancelled by +generation_controller+ (the turn's child)
869
874
  # when there is one.
870
875
  def request_generation(prompt, generation_controller:, cancel_controller:, **)
871
876
  kwargs = complete_kwargs(cancel_controller: generation_controller || cancel_controller, **)
872
877
  # Behind a turn-end warm-up still running on its slot (PromptWarmup).
873
- slot = @warmup&.take_pin(@client)
878
+ slot = @services.warmup&.take_pin(@client)
874
879
  kwargs[:slot] = slot if slot
875
880
  @client.complete(prompt, **kwargs)
876
881
  end
@@ -940,10 +945,10 @@ module Samagotchi
940
945
 
941
946
  # A payload dump (model response, tool call/result, context status): the
942
947
  # debug level only, tagged with the model it came from.
943
- def dump_log(event, payload, **fields)
948
+ def dump_log(event, payload, model: turn_settings.model_name, **fields)
944
949
  return unless Log.level?(:debug)
945
950
 
946
- Log.debug(:model, event, payload: payload, model: @turn_settings.model_name, **fields)
951
+ Log.debug(:model, event, payload: payload, model: model, **fields)
947
952
  end
948
953
 
949
954
  # Estimate context usage for this iteration's prompt (ContextStatus#observe)
@@ -971,12 +976,14 @@ module Samagotchi
971
976
  # for the model the next turn runs on, after a /model switch too),
972
977
  # else the last turn's. Returns [prompt, images], or nil when the cut
973
978
  # isn't found.
974
- def warmup_prompt(messages, llm_context: @turn_settings&.llm_context)
979
+ # +vision+: the next turn's VisionContext (the last turn's settings'),
980
+ # else the last run's.
981
+ def warmup_prompt(messages, llm_context: turn_settings&.llm_context, vision: turn_settings.vision)
975
982
  conversation = prepare_conversation(messages)
976
983
  apply_llm_context!(conversation, llm_context, warmup: true)
977
984
  conversation << { role: "user", content: WARMUP_CUT }
978
985
  prompt, images = Prompt.format_with_images(llm_context_view(llm_context).messages(conversation),
979
- profile: @profile, vision: @turn_settings.vision)
986
+ profile: @profile, vision: vision)
980
987
  head = prompt[0, prompt.index(WARMUP_CUT) || 0]
981
988
  cut = head.rindex(user_opener)
982
989
  cut&.positive? ? [head[0, cut], images] : nil
@@ -985,119 +992,52 @@ module Samagotchi
985
992
  WARMUP_CUT = "chi-warmup-cut"
986
993
  private_constant :WARMUP_CUT
987
994
 
988
- # What both loops send of the conversation (LLMContextView), under the
989
- # turn's strategy (TurnSettings#llm_context, none without one), or the
990
- # one given (the warm-up's, the next turn's): the prompt, the warm-up
991
- # and the chat messages all format through it, so the warm-up warms
992
- # the prompt the next turn sends.
993
- def llm_context_view(llm_context = @turn_settings&.llm_context)
994
- LLMContextView.new(strategy: llm_context&.strategy || LLMContextView::NONE)
995
- end
996
-
997
- # The turn's LLM context layers ([] under none): the tools they add
998
- # (Tools::Registry#entries) are the turn's to offer and dispatch.
999
- def llm_context_layers = @turn_settings&.llm_context&.active_layers || []
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)
1000
999
 
1001
1000
  # The model's forget_outputs call (LLMContextForget) on the running
1002
- # turn, under its strategy; the apply rule runs on the turn's
1003
- # conversation at once, as at a request, so the result says whether the
1004
- # stubs go with the next request or wait for the turn's end.
1001
+ # turn (the loop's llm_context_turn), under +context+'s strategy (the
1002
+ # call's TurnContext); the apply rule runs on the turn's conversation at
1003
+ # once, as at a request, so the result says whether the stubs go with
1004
+ # the next request or wait for the turn's end.
1005
1005
  # @return [String] the tool result
1006
- def forget_outputs(call)
1007
- llm_context = @turn_settings&.llm_context
1006
+ def forget_outputs(call, context)
1007
+ llm_context = context.settings&.llm_context
1008
1008
  turn = @llm_context_turn
1009
1009
  unless turn && llm_context&.active_layers&.include?(:forget)
1010
1010
  return "Error: forget_outputs is off (the llm_context strategy has no forget layer)"
1011
1011
  end
1012
1012
 
1013
- apply = -> { apply_llm_context!(turn.conversation, llm_context, context: turn.context, emit: turn.emit) }
1014
- LLMContextForget.call(turn, call, llm_context: llm_context, apply: apply, pinned: method(:pinned_ids))
1013
+ apply = lambda {
1014
+ apply_llm_context!(turn.conversation, llm_context, context: turn.context, emit: turn.emit,
1015
+ model: context.settings.model_name)
1016
+ }
1017
+ LLMContextForget.call(turn, call, llm_context: llm_context, apply: apply,
1018
+ pinned: ->(conversation) { pinned_ids(conversation, context) })
1015
1019
  end
1016
1020
 
1017
- # The ids of +conversation+'s outputs the session's pins hold.
1018
- def pinned_ids(conversation)
1019
- pins = Array(@working_set_pins&.call)
1020
- WorkingSet.pinned_ids(conversation, pins: pins, cwd: @session_cwd&.call)
1021
+ # The ids of +conversation+'s outputs the session's pins hold (the
1022
+ # working-set pins of +context+'s session, read now).
1023
+ def pinned_ids(conversation, context)
1024
+ WorkingSet.pinned_ids(conversation, pins: context.working_set_pins, cwd: context.cwd)
1021
1025
  end
1022
1026
  private :pinned_ids
1023
1027
 
1024
- # Before each request of both loops (+moment+ :request; the warm-up
1025
- # too, on its own copy, as the next turn's first request) and at the
1026
- # end of a turn the model answered (:turn_end): the strategy's layers
1027
- # stage their edits on +conversation+ and the apply rule
1028
- # (LLMContextApply, llm_context.apply) saves the batch it lets through
1029
- # as applied, so the request about to be sent (or the next turn's
1030
- # first, and the warm-up) is the first with the stubs. +context+: the
1031
- # turn's ContextStatus (payoff applies in its top bucket; an applied
1032
- # batch rebases its estimate, ContextStatus#edited!). none changes
1033
- # nothing. Logged as llm_context_apply when a batch is applied, and
1034
- # shown to the user as a ✂ row (+emit+ takes the :llm_context_edited
1035
- # 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.
1036
1031
  # @return [LLMContextApply::Outcome]
1037
- def apply_llm_context!(conversation, llm_context = @turn_settings&.llm_context, moment: :request, context: nil,
1038
- warmup: false, emit: nil)
1039
- view = llm_context_view(llm_context)
1040
- return LLMContextApply::Outcome.none if view.none?
1041
-
1042
- outcome = LLMContextApply.run!(conversation, layers: view.layers, rule: llm_context.apply, moment: moment,
1043
- protect_steps: llm_context.protect_steps,
1044
- top_bucket: context&.top_bucket? || false,
1045
- changes: llm_context.stale_edits)
1046
- if outcome.applied?
1047
- # The prompt just shrank: the context estimate counts from it.
1048
- context&.edited!
1049
- unless warmup
1050
- log_llm_context(outcome, llm_context, moment)
1051
- emit_llm_context(emit, conversation, outcome, moment) if emit
1052
- end
1053
- end
1054
- outcome
1055
- end
1056
-
1057
- def log_llm_context(outcome, llm_context, moment)
1058
- Log.info(:model, "llm_context_apply", rule: llm_context.apply, at: moment, why: outcome.why,
1059
- applied: outcome.applied.size, ids: outcome.applied.map(&:id).join(","),
1060
- staged: outcome.staged, freed: (outcome.freed_chars / TokenUsage::CHARS_PER_TOKEN).ceil,
1061
- tail: (outcome.tail_chars / TokenUsage::CHARS_PER_TOKEN).ceil,
1062
- model: @turn_settings&.model_name)
1063
- end
1064
- private :log_llm_context
1065
-
1066
- # The batch's ✂ row; a row that can't be built never breaks the turn.
1067
- def emit_llm_context(emit, conversation, outcome, moment)
1068
- emit.call(LLMContextNotice.event(conversation, outcome, moment: moment))
1069
- rescue StandardError => e
1070
- Log.warn(:model, "llm_context_notice_failed", error: e.class.name, message: e.message)
1071
- end
1072
- private :emit_llm_context
1073
-
1074
- # A request's re-prefilled tokens, for both loops: of the prompt the
1075
- # last request on +model+ through this client (the host) sent (this
1076
- # kernel's, any turn), what the server didn't reuse from its cache this
1077
- # time (that prompt less +cached+), so an LLM context edit's cache break
1078
- # shows, and so does anything else that rewrote the prompt's middle or
1079
- # lost the cache. nil under TokenUsage::REPREFILL_MIN_TOKENS (a cache
1080
- # block's rounding), for a server that reports no cached count (or 0: a
1081
- # cold cache isn't told from no cache), the first request, a model
1082
- # switch (another bare model, or the same one on another host: the
1083
- # Engine points #client at the new host's; SessionMetrics counts that
1084
- # prefill as the switch's), and the first request after a warm-up
1085
- # (#warmed_up!).
1086
- # @return [Integer, nil]
1087
- def reprefilled_tokens(model, prompt:, cached:)
1088
- previous = @reprefill_baseline
1089
- @reprefill_baseline = { model: model, client: @client, prompt: prompt.to_i } if prompt.to_i.positive?
1090
- return nil unless previous && previous[:model] == model && previous[:client].equal?(@client) && cached.to_i.positive?
1091
-
1092
- tokens = previous[:prompt] - cached.to_i
1093
- 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, **)
1094
1034
  end
1095
1035
 
1096
1036
  # The Engine started a turn-end warm-up: it prefills the next prompt
1097
1037
  # off the clock, so the next request's cache counts are against it, not
1098
- # the last request (#reprefilled_tokens starts over).
1038
+ # the last request (the re-prefill meter starts over, ReprefillMeter#reset!).
1099
1039
  def warmed_up!
1100
- @reprefill_baseline = nil
1040
+ @reprefill.reset!
1101
1041
  end
1102
1042
 
1103
1043
  # Public wrapper so other loops (e.g. the chat loop) can strip
@@ -1188,78 +1128,40 @@ module Samagotchi
1188
1128
  # unknown-tool handling, and activity events are shared, not duplicated. Callers
1189
1129
  # are responsible for normalizing the provider's native call into this shape
1190
1130
  # first (see Samagotchi::LLM::NativeToolNormalizer); dispatch itself never
1191
- # parses provider text.
1192
- def dispatch_tool_call(call)
1193
- dispatch(call)
1131
+ # parses provider text. +context+: the call's turn (TurnContext: its
1132
+ # ToolSurface, the handler's ToolContext); a bare kernel's own
1133
+ # (#own_context) by default.
1134
+ def dispatch_tool_call(call, context = own_context)
1135
+ dispatch(call, context)
1194
1136
  end
1195
1137
 
1196
- private
1197
-
1198
- # Shell-like names (the harness's is `execute`). The unknown-tool error
1199
- # never echoes the name the model used — a model that sees its own wrong
1200
- # name again calls it again (a steer that named `bash` raised one model's
1201
- # bash rate from 9% to 30%); the activity event and the logs keep it.
1202
- SHELL_LIKE_TOOL_NAMES = %w[
1203
- bash sh zsh shell terminal cmd command run run_command run_shell exec execute_command
1204
- ].freeze
1205
-
1206
- # The error an unknown tool name gets back. A shell-like name with
1207
- # `execute` offered this turn points at `execute`; every other unknown
1208
- # name lists what is offered. Neither echoes the wrong name.
1209
- #
1210
- # A tool the session's toolkit leaves out is a real one, so its error
1211
- # does name it (deliberately, against the rule above): the model learns
1212
- # it exists, and how to reach it (call_tool, when the toolkit reaches
1213
- # it that way) or that only the user can load it. A call reached
1214
- # through call_tool (+via+) of a tool the toolkit declares is told to
1215
- # call it directly: the model's call_tool(tool: "chi/read") with read
1216
- # declared, or a hook's rewrite (a direct call can't carry via:).
1217
- # The built-in +name+ names when the turn's host loads tools and the
1218
- # toolkit reaches it (Registry#indexed): a direct call runs it.
1219
- def loaded_entry(name)
1220
- return nil unless @turn_settings&.loads_tools && @tools.respond_to?(:indexed)
1221
-
1222
- @tools.indexed(name, layers: llm_context_layers)
1223
- end
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)
1224
1144
 
1225
- def unknown_tool_error(name, layers, via: nil)
1226
- names = @tools.names(layers: layers)
1227
- toolkit = @tools.toolkit&.name
1228
- in_toolkit = " in this session#{" (toolkit #{toolkit})" if toolkit}"
1229
- if via && @tools.offered(name, layers: layers)
1230
- "Error: #{name} is declared#{in_toolkit}; call it directly. Available: #{names.join(", ")}"
1231
- elsif !via && @tools.respond_to?(:indexed) && @tools.indexed(name, layers: layers)
1232
- "Error: #{name} isn't declared#{in_toolkit}; call it with #{Tools::BuiltinCalls::VIA}(tool: \"chi/#{name}\", args: {…}) " \
1233
- "(find_tools gives its inputSchema). Available: #{names.join(", ")}"
1234
- elsif @tools.respond_to?(:left_out) && @tools.left_out(name, layers: layers)
1235
- "Error: #{name} isn't loaded#{in_toolkit} and can't be loaded from " \
1236
- "here; only the user can (/toolkit add #{name}). Available: #{names.join(", ")}"
1237
- elsif SHELL_LIKE_TOOL_NAMES.include?(name.to_s.downcase) && names.include?("execute")
1238
- "Error: no such tool. Shell commands run with `execute` (same arguments)."
1239
- else
1240
- "Error: no such tool. Available: #{names.join(", ")}"
1241
- end
1242
- end
1145
+ private
1243
1146
 
1244
- def dispatch(call)
1245
- # A built-in reached through call_tool runs only while the toolkit
1246
- # reaches it that way (Registry#indexed); any other call by its name,
1247
- # on a host that loads tools (TurnSettings#loads_tools) a reachable
1248
- # one too.
1249
- entry = if call[:via]
1250
- @tools.respond_to?(:indexed) ? @tools.indexed(call[:name], layers: llm_context_layers) : nil
1251
- else
1252
- @tools.offered(call[:name], layers: llm_context_layers) || loaded_entry(call[:name])
1253
- end
1147
+ # The call runs on the entry the turn's surface gives it
1148
+ # (Tools::ToolSurface#entry_for: a call_tool-reached one only while the
1149
+ # toolkit reaches it that way, a reachable one by its name on a host
1150
+ # that loads tools); without one it gets ToolSurface#unknown_tool_error.
1151
+ # A context without a surface raises Tools::MissingSurface.
1152
+ def dispatch(call, context)
1153
+ model = context.settings&.model_name
1154
+ surface = Tools::ToolSurface.of(context)
1155
+ entry = surface.entry_for(call)
1254
1156
  unless entry
1255
- result = unknown_tool_error(call[:name], llm_context_layers, via: call[:via])
1157
+ result = surface.unknown_tool_error(call[:name], via: call[:via])
1256
1158
  return {
1257
1159
  output: result,
1258
- activity: ToolActivity.tool_activity_event(call[:name], call, result, registry: @tools)
1160
+ activity: ToolActivity.tool_activity_event(call[:name], call, result, registry: tools, surface: surface)
1259
1161
  }
1260
1162
  end
1261
1163
 
1262
- dump_log("tool_call", call[:content], tool: call[:name], path: call[:path], scope: call[:scope])
1164
+ dump_log("tool_call", call[:content], model: model, tool: call[:name], path: call[:path], scope: call[:scope])
1263
1165
 
1264
1166
  if call[:called_as]
1265
1167
  # The model's spelling for a tool an alias ran (bash → execute); its
@@ -1268,12 +1170,12 @@ module Samagotchi
1268
1170
  Log.info(:turn, "tool_alias", name: call[:called_as], as: call[:name])
1269
1171
  end
1270
1172
 
1271
- result = entry.handler.call(call, tool_context)
1173
+ result = entry.handler.call(call, ToolContext.new(self, context))
1272
1174
 
1273
- dump_log("tool_result", result, tool: call[:name])
1175
+ dump_log("tool_result", result, model: model, tool: call[:name])
1274
1176
  dispatched = {
1275
1177
  output: "[#{call[:name]}]\n#{result}",
1276
- activity: ToolActivity.tool_activity_event(call[:name], call, result, registry: @tools)
1178
+ activity: ToolActivity.tool_activity_event(call[:name], call, result, registry: tools, surface: surface)
1277
1179
  }
1278
1180
  # Images the tool returned (read's ImageResult, a plugin's ToolResult):
1279
1181
  # ToolRunner attaches them (or says why not).
@@ -1292,12 +1194,12 @@ module Samagotchi
1292
1194
  end
1293
1195
  dispatched
1294
1196
  rescue StandardError => e
1295
- dump_log("tool_error", e.message, tool: call[:name], error: e.class.name)
1197
+ dump_log("tool_error", e.message, model: context.settings&.model_name, tool: call[:name], error: e.class.name)
1296
1198
  result = "Error: #{e.message}"
1297
- {
1298
- output: "[#{call[:name]}] #{result}",
1299
- activity: ToolActivity.tool_activity_event(call[:name], call, result, registry: @tools)
1300
- }
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 }
1301
1203
  end
1302
1204
  end
1303
1205
  end