samagotchi 0.154.1 → 0.155.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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -1
  3. data/docs/architecture.md +3 -2
  4. data/docs/bundles.md +21 -0
  5. data/docs/cli.md +1 -1
  6. data/docs/guardrails.md +2 -2
  7. data/docs/hooks.md +1 -1
  8. data/docs/internals/anthropic.md +4 -4
  9. data/docs/internals/attached-context.md +8 -6
  10. data/docs/internals/block-renderers.md +8 -1
  11. data/docs/internals/engine.md +220 -99
  12. data/docs/internals/inline-widgets.md +136 -0
  13. data/docs/internals/objectives.md +8 -6
  14. data/docs/internals/participants.md +5 -5
  15. data/docs/internals/prompt-caching.md +9 -5
  16. data/docs/internals/web-frontend.md +1 -1
  17. data/docs/internals/working-set.md +2 -1
  18. data/docs/plugins.md +2 -0
  19. data/docs/releasing.md +1 -1
  20. data/docs/sessions.md +2 -2
  21. data/docs/testing.md +1 -1
  22. data/lib/samagotchi/block_renderers.rb +5 -0
  23. data/lib/samagotchi/bundle_command/check.rb +1 -1
  24. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  25. data/lib/samagotchi/bundles/inline-widgets/manifest.yml +11 -0
  26. data/lib/samagotchi/bundles/inline-widgets/plugin.rb +76 -0
  27. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  28. data/lib/samagotchi/engine/context_judge.rb +19 -125
  29. data/lib/samagotchi/engine/guardrails.rb +4 -2
  30. data/lib/samagotchi/engine/messages.rb +9 -6
  31. data/lib/samagotchi/engine/objectives.rb +22 -90
  32. data/lib/samagotchi/engine/participants.rb +15 -10
  33. data/lib/samagotchi/engine/plugins.rb +3 -1
  34. data/lib/samagotchi/engine/questions.rb +1 -1
  35. data/lib/samagotchi/engine/side_requests.rb +5 -2
  36. data/lib/samagotchi/engine/toolkit.rb +33 -190
  37. data/lib/samagotchi/engine/turn_end.rb +1 -1
  38. data/lib/samagotchi/engine/turn_helpers.rb +16 -39
  39. data/lib/samagotchi/engine/verify.rb +7 -143
  40. data/lib/samagotchi/engine.rb +162 -99
  41. data/lib/samagotchi/engine_peers.rb +1 -1
  42. data/lib/samagotchi/event_fanout.rb +18 -12
  43. data/lib/samagotchi/guardrail_wiring.rb +83 -82
  44. data/lib/samagotchi/guardrails/gate.rb +41 -40
  45. data/lib/samagotchi/guardrails/pair_boundary.rb +2 -1
  46. data/lib/samagotchi/guardrails/review_boundary.rb +2 -1
  47. data/lib/samagotchi/guardrails/targets.rb +6 -4
  48. data/lib/samagotchi/guardrails_command.rb +1 -1
  49. data/lib/samagotchi/inline_widgets.rb +325 -0
  50. data/lib/samagotchi/kernel_loop.rb +190 -213
  51. data/lib/samagotchi/llm/backend.rb +3 -1
  52. data/lib/samagotchi/llm/chat_loop.rb +63 -41
  53. data/lib/samagotchi/llm/native_backend.rb +3 -2
  54. data/lib/samagotchi/llm_context_forget.rb +5 -3
  55. data/lib/samagotchi/model_binding.rb +3 -3
  56. data/lib/samagotchi/plugin/prompt_sections.rb +7 -0
  57. data/lib/samagotchi/question_desk.rb +1 -1
  58. data/lib/samagotchi/revise_anchor.rb +1 -1
  59. data/lib/samagotchi/system_prompt.rb +37 -30
  60. data/lib/samagotchi/testing/boundary.rb +7 -0
  61. data/lib/samagotchi/testing/bundle.rb +7 -5
  62. data/lib/samagotchi/testing/install.rb +3 -2
  63. data/lib/samagotchi/tool_activity.rb +8 -6
  64. data/lib/samagotchi/tool_declarations.rb +4 -4
  65. data/lib/samagotchi/tool_response.rb +4 -3
  66. data/lib/samagotchi/tool_runner.rb +91 -100
  67. data/lib/samagotchi/toolkit_binding.rb +40 -14
  68. data/lib/samagotchi/tools/deferred_tools.rb +17 -21
  69. data/lib/samagotchi/tools/registry.rb +43 -40
  70. data/lib/samagotchi/tools/tool_index.rb +135 -88
  71. data/lib/samagotchi/tools/tool_surface.rb +173 -0
  72. data/lib/samagotchi/turn_context.rb +67 -0
  73. data/lib/samagotchi/turn_stages/context_judge.rb +170 -0
  74. data/lib/samagotchi/turn_stages/objectives.rb +146 -0
  75. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +261 -0
  76. data/lib/samagotchi/turn_stages/verify_anchors.rb +169 -0
  77. data/lib/samagotchi/turn_stages/warmup.rb +83 -0
  78. data/lib/samagotchi/turn_state.rb +46 -4
  79. data/lib/samagotchi/version.rb +1 -1
  80. data/lib/samagotchi/web/markdown_renderer.rb +39 -14
  81. data/lib/samagotchi/web/message_display.rb +1 -1
  82. data/lib/samagotchi/web/public/app.js +5 -1
  83. data/lib/samagotchi/web/public/block_host.js +74 -199
  84. data/lib/samagotchi/web/public/block_renderers.css +2 -0
  85. data/lib/samagotchi/web/public/block_source.js +7 -4
  86. data/lib/samagotchi/web/public/block_watch.js +256 -0
  87. data/lib/samagotchi/web/public/composer_fold.js +3 -0
  88. data/lib/samagotchi/web/public/index.html +28 -9
  89. data/lib/samagotchi/web/public/inline_widgets.js +146 -0
  90. data/lib/samagotchi/web/public/owner_verify.js +1 -1
  91. data/lib/samagotchi/web/public/participant.js +1 -1
  92. data/lib/samagotchi/web/public/revise_stack.js +1 -1
  93. data/lib/samagotchi/web/public/stage_watch.js +6 -2
  94. data/lib/samagotchi/web/public/text_roots.js +1 -1
  95. data/lib/samagotchi/web/public/verify_target.js +1 -1
  96. data/lib/samagotchi/web/public/widget_picks.js +130 -0
  97. data/lib/samagotchi/web/public/widget_walk.js +230 -0
  98. data/lib/samagotchi/web/public/widgets.css +70 -0
  99. metadata +17 -1
@@ -24,6 +24,7 @@ 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"
29
30
  require_relative "tool_runner"
@@ -38,6 +39,7 @@ require_relative "answer_display"
38
39
  require_relative "message_time"
39
40
  require_relative "llm/model_result"
40
41
  require_relative "llm/turn_settings"
42
+ require_relative "turn_context"
41
43
  require_relative "llm/stream_drops"
42
44
 
43
45
  module Samagotchi
@@ -59,30 +61,34 @@ module Samagotchi
59
61
  # The built-in tool classes (Tools::Builtins registers them).
60
62
  TOOLS = Tools::Builtins::CLASSES
61
63
 
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).
64
+ # What a tool handler (call, kctx) gets: the kernel's reminder store
65
+ # and peers, the call's turn (+context+, a TurnContext: the participant's
66
+ # mode, the model key), and the memory read and ask-user flows that need
67
+ # them. One per dispatched call.
65
68
  class ToolContext
66
- def initialize(kernel)
69
+ def initialize(kernel, context)
67
70
  @kernel = kernel
71
+ @context = context
68
72
  end
69
73
 
70
74
  def reminder_store = @kernel.reminder_store
71
- def peers = @kernel.peers
75
+ def peers = @kernel.services.peers
72
76
 
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
77
+ # The participant turn's mode (TurnContext#participant_mode: :pair,
78
+ # :review), nil for the owner's turns.
79
+ def participant_mode = @context.participant_mode
80
+ # The memory overlay key (TurnContext#model_key, frozen at the turn's
81
+ # generate).
82
+ def model_key = @context.model_key
77
83
 
78
84
  # memory_read with the session's mutes applied: a blank name (the index)
79
85
  # loses the muted memories' lines; a muted name in a comma list is
80
86
  # refused with its own error line and the rest is read as usual.
81
87
  def muted_memory_read(call)
82
- muted = Array(@kernel.muted_memory_names)
88
+ muted = Array(@kernel.services.muted_memory_names)
83
89
  content = call[:content].to_s
84
90
  read = lambda do |names|
85
- Tools::MemoryRead.call(names, scope: call[:scope], model_key: model_key, fallback_model_key: @kernel.model_key_fallback)
91
+ Tools::MemoryRead.call(names, scope: call[:scope], model_key: model_key, fallback_model_key: @context.fallback_model_key)
86
92
  end
87
93
  return read.call(content) if muted.empty?
88
94
  return MutedMemories.filter_index(read.call(content), muted) if content.strip.empty?
@@ -98,14 +104,14 @@ module Samagotchi
98
104
  end
99
105
 
100
106
  # ask_user_question: validate the call, then hand the payload to the
101
- # Engine's question flow (the kernel's question_handler, which blocks
107
+ # Engine's question flow (the kernel's Services#question_handler, which blocks
102
108
  # until the user answers). Without one (headless), the payload as JSON
103
109
  # so the model sees the options and can ask in plain text.
104
110
  def ask_user_question(call)
105
111
  payload = Tools::AskUserQuestion.validate(call)
106
112
  return payload if payload.is_a?(String)
107
113
 
108
- handler = @kernel.question_handler
114
+ handler = @kernel.services.question_handler
109
115
  return JSON.pretty_generate(payload) unless handler
110
116
 
111
117
  handler.call(payload).to_s
@@ -115,7 +121,12 @@ module Samagotchi
115
121
 
116
122
  # forget_outputs: the forget layer's call on the running turn's
117
123
  # conversation (KernelLoop#forget_outputs).
118
- def forget_outputs(call) = @kernel.forget_outputs(call)
124
+ def forget_outputs(call) = @kernel.forget_outputs(call, @context)
125
+
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
+ # index's handlers (find_tools, call_tool) read.
129
+ def surface = Tools::ToolSurface.of(@context, @kernel.tools)
119
130
  end
120
131
 
121
132
  DEFAULT_MAX_TOOL_OUTPUT_CHARS = 10_000
@@ -124,35 +135,82 @@ module Samagotchi
124
135
  MALFORMED_TAIL_CHARS = 400
125
136
  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
137
 
127
- # @param tools [Tools::Registry, nil] the tools calls dispatch to (the
128
- # Engine's; nil: the built-ins alone)
138
+ # What a kernel holds of its Engine for the Engine's life, one value
139
+ # (Engine#wire_kernel builds it; a spec's kernel takes one through
140
+ # #attach): nothing per turn (a turn's TurnContext goes down #run).
141
+ #
142
+ # - hooks: the Hooks::Registry the loop fires (nil: none); the Engine's
143
+ # - tools: the Tools::Registry #dispatch runs (the built-ins by default)
144
+ # - reminder_store: the ReminderStore the reminder tools write (nil: none)
145
+ # - muted_memory_names: the session's muted memories (normalized names,
146
+ # MutedMemories), refused by memory_read; nil: none
147
+ # - question_handler: answers ask_user_question (payload → answer
148
+ # string; the Engine's blocking request_question); nil: headless
149
+ # - peers: Tools::Peers (or the Engine's PeerView): the session
150
+ # list_sessions and send_note speak for; nil outside a session
151
+ # - gate: the Guardrails::Gate ToolRunner asks before each call; nil:
152
+ # ToolRunner's own, hooks only
153
+ # - warmup: the Engine's PromptWarmup, asked for a slot pin per request
154
+ # (#take_pin) while the turn-end warm-up runs; nil: never pinned
155
+ # - generation_guard: called before each generation with the turn's
156
+ # TurnContext, before the before_generation hook; the Engine's check
157
+ # that may stop the turn (a pair turn past its participant's cap,
158
+ # Engine#guard_participant_spend); nil: none
159
+ #
160
+ # The client is not one of them: a model switch swaps it
161
+ # (#client=, Engine#sync_kernel_client!).
162
+ Services = Data.define(:hooks, :tools, :reminder_store, :muted_memory_names, :question_handler, :peers, :gate,
163
+ :warmup, :generation_guard) do
164
+ # A bare kernel's (specs, a kernel built without its Engine): the
165
+ # built-in tools and nothing else, or the +fields+ given.
166
+ # @return [Services]
167
+ def self.none(**fields)
168
+ new(hooks: nil, tools: Tools::Builtins.default, reminder_store: nil, muted_memory_names: nil,
169
+ question_handler: nil, peers: nil, gate: nil, warmup: nil, generation_guard: nil).with(**fields)
170
+ end
171
+ end
172
+
173
+ # @param services [Services, nil] what the kernel holds of its Engine
174
+ # (Services.none by default: the built-in tools alone)
129
175
  # @param sleeper [#call, nil] waits the given seconds before a dropped
130
176
  # 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)
177
+ def initialize(client: nil, profile: nil, model_name: nil, model_key: nil, services: nil, sleeper: nil)
133
178
  @client = client || Client.new
134
179
  @sleeper = sleeper || ->(seconds) { sleep(seconds) }
135
- @tools = tools || Tools::Builtins.default
180
+ @services = services || Services.none
136
181
  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)
182
+ # The context of the run in progress, else of the last run (#context):
183
+ # a kernel that ran none has its own (no turn, no settings but its
184
+ # model name, its model key). #run takes the one it is given.
185
+ @context = TurnContext.none(settings: LLM::TurnSettings.none.with(model_name: resolved_model_name),
186
+ model_key: model_key)
143
187
  @profile = profile ? ModelProfile.normalize(profile) : ModelProfile.from_model_name(resolved_model_name)
144
188
  # Where @profile came from, as /stats shows it (a Resolution's label
145
189
  # once the Engine resolves one, see #use_profile!).
146
190
  @profile_source = profile ? "given" : "name"
147
- @hooks = hooks
148
- @reminder_store = reminder_store
149
- @model_key = model_key
150
- @model_key_fallback = nil
151
191
  end
152
192
 
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
193
+ # @return [Services] what the kernel holds of its Engine (#attach)
194
+ attr_reader :services
195
+
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.
198
+ # @param services [Services]
199
+ # @return [Services]
200
+ def attach(services)
201
+ @services = services
202
+ end
203
+
204
+ # @return [Hooks::Registry, nil] the hooks the loop fires (Services#hooks)
205
+ def hooks = @services.hooks
206
+ # @return [Tools::Registry] the tools #dispatch runs (Services#tools)
207
+ def tools = @services.tools
208
+ # @return [ReminderStore, nil] the store the reminder tools write
209
+ # (Services#reminder_store)
210
+ def reminder_store = @services.reminder_store
211
+ # @return [Guardrails::Gate, nil] the gate ToolRunner asks before each
212
+ # call (Services#gate)
213
+ def guardrail_gate = @services.gate
156
214
 
157
215
  # @return [Float, nil] the last counted request's chars per token
158
216
  # (ContextStatus#counted_chars_per_token), for the next turn's first
@@ -162,79 +220,43 @@ module Samagotchi
162
220
  # @return [ModelProfile] the active prompt profile
163
221
  attr_reader :profile
164
222
 
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.
223
+ # The model client; a model switch swaps it (Engine#sync_kernel_client!).
224
+ attr_accessor :client
225
+
226
+ # @return [String, nil] the model key a kernel was built with (its own
227
+ # #context's, for a run given none)
228
+ def model_key = @context.model_key
229
+
197
230
  # 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.
231
+ # on the client and +settings+, a turn's LLM::TurnSettings): ToolRunner
232
+ # halves it for memory_read's overflow guard. nil when it can't be read.
200
233
  # @return [ContextWindow::Resolved, nil]
201
- def tool_output_window
234
+ def tool_output_window(settings = turn_settings)
202
235
  Degrade.call(:model, "context_window_unresolved") do
203
- ContextWindow.resolve(client: @client, model: @turn_settings&.model_name, setting: @turn_settings&.window_setting)
236
+ ContextWindow.resolve(client: @client, model: settings&.model_name, setting: settings&.window_setting)
204
237
  end
205
238
  end
206
239
 
207
- def guard_generation
208
- @generation_guard&.call
240
+ # The generation guard for +context+'s turn, if any; one that raises
241
+ # never breaks the turn.
242
+ def guard_generation(context = @context)
243
+ @services.generation_guard&.call(context)
209
244
  rescue StandardError => e
210
245
  Log.warn(:turn, "generation_guard_failed", error: "#{e.class}: #{e.message}")
211
246
  nil
212
247
  end
213
248
 
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
249
+ # @return [TurnContext] the context of the run in progress, else of the
250
+ # last run (the kernel's own before any): its turn's settings,
251
+ # session folders and participant mode. Read on the turn's thread
252
+ # only; another thread reads the Engine's (Engine#turn_context).
253
+ attr_reader :context
254
+
255
+ # @return [LLM::TurnSettings] the vision, sampling, thinking and model
256
+ # name of the run in progress, else of the last one (#context's; #run
257
+ # sets the model name); the thinking level is its own field, since the
258
+ # native path sends the sampling as is.
259
+ def turn_settings = @context.settings
238
260
  # The turn a forget_outputs call works on (LLMContextForget::Turn: its
239
261
  # conversation and ContextStatus); both loops set it as a turn starts.
240
262
  attr_accessor :llm_context_turn
@@ -265,10 +287,14 @@ module Samagotchi
265
287
  # run it resumes started in +messages+; that run's model messages keep
266
288
  # their thought blocks (#prepare_conversation), as within one run. nil:
267
289
  # every earlier thought is stripped.
290
+ # @param context [TurnContext] the run's turn (Engine#generate's); a
291
+ # bare kernel's last (#context) by default. Its settings take the
292
+ # run's model name: this run's own copy, never published.
268
293
  # @return [LLM::ModelResult] final visible response with continuation metadata
269
294
  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))
295
+ reasoning_since: nil, context: @context)
296
+ settings = context.settings || LLM::TurnSettings.none
297
+ @context = context.with(settings: settings.with(model_name: completion_model_name(model_name)))
272
298
  @last_slot = nil
273
299
  turn = start_turn(messages, on_stream_event: on_stream_event, cancel_controller: cancel_controller,
274
300
  pending_input: pending_input, cap: resolve_output_char_cap(max_tool_output_chars),
@@ -304,13 +330,6 @@ module Samagotchi
304
330
  @profile_source = resolution.label
305
331
  end
306
332
 
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
333
  # Resolve the per-output character cap for the tool outputs the model
315
334
  # gets back and the emitted tool call events.
316
335
  #
@@ -354,10 +373,11 @@ module Samagotchi
354
373
 
355
374
  def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:, reasoning_since: nil)
356
375
  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,
376
+ context = ContextStatus.new(conversation: conversation, llm_context: turn_settings.llm_context,
358
377
  counted_chars_per_token: @counted_chars_per_token)
359
378
  emit = ->(event) { emit_stream_event(on_stream_event, event) }
360
- @llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: context, emit: emit)
379
+ @llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: context, emit: emit,
380
+ turn_id: @context.id)
361
381
  Turn.new(
362
382
  conversation: conversation, context: context,
363
383
  empty_retry: EmptyAnswerRetry.new, empty_steps: [], tool_activity: [], buffer: +"", streamed_thinking: +"",
@@ -366,8 +386,8 @@ module Samagotchi
366
386
  # answers at once; on, an open one where the server's template has
367
387
  # it. Kept in the turn's model messages, so each tool-loop prompt
368
388
  # 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,
389
+ prefill: native_prefill(turn_settings.thinking || Thinking::DEFAULT),
390
+ pending_tool_calls: false, model_name: turn_settings.model_name, pending_input: pending_input,
371
391
  on_stream_event: on_stream_event, cancel_controller: cancel_controller, cap: cap,
372
392
  emit: emit
373
393
  )
@@ -385,7 +405,7 @@ module Samagotchi
385
405
 
386
406
  def model_props
387
407
  Degrade.call(:model, "server_props_unreadable") do
388
- @client.respond_to?(:server_props) ? @client.server_props(model: @turn_settings.model_name) : nil
408
+ @client.respond_to?(:server_props) ? @client.server_props(model: turn_settings.model_name) : nil
389
409
  end
390
410
  end
391
411
 
@@ -435,7 +455,7 @@ module Samagotchi
435
455
  apply_llm_context!(turn.conversation, context: turn.context, emit: turn.emit)
436
456
  prompt, images = format_prompt(turn)
437
457
  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)
458
+ window = ContextWindow.resolve(client: @client, model: turn.model_name, setting: turn_settings.window_setting)
439
459
  emit_context_status_event(turn.on_stream_event, turn.context, prompt, iteration_index: turn.iteration - 1,
440
460
  window: window, image_tokens: image_tokens)
441
461
  if (line = turn.context.take_guidance)
@@ -450,7 +470,7 @@ module Samagotchi
450
470
 
451
471
  def format_prompt(turn)
452
472
  Prompt.format_with_images(llm_context_view.messages(turn.conversation), profile: @profile,
453
- vision: @turn_settings.vision, prefill: turn.prefill)
473
+ vision: turn_settings.vision, prefill: turn.prefill)
454
474
  end
455
475
 
456
476
  # One streamed request, under the generation's own controller: a
@@ -460,8 +480,8 @@ module Samagotchi
460
480
  def generate(turn, request)
461
481
  # Before the generation starts: a hook that waits (a question) isn't
462
482
  # 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
483
+ guard_generation(@context)
484
+ fire_hook(:before_generation, { type: :before_generation, iteration: turn.iteration }) if hooks
465
485
  raise Client::RequestCancelled, turn.cancel_controller.reason if turn.cancel_controller&.cancelled?
466
486
 
467
487
  # Each ask splits its own stream (run_generation rebuilds the
@@ -469,7 +489,7 @@ module Samagotchi
469
489
  # leave the next one "inside" it.
470
490
  emit(turn, type: :generation_started, iteration: turn.iteration, context_window_tokens: request.window.tokens,
471
491
  context_window_source: request.window.source, profile: @profile.name, profile_source: @profile_source,
472
- price: @turn_settings.price&.to_h)
492
+ price: turn_settings.price&.to_h)
473
493
  # This generation's own server counts (the run-long ones in the
474
494
  # context tracker can be an earlier one's), and the thinking it
475
495
  # streamed, for the log (a stuck thinking generation shows as
@@ -505,7 +525,7 @@ module Samagotchi
505
525
  # The empty-retry sampling is taken once, before the ask-again loop:
506
526
  # a drop's restart must still carry the nudge's 0.6 (as ChatLoop does
507
527
  # per step), not lose it when request_sampling is asked again.
508
- sampling = turn.empty_retry.request_sampling(@turn_settings.sampling)
528
+ sampling = turn.empty_retry.request_sampling(turn_settings.sampling)
509
529
  count = LLM::StreamDrops::DropCount.new(0, 0)
510
530
  begin
511
531
  # Each ask splits its own stream: a drop mid tool call (or mid
@@ -666,7 +686,7 @@ module Samagotchi
666
686
  dump_log("response", generation.response, iteration: turn.iteration)
667
687
  # The after_generation hook (after the model returns, before the tool
668
688
  # parse), with a read-only copy of the conversation as sent.
669
- return unless @hooks
689
+ return unless hooks
670
690
 
671
691
  fire_hook(:after_generation, { type: :after_generation, iteration: turn.iteration, response: generation.response,
672
692
  messages: AnswerDisplay.strip_all(turn.conversation).map(&:dup).freeze })
@@ -780,7 +800,7 @@ module Samagotchi
780
800
  turn.qwen_partial = nil
781
801
  runs = []
782
802
  begin
783
- ToolResponse.run_batch(tool_runner, calls, iteration: turn.iteration, emit: turn.emit,
803
+ ToolResponse.run_batch(tool_runner, calls, context: @context, iteration: turn.iteration, emit: turn.emit,
784
804
  on_stream_event: turn.on_stream_event, cap: turn.cap) { |run, _| runs << run }
785
805
  rescue Interrupt
786
806
  # A Ctrl-C mid-batch: the entry still answers every call (the
@@ -848,17 +868,15 @@ module Samagotchi
848
868
  end
849
869
 
850
870
  def fire_hook(name, event)
851
- return unless @hooks
871
+ return unless hooks
852
872
 
853
873
  # A failing hook must not break the turn.
854
- Degrade.call(:hooks, "hook_fire_failed", hook: name) { @hooks.fire(name, event) }
874
+ Degrade.call(:hooks, "hook_fire_failed", hook: name) { hooks.fire(name, event) }
855
875
  end
856
876
 
857
877
  # Coerce a value to boolean — handles true/false, nil, and string "true"/"false".
858
878
  def truthy?(val) = Tools::Builtins.truthy?(val)
859
879
 
860
- def tool_context = @tool_context ||= ToolContext.new(self)
861
-
862
880
  # ── Output char cap resolution ─────────────────────────────────────────────
863
881
 
864
882
  def resolve_output_char_cap(override)
@@ -870,7 +888,7 @@ module Samagotchi
870
888
  def request_generation(prompt, generation_controller:, cancel_controller:, **)
871
889
  kwargs = complete_kwargs(cancel_controller: generation_controller || cancel_controller, **)
872
890
  # Behind a turn-end warm-up still running on its slot (PromptWarmup).
873
- slot = @warmup&.take_pin(@client)
891
+ slot = @services.warmup&.take_pin(@client)
874
892
  kwargs[:slot] = slot if slot
875
893
  @client.complete(prompt, **kwargs)
876
894
  end
@@ -940,10 +958,10 @@ module Samagotchi
940
958
 
941
959
  # A payload dump (model response, tool call/result, context status): the
942
960
  # debug level only, tagged with the model it came from.
943
- def dump_log(event, payload, **fields)
961
+ def dump_log(event, payload, model: turn_settings.model_name, **fields)
944
962
  return unless Log.level?(:debug)
945
963
 
946
- Log.debug(:model, event, payload: payload, model: @turn_settings.model_name, **fields)
964
+ Log.debug(:model, event, payload: payload, model: model, **fields)
947
965
  end
948
966
 
949
967
  # Estimate context usage for this iteration's prompt (ContextStatus#observe)
@@ -971,12 +989,14 @@ module Samagotchi
971
989
  # for the model the next turn runs on, after a /model switch too),
972
990
  # else the last turn's. Returns [prompt, images], or nil when the cut
973
991
  # isn't found.
974
- def warmup_prompt(messages, llm_context: @turn_settings&.llm_context)
992
+ # +vision+: the next turn's VisionContext (the last turn's settings'),
993
+ # else the last run's.
994
+ def warmup_prompt(messages, llm_context: turn_settings&.llm_context, vision: turn_settings.vision)
975
995
  conversation = prepare_conversation(messages)
976
996
  apply_llm_context!(conversation, llm_context, warmup: true)
977
997
  conversation << { role: "user", content: WARMUP_CUT }
978
998
  prompt, images = Prompt.format_with_images(llm_context_view(llm_context).messages(conversation),
979
- profile: @profile, vision: @turn_settings.vision)
999
+ profile: @profile, vision: vision)
980
1000
  head = prompt[0, prompt.index(WARMUP_CUT) || 0]
981
1001
  cut = head.rindex(user_opener)
982
1002
  cut&.positive? ? [head[0, cut], images] : nil
@@ -990,34 +1010,35 @@ module Samagotchi
990
1010
  # one given (the warm-up's, the next turn's): the prompt, the warm-up
991
1011
  # and the chat messages all format through it, so the warm-up warms
992
1012
  # the prompt the next turn sends.
993
- def llm_context_view(llm_context = @turn_settings&.llm_context)
1013
+ def llm_context_view(llm_context = turn_settings&.llm_context)
994
1014
  LLMContextView.new(strategy: llm_context&.strategy || LLMContextView::NONE)
995
1015
  end
996
1016
 
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 || []
1000
-
1001
1017
  # 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.
1018
+ # turn (the loop's llm_context_turn), under +context+'s strategy (the
1019
+ # call's TurnContext); the apply rule runs on the turn's conversation at
1020
+ # once, as at a request, so the result says whether the stubs go with
1021
+ # the next request or wait for the turn's end.
1005
1022
  # @return [String] the tool result
1006
- def forget_outputs(call)
1007
- llm_context = @turn_settings&.llm_context
1023
+ def forget_outputs(call, context)
1024
+ llm_context = context.settings&.llm_context
1008
1025
  turn = @llm_context_turn
1009
1026
  unless turn && llm_context&.active_layers&.include?(:forget)
1010
1027
  return "Error: forget_outputs is off (the llm_context strategy has no forget layer)"
1011
1028
  end
1012
1029
 
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))
1030
+ apply = lambda {
1031
+ apply_llm_context!(turn.conversation, llm_context, context: turn.context, emit: turn.emit,
1032
+ model: context.settings.model_name)
1033
+ }
1034
+ LLMContextForget.call(turn, call, llm_context: llm_context, apply: apply,
1035
+ pinned: ->(conversation) { pinned_ids(conversation, context) })
1015
1036
  end
1016
1037
 
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)
1038
+ # The ids of +conversation+'s outputs the session's pins hold (the
1039
+ # working-set pins of +context+'s session, read now).
1040
+ def pinned_ids(conversation, context)
1041
+ WorkingSet.pinned_ids(conversation, pins: context.working_set_pins, cwd: context.cwd)
1021
1042
  end
1022
1043
  private :pinned_ids
1023
1044
 
@@ -1034,8 +1055,9 @@ module Samagotchi
1034
1055
  # shown to the user as a ✂ row (+emit+ takes the :llm_context_edited
1035
1056
  # event, LLMContextNotice; the warm-up's own copy has none).
1036
1057
  # @return [LLMContextApply::Outcome]
1037
- def apply_llm_context!(conversation, llm_context = @turn_settings&.llm_context, moment: :request, context: nil,
1038
- warmup: false, emit: nil)
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)
1039
1061
  view = llm_context_view(llm_context)
1040
1062
  return LLMContextApply::Outcome.none if view.none?
1041
1063
 
@@ -1047,19 +1069,19 @@ module Samagotchi
1047
1069
  # The prompt just shrank: the context estimate counts from it.
1048
1070
  context&.edited!
1049
1071
  unless warmup
1050
- log_llm_context(outcome, llm_context, moment)
1072
+ log_llm_context(outcome, llm_context, moment, model)
1051
1073
  emit_llm_context(emit, conversation, outcome, moment) if emit
1052
1074
  end
1053
1075
  end
1054
1076
  outcome
1055
1077
  end
1056
1078
 
1057
- def log_llm_context(outcome, llm_context, moment)
1079
+ def log_llm_context(outcome, llm_context, moment, model)
1058
1080
  Log.info(:model, "llm_context_apply", rule: llm_context.apply, at: moment, why: outcome.why,
1059
1081
  applied: outcome.applied.size, ids: outcome.applied.map(&:id).join(","),
1060
1082
  staged: outcome.staged, freed: (outcome.freed_chars / TokenUsage::CHARS_PER_TOKEN).ceil,
1061
1083
  tail: (outcome.tail_chars / TokenUsage::CHARS_PER_TOKEN).ceil,
1062
- model: @turn_settings&.model_name)
1084
+ model: model)
1063
1085
  end
1064
1086
  private :log_llm_context
1065
1087
 
@@ -1188,78 +1210,33 @@ module Samagotchi
1188
1210
  # unknown-tool handling, and activity events are shared, not duplicated. Callers
1189
1211
  # are responsible for normalizing the provider's native call into this shape
1190
1212
  # first (see Samagotchi::LLM::NativeToolNormalizer); dispatch itself never
1191
- # parses provider text.
1192
- def dispatch_tool_call(call)
1193
- dispatch(call)
1213
+ # parses provider text. +context+: the call's turn (TurnContext: its
1214
+ # ToolSurface, the handler's ToolContext); a bare kernel's own
1215
+ # (#context) by default.
1216
+ def dispatch_tool_call(call, context = @context)
1217
+ dispatch(call, context)
1194
1218
  end
1195
1219
 
1196
1220
  private
1197
1221
 
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
1224
-
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
1243
-
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
1222
+ # The call runs on the entry the turn's surface gives it
1223
+ # (Tools::ToolSurface#entry_for: a call_tool-reached one only while the
1224
+ # toolkit reaches it that way, a reachable one by its name on a host
1225
+ # that loads tools); without one it gets ToolSurface#unknown_tool_error.
1226
+ # A context without a surface (a bare kernel) computes one now.
1227
+ def dispatch(call, context)
1228
+ model = context.settings&.model_name
1229
+ surface = Tools::ToolSurface.of(context, tools)
1230
+ entry = surface.entry_for(call)
1254
1231
  unless entry
1255
- result = unknown_tool_error(call[:name], llm_context_layers, via: call[:via])
1232
+ result = surface.unknown_tool_error(call[:name], via: call[:via])
1256
1233
  return {
1257
1234
  output: result,
1258
- activity: ToolActivity.tool_activity_event(call[:name], call, result, registry: @tools)
1235
+ activity: ToolActivity.tool_activity_event(call[:name], call, result, registry: tools, surface: surface)
1259
1236
  }
1260
1237
  end
1261
1238
 
1262
- dump_log("tool_call", call[:content], tool: call[:name], path: call[:path], scope: call[:scope])
1239
+ dump_log("tool_call", call[:content], model: model, tool: call[:name], path: call[:path], scope: call[:scope])
1263
1240
 
1264
1241
  if call[:called_as]
1265
1242
  # The model's spelling for a tool an alias ran (bash → execute); its
@@ -1268,12 +1245,12 @@ module Samagotchi
1268
1245
  Log.info(:turn, "tool_alias", name: call[:called_as], as: call[:name])
1269
1246
  end
1270
1247
 
1271
- result = entry.handler.call(call, tool_context)
1248
+ result = entry.handler.call(call, ToolContext.new(self, context))
1272
1249
 
1273
- dump_log("tool_result", result, tool: call[:name])
1250
+ dump_log("tool_result", result, model: model, tool: call[:name])
1274
1251
  dispatched = {
1275
1252
  output: "[#{call[:name]}]\n#{result}",
1276
- activity: ToolActivity.tool_activity_event(call[:name], call, result, registry: @tools)
1253
+ activity: ToolActivity.tool_activity_event(call[:name], call, result, registry: tools, surface: surface)
1277
1254
  }
1278
1255
  # Images the tool returned (read's ImageResult, a plugin's ToolResult):
1279
1256
  # ToolRunner attaches them (or says why not).
@@ -1292,11 +1269,11 @@ module Samagotchi
1292
1269
  end
1293
1270
  dispatched
1294
1271
  rescue StandardError => e
1295
- dump_log("tool_error", e.message, tool: call[:name], error: e.class.name)
1272
+ dump_log("tool_error", e.message, model: context.settings&.model_name, tool: call[:name], error: e.class.name)
1296
1273
  result = "Error: #{e.message}"
1297
1274
  {
1298
1275
  output: "[#{call[:name]}] #{result}",
1299
- activity: ToolActivity.tool_activity_event(call[:name], call, result, registry: @tools)
1276
+ activity: ToolActivity.tool_activity_event(call[:name], call, result, registry: tools, surface: surface)
1300
1277
  }
1301
1278
  end
1302
1279
  end