samagotchi 0.10.0 → 0.11.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +78 -1
- data/docs/architecture.md +19 -0
- data/docs/cli.md +24 -7
- data/docs/configuration.md +44 -11
- data/docs/guardrails.md +3 -1
- data/docs/hooks.md +3 -2
- data/docs/internals/context-telemetry.md +4 -2
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -2
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +10 -3
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
- data/lib/samagotchi/bundles/skills/plugin.rb +8 -17
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/client.rb +21 -1
- data/lib/samagotchi/config.rb +44 -40
- data/lib/samagotchi/context_status.rb +17 -16
- data/lib/samagotchi/empty_answer_retry.rb +67 -8
- data/lib/samagotchi/engine.rb +64 -20
- data/lib/samagotchi/host_registry.rb +50 -90
- data/lib/samagotchi/kernel_loop.rb +288 -305
- data/lib/samagotchi/llm/chat_loop.rb +78 -87
- data/lib/samagotchi/model_profile.rb +22 -3
- data/lib/samagotchi/model_ref.rb +60 -0
- data/lib/samagotchi/prompt_history.rb +91 -0
- data/lib/samagotchi/recap_setup.rb +15 -3
- data/lib/samagotchi/self_report.rb +11 -6
- data/lib/samagotchi/send_command.rb +8 -8
- data/lib/samagotchi/session.rb +9 -2
- data/lib/samagotchi/session_commands.rb +10 -12
- data/lib/samagotchi/session_manager.rb +20 -3
- data/lib/samagotchi/sessions_command.rb +57 -51
- data/lib/samagotchi/steer.rb +23 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -6
- data/lib/samagotchi/terminal_ui/input_support.rb +78 -33
- data/lib/samagotchi/terminal_ui/reline_history_hook.rb +93 -0
- data/lib/samagotchi/terminal_ui.rb +9 -8
- data/lib/samagotchi/tool_response.rb +67 -0
- data/lib/samagotchi/tool_runner.rb +6 -1
- data/lib/samagotchi/tools/delegate.rb +4 -3
- data/lib/samagotchi/tools/memory.rb +8 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +28 -2
- data/lib/samagotchi/web/public/app.js +29 -2
- data/lib/samagotchi/web/public/data.js +9 -1
- data/lib/samagotchi/web/public/prompt_history.js +63 -0
- data/lib/samagotchi/worker.rb +2 -1
- metadata +6 -1
|
@@ -20,6 +20,7 @@ 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"
|
|
24
25
|
|
|
25
26
|
module Samagotchi
|
|
@@ -78,11 +79,9 @@ module Samagotchi
|
|
|
78
79
|
|
|
79
80
|
# @param tools [Tools::Registry, nil] the tools calls dispatch to (the
|
|
80
81
|
# Engine's; nil: the built-ins alone)
|
|
81
|
-
def initialize(client: nil, profile: nil, model_name: nil,
|
|
82
|
-
tools: nil)
|
|
82
|
+
def initialize(client: nil, profile: nil, model_name: nil, hooks: nil, reminder_store: nil, model_key: nil, tools: nil)
|
|
83
83
|
@client = client || Client.new
|
|
84
84
|
@tools = tools || Tools::Builtins.default
|
|
85
|
-
@no_interrupt = no_interrupt
|
|
86
85
|
resolved_model_name = ModelProfile.required_model_name(model_name)
|
|
87
86
|
# The resolved model id actually used for this run (per-run override wins
|
|
88
87
|
# over the config alias); on every debug dump so we can see exactly
|
|
@@ -96,6 +95,7 @@ module Samagotchi
|
|
|
96
95
|
@hooks = hooks
|
|
97
96
|
@reminder_store = reminder_store
|
|
98
97
|
@model_key = model_key
|
|
98
|
+
@model_key_fallback = nil
|
|
99
99
|
end
|
|
100
100
|
|
|
101
101
|
# @return [ReminderStore, nil] the reminder store for inspection (used by
|
|
@@ -157,296 +157,314 @@ module Samagotchi
|
|
|
157
157
|
# :pending_input_merged stream event is emitted.
|
|
158
158
|
# @return [Result] final visible response with continuation metadata
|
|
159
159
|
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
|
-
|
|
161
|
-
|
|
160
|
+
@current_model_name = completion_model_name(model_name)
|
|
161
|
+
turn = start_turn(messages, on_stream_event: on_stream_event, cancel_controller: cancel_controller,
|
|
162
|
+
pending_input: pending_input, cap: resolve_output_char_cap(max_tool_output_chars))
|
|
163
|
+
|
|
164
|
+
max_iterations.times do |iteration_index|
|
|
165
|
+
turn.iteration = iteration_index + 1
|
|
166
|
+
outcome = iterate(turn)
|
|
167
|
+
return outcome if outcome.is_a?(Result)
|
|
168
|
+
break if outcome == :answer
|
|
169
|
+
rescue Client::RequestCancelled => e
|
|
170
|
+
emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: e.reason)
|
|
171
|
+
return cancelled_result(turn.conversation, tool_activity: turn.tool_activity, reason: e.reason,
|
|
172
|
+
partial_assistant_text: turn.buffer)
|
|
173
|
+
end
|
|
174
|
+
finish(turn)
|
|
175
|
+
rescue StandardError => e
|
|
176
|
+
LLM::FailedTurn.attach(e, turn && duplicate_conversation(turn.conversation))
|
|
177
|
+
raise
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Use a resolved profile (ModelProfile::Resolution) from now on,
|
|
181
|
+
# whatever model name later runs carry.
|
|
182
|
+
def use_profile!(resolution)
|
|
183
|
+
@profile = resolution.profile
|
|
184
|
+
@profile_source = resolution.label
|
|
185
|
+
end
|
|
162
186
|
|
|
187
|
+
# @param fallback [String, nil] a key whose overlay memory_read takes
|
|
188
|
+
# when +key+ has none (the alias a model was typed as)
|
|
189
|
+
def sync_model_key!(key, fallback: nil)
|
|
190
|
+
@model_key = key
|
|
191
|
+
@model_key_fallback = fallback
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
private
|
|
195
|
+
|
|
196
|
+
# ── One turn (#run) ────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
# One run's state, threaded through its steps: the conversation, the
|
|
199
|
+
# context tracker and the retry budget, the tool activity, the visible
|
|
200
|
+
# text streamed so far (salvaged on a cancel), the Qwen recovery state,
|
|
201
|
+
# the prefill, and what the caller gave.
|
|
202
|
+
Turn = Struct.new(:conversation, :context, :empty_retry, :tool_activity, :buffer, :qwen_attempts, :qwen_partial,
|
|
203
|
+
:prefill, :pending_tool_calls, :model_name, :pending_input, :on_stream_event,
|
|
204
|
+
:cancel_controller, :cap, :emit, :iteration, keyword_init: true)
|
|
205
|
+
# One request: the prompt and its images as sent, the images' token
|
|
206
|
+
# estimate, and the window it was measured against.
|
|
207
|
+
Request = Struct.new(:prompt, :images, :image_tokens, :window, keyword_init: true)
|
|
208
|
+
# One generation: the response (nil when cut), the cut's detail, this
|
|
209
|
+
# generation's own server counts, the model the server named, the
|
|
210
|
+
# thinking it streamed, and why it stopped (the transport's finish
|
|
211
|
+
# reason, Client::Transport#finish_reason_from; nil when not named).
|
|
212
|
+
Generation = Struct.new(:response, :cut, :usage, :served_model, :streamed_thinking, :finish_reason, keyword_init: true)
|
|
213
|
+
private_constant :Turn, :Request, :Generation
|
|
214
|
+
|
|
215
|
+
def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:)
|
|
163
216
|
conversation = prepare_conversation(messages)
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
217
|
+
Turn.new(
|
|
218
|
+
conversation: conversation, context: ContextStatus.new(conversation: conversation),
|
|
219
|
+
empty_retry: EmptyAnswerRetry.new, tool_activity: [], buffer: +"", qwen_attempts: 0, qwen_partial: nil,
|
|
220
|
+
# Qwen with thinking off: an empty thought after the cue, so the model
|
|
221
|
+
# answers at once. Kept in the turn's model messages, so each tool-loop
|
|
222
|
+
# prompt starts with what the server already has cached.
|
|
223
|
+
prefill: Thinking.native(@thinking || Thinking::DEFAULT, @profile).prefill,
|
|
224
|
+
pending_tool_calls: false, model_name: @current_model_name, pending_input: pending_input,
|
|
225
|
+
on_stream_event: on_stream_event, cancel_controller: cancel_controller, cap: cap,
|
|
226
|
+
emit: ->(event) { emit_stream_event(on_stream_event, event) }
|
|
227
|
+
)
|
|
228
|
+
end
|
|
360
229
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
230
|
+
# One iteration. Returns :next, :answer (the turn ends), or the turn's
|
|
231
|
+
# Result (it ended cancelled).
|
|
232
|
+
def iterate(turn)
|
|
233
|
+
inject_pending_input!(turn)
|
|
234
|
+
request = prepare_request(turn)
|
|
235
|
+
generation = generate(turn, request)
|
|
236
|
+
return after_cut(turn, generation) if generation.cut
|
|
237
|
+
|
|
238
|
+
turn.conversation << { role: "model", content: turn.prefill + generation.response.to_s }
|
|
239
|
+
# Profile-specific parse (incl. Qwen unterminated-block recovery); the
|
|
240
|
+
# returned fragment (non-nil only for Qwen) is fed back on the next
|
|
241
|
+
# iteration if the model opened a tool-call block it did not close.
|
|
242
|
+
calls, turn.qwen_partial = parser.parse_with_recovery(generation.response, turn.qwen_partial)
|
|
243
|
+
calls = calls.map { |call| PromptLiteralGuard.restore_call(call, profile: @profile) }
|
|
244
|
+
return after_text(turn, request, generation) if calls.empty?
|
|
245
|
+
|
|
246
|
+
dispatch_calls(turn, calls)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# The prompt for this iteration, after the context check: a rise into a
|
|
250
|
+
# bucket that asks the model for a change puts its line on the tail
|
|
251
|
+
# (the prompt cache keeps its prefix), and the prompt is formatted again
|
|
252
|
+
# with it.
|
|
253
|
+
def prepare_request(turn)
|
|
254
|
+
prompt, images = format_prompt(turn)
|
|
255
|
+
image_tokens = images.empty? ? 0 : ImagePlan.estimated_tokens(turn.conversation)
|
|
256
|
+
window = ContextWindow.resolve(client: @client, model: turn.model_name)
|
|
257
|
+
emit_context_status_event(turn.on_stream_event, turn.context, prompt, iteration_index: turn.iteration - 1,
|
|
258
|
+
window: window, image_tokens: image_tokens)
|
|
259
|
+
if (line = turn.context.take_guidance)
|
|
260
|
+
turn.conversation << line
|
|
261
|
+
prompt, images = format_prompt(turn)
|
|
262
|
+
end
|
|
263
|
+
Request.new(prompt: prompt, images: images, image_tokens: image_tokens, window: window)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def format_prompt(turn)
|
|
267
|
+
Prompt.format_with_images(turn.conversation, profile: @profile, vision: @vision, prefill: turn.prefill)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# One streamed request, under the generation's own controller: a
|
|
271
|
+
# plugin's stop_generation cuts it alone, and the turn goes on (a cut).
|
|
272
|
+
# Ends with its :generation_completed (and, unless cut, the
|
|
273
|
+
# after_generation hook).
|
|
274
|
+
def generate(turn, request)
|
|
275
|
+
# Each generation splits its own stream: one that ended inside a
|
|
276
|
+
# thought or a tool call doesn't leave the next one "inside" it.
|
|
277
|
+
stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
|
|
278
|
+
emit(turn, type: :generation_started, iteration: turn.iteration, context_window_tokens: request.window.tokens,
|
|
279
|
+
context_window_source: request.window.source, profile: @profile.name, profile_source: @profile_source)
|
|
280
|
+
# This generation's own server counts (the run-long ones in the
|
|
281
|
+
# context tracker can be an earlier one's), and the thinking it
|
|
282
|
+
# streamed, for the log (a stuck thinking generation shows as
|
|
283
|
+
# thinking_chars=N content_length=…).
|
|
284
|
+
generation = Generation.new(usage: nil, served_model: nil, streamed_thinking: 0)
|
|
285
|
+
fire_hook(:before_generation, { type: :before_generation, iteration: turn.iteration }) if @hooks
|
|
286
|
+
buffer_mark = turn.buffer.length
|
|
287
|
+
generation.response = with_generation(turn.cancel_controller) do |generation_controller|
|
|
288
|
+
request_generation(
|
|
289
|
+
request.prompt,
|
|
290
|
+
generation_controller: generation_controller,
|
|
291
|
+
cancel_controller: turn.cancel_controller,
|
|
292
|
+
model_name: turn.model_name,
|
|
293
|
+
sampling: turn.empty_retry.request_sampling(@sampling),
|
|
294
|
+
on_chunk: ->(chunk) { stream_chunk(turn, generation, stream_splitter, chunk) },
|
|
295
|
+
on_retry: lambda { |retry_event|
|
|
296
|
+
# The retry streams from the start: its counts replace these.
|
|
297
|
+
generation.usage = nil
|
|
298
|
+
generation.streamed_thinking = 0
|
|
299
|
+
generation.finish_reason = nil
|
|
300
|
+
turn.emit.call({ type: :generation_retrying, iteration: turn.iteration }.merge(retry_event)) if turn.on_stream_event
|
|
301
|
+
},
|
|
302
|
+
images: request.images
|
|
404
303
|
)
|
|
405
|
-
|
|
304
|
+
rescue Client::RequestCancelled
|
|
305
|
+
raise unless cut?(generation_controller, turn.cancel_controller)
|
|
306
|
+
|
|
307
|
+
generation.cut = generation_controller.detail || {}
|
|
308
|
+
nil
|
|
406
309
|
end
|
|
310
|
+
generation.cut ? cut_generation(turn, generation, buffer_mark) : completed_generation(turn, request, generation)
|
|
311
|
+
generation
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def stream_chunk(turn, generation, stream_splitter, chunk)
|
|
315
|
+
generation.usage = turn.context.capture(chunk[:payload]) || generation.usage
|
|
316
|
+
# llama.cpp names the loaded model in the stream's last payload.
|
|
317
|
+
named = chunk[:payload]["model"] if chunk[:payload].is_a?(Hash)
|
|
318
|
+
generation.served_model = named if named.is_a?(String) && !named.strip.empty?
|
|
319
|
+
generation.finish_reason = chunk[:finish_reason] if chunk[:finish_reason]
|
|
320
|
+
split = stream_splitter.feed(chunk[:content])
|
|
321
|
+
turn.buffer << split[:text]
|
|
322
|
+
generation.streamed_thinking += split[:thinking].to_s.length
|
|
323
|
+
return unless turn.on_stream_event
|
|
324
|
+
|
|
325
|
+
emit(turn, type: :generation_chunk, iteration: turn.iteration, content: chunk[:content], text: split[:text],
|
|
326
|
+
thinking: split[:thinking], payload: chunk[:payload])
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# The cut stream's visible text goes with it: the buffer as it was
|
|
330
|
+
# before (the next generation gets a fresh splitter).
|
|
331
|
+
def cut_generation(turn, generation, buffer_mark)
|
|
332
|
+
turn.buffer.slice!(buffer_mark..)
|
|
333
|
+
emit(turn, type: :generation_completed, iteration: turn.iteration, content_length: 0,
|
|
334
|
+
thinking_chars: generation.streamed_thinking, served_model: generation.served_model,
|
|
335
|
+
requested_model: turn.model_name, finish_reason: "stopped", stopped_by: generation.cut[:by],
|
|
336
|
+
stop_reason: generation.cut[:reason])
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def completed_generation(turn, request, generation)
|
|
340
|
+
response = generation.response.to_s
|
|
341
|
+
turn.context.generation_done(generation.usage, prompt_chars: request.prompt.length, image_tokens: request.image_tokens,
|
|
342
|
+
window: request.window)
|
|
343
|
+
emit(turn, type: :generation_completed, iteration: turn.iteration, content_length: response.length,
|
|
344
|
+
thinking_chars: thinking_chars(response, generation.streamed_thinking),
|
|
345
|
+
served_model: generation.served_model, requested_model: turn.model_name,
|
|
346
|
+
finish_reason: generation.finish_reason)
|
|
347
|
+
dump_log("response", generation.response, iteration: turn.iteration)
|
|
348
|
+
# The after_generation hook (after the model returns, before the tool
|
|
349
|
+
# parse), with a read-only copy of the conversation as sent.
|
|
350
|
+
return unless @hooks
|
|
351
|
+
|
|
352
|
+
fire_hook(:after_generation, { type: :after_generation, iteration: turn.iteration, response: generation.response,
|
|
353
|
+
messages: AnswerDisplay.strip_all(turn.conversation).map(&:dup).freeze })
|
|
354
|
+
end
|
|
407
355
|
|
|
408
|
-
|
|
409
|
-
|
|
356
|
+
# A generation a plugin cut is an empty answer made early: asked again
|
|
357
|
+
# with its own nudge while the retry budget lasts (queued input goes in
|
|
358
|
+
# place of the nudge), else the turn ends as cancelled (hook), with
|
|
359
|
+
# nothing salvaged and without the spent nudge. A Stop that came right
|
|
360
|
+
# after the cut is a plain cancel. Returns :next or the turn's Result.
|
|
361
|
+
def after_cut(turn, generation)
|
|
362
|
+
cut = generation.cut
|
|
363
|
+
raise Client::RequestCancelled.new(turn.cancel_controller.reason) if turn.cancel_controller.cancelled?
|
|
364
|
+
|
|
365
|
+
if turn.empty_retry.left?
|
|
366
|
+
return :next if inject_pending_input!(turn)
|
|
367
|
+
|
|
368
|
+
turn.empty_retry.nudge!(turn.conversation, TurnNote.cut_retry(cut[:by], cut[:reason]),
|
|
369
|
+
emit: turn.emit, iteration: turn.iteration, finish_reason: "stopped",
|
|
370
|
+
thinking_chars: generation.streamed_thinking, stopped_by: cut[:by])
|
|
371
|
+
return :next
|
|
372
|
+
end
|
|
373
|
+
turn.empty_retry.drop_nudge!(turn.conversation)
|
|
374
|
+
turn.cancel_controller.cancel!(:hook, cut)
|
|
375
|
+
emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: :hook)
|
|
376
|
+
cancelled_result(turn.conversation, tool_activity: turn.tool_activity, reason: :hook, partial_assistant_text: "")
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# A generation with no tool calls: a Qwen call left open is asked to be
|
|
380
|
+
# finished; an empty answer is asked again (EmptyAnswerRetry: not when
|
|
381
|
+
# a length stop came with the context full); queued input keeps the
|
|
382
|
+
# turn going (the model answers it); else it is the answer. Returns
|
|
383
|
+
# :next or :answer.
|
|
384
|
+
def after_text(turn, request, generation)
|
|
385
|
+
turn.pending_tool_calls = false
|
|
386
|
+
if turn.qwen_partial && turn.qwen_attempts < QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_LIMIT
|
|
387
|
+
turn.qwen_attempts += 1
|
|
388
|
+
turn.conversation << { role: "user", content: QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_PROMPT, preserve_literals: true }
|
|
389
|
+
return :next
|
|
410
390
|
end
|
|
411
391
|
|
|
412
|
-
|
|
413
|
-
|
|
392
|
+
response = generation.response
|
|
393
|
+
answer = -> { PromptLiteralGuard.restore(strip_thought_blocks(response), profile: @profile) }
|
|
394
|
+
empty = strip_thought_blocks(response.to_s).strip.empty?
|
|
395
|
+
retry_empty = empty && turn.empty_retry.retry_empty?(iteration: turn.iteration,
|
|
396
|
+
cancelled: turn.cancel_controller&.cancelled?,
|
|
397
|
+
finish_reason: generation.finish_reason,
|
|
398
|
+
used_tokens: generation.usage&.dig(:total_tokens),
|
|
399
|
+
window_tokens: request.window.tokens)
|
|
400
|
+
# The empty generation goes (its thinking would be sent again and
|
|
401
|
+
# prime the same loop); an empty answer that will be retried is no
|
|
402
|
+
# answer site, so a plugin's steer joins the retry.
|
|
403
|
+
turn.conversation.pop if retry_empty
|
|
404
|
+
# Queued steering keeps the turn going: the model answers the
|
|
405
|
+
# injected message instead of stopping here.
|
|
406
|
+
return :next if inject_pending_input!(turn, answer: retry_empty ? nil : answer)
|
|
407
|
+
|
|
408
|
+
if retry_empty
|
|
409
|
+
turn.empty_retry.nudge!(turn.conversation, TurnNote.empty_retry,
|
|
410
|
+
emit: turn.emit, iteration: turn.iteration, finish_reason: generation.finish_reason,
|
|
411
|
+
thinking_chars: thinking_chars(response.to_s, generation.streamed_thinking))
|
|
412
|
+
return :next
|
|
413
|
+
end
|
|
414
|
+
# The Engine's TurnNote.empty says it all: the spent nudge goes.
|
|
415
|
+
turn.empty_retry.drop_nudge!(turn.conversation) if empty && turn.empty_retry.used?
|
|
416
|
+
:answer
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# The batch runs (ToolResponse) and its results go back as one entry.
|
|
420
|
+
def dispatch_calls(turn, calls)
|
|
421
|
+
turn.qwen_attempts = 0
|
|
422
|
+
turn.qwen_partial = nil
|
|
423
|
+
runs = ToolResponse.run_batch(tool_runner, calls, iteration: turn.iteration, emit: turn.emit,
|
|
424
|
+
on_stream_event: turn.on_stream_event, cap: turn.cap)
|
|
425
|
+
turn.tool_activity.concat(ToolResponse.activities(runs))
|
|
426
|
+
# One entry for the batch: the full outputs joined (ToolResponse.joined).
|
|
427
|
+
turn.conversation << ToolResponse.joined(runs)
|
|
428
|
+
turn.pending_tool_calls = true
|
|
429
|
+
:next
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# The turn's Result. It is exhausted only when it stopped on tool
|
|
433
|
+
# results it never answered; then the answer shows only its text.
|
|
434
|
+
def finish(turn)
|
|
435
|
+
exhausted = turn.pending_tool_calls && tool_response_turn?(turn.conversation.last)
|
|
436
|
+
output = strip_thought_blocks(last_model_content(turn.conversation))
|
|
414
437
|
output = parser.strip_tool_calls(output) if exhausted
|
|
415
438
|
Result.new(
|
|
416
439
|
output: PromptLiteralGuard.restore(output, profile: @profile),
|
|
417
|
-
conversation: duplicate_conversation(conversation),
|
|
440
|
+
conversation: duplicate_conversation(turn.conversation),
|
|
418
441
|
exhausted: exhausted,
|
|
419
|
-
pending_tool_calls: pending_tool_calls,
|
|
420
|
-
tool_activity: tool_activity,
|
|
442
|
+
pending_tool_calls: turn.pending_tool_calls,
|
|
443
|
+
tool_activity: turn.tool_activity,
|
|
421
444
|
canceled: false,
|
|
422
445
|
cancellation_reason: nil,
|
|
423
|
-
context_status: context.display
|
|
446
|
+
context_status: turn.context.display
|
|
424
447
|
)
|
|
425
|
-
rescue StandardError => e
|
|
426
|
-
LLM::FailedTurn.attach(e, conversation && duplicate_conversation(conversation))
|
|
427
|
-
raise
|
|
428
448
|
end
|
|
429
449
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
def use_profile!(resolution)
|
|
433
|
-
@profile = resolution.profile
|
|
434
|
-
@profile_source = resolution.label
|
|
450
|
+
def emit(turn, **event)
|
|
451
|
+
emit_stream_event(turn.on_stream_event, event)
|
|
435
452
|
end
|
|
436
453
|
|
|
437
|
-
|
|
438
|
-
|
|
454
|
+
# Queued input at an iteration boundary (Steer.inject!); +answer+ is a
|
|
455
|
+
# proc, built only on a merge. Returns true when anything was injected.
|
|
456
|
+
def inject_pending_input!(turn, answer: nil)
|
|
457
|
+
Steer.inject!(turn.conversation, turn.pending_input, iteration: turn.iteration, emit: turn.emit,
|
|
458
|
+
cancel_controller: turn.cancel_controller, answer: answer)
|
|
439
459
|
end
|
|
440
460
|
|
|
441
|
-
private
|
|
442
|
-
|
|
443
461
|
# memory_read with the session's mutes applied: a blank name (the index)
|
|
444
462
|
# loses the muted memories' lines; a muted name in a comma list is
|
|
445
463
|
# refused with its own error line and the rest is read as usual.
|
|
446
464
|
def muted_memory_read(tool, call)
|
|
447
465
|
muted = Array(@muted_memory_names)
|
|
448
466
|
content = call[:content].to_s
|
|
449
|
-
read = ->(names) { tool.call(names, scope: call[:scope], model_key: @model_key) }
|
|
467
|
+
read = ->(names) { tool.call(names, scope: call[:scope], model_key: @model_key, fallback_model_key: @model_key_fallback) }
|
|
450
468
|
return read.call(content) if muted.empty?
|
|
451
469
|
return MutedMemories.filter_index(read.call(content), muted) if content.strip.empty?
|
|
452
470
|
|
|
@@ -466,34 +484,6 @@ module Samagotchi
|
|
|
466
484
|
nil
|
|
467
485
|
end
|
|
468
486
|
|
|
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
487
|
|
|
498
488
|
# ── Hook dispatch helper ───────────────────────────────────────────────────
|
|
499
489
|
|
|
@@ -553,7 +543,8 @@ module Samagotchi
|
|
|
553
543
|
generation_controller&.cancelled? && !cancel_controller.cancelled?
|
|
554
544
|
end
|
|
555
545
|
|
|
556
|
-
|
|
546
|
+
# +sampling+: this request's (EmptyAnswerRetry#request_sampling).
|
|
547
|
+
def complete_kwargs(cancel_controller:, model_name: nil, on_chunk: nil, on_retry: nil, images: [], sampling: nil)
|
|
557
548
|
kwargs = {}
|
|
558
549
|
# Only a request with images names them: a text-only call is unchanged.
|
|
559
550
|
kwargs[:images] = images unless images.empty?
|
|
@@ -565,8 +556,6 @@ module Samagotchi
|
|
|
565
556
|
kwargs[:n_predict] = n_predict if n_predict && client_supports_keyword?(:n_predict)
|
|
566
557
|
resolved_model_name = completion_model_name(model_name)
|
|
567
558
|
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
559
|
kwargs[:sampling] = sampling if sampling && !sampling.empty? && client_supports_keyword?(:sampling)
|
|
571
560
|
kwargs
|
|
572
561
|
end
|
|
@@ -627,7 +616,7 @@ module Samagotchi
|
|
|
627
616
|
# :context_status event. The telemetry is not for the model (it used to
|
|
628
617
|
# be injected as a synthetic system message).
|
|
629
618
|
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)
|
|
619
|
+
event = context.observe(prompt.length, iteration_index: iteration_index, window: window, image_tokens: image_tokens)
|
|
631
620
|
return unless event
|
|
632
621
|
|
|
633
622
|
emit_stream_event(on_stream_event, type: :context_status, iteration: iteration_index + 1, **event)
|
|
@@ -645,8 +634,7 @@ module Samagotchi
|
|
|
645
634
|
end
|
|
646
635
|
|
|
647
636
|
# The status line's context value ({est_pct:, bucket:}) for +used_tokens+
|
|
648
|
-
# of +window_tokens+; nil without both, or with context.status off.
|
|
649
|
-
# chat loop builds its value with it too.
|
|
637
|
+
# of +window_tokens+; nil without both, or with context.status off.
|
|
650
638
|
def context_display(used_tokens:, window_tokens:)
|
|
651
639
|
ContextStatus.new.display_for(used_tokens: used_tokens, window_tokens: window_tokens)
|
|
652
640
|
end
|
|
@@ -696,11 +684,6 @@ module Samagotchi
|
|
|
696
684
|
messages.map(&:dup)
|
|
697
685
|
end
|
|
698
686
|
|
|
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
687
|
def last_model_content(conversation)
|
|
705
688
|
message = conversation.reverse.find { |entry| entry[:role] == "model" }
|
|
706
689
|
message ? message[:content].to_s : ""
|