samagotchi 0.10.1 → 0.12.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -1
  3. data/bin/chi +5 -2
  4. data/docs/architecture.md +23 -4
  5. data/docs/cli.md +49 -8
  6. data/docs/configuration.md +44 -11
  7. data/docs/desktop.md +24 -2
  8. data/docs/guardrails.md +2 -1
  9. data/docs/hooks.md +3 -2
  10. data/docs/internals/context-telemetry.md +4 -2
  11. data/docs/sessions.md +3 -3
  12. data/lib/samagotchi/bootstrap/config_writer.rb +2 -2
  13. data/lib/samagotchi/bridge.rb +4 -4
  14. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +5 -3
  15. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
  17. data/lib/samagotchi/bundles/skills/plugin.rb +3 -23
  18. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  19. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  20. data/lib/samagotchi/client.rb +21 -1
  21. data/lib/samagotchi/config.rb +44 -40
  22. data/lib/samagotchi/context_status.rb +17 -16
  23. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  24. data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
  25. data/lib/samagotchi/desktop/macos/Models.swift +238 -0
  26. data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
  27. data/lib/samagotchi/empty_answer_retry.rb +67 -8
  28. data/lib/samagotchi/engine.rb +506 -499
  29. data/lib/samagotchi/host_registry.rb +76 -92
  30. data/lib/samagotchi/idle_reminders.rb +13 -36
  31. data/lib/samagotchi/kernel_loop.rb +352 -407
  32. data/lib/samagotchi/llm/chat_loop.rb +95 -125
  33. data/lib/samagotchi/llm/model_result.rb +14 -20
  34. data/lib/samagotchi/llm/native_backend.rb +3 -21
  35. data/lib/samagotchi/llm/openai_chat.rb +1 -1
  36. data/lib/samagotchi/llm/turn_settings.rb +19 -0
  37. data/lib/samagotchi/llm/usage.rb +2 -57
  38. data/lib/samagotchi/memory_bundle/installer.rb +1 -1
  39. data/lib/samagotchi/model_catalog.rb +101 -0
  40. data/lib/samagotchi/model_profile.rb +23 -4
  41. data/lib/samagotchi/model_ref.rb +60 -0
  42. data/lib/samagotchi/models_command.rb +112 -0
  43. data/lib/samagotchi/prompt_history.rb +91 -0
  44. data/lib/samagotchi/recap_setup.rb +15 -3
  45. data/lib/samagotchi/reminder_queue.rb +73 -0
  46. data/lib/samagotchi/reminder_store.rb +3 -3
  47. data/lib/samagotchi/self_report.rb +19 -7
  48. data/lib/samagotchi/send_command.rb +9 -9
  49. data/lib/samagotchi/session.rb +9 -2
  50. data/lib/samagotchi/session_commands.rb +15 -17
  51. data/lib/samagotchi/session_manager.rb +22 -9
  52. data/lib/samagotchi/sessions_command.rb +57 -51
  53. data/lib/samagotchi/steer.rb +23 -0
  54. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -2
  55. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -6
  56. data/lib/samagotchi/terminal_ui/input_support.rb +78 -33
  57. data/lib/samagotchi/terminal_ui/reline_history_hook.rb +93 -0
  58. data/lib/samagotchi/terminal_ui.rb +16 -35
  59. data/lib/samagotchi/thinking.rb +1 -1
  60. data/lib/samagotchi/tool_response.rb +67 -0
  61. data/lib/samagotchi/tool_runner.rb +10 -7
  62. data/lib/samagotchi/tools/builtins.rb +1 -1
  63. data/lib/samagotchi/tools/delegate.rb +4 -3
  64. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  65. data/lib/samagotchi/tools/memory.rb +8 -6
  66. data/lib/samagotchi/turn_flow.rb +4 -4
  67. data/lib/samagotchi/turn_state.rb +126 -0
  68. data/lib/samagotchi/used_memories.rb +88 -0
  69. data/lib/samagotchi/version.rb +1 -1
  70. data/lib/samagotchi/vision_support.rb +4 -4
  71. data/lib/samagotchi/web/app.rb +42 -35
  72. data/lib/samagotchi/web/public/app.js +29 -2
  73. data/lib/samagotchi/web/public/data.js +9 -1
  74. data/lib/samagotchi/web/public/prompt_history.js +63 -0
  75. data/lib/samagotchi/web/session_hub.rb +0 -4
  76. data/lib/samagotchi/web/session_summary.rb +6 -6
  77. data/lib/samagotchi/worker.rb +3 -2
  78. metadata +13 -1
@@ -20,7 +20,10 @@ require_relative "tools/builtins"
20
20
  require_relative "muted_memories"
21
21
  require_relative "tool_activity"
22
22
  require_relative "tool_runner"
23
+ require_relative "tool_response"
23
24
  require_relative "answer_display"
25
+ require_relative "llm/model_result"
26
+ require_relative "llm/turn_settings"
24
27
 
25
28
  module Samagotchi
26
29
  # The KernelLoop drives the model ↔ tool interaction cycle.
@@ -36,30 +39,12 @@ module Samagotchi
36
39
  # - Gemma 4: <|tool_call>call:NAME{params}<tool_call|>
37
40
  # - Qwen 3.6: <tool_call><function=NAME><parameter=KEY>VALUE</parameter></function></tool_call>
38
41
  class KernelLoop
39
- Result = Struct.new(:output, :conversation, :exhausted, :pending_tool_calls, :tool_activity, :canceled, :cancellation_reason, :context_status, keyword_init: true) do
40
- def exhausted?
41
- exhausted
42
- end
43
-
44
- def pending_tool_calls?
45
- pending_tool_calls
46
- end
47
-
48
- def resumable?
49
- exhausted? && pending_tool_calls?
50
- end
51
-
52
- def canceled?
53
- canceled
54
- end
55
- end
56
-
57
42
  # The built-in tool classes (Tools::Builtins registers them).
58
43
  TOOLS = Tools::Builtins::CLASSES
59
44
 
60
45
  # What a tool handler (call, kctx) gets from the kernel: the reminder
61
46
  # store, the peers, the model key, and the memory read and ask-user
62
- # flows that need its state.
47
+ # flows that need its state (read through the kernel's public readers).
63
48
  class ToolContext
64
49
  def initialize(kernel)
65
50
  @kernel = kernel
@@ -68,8 +53,44 @@ module Samagotchi
68
53
  def reminder_store = @kernel.reminder_store
69
54
  def peers = @kernel.peers
70
55
  def model_key = @kernel.model_key
71
- def muted_memory_read(call) = @kernel.__send__(:muted_memory_read, Tools::MemoryRead, call)
72
- def ask_user_question(call) = @kernel.__send__(:handle_ask_user_question, call)
56
+
57
+ # memory_read with the session's mutes applied: a blank name (the index)
58
+ # loses the muted memories' lines; a muted name in a comma list is
59
+ # refused with its own error line and the rest is read as usual.
60
+ def muted_memory_read(call)
61
+ muted = Array(@kernel.muted_memory_names)
62
+ content = call[:content].to_s
63
+ read = lambda do |names|
64
+ Tools::MemoryRead.call(names, scope: call[:scope], model_key: model_key, fallback_model_key: @kernel.model_key_fallback)
65
+ end
66
+ return read.call(content) if muted.empty?
67
+ return MutedMemories.filter_index(read.call(content), muted) if content.strip.empty?
68
+
69
+ names = Tools::MemoryRead.parse_names(content)
70
+ refused, allowed = names.partition { |name| MutedMemories.muted?(name, muted) }
71
+ return read.call(content) if refused.empty?
72
+
73
+ errors = refused.map { |name| "Error: memory '#{name}' is muted for this session" }
74
+ return errors.join("\n") if allowed.empty?
75
+
76
+ [read.call(allowed.join(",")), *errors].join(Tools::MemoryRead::SEPARATOR)
77
+ end
78
+
79
+ # ask_user_question: validate the call, then hand the payload to the
80
+ # Engine's question flow (the kernel's question_handler, which blocks
81
+ # until the user answers). Without one (headless), the payload as JSON
82
+ # so the model sees the options and can ask in plain text.
83
+ def ask_user_question(call)
84
+ payload = Tools::AskUserQuestion.validate(call)
85
+ return payload if payload.is_a?(String)
86
+
87
+ handler = @kernel.question_handler
88
+ return JSON.pretty_generate(payload) unless handler
89
+
90
+ handler.call(payload).to_s
91
+ rescue StandardError => e
92
+ "Error: ask_user_question handler failed: #{e.message}"
93
+ end
73
94
  end
74
95
 
75
96
  DEFAULT_MAX_TOOL_OUTPUT_CHARS = 10_000
@@ -78,17 +99,16 @@ module Samagotchi
78
99
 
79
100
  # @param tools [Tools::Registry, nil] the tools calls dispatch to (the
80
101
  # Engine's; nil: the built-ins alone)
81
- def initialize(client: nil, profile: nil, model_name: nil, no_interrupt: false, hooks: nil, reminder_store: nil, model_key: nil,
82
- tools: nil)
102
+ def initialize(client: nil, profile: nil, model_name: nil, hooks: nil, reminder_store: nil, model_key: nil, tools: nil)
83
103
  @client = client || Client.new
84
104
  @tools = tools || Tools::Builtins.default
85
- @no_interrupt = no_interrupt
86
105
  resolved_model_name = ModelProfile.required_model_name(model_name)
87
- # The resolved model id actually used for this run (per-run override wins
106
+ # The turn's settings (LLM::TurnSettings). Its model name is the
107
+ # resolved model id actually used for this run (per-run override wins
88
108
  # over the config alias); on every debug dump so we can see exactly
89
- # which model each request went to. The Engine sets it per turn too:
109
+ # which model each request went to. The Engine sets them per turn:
90
110
  # the chat loop dispatches tools here without going through #run.
91
- @current_model_name = resolved_model_name
111
+ @turn_settings = LLM::TurnSettings.none.with(model_name: resolved_model_name)
92
112
  @profile = profile ? ModelProfile.normalize(profile) : ModelProfile.from_model_name(resolved_model_name)
93
113
  # Where @profile came from, as /stats shows it (a Resolution's label
94
114
  # once the Engine resolves one, see #use_profile!).
@@ -96,26 +116,28 @@ module Samagotchi
96
116
  @hooks = hooks
97
117
  @reminder_store = reminder_store
98
118
  @model_key = model_key
119
+ @model_key_fallback = nil
99
120
  end
100
121
 
101
- # @return [ReminderStore, nil] the reminder store for inspection (used by
102
- # Engine to share the same store with the KernelLoop when TerminalUI
103
- # creates both).
104
- attr_reader :reminder_store
122
+ # @return [ReminderStore, nil] the reminder store the reminder tools
123
+ # write; the Engine's own (it hands it to a kernel it was given).
124
+ attr_accessor :reminder_store
105
125
 
106
126
  # @return [ModelProfile] the active prompt profile
107
127
  attr_reader :profile
108
128
 
109
129
  # @return [Samagotchi::Hooks::Registry, nil] hooks registry shared with Engine.
110
130
  # Engine owns the registry; KernelLoop only fires events. Accessor allows
111
- # Engine to propagate its registry to an externally-created kernel (TUI path).
131
+ # Engine to hand its registry to a kernel it is given (specs).
112
132
  attr_accessor :hooks
113
133
  # @return [Tools::Registry] the tools #dispatch runs. The Engine sets
114
- # its own on a kernel built before it (the REPL's), as with hooks.
134
+ # its own on a kernel it is given, as with hooks.
115
135
  attr_accessor :tools
116
136
  attr_accessor :client
117
137
  attr_accessor :model_key
118
- attr_accessor :current_model_name
138
+ # @return [String, nil] the key whose overlay memory_read takes when
139
+ # #model_key has none (#sync_model_key!)
140
+ attr_reader :model_key_fallback
119
141
  # @return [Array<String>, nil] the session's muted memories (normalized
120
142
  # names, see MutedMemories); memory_read refuses them. The Engine sets it.
121
143
  attr_accessor :muted_memory_names
@@ -125,15 +147,11 @@ module Samagotchi
125
147
  # The Guardrails::Gate ToolRunner asks before each call; the Engine sets
126
148
  # it (nil: ToolRunner's own, hooks only).
127
149
  attr_accessor :guardrail_gate
128
- # The turn's VisionContext (images: capability, files, limits), set by
129
- # the Engine per turn; nil sends no images (placeholders instead).
130
- attr_accessor :vision
131
- # The turn's request parameters (SamplingSettings.for), set by the Engine
132
- # per turn; empty or nil sends none.
133
- attr_accessor :sampling
134
- # The turn's thinking level (Thinking.resolve), set by the Engine per
135
- # turn; its own accessor, since the native path sends @sampling as is.
136
- attr_accessor :thinking
150
+ # @return [LLM::TurnSettings] the turn's vision, sampling, thinking and
151
+ # model name, set by the Engine per turn (#run sets the model name
152
+ # too); the thinking level is its own field, since the native path
153
+ # sends the sampling as is.
154
+ attr_accessor :turn_settings
137
155
  # Tools::Peers (or the Engine's live view of it): the session
138
156
  # list_sessions and send_note speak for; nil outside a session.
139
157
  attr_accessor :peers
@@ -142,7 +160,7 @@ module Samagotchi
142
160
  # resumable conversation state when execution stops at max_iterations.
143
161
  #
144
162
  # @param messages [Array<Hash>] conversation so far ({role:, content:});
145
- # a stopped run resumes from its Result#conversation
163
+ # a stopped run resumes from its result's #conversation
146
164
  # @param max_iterations [Integer] safety cap on tool-call rounds
147
165
  # @param on_stream_event [Proc, nil] optional callback for generation events
148
166
  # @param cancel_controller [CancellationController, nil] optional cancellation source
@@ -155,275 +173,25 @@ module Samagotchi
155
173
  # steering mid-stream); drained lines merge into ONE user message appended
156
174
  # at the conversation tail (prefix KV cache preserved) and a
157
175
  # :pending_input_merged stream event is emitted.
158
- # @return [Result] final visible response with continuation metadata
176
+ # @return [LLM::ModelResult] final visible response with continuation metadata
159
177
  def run(messages, max_iterations: 100, on_stream_event: nil, cancel_controller: nil, model_name: nil, max_tool_output_chars: nil, pending_input: nil)
160
- resolved_model_name = completion_model_name(model_name)
161
- @current_model_name = resolved_model_name
162
-
163
- conversation = prepare_conversation(messages)
164
- context = ContextStatus.new(conversation: conversation)
165
- exhausted = false
166
- pending_tool_calls = false
167
- tool_activity = []
168
- qwen_recovery_attempts = 0
169
- qwen_partial_tool_call = nil
170
- empty_retries = 0
171
- empty_retry_limit = EmptyAnswerRetry.limit
172
- @retry_generation = false
173
- # Qwen with thinking off: an empty thought after the cue, so the model
174
- # answers at once. Kept in the turn's model messages, so each tool-loop
175
- # prompt starts with what the server already has cached.
176
- prefill = Thinking.native(@thinking || Thinking::DEFAULT, @profile).prefill
177
- partial_assistant_buffer = +""
178
-
179
- effective_max_iterations = @no_interrupt ? 1000 : max_iterations
180
- effective_max_tool_output_chars = resolve_output_char_cap(max_tool_output_chars)
181
- effective_max_iterations.times do |iteration_index|
182
- inject_pending_input!(conversation, pending_input, on_stream_event, iteration_index + 1, cancel_controller)
183
- prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @vision, prefill: prefill)
184
- image_tokens = images.empty? ? 0 : ImagePlan.estimated_tokens(conversation)
185
- context_window = ContextWindow.resolve(client: @client, model: resolved_model_name)
186
- emit_context_status_event(on_stream_event, context, prompt, iteration_index: iteration_index, window: context_window,
187
- image_tokens: image_tokens)
188
- if (line = context.take_guidance)
189
- # The model's own copy, on the tail (the prompt cache keeps its
190
- # prefix), then the prompt again with it.
191
- conversation << line
192
- prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @vision, prefill: prefill)
193
- end
194
- # Each generation splits its own stream: one that ended inside a
195
- # thought or a tool call doesn't leave the next one "inside" it.
196
- stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
197
- emit_stream_event(
198
- on_stream_event,
199
- type: :generation_started,
200
- iteration: iteration_index + 1,
201
- context_window_tokens: context_window.tokens,
202
- context_window_source: context_window.source,
203
- profile: @profile.name,
204
- profile_source: @profile_source
205
- )
206
- served_model = nil
207
- # This generation's own server counts (the run-long ones in
208
- # `context` can be an earlier one's).
209
- generation_usage = nil
210
- # The thinking this generation streamed, for the log (a stuck
211
- # thinking generation shows as thinking_chars=N content_length=…).
212
- streamed_thinking = 0
213
- # Fire :before_generation hook
214
- gen_event = { type: :before_generation, iteration: iteration_index + 1 }
215
- fire_hook(:before_generation, gen_event) if @hooks
216
- # The request runs under the generation's own controller: a plugin's
217
- # stop_generation cuts it alone, and the turn goes on (a cut).
218
- buffer_mark = partial_assistant_buffer.length
219
- cut = nil
220
- response = with_generation(cancel_controller) do |generation_controller|
221
- request_generation(
222
- prompt,
223
- generation_controller: generation_controller,
224
- cancel_controller: cancel_controller,
225
- model_name: resolved_model_name,
226
- on_chunk: lambda { |chunk|
227
- generation_usage = context.capture(chunk[:payload]) || generation_usage
228
- # llama.cpp names the loaded model in the stream's last payload.
229
- named = chunk[:payload]["model"] if chunk[:payload].is_a?(Hash)
230
- served_model = named if named.is_a?(String) && !named.strip.empty?
231
- split = stream_splitter.feed(chunk[:content])
232
- partial_assistant_buffer << split[:text]
233
- streamed_thinking += split[:thinking].to_s.length
234
- if on_stream_event
235
- emit_stream_event(
236
- on_stream_event,
237
- type: :generation_chunk,
238
- iteration: iteration_index + 1,
239
- content: chunk[:content],
240
- text: split[:text],
241
- thinking: split[:thinking],
242
- payload: chunk[:payload]
243
- )
244
- end
245
- },
246
- on_retry: lambda { |retry_event|
247
- # The retry streams from the start: its counts replace these.
248
- generation_usage = nil
249
- streamed_thinking = 0
250
- next unless on_stream_event
251
-
252
- emit_stream_event(
253
- on_stream_event,
254
- {
255
- type: :generation_retrying,
256
- iteration: iteration_index + 1
257
- }.merge(retry_event)
258
- )
259
- },
260
- images: images
261
- )
262
- rescue Client::RequestCancelled
263
- raise unless cut?(generation_controller, cancel_controller)
264
-
265
- cut = generation_controller.detail || {}
266
- ""
267
- end
268
- if cut
269
- # The cut stream's visible text goes with it: the buffer as it was
270
- # before (the next generation gets a fresh splitter).
271
- partial_assistant_buffer.slice!(buffer_mark..)
272
- emit_stream_event(on_stream_event, type: :generation_completed, iteration: iteration_index + 1,
273
- content_length: 0, thinking_chars: streamed_thinking,
274
- served_model: served_model, requested_model: resolved_model_name,
275
- finish_reason: "stopped", stopped_by: cut[:by], stop_reason: cut[:reason])
276
- # A Stop that came right after the cut is a plain cancel.
277
- raise Client::RequestCancelled.new(cancel_controller.reason) if cancel_controller.cancelled?
278
-
279
- if empty_retries < empty_retry_limit
280
- # Queued input (the user's line, a plugin's steer) goes in place of the nudge.
281
- next if inject_pending_input!(conversation, pending_input, on_stream_event, iteration_index + 1, cancel_controller)
282
-
283
- empty_retries += 1
284
- @retry_generation = true
285
- emit_stream_event(on_stream_event, type: :empty_answer_retry, iteration: iteration_index + 1,
286
- attempt: empty_retries, of: empty_retry_limit,
287
- thinking_chars: streamed_thinking, stopped_by: cut[:by])
288
- conversation << TurnNote.cut_retry(cut[:by], cut[:reason])
289
- next
290
- end
291
- # No retry left: the turn ends as cancelled (hook), with nothing
292
- # salvaged and without the spent nudge.
293
- drop_last_empty_retry!(conversation)
294
- cancel_controller.cancel!(:hook, cut)
295
- emit_stream_event(on_stream_event, type: :generation_cancelled, iteration: iteration_index + 1, reason: :hook)
296
- return cancelled_result(conversation, tool_activity: tool_activity, reason: :hook, partial_assistant_text: "")
297
- end
298
- context.generation_done(generation_usage, prompt_chars: prompt.length, image_tokens: image_tokens, window: context_window)
299
- emit_stream_event(
300
- on_stream_event,
301
- type: :generation_completed,
302
- iteration: iteration_index + 1,
303
- content_length: response.to_s.length,
304
- thinking_chars: thinking_chars(response.to_s, streamed_thinking),
305
- served_model: served_model,
306
- requested_model: resolved_model_name
307
- )
308
- dump_log("response", response, iteration: iteration_index + 1)
309
- # Fire :after_generation hook (after LLM returns, before tool parse),
310
- # with a read-only copy of the conversation as sent.
311
- after_gen_event = { type: :after_generation, iteration: iteration_index + 1, response: response,
312
- messages: AnswerDisplay.strip_all(conversation).map(&:dup).freeze }
313
- fire_hook(:after_generation, after_gen_event) if @hooks
314
- conversation << { role: "model", content: prefill + response.to_s }
315
-
316
- # Profile-specific parse (incl. Qwen unterminated-block recovery); the
317
- # returned fragment (non-nil only for Qwen) is fed back on the next
318
- # iteration if the model opened a tool-call block it did not close.
319
- calls, qwen_partial_tool_call = parser.parse_with_recovery(response, qwen_partial_tool_call)
320
- calls = calls.map do |call|
321
- PromptLiteralGuard.restore_call(call, profile: @profile)
322
- end
323
- qwen_incomplete_tool_call = !qwen_partial_tool_call.nil?
324
-
325
- if calls.empty?
326
- if qwen_incomplete_tool_call && qwen_recovery_attempts < QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_LIMIT
327
- qwen_recovery_attempts += 1
328
- conversation << { role: "user", content: QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_PROMPT, preserve_literals: true }
329
- pending_tool_calls = false
330
- next
331
- end
332
-
333
- pending_tool_calls = false
334
- answer = -> { PromptLiteralGuard.restore(strip_thought_blocks(response), profile: @profile) }
335
- empty = strip_thought_blocks(response.to_s).strip.empty?
336
- retry_empty = empty && empty_retries < empty_retry_limit && !cancel_controller&.cancelled?
337
- # The empty generation goes (its thinking would be sent again and
338
- # prime the same loop); an empty answer that will be retried is no
339
- # answer site, so a plugin's steer joins the retry.
340
- conversation.pop if retry_empty
341
- unless inject_pending_input!(conversation, pending_input, on_stream_event, iteration_index + 1, cancel_controller,
342
- answer: retry_empty ? nil : answer)
343
- if retry_empty
344
- empty_retries += 1
345
- @retry_generation = true
346
- emit_stream_event(on_stream_event, type: :empty_answer_retry, iteration: iteration_index + 1,
347
- attempt: empty_retries, of: empty_retry_limit,
348
- thinking_chars: thinking_chars(response.to_s, streamed_thinking))
349
- conversation << TurnNote.empty_retry
350
- next
351
- end
352
- # The Engine's TurnNote.empty says it all: the spent nudge goes.
353
- drop_last_empty_retry!(conversation) if empty && empty_retries.positive?
354
- break
355
- end
356
- # Queued steering keeps the turn going: loop again so the model
357
- # answers the injected message instead of stopping here.
358
- next
359
- end
360
-
361
- qwen_recovery_attempts = 0
362
- qwen_partial_tool_call = nil
363
-
364
- emit_stream_event(on_stream_event, type: :tool_dispatch_started, iteration: iteration_index + 1, call_count: calls.length)
365
- tool_images = []
366
- image_counts = []
367
- shown_params = []
368
- shown_labels = []
369
- diffs = []
370
- results = calls.map.with_index do |call, call_index|
371
- run = tool_runner.run(call, iteration: iteration_index + 1, call_index: call_index + 1,
372
- call_count: calls.length, on_stream_event: on_stream_event,
373
- max_tool_output_chars: effective_max_tool_output_chars)
374
- tool_activity << run[:activity]
375
- tool_images.concat(Array(run[:images]))
376
- image_counts << Array(run[:images]).size
377
- shown_params << run[:shown_params]
378
- shown_labels << run[:shown_label]
379
- diffs << run[:diff]
380
- run[:output]
381
- end.join("\n\n---\n\n")
382
- emit_stream_event(on_stream_event, type: :tool_dispatch_completed, iteration: iteration_index + 1, call_count: calls.length)
383
- # The joined results carry every call's images, in call order.
384
- tool_response = { role: "tool_response", content: results }
385
- tool_response[:images] = tool_images unless tool_images.empty?
386
- # How many of them each call returned, in call order, so the web's
387
- # reload puts each on its own tool row; the prompt never reads it.
388
- tool_response[:image_counts] = image_counts unless tool_images.empty?
389
- # A plugin tool's params line, one per call in call order (nil for
390
- # a built-in), for the web's reload; the prompt never reads it.
391
- tool_response[:tool_params] = shown_params if shown_params.any?
392
- tool_response[:tool_labels] = shown_labels if shown_labels.any?
393
- # What each edit/write changed (nil for other calls), in call order,
394
- # for the web's reload; the prompt never reads it.
395
- tool_response[:tool_diffs] = diffs if diffs.any?
396
- conversation << tool_response
397
- pending_tool_calls = true
178
+ @turn_settings = @turn_settings.with(model_name: completion_model_name(model_name))
179
+ turn = start_turn(messages, on_stream_event: on_stream_event, cancel_controller: cancel_controller,
180
+ pending_input: pending_input, cap: resolve_output_char_cap(max_tool_output_chars))
181
+
182
+ max_iterations.times do |iteration_index|
183
+ turn.iteration = iteration_index + 1
184
+ outcome = iterate(turn)
185
+ return outcome if outcome.is_a?(LLM::ModelResult)
186
+ break if outcome == :answer
398
187
  rescue Client::RequestCancelled => e
399
- emit_stream_event(
400
- on_stream_event,
401
- type: :generation_cancelled,
402
- iteration: iteration_index + 1,
403
- reason: e.reason
404
- )
405
- return cancelled_result(conversation, tool_activity: tool_activity, reason: e.reason, partial_assistant_text: partial_assistant_buffer)
406
- end
407
-
408
- if pending_tool_calls && tool_response_turn?(conversation.last)
409
- exhausted = true
188
+ emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: e.reason)
189
+ return cancelled_result(turn.conversation, tool_activity: turn.tool_activity, reason: e.reason,
190
+ partial_assistant_text: turn.buffer)
410
191
  end
411
-
412
- output = strip_thought_blocks(last_model_content(conversation))
413
- # A turn stopped at the limit ends on a call it never ran: show only its text.
414
- output = parser.strip_tool_calls(output) if exhausted
415
- Result.new(
416
- output: PromptLiteralGuard.restore(output, profile: @profile),
417
- conversation: duplicate_conversation(conversation),
418
- exhausted: exhausted,
419
- pending_tool_calls: pending_tool_calls,
420
- tool_activity: tool_activity,
421
- canceled: false,
422
- cancellation_reason: nil,
423
- context_status: context.display
424
- )
192
+ finish(turn)
425
193
  rescue StandardError => e
426
- LLM::FailedTurn.attach(e, conversation && duplicate_conversation(conversation))
194
+ LLM::FailedTurn.attach(e, turn && duplicate_conversation(turn.conversation))
427
195
  raise
428
196
  end
429
197
 
@@ -434,30 +202,277 @@ module Samagotchi
434
202
  @profile_source = resolution.label
435
203
  end
436
204
 
437
- def sync_model_key!(key)
205
+ # @param fallback [String, nil] a key whose overlay memory_read takes
206
+ # when +key+ has none (the alias a model was typed as)
207
+ def sync_model_key!(key, fallback: nil)
438
208
  @model_key = key
209
+ @model_key_fallback = fallback
439
210
  end
440
211
 
441
212
  private
442
213
 
443
- # memory_read with the session's mutes applied: a blank name (the index)
444
- # loses the muted memories' lines; a muted name in a comma list is
445
- # refused with its own error line and the rest is read as usual.
446
- def muted_memory_read(tool, call)
447
- muted = Array(@muted_memory_names)
448
- content = call[:content].to_s
449
- read = ->(names) { tool.call(names, scope: call[:scope], model_key: @model_key) }
450
- return read.call(content) if muted.empty?
451
- return MutedMemories.filter_index(read.call(content), muted) if content.strip.empty?
214
+ # ── One turn (#run) ────────────────────────────────────────────────────────
215
+
216
+ # One run's state, threaded through its steps: the conversation, the
217
+ # context tracker and the retry budget, the tool activity, the visible
218
+ # text streamed so far (salvaged on a cancel), the Qwen recovery state,
219
+ # the prefill, and what the caller gave.
220
+ Turn = Struct.new(:conversation, :context, :empty_retry, :tool_activity, :buffer, :qwen_attempts, :qwen_partial,
221
+ :prefill, :pending_tool_calls, :model_name, :pending_input, :on_stream_event,
222
+ :cancel_controller, :cap, :emit, :iteration, keyword_init: true)
223
+ # One request: the prompt and its images as sent, the images' token
224
+ # estimate, and the window it was measured against.
225
+ Request = Struct.new(:prompt, :images, :image_tokens, :window, keyword_init: true)
226
+ # One generation: the response (nil when cut), the cut's detail, this
227
+ # generation's own server counts, the model the server named, the
228
+ # thinking it streamed, and why it stopped (the transport's finish
229
+ # reason, Client::Transport#finish_reason_from; nil when not named).
230
+ Generation = Struct.new(:response, :cut, :usage, :served_model, :streamed_thinking, :finish_reason, keyword_init: true)
231
+ private_constant :Turn, :Request, :Generation
232
+
233
+ def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:)
234
+ conversation = prepare_conversation(messages)
235
+ Turn.new(
236
+ conversation: conversation, context: ContextStatus.new(conversation: conversation),
237
+ empty_retry: EmptyAnswerRetry.new, tool_activity: [], buffer: +"", qwen_attempts: 0, qwen_partial: nil,
238
+ # Qwen with thinking off: an empty thought after the cue, so the model
239
+ # answers at once. Kept in the turn's model messages, so each tool-loop
240
+ # prompt starts with what the server already has cached.
241
+ prefill: Thinking.native(@turn_settings.thinking || Thinking::DEFAULT, @profile).prefill,
242
+ pending_tool_calls: false, model_name: @turn_settings.model_name, pending_input: pending_input,
243
+ on_stream_event: on_stream_event, cancel_controller: cancel_controller, cap: cap,
244
+ emit: ->(event) { emit_stream_event(on_stream_event, event) }
245
+ )
246
+ end
247
+
248
+ # One iteration. Returns :next, :answer (the turn ends), or the turn's
249
+ # result (it ended cancelled).
250
+ def iterate(turn)
251
+ inject_pending_input!(turn)
252
+ request = prepare_request(turn)
253
+ generation = generate(turn, request)
254
+ return after_cut(turn, generation) if generation.cut
255
+
256
+ turn.conversation << { role: "model", content: turn.prefill + generation.response.to_s }
257
+ # Profile-specific parse (incl. Qwen unterminated-block recovery); the
258
+ # returned fragment (non-nil only for Qwen) is fed back on the next
259
+ # iteration if the model opened a tool-call block it did not close.
260
+ calls, turn.qwen_partial = parser.parse_with_recovery(generation.response, turn.qwen_partial)
261
+ calls = calls.map { |call| PromptLiteralGuard.restore_call(call, profile: @profile) }
262
+ return after_text(turn, request, generation) if calls.empty?
263
+
264
+ dispatch_calls(turn, calls)
265
+ end
266
+
267
+ # The prompt for this iteration, after the context check: a rise into a
268
+ # bucket that asks the model for a change puts its line on the tail
269
+ # (the prompt cache keeps its prefix), and the prompt is formatted again
270
+ # with it.
271
+ def prepare_request(turn)
272
+ prompt, images = format_prompt(turn)
273
+ image_tokens = images.empty? ? 0 : ImagePlan.estimated_tokens(turn.conversation)
274
+ window = ContextWindow.resolve(client: @client, model: turn.model_name)
275
+ emit_context_status_event(turn.on_stream_event, turn.context, prompt, iteration_index: turn.iteration - 1,
276
+ window: window, image_tokens: image_tokens)
277
+ if (line = turn.context.take_guidance)
278
+ turn.conversation << line
279
+ prompt, images = format_prompt(turn)
280
+ end
281
+ Request.new(prompt: prompt, images: images, image_tokens: image_tokens, window: window)
282
+ end
283
+
284
+ def format_prompt(turn)
285
+ Prompt.format_with_images(turn.conversation, profile: @profile, vision: @turn_settings.vision, prefill: turn.prefill)
286
+ end
287
+
288
+ # One streamed request, under the generation's own controller: a
289
+ # plugin's stop_generation cuts it alone, and the turn goes on (a cut).
290
+ # Ends with its :generation_completed (and, unless cut, the
291
+ # after_generation hook).
292
+ def generate(turn, request)
293
+ # Each generation splits its own stream: one that ended inside a
294
+ # thought or a tool call doesn't leave the next one "inside" it.
295
+ stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
296
+ emit(turn, type: :generation_started, iteration: turn.iteration, context_window_tokens: request.window.tokens,
297
+ context_window_source: request.window.source, profile: @profile.name, profile_source: @profile_source)
298
+ # This generation's own server counts (the run-long ones in the
299
+ # context tracker can be an earlier one's), and the thinking it
300
+ # streamed, for the log (a stuck thinking generation shows as
301
+ # thinking_chars=N content_length=…).
302
+ generation = Generation.new(usage: nil, served_model: nil, streamed_thinking: 0)
303
+ fire_hook(:before_generation, { type: :before_generation, iteration: turn.iteration }) if @hooks
304
+ buffer_mark = turn.buffer.length
305
+ generation.response = with_generation(turn.cancel_controller) do |generation_controller|
306
+ request_generation(
307
+ request.prompt,
308
+ generation_controller: generation_controller,
309
+ cancel_controller: turn.cancel_controller,
310
+ model_name: turn.model_name,
311
+ sampling: turn.empty_retry.request_sampling(@turn_settings.sampling),
312
+ on_chunk: ->(chunk) { stream_chunk(turn, generation, stream_splitter, chunk) },
313
+ on_retry: lambda { |retry_event|
314
+ # The retry streams from the start: its counts replace these.
315
+ generation.usage = nil
316
+ generation.streamed_thinking = 0
317
+ generation.finish_reason = nil
318
+ turn.emit.call({ type: :generation_retrying, iteration: turn.iteration }.merge(retry_event)) if turn.on_stream_event
319
+ },
320
+ images: request.images
321
+ )
322
+ rescue Client::RequestCancelled
323
+ raise unless cut?(generation_controller, turn.cancel_controller)
452
324
 
453
- names = Tools::MemoryRead.parse_names(content)
454
- refused, allowed = names.partition { |name| MutedMemories.muted?(name, muted) }
455
- return read.call(content) if refused.empty?
325
+ generation.cut = generation_controller.detail || {}
326
+ nil
327
+ end
328
+ generation.cut ? cut_generation(turn, generation, buffer_mark) : completed_generation(turn, request, generation)
329
+ generation
330
+ end
331
+
332
+ def stream_chunk(turn, generation, stream_splitter, chunk)
333
+ generation.usage = turn.context.capture(chunk[:payload]) || generation.usage
334
+ # llama.cpp names the loaded model in the stream's last payload.
335
+ named = chunk[:payload]["model"] if chunk[:payload].is_a?(Hash)
336
+ generation.served_model = named if named.is_a?(String) && !named.strip.empty?
337
+ generation.finish_reason = chunk[:finish_reason] if chunk[:finish_reason]
338
+ split = stream_splitter.feed(chunk[:content])
339
+ turn.buffer << split[:text]
340
+ generation.streamed_thinking += split[:thinking].to_s.length
341
+ return unless turn.on_stream_event
342
+
343
+ emit(turn, type: :generation_chunk, iteration: turn.iteration, content: chunk[:content], text: split[:text],
344
+ thinking: split[:thinking], payload: chunk[:payload])
345
+ end
346
+
347
+ # The cut stream's visible text goes with it: the buffer as it was
348
+ # before (the next generation gets a fresh splitter).
349
+ def cut_generation(turn, generation, buffer_mark)
350
+ turn.buffer.slice!(buffer_mark..)
351
+ emit(turn, type: :generation_completed, iteration: turn.iteration, content_length: 0,
352
+ thinking_chars: generation.streamed_thinking, served_model: generation.served_model,
353
+ requested_model: turn.model_name, finish_reason: "stopped", stopped_by: generation.cut[:by],
354
+ stop_reason: generation.cut[:reason])
355
+ end
356
+
357
+ def completed_generation(turn, request, generation)
358
+ response = generation.response.to_s
359
+ turn.context.generation_done(generation.usage, prompt_chars: request.prompt.length, image_tokens: request.image_tokens,
360
+ window: request.window)
361
+ emit(turn, type: :generation_completed, iteration: turn.iteration, content_length: response.length,
362
+ thinking_chars: thinking_chars(response, generation.streamed_thinking),
363
+ served_model: generation.served_model, requested_model: turn.model_name,
364
+ finish_reason: generation.finish_reason)
365
+ dump_log("response", generation.response, iteration: turn.iteration)
366
+ # The after_generation hook (after the model returns, before the tool
367
+ # parse), with a read-only copy of the conversation as sent.
368
+ return unless @hooks
456
369
 
457
- errors = refused.map { |name| "Error: memory '#{name}' is muted for this session" }
458
- return errors.join("\n") if allowed.empty?
370
+ fire_hook(:after_generation, { type: :after_generation, iteration: turn.iteration, response: generation.response,
371
+ messages: AnswerDisplay.strip_all(turn.conversation).map(&:dup).freeze })
372
+ end
459
373
 
460
- [read.call(allowed.join(",")), *errors].join(Tools::MemoryRead::SEPARATOR)
374
+ # A generation a plugin cut is an empty answer made early: asked again
375
+ # with its own nudge while the retry budget lasts (queued input goes in
376
+ # place of the nudge), else the turn ends as cancelled (hook), with
377
+ # nothing salvaged and without the spent nudge. A Stop that came right
378
+ # after the cut is a plain cancel. Returns :next or the turn's result.
379
+ def after_cut(turn, generation)
380
+ cut = generation.cut
381
+ raise Client::RequestCancelled.new(turn.cancel_controller.reason) if turn.cancel_controller.cancelled?
382
+
383
+ if turn.empty_retry.left?
384
+ return :next if inject_pending_input!(turn)
385
+
386
+ turn.empty_retry.nudge!(turn.conversation, TurnNote.cut_retry(cut[:by], cut[:reason]),
387
+ emit: turn.emit, iteration: turn.iteration, finish_reason: "stopped",
388
+ thinking_chars: generation.streamed_thinking, stopped_by: cut[:by])
389
+ return :next
390
+ end
391
+ turn.empty_retry.drop_nudge!(turn.conversation)
392
+ turn.cancel_controller.cancel!(:hook, cut)
393
+ emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: :hook)
394
+ cancelled_result(turn.conversation, tool_activity: turn.tool_activity, reason: :hook, partial_assistant_text: "")
395
+ end
396
+
397
+ # A generation with no tool calls: a Qwen call left open is asked to be
398
+ # finished; an empty answer is asked again (EmptyAnswerRetry: not when
399
+ # a length stop came with the context full); queued input keeps the
400
+ # turn going (the model answers it); else it is the answer. Returns
401
+ # :next or :answer.
402
+ def after_text(turn, request, generation)
403
+ turn.pending_tool_calls = false
404
+ if turn.qwen_partial && turn.qwen_attempts < QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_LIMIT
405
+ turn.qwen_attempts += 1
406
+ turn.conversation << { role: "user", content: QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_PROMPT, preserve_literals: true }
407
+ return :next
408
+ end
409
+
410
+ response = generation.response
411
+ answer = -> { PromptLiteralGuard.restore(strip_thought_blocks(response), profile: @profile) }
412
+ empty = strip_thought_blocks(response.to_s).strip.empty?
413
+ retry_empty = empty && turn.empty_retry.retry_empty?(iteration: turn.iteration,
414
+ cancelled: turn.cancel_controller&.cancelled?,
415
+ finish_reason: generation.finish_reason,
416
+ used_tokens: generation.usage&.dig(:total_tokens),
417
+ window_tokens: request.window.tokens)
418
+ # The empty generation goes (its thinking would be sent again and
419
+ # prime the same loop); an empty answer that will be retried is no
420
+ # answer site, so a plugin's steer joins the retry.
421
+ turn.conversation.pop if retry_empty
422
+ # Queued steering keeps the turn going: the model answers the
423
+ # injected message instead of stopping here.
424
+ return :next if inject_pending_input!(turn, answer: retry_empty ? nil : answer)
425
+
426
+ if retry_empty
427
+ turn.empty_retry.nudge!(turn.conversation, TurnNote.empty_retry,
428
+ emit: turn.emit, iteration: turn.iteration, finish_reason: generation.finish_reason,
429
+ thinking_chars: thinking_chars(response.to_s, generation.streamed_thinking))
430
+ return :next
431
+ end
432
+ # The Engine's TurnNote.empty says it all: the spent nudge goes.
433
+ turn.empty_retry.drop_nudge!(turn.conversation) if empty && turn.empty_retry.used?
434
+ :answer
435
+ end
436
+
437
+ # The batch runs (ToolResponse) and its results go back as one entry.
438
+ def dispatch_calls(turn, calls)
439
+ turn.qwen_attempts = 0
440
+ turn.qwen_partial = nil
441
+ runs = ToolResponse.run_batch(tool_runner, calls, iteration: turn.iteration, emit: turn.emit,
442
+ on_stream_event: turn.on_stream_event, cap: turn.cap)
443
+ turn.tool_activity.concat(ToolResponse.activities(runs))
444
+ # One entry for the batch: the full outputs joined (ToolResponse.joined).
445
+ turn.conversation << ToolResponse.joined(runs)
446
+ turn.pending_tool_calls = true
447
+ :next
448
+ end
449
+
450
+ # The turn's result. It is exhausted only when it stopped on tool
451
+ # results it never answered (so exhausted? alone means resumable?);
452
+ # then the answer shows only its text.
453
+ def finish(turn)
454
+ exhausted = turn.pending_tool_calls && tool_response_turn?(turn.conversation.last)
455
+ output = strip_thought_blocks(last_model_content(turn.conversation))
456
+ output = parser.strip_tool_calls(output) if exhausted
457
+ LLM::ModelResult.new(
458
+ text: PromptLiteralGuard.restore(output, profile: @profile).to_s,
459
+ conversation: duplicate_conversation(turn.conversation),
460
+ exhausted: exhausted,
461
+ pending_tool_calls: turn.pending_tool_calls,
462
+ tool_activity: turn.tool_activity,
463
+ context_status: turn.context.display
464
+ )
465
+ end
466
+
467
+ def emit(turn, **event)
468
+ emit_stream_event(turn.on_stream_event, event)
469
+ end
470
+
471
+ # Queued input at an iteration boundary (Steer.inject!); +answer+ is a
472
+ # proc, built only on a merge. Returns true when anything was injected.
473
+ def inject_pending_input!(turn, answer: nil)
474
+ Steer.inject!(turn.conversation, turn.pending_input, iteration: turn.iteration, emit: turn.emit,
475
+ cancel_controller: turn.cancel_controller, answer: answer)
461
476
  end
462
477
 
463
478
  def emit_stream_event(callback, event)
@@ -466,34 +481,6 @@ module Samagotchi
466
481
  nil
467
482
  end
468
483
 
469
- # Drain the pending input queue (if any) and, when messages are waiting,
470
- # append them as ONE merged user message at the conversation tail and emit
471
- # :pending_input_merged. Tail-append only: head mutation would invalidate
472
- # the server-side prefix KV cache. Returns true when a message was injected.
473
- # A plugin's steers (Steer) follow the user's message, each its own
474
- # message; after an answer the Engine's drain has already dropped them.
475
- # After a cancel the input stays queued: it runs as the next turn instead
476
- # of dying with this one. +answer+ (a proc, called only on a merge) is the
477
- # answer the merge follows: the UIs show it, the turn summary has only the
478
- # last one.
479
- def inject_pending_input!(conversation, pending_input, on_stream_event, iteration, cancel_controller = nil, answer: nil)
480
- return false unless pending_input
481
- return false if cancel_controller&.cancelled?
482
-
483
- merge = Steer.merge(Steer.drain(pending_input, at_answer: !answer.nil?))
484
- return false if merge.empty?
485
-
486
- answer = answer.call.to_s if answer
487
- conversation.concat(merge.messages)
488
- emit_stream_event(
489
- on_stream_event,
490
- type: :pending_input_merged,
491
- iteration: iteration,
492
- **merge.event_fields,
493
- answer: answer.to_s.strip.empty? ? nil : answer
494
- )
495
- true
496
- end
497
484
 
498
485
  # ── Hook dispatch helper ───────────────────────────────────────────────────
499
486
 
@@ -553,21 +540,20 @@ module Samagotchi
553
540
  generation_controller&.cancelled? && !cancel_controller.cancelled?
554
541
  end
555
542
 
556
- def complete_kwargs(cancel_controller:, model_name: nil, on_chunk: nil, on_retry: nil, images: [])
543
+ # +sampling+: this request's (EmptyAnswerRetry#request_sampling).
544
+ def complete_kwargs(cancel_controller:, model_name: nil, on_chunk: nil, on_retry: nil, images: [], sampling: nil)
557
545
  kwargs = {}
558
546
  # Only a request with images names them: a text-only call is unchanged.
559
547
  kwargs[:images] = images unless images.empty?
560
548
  kwargs[:on_chunk] = on_chunk if on_chunk
561
- kwargs[:on_retry] = on_retry if on_retry && client_supports_keyword?(:on_retry)
562
- kwargs[:cancel_controller] = cancel_controller if cancel_controller && client_supports_keyword?(:cancel_controller)
563
- kwargs[:stop] = @profile.stop_sequences if client_supports_keyword?(:stop)
549
+ kwargs[:on_retry] = on_retry if on_retry
550
+ kwargs[:cancel_controller] = cancel_controller if cancel_controller
551
+ kwargs[:stop] = @profile.stop_sequences
564
552
  n_predict = completion_n_predict
565
- kwargs[:n_predict] = n_predict if n_predict && client_supports_keyword?(:n_predict)
553
+ kwargs[:n_predict] = n_predict if n_predict
566
554
  resolved_model_name = completion_model_name(model_name)
567
- kwargs[:model] = resolved_model_name if resolved_model_name && client_supports_keyword?(:model)
568
- sampling = @retry_generation ? EmptyAnswerRetry.sampling(@sampling) : @sampling
569
- @retry_generation = false
570
- kwargs[:sampling] = sampling if sampling && !sampling.empty? && client_supports_keyword?(:sampling)
555
+ kwargs[:model] = resolved_model_name if resolved_model_name
556
+ kwargs[:sampling] = sampling if sampling && !sampling.empty?
571
557
  kwargs
572
558
  end
573
559
 
@@ -580,19 +566,6 @@ module Samagotchi
580
566
  ModelProfile.required_model_name(override)
581
567
  end
582
568
 
583
- def client_supports_keyword?(keyword)
584
- @client_complete_keyword_support ||= {}
585
- return @client_complete_keyword_support[keyword] if @client_complete_keyword_support.key?(keyword)
586
-
587
- @client_complete_keyword_support[keyword] = begin
588
- parameters = @client.method(:complete).parameters
589
- parameters.any? { |kind, name| (kind == :key || kind == :keyreq) && name == keyword } ||
590
- parameters.any? { |kind, _name| kind == :keyrest }
591
- rescue StandardError
592
- false
593
- end
594
- end
595
-
596
569
  def cancelled_result(conversation, tool_activity:, reason:, partial_assistant_text: "")
597
570
  partial = partial_assistant_text.to_s.strip
598
571
  conversation = duplicate_conversation(conversation)
@@ -603,11 +576,9 @@ module Samagotchi
603
576
  unless partial.empty?
604
577
  conversation << { role: "model", content: "#{partial}\n[interrupted]", interrupted: true }
605
578
  end
606
- Result.new(
607
- output: "",
579
+ LLM::ModelResult.new(
580
+ text: "",
608
581
  conversation: conversation,
609
- exhausted: false,
610
- pending_tool_calls: false,
611
582
  tool_activity: tool_activity,
612
583
  canceled: true,
613
584
  cancellation_reason: reason
@@ -619,7 +590,7 @@ module Samagotchi
619
590
  def dump_log(event, payload, **fields)
620
591
  return unless Log.level?(:debug)
621
592
 
622
- Log.debug(:model, event, payload: payload, model: @current_model_name, **fields)
593
+ Log.debug(:model, event, payload: payload, model: @turn_settings.model_name, **fields)
623
594
  end
624
595
 
625
596
  # Estimate context usage for this iteration's prompt (ContextStatus#observe)
@@ -627,7 +598,7 @@ module Samagotchi
627
598
  # :context_status event. The telemetry is not for the model (it used to
628
599
  # be injected as a synthetic system message).
629
600
  def emit_context_status_event(on_stream_event, context, prompt, iteration_index:, window:, image_tokens: 0)
630
- event = context.observe(prompt, iteration_index: iteration_index, window: window, image_tokens: image_tokens)
601
+ event = context.observe(prompt.length, iteration_index: iteration_index, window: window, image_tokens: image_tokens)
631
602
  return unless event
632
603
 
633
604
  emit_stream_event(on_stream_event, type: :context_status, iteration: iteration_index + 1, **event)
@@ -644,13 +615,6 @@ module Samagotchi
644
615
  strip_thought_blocks(text)
645
616
  end
646
617
 
647
- # The status line's context value ({est_pct:, bucket:}) for +used_tokens+
648
- # of +window_tokens+; nil without both, or with context.status off. The
649
- # chat loop builds its value with it too.
650
- def context_display(used_tokens:, window_tokens:)
651
- ContextStatus.new.display_for(used_tokens: used_tokens, window_tokens: window_tokens)
652
- end
653
-
654
618
  # Per-profile parse strategy. Rebuilt when the active profile changes
655
619
  # (the profile may be re-inferred per run when not explicitly pinned).
656
620
  # Public so other loops (and specs) can reach the active profile's parser.
@@ -696,11 +660,6 @@ module Samagotchi
696
660
  messages.map(&:dup)
697
661
  end
698
662
 
699
- def drop_last_empty_retry!(conversation)
700
- index = conversation.rindex { |entry| TurnNote.retry_nudge?(entry) }
701
- conversation.delete_at(index) if index
702
- end
703
-
704
663
  def last_model_content(conversation)
705
664
  message = conversation.reverse.find { |entry| entry[:role] == "model" }
706
665
  message ? message[:content].to_s : ""
@@ -760,19 +719,5 @@ module Samagotchi
760
719
  activity: ToolActivity.tool_activity_event(call[:name], call, result, registry: @tools)
761
720
  }
762
721
  end
763
-
764
- # ask_user_question: validate the call, then hand the payload to the
765
- # Engine's question flow (question_handler, which blocks until the user
766
- # answers). Without one (headless), the payload as JSON so the model sees
767
- # the options and can ask in plain text.
768
- def handle_ask_user_question(call)
769
- payload = Samagotchi::Tools::AskUserQuestion.validate(call)
770
- return payload if payload.is_a?(String)
771
- return JSON.pretty_generate(payload) unless @question_handler
772
-
773
- @question_handler.call(payload).to_s
774
- rescue => e
775
- "Error: ask_user_question handler failed: #{e.message}"
776
- end
777
722
  end
778
723
  end