samagotchi 0.22.0 → 0.24.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 (176) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -1
  3. data/bin/chi +9 -4
  4. data/docs/cli.md +6 -1
  5. data/docs/configuration.md +13 -4
  6. data/docs/hooks.md +20 -6
  7. data/docs/plugins.md +1 -1
  8. data/docs/releasing.md +1 -1
  9. data/docs/sub-agent.md +4 -3
  10. data/docs/testing.md +6 -0
  11. data/lib/samagotchi/answer_command.rb +4 -2
  12. data/lib/samagotchi/bootstrap/config_writer.rb +2 -1
  13. data/lib/samagotchi/bootstrap/probe.rb +1 -1
  14. data/lib/samagotchi/bootstrap_command.rb +2 -2
  15. data/lib/samagotchi/bridge/bounded_queue.rb +0 -2
  16. data/lib/samagotchi/bridge/card_store.rb +4 -3
  17. data/lib/samagotchi/bridge/sse_writer.rb +0 -6
  18. data/lib/samagotchi/bridge/turn_accumulator.rb +6 -3
  19. data/lib/samagotchi/bridge/turn_notice.rb +1 -0
  20. data/lib/samagotchi/bridge.rb +19 -9
  21. data/lib/samagotchi/bridge_client/event_stream.rb +2 -2
  22. data/lib/samagotchi/bridge_client.rb +1 -1
  23. data/lib/samagotchi/bundle_command.rb +39 -29
  24. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/btw/plugin.rb +2 -0
  26. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/check-in/plugin.rb +4 -1
  28. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +6 -5
  29. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/loop-guard/plugin.rb +2 -0
  32. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  33. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -0
  34. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/skills/plugin.rb +5 -3
  36. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -7
  37. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  38. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  39. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  40. data/lib/samagotchi/cancellation_controller.rb +1 -1
  41. data/lib/samagotchi/cli/command.rb +1 -1
  42. data/lib/samagotchi/client.rb +3 -5
  43. data/lib/samagotchi/commands/registry.rb +2 -2
  44. data/lib/samagotchi/config.rb +42 -14
  45. data/lib/samagotchi/context_note.rb +1 -1
  46. data/lib/samagotchi/context_status.rb +6 -6
  47. data/lib/samagotchi/continue_offer.rb +15 -8
  48. data/lib/samagotchi/debug_log.rb +1 -1
  49. data/lib/samagotchi/desktop/macos.rb +5 -4
  50. data/lib/samagotchi/empty_answer_retry.rb +1 -1
  51. data/lib/samagotchi/engine.rb +97 -18
  52. data/lib/samagotchi/gem_update.rb +2 -1
  53. data/lib/samagotchi/generation_phase.rb +83 -0
  54. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  55. data/lib/samagotchi/guardrails/approval.rb +5 -5
  56. data/lib/samagotchi/guardrails/approvals.rb +2 -2
  57. data/lib/samagotchi/guardrails/gate.rb +2 -2
  58. data/lib/samagotchi/guardrails/read_only_shell.rb +5 -3
  59. data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
  60. data/lib/samagotchi/guardrails/shell_lex.rb +113 -5
  61. data/lib/samagotchi/guardrails/shell_paths.rb +3 -3
  62. data/lib/samagotchi/hooks/bundle_loader.rb +9 -6
  63. data/lib/samagotchi/hooks/loader.rb +18 -16
  64. data/lib/samagotchi/hooks/registry.rb +10 -2
  65. data/lib/samagotchi/host_registry.rb +1 -3
  66. data/lib/samagotchi/idle_recap.rb +22 -17
  67. data/lib/samagotchi/idle_reminders.rb +6 -3
  68. data/lib/samagotchi/image_store.rb +5 -2
  69. data/lib/samagotchi/kernel_loop.rb +50 -40
  70. data/lib/samagotchi/llm/chat_loop.rb +29 -16
  71. data/lib/samagotchi/llm/errors.rb +2 -2
  72. data/lib/samagotchi/llm/http.rb +12 -12
  73. data/lib/samagotchi/llm/openai_chat.rb +42 -28
  74. data/lib/samagotchi/llm/prompt_cache.rb +1 -1
  75. data/lib/samagotchi/log.rb +2 -2
  76. data/lib/samagotchi/log_subscriber.rb +5 -3
  77. data/lib/samagotchi/memory_bundle/builder.rb +9 -3
  78. data/lib/samagotchi/memory_bundle/index_updater.rb +13 -1
  79. data/lib/samagotchi/memory_bundle/installer.rb +67 -57
  80. data/lib/samagotchi/memory_bundle/listing.rb +5 -1
  81. data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
  82. data/lib/samagotchi/memory_bundle/merger.rb +6 -4
  83. data/lib/samagotchi/memory_bundle/provenance.rb +16 -13
  84. data/lib/samagotchi/memory_bundle/source.rb +7 -3
  85. data/lib/samagotchi/memory_bundle/status.rb +3 -1
  86. data/lib/samagotchi/memory_bundle/system_bundle.rb +3 -2
  87. data/lib/samagotchi/memory_bundle/trash.rb +5 -2
  88. data/lib/samagotchi/memory_bundle/uninstaller.rb +9 -6
  89. data/lib/samagotchi/memory_bundle.rb +1 -0
  90. data/lib/samagotchi/model_overlay.rb +3 -5
  91. data/lib/samagotchi/model_profile.rb +12 -8
  92. data/lib/samagotchi/note_command.rb +2 -1
  93. data/lib/samagotchi/output_formatter.rb +18 -17
  94. data/lib/samagotchi/owner_lock.rb +2 -1
  95. data/lib/samagotchi/plugin/api.rb +8 -5
  96. data/lib/samagotchi/plugin/context.rb +1 -3
  97. data/lib/samagotchi/plugin/loader.rb +1 -1
  98. data/lib/samagotchi/plugin/service.rb +1 -1
  99. data/lib/samagotchi/plugin/sessions.rb +1 -0
  100. data/lib/samagotchi/plugin_tasks.rb +1 -1
  101. data/lib/samagotchi/prompt.rb +72 -13
  102. data/lib/samagotchi/prompt_literal_guard.rb +1 -1
  103. data/lib/samagotchi/question_desk.rb +7 -7
  104. data/lib/samagotchi/recap_setup.rb +1 -1
  105. data/lib/samagotchi/relay_desk.rb +1 -1
  106. data/lib/samagotchi/reminder_store.rb +2 -1
  107. data/lib/samagotchi/send_command.rb +4 -2
  108. data/lib/samagotchi/session.rb +2 -2
  109. data/lib/samagotchi/session_archive_command.rb +5 -1
  110. data/lib/samagotchi/session_commands.rb +1 -3
  111. data/lib/samagotchi/session_manager.rb +2 -4
  112. data/lib/samagotchi/session_metrics.rb +10 -10
  113. data/lib/samagotchi/session_observer.rb +3 -2
  114. data/lib/samagotchi/session_retention.rb +2 -2
  115. data/lib/samagotchi/sessions_command.rb +14 -5
  116. data/lib/samagotchi/steer.rb +103 -8
  117. data/lib/samagotchi/system_prompt.rb +19 -8
  118. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
  119. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
  120. data/lib/samagotchi/terminal_ui/event_renderer.rb +4 -2
  121. data/lib/samagotchi/terminal_ui/formatting.rb +19 -10
  122. data/lib/samagotchi/terminal_ui/input_support.rb +2 -2
  123. data/lib/samagotchi/terminal_ui/line_reader.rb +3 -3
  124. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
  125. data/lib/samagotchi/terminal_ui/question_prompt.rb +23 -23
  126. data/lib/samagotchi/terminal_ui/question_slot.rb +10 -10
  127. data/lib/samagotchi/terminal_ui/reline_seam.rb +1 -1
  128. data/lib/samagotchi/terminal_ui/repl_input.rb +3 -2
  129. data/lib/samagotchi/terminal_ui.rb +14 -5
  130. data/lib/samagotchi/text_diff.rb +12 -12
  131. data/lib/samagotchi/thinking.rb +6 -2
  132. data/lib/samagotchi/thought_stream_splitter.rb +15 -6
  133. data/lib/samagotchi/tool_activity.rb +1 -0
  134. data/lib/samagotchi/tool_call_parser.rb +13 -14
  135. data/lib/samagotchi/tool_declarations.rb +36 -35
  136. data/lib/samagotchi/tool_runner.rb +7 -0
  137. data/lib/samagotchi/tool_view.rb +40 -0
  138. data/lib/samagotchi/tools/ask_user_question.rb +3 -4
  139. data/lib/samagotchi/tools/builtin_calls.rb +1 -1
  140. data/lib/samagotchi/tools/delegate.rb +1 -1
  141. data/lib/samagotchi/tools/delegate_relay.rb +1 -1
  142. data/lib/samagotchi/tools/delegate_wait.rb +0 -1
  143. data/lib/samagotchi/tools/edit.rb +14 -13
  144. data/lib/samagotchi/tools/execute.rb +3 -5
  145. data/lib/samagotchi/tools/list_reminders.rb +1 -1
  146. data/lib/samagotchi/tools/memory.rb +21 -22
  147. data/lib/samagotchi/tools/read.rb +7 -7
  148. data/lib/samagotchi/tools/task_create.rb +1 -1
  149. data/lib/samagotchi/tools/task_get.rb +1 -1
  150. data/lib/samagotchi/tools/task_list.rb +1 -1
  151. data/lib/samagotchi/tools/task_runtime.rb +8 -9
  152. data/lib/samagotchi/tools/task_stop.rb +1 -1
  153. data/lib/samagotchi/tools/task_wait.rb +3 -4
  154. data/lib/samagotchi/tools/web_fetch.rb +2 -4
  155. data/lib/samagotchi/tools/write.rb +1 -1
  156. data/lib/samagotchi/turn_flow.rb +8 -8
  157. data/lib/samagotchi/turn_note.rb +5 -1
  158. data/lib/samagotchi/version.rb +1 -1
  159. data/lib/samagotchi/vision_support.rb +2 -2
  160. data/lib/samagotchi/waiting_steer.rb +64 -0
  161. data/lib/samagotchi/web/app.rb +13 -6
  162. data/lib/samagotchi/web/markdown_renderer.rb +2 -1
  163. data/lib/samagotchi/web/message_parts.rb +5 -0
  164. data/lib/samagotchi/web/public/activity.js +5 -0
  165. data/lib/samagotchi/web/public/app.js +2 -1
  166. data/lib/samagotchi/web/public/command_view.js +28 -0
  167. data/lib/samagotchi/web/public/copy.js +7 -0
  168. data/lib/samagotchi/web/public/index.html +8 -0
  169. data/lib/samagotchi/web/public/stage_model.js +11 -4
  170. data/lib/samagotchi/web/public/stage_view.js +5 -2
  171. data/lib/samagotchi/web/public/turn_events.js +15 -1
  172. data/lib/samagotchi/web/public/turn_view.js +15 -6
  173. data/lib/samagotchi/web/qr.rb +7 -1
  174. data/lib/samagotchi/web/server.rb +6 -2
  175. data/lib/samagotchi/worker.rb +14 -3
  176. metadata +24 -20
@@ -46,6 +46,7 @@ module Samagotchi
46
46
  def should_check?
47
47
  return false if @engine.turn_running?
48
48
  return false unless last_idle_seconds >= @inactivity
49
+
49
50
  true
50
51
  end
51
52
 
@@ -57,15 +58,17 @@ module Samagotchi
57
58
 
58
59
  def check_due_reminders
59
60
  return unless @queue
61
+
60
62
  due_names = @queue.due.map { |r| r[:name] }
61
63
  return if due_names.empty?
64
+
62
65
  # Signal the engine to create a synthetic turn via callback.
63
66
  # The callback is responsible for triggering a turn (e.g. SessionManager
64
67
  # writes a file, TerminalUI queues input). The engine's run_turn or
65
68
  # REPL injection point then picks up and delivers the reminders.
66
- if @auto_turn_callback
67
- @auto_turn_callback.call(due_names)
68
- end
69
+ return unless @auto_turn_callback
70
+
71
+ @auto_turn_callback.call(due_names)
69
72
  end
70
73
  end
71
74
  end
@@ -100,7 +100,8 @@ module Samagotchi
100
100
  # Downscales and converts images with whatever tool the machine has:
101
101
  # macOS sips first, then ImageMagick. `none` when neither is installed.
102
102
  class ImageResizer
103
- Error = Class.new(StandardError)
103
+ class Error < StandardError
104
+ end
104
105
 
105
106
  attr_reader :tool
106
107
 
@@ -191,7 +192,8 @@ module Samagotchi
191
192
  # Messages hold small refs to those files; the base64 is built only when
192
193
  # a request is sent.
193
194
  module ImageStore
194
- Error = Class.new(StandardError)
195
+ class Error < StandardError
196
+ end
195
197
 
196
198
  DIR = "images"
197
199
  REF_RE = %r{\Aimages/[0-9a-f]{16}\.(png|jpe?g|gif|webp)\z}
@@ -234,6 +236,7 @@ module Samagotchi
234
236
 
235
237
  name = (name || (path && File.basename(path)) || "image").to_s
236
238
  raise Error, "#{name} is too large (over 50 MB)" if bytes.bytesize > MAX_SOURCE_BYTES
239
+
237
240
  info = ImageHeader.read(bytes)
238
241
  raise Error, "#{name} is not an image chi can send (png, jpeg, gif, webp)" unless info
239
242
 
@@ -134,8 +134,7 @@ module Samagotchi
134
134
  # @return [Tools::Registry] the tools #dispatch runs. The Engine sets
135
135
  # its own on a kernel it is given, as with hooks.
136
136
  attr_accessor :tools
137
- attr_accessor :client
138
- attr_accessor :model_key
137
+ attr_accessor :client, :model_key
139
138
  # @return [String, nil] the key whose overlay memory_read takes when
140
139
  # #model_key has none (#sync_model_key!)
141
140
  attr_reader :model_key_fallback
@@ -210,6 +209,18 @@ module Samagotchi
210
209
  @model_key_fallback = fallback
211
210
  end
212
211
 
212
+ # Resolve the per-output character cap for the emitted tool call events.
213
+ #
214
+ # Precedence: an explicit override wins, then max_tool_output_chars
215
+ # (Config). A non-positive value falls back to
216
+ # DEFAULT_MAX_TOOL_OUTPUT_CHARS (there is intentionally no "unlimited" — live UIs get a
217
+ # bounded `output:` plus a truthful `output_truncated:` flag).
218
+ # Class-level so the chat loop resolves it the same way.
219
+ def self.resolve_output_char_cap(override)
220
+ parsed = (override || Samagotchi::Config.get("max_tool_output_chars")).to_i
221
+ parsed.positive? ? parsed : DEFAULT_MAX_TOOL_OUTPUT_CHARS
222
+ end
223
+
213
224
  private
214
225
 
215
226
  # ── One turn (#run) ────────────────────────────────────────────────────────
@@ -223,7 +234,7 @@ module Samagotchi
223
234
  :cancel_controller, :cap, :emit, :iteration, :empty_steps, :ended_empty, keyword_init: true)
224
235
  # One request: the prompt and its images as sent, the images' token
225
236
  # estimate, and the window it was measured against.
226
- Request = Struct.new(:prompt, :images, :image_tokens, :window, keyword_init: true)
237
+ Request = Struct.new(:prompt, :images, :image_tokens, :window, :prefill, keyword_init: true)
227
238
  # One generation: the response (nil when cut), the cut's detail, this
228
239
  # generation's own server counts, the model the server named, the
229
240
  # thinking it streamed, and why it stopped (the transport's finish
@@ -254,7 +265,7 @@ module Samagotchi
254
265
  generation = generate(turn, request)
255
266
  return after_cut(turn, generation) if generation.cut
256
267
 
257
- turn.conversation << { role: "model", content: turn.prefill + generation.response.to_s }
268
+ turn.conversation << { role: "model", content: request.prefill + generation.response.to_s }
258
269
  # Profile-specific parse (incl. Qwen unterminated-block recovery); the
259
270
  # returned fragment (non-nil only for Qwen) is fed back on the next
260
271
  # iteration if the model opened a tool-call block it did not close.
@@ -279,7 +290,10 @@ module Samagotchi
279
290
  turn.conversation << line
280
291
  prompt, images = format_prompt(turn)
281
292
  end
282
- Request.new(prompt: prompt, images: images, image_tokens: image_tokens, window: window)
293
+ # The prefill the prompt ended with (none where Gemma's turn goes on
294
+ # after a tool response): the model message keeps it.
295
+ prefill = Prompt.prefill_for(turn.conversation, @profile, turn.prefill)
296
+ Request.new(prompt: prompt, images: images, image_tokens: image_tokens, window: window, prefill: prefill)
283
297
  end
284
298
 
285
299
  def format_prompt(turn)
@@ -341,8 +355,12 @@ module Samagotchi
341
355
  generation.streamed_thinking += split[:thinking].to_s.length
342
356
  return unless turn.on_stream_event
343
357
 
344
- emit(turn, type: :generation_chunk, iteration: turn.iteration, content: chunk[:content], text: split[:text],
345
- thinking: split[:thinking], payload: chunk[:payload])
358
+ event = { type: :generation_chunk, iteration: turn.iteration, content: chunk[:content], text: split[:text],
359
+ thinking: split[:thinking], payload: chunk[:payload] }
360
+ # A chunk of a tool call (its bytes are dropped from both lanes): a
361
+ # steer doesn't cut this generation (Engine#cut_for_steer).
362
+ event[:tool_call] = true if split[:tool]
363
+ emit(turn, **event)
346
364
  end
347
365
 
348
366
  # The cut stream's visible text goes with it: the buffer as it was
@@ -372,18 +390,24 @@ module Samagotchi
372
390
  messages: AnswerDisplay.strip_all(turn.conversation).map(&:dup).freeze })
373
391
  end
374
392
 
375
- # A generation a plugin cut is an empty answer made early: asked again
376
- # with its own nudge while the retry budget lasts (queued input goes in
377
- # place of the nudge), else the turn ends as cancelled (hook), with
378
- # nothing salvaged and without the spent nudge. A Stop that came right
379
- # after the cut is a plain cancel. Returns :next or the turn's result.
393
+ # A generation a plugin cut is an empty answer made early. Queued input
394
+ # (a user's line, a plugin's steer) goes in first, with or without a
395
+ # retry left, and spends no attempt: the model answers it. A steer's cut
396
+ # (Engine#cut_for_steer) with nothing queued (its message was taken at
397
+ # a boundary already) is asked again as is. Else it is asked again with
398
+ # its own nudge while the retry budget lasts, else the turn ends as
399
+ # cancelled (hook), with nothing salvaged and without the spent nudge.
400
+ # A Stop that came right after the cut is a plain cancel. Returns :next
401
+ # or the turn's result.
380
402
  def after_cut(turn, generation)
381
403
  cut = generation.cut
382
- raise Client::RequestCancelled.new(turn.cancel_controller.reason) if turn.cancel_controller.cancelled?
404
+ raise Client::RequestCancelled, turn.cancel_controller.reason if turn.cancel_controller.cancelled?
383
405
 
384
- if turn.empty_retry.left?
385
- return :next if inject_pending_input!(turn)
406
+ # The row under the cut step, above the message it was cut for.
407
+ emit(turn, type: :steer_cut, iteration: turn.iteration, source: cut[:source].to_s) if cut[:steer]
408
+ return :next if inject_pending_input!(turn) || cut[:steer]
386
409
 
410
+ if turn.empty_retry.left?
387
411
  turn.empty_retry.nudge!(turn.conversation, TurnNote.cut_retry(cut[:by], cut[:reason]),
388
412
  emit: turn.emit, iteration: turn.iteration, finish_reason: "stopped",
389
413
  thinking_chars: generation.streamed_thinking, stopped_by: cut[:by])
@@ -493,7 +517,6 @@ module Samagotchi
493
517
  nil
494
518
  end
495
519
 
496
-
497
520
  # ── Hook dispatch helper ───────────────────────────────────────────────────
498
521
 
499
522
  # Fire a named hook on the registry (if present).
@@ -505,6 +528,7 @@ module Samagotchi
505
528
 
506
529
  def fire_hook(name, event)
507
530
  return unless @hooks
531
+
508
532
  @hooks.fire(name, event)
509
533
  rescue StandardError
510
534
  # A failing hook must not break the turn.
@@ -517,34 +541,22 @@ module Samagotchi
517
541
 
518
542
  # ── Output char cap resolution ─────────────────────────────────────────────
519
543
 
520
- # Resolve the per-output character cap for the emitted tool call events.
521
- #
522
- # Precedence: an explicit override wins, then max_tool_output_chars
523
- # (Config). A non-positive value falls back to
524
- # DEFAULT_MAX_TOOL_OUTPUT_CHARS (there is intentionally no "unlimited" — live UIs get a
525
- # bounded `output:` plus a truthful `output_truncated:` flag).
526
- # Class-level so the chat loop resolves it the same way.
527
- def self.resolve_output_char_cap(override)
528
- parsed = (override || Samagotchi::Config.get("max_tool_output_chars")).to_i
529
- parsed.positive? ? parsed : DEFAULT_MAX_TOOL_OUTPUT_CHARS
530
- end
531
-
532
544
  def resolve_output_char_cap(override)
533
545
  self.class.resolve_output_char_cap(override)
534
546
  end
535
547
 
536
548
  # One request, cancelled by +generation_controller+ (the turn's child)
537
549
  # when there is one.
538
- def request_generation(prompt, generation_controller:, cancel_controller:, **options)
539
- @client.complete(prompt, **complete_kwargs(cancel_controller: generation_controller || cancel_controller, **options))
550
+ def request_generation(prompt, generation_controller:, cancel_controller:, **)
551
+ @client.complete(prompt, **complete_kwargs(cancel_controller: generation_controller || cancel_controller, **))
540
552
  end
541
553
 
542
554
  # Yields the turn controller's child for one generation (nil without a
543
555
  # controller).
544
- def with_generation(cancel_controller, &block)
556
+ def with_generation(cancel_controller, &)
545
557
  return yield(nil) unless cancel_controller
546
558
 
547
- cancel_controller.generation(&block)
559
+ cancel_controller.generation(&)
548
560
  end
549
561
 
550
562
  # The generation was cut (stop_generation) and the turn goes on.
@@ -561,19 +573,14 @@ module Samagotchi
561
573
  kwargs[:on_retry] = on_retry if on_retry
562
574
  kwargs[:cancel_controller] = cancel_controller if cancel_controller
563
575
  kwargs[:stop] = @profile.stop_sequences
564
- n_predict = completion_n_predict
565
- kwargs[:n_predict] = n_predict if n_predict
576
+ max_tokens = Samagotchi::Config.max_tokens
577
+ kwargs[:n_predict] = max_tokens if max_tokens
566
578
  resolved_model_name = completion_model_name(model_name)
567
579
  kwargs[:model] = resolved_model_name if resolved_model_name
568
580
  kwargs[:sampling] = sampling if sampling && !sampling.empty?
569
581
  kwargs
570
582
  end
571
583
 
572
- def completion_n_predict
573
- value = Samagotchi::Config.get("default.n_predict").to_i
574
- value if value.positive?
575
- end
576
-
577
584
  def completion_model_name(override = nil)
578
585
  ModelProfile.required_model_name(override)
579
586
  end
@@ -635,6 +642,7 @@ module Samagotchi
635
642
  @parser_profile = @profile
636
643
  @parser
637
644
  end
645
+
638
646
  private
639
647
 
640
648
  # The generation's thinking: what the stream split into the thinking
@@ -682,6 +690,7 @@ module Samagotchi
682
690
  end
683
691
 
684
692
  public
693
+
685
694
  # Public entry point for executing an ALREADY-NORMALIZED internal tool call
686
695
  # (the {name:, content:, path:, scope:, …} shape).
687
696
  #
@@ -696,6 +705,7 @@ module Samagotchi
696
705
  end
697
706
 
698
707
  private
708
+
699
709
  def dispatch(call)
700
710
  entry = @tools[call[:name]]
701
711
  unless entry
@@ -723,7 +733,7 @@ module Samagotchi
723
733
  dispatched[:image_only] = true if result.respond_to?(:image_only?) && result.image_only?
724
734
  end
725
735
  dispatched
726
- rescue => e
736
+ rescue StandardError => e
727
737
  dump_log("tool_error", e.message, tool: call[:name], error: e.class.name)
728
738
  result = "Error: #{e.message}"
729
739
  {
@@ -107,7 +107,9 @@ module Samagotchi
107
107
  when "system"
108
108
  wire << { role: "system", content: entry[:content].to_s }
109
109
  when "user"
110
- wire << { role: "user", content: parts(entry[:content], items) }
110
+ # A steer or merged input (always a String) goes with its header.
111
+ content = Steer.header(entry) ? Steer.wire_text(entry, entry[:content].to_s) : entry[:content]
112
+ wire << { role: "user", content: parts(content, items) }
111
113
  when "model"
112
114
  wire << assistant_message(entry, paired)
113
115
  when "tool_response"
@@ -155,12 +157,15 @@ module Samagotchi
155
157
  (@thinking_refused ||= Set.new) << model
156
158
  end
157
159
 
158
- # One generation's options: the thinking fields under the sampling
159
- # (a sampling key wins, chat_template_kwargs merges per sub-key), the
160
- # empty-answer retry's temperature on top, then every null dropped at
161
- # any depth (a sampling null means "don't send it").
160
+ # One generation's options: default.max_tokens as max_tokens (the
161
+ # adapter's own default, OpenRouter's, otherwise), the thinking fields
162
+ # under the sampling (a sampling key wins, chat_template_kwargs merges
163
+ # per sub-key), the empty-answer retry's temperature on top, then every
164
+ # null dropped at any depth (a sampling null means "don't send it").
162
165
  def request_options(retry_generation: false, model: nil)
166
+ max_tokens = Samagotchi::Config.max_tokens
163
167
  options = deep_merge(thinking_fields(model), sampling)
168
+ options = { max_tokens: max_tokens }.merge(options) if max_tokens
164
169
  options = EmptyAnswerRetry.sampling(options) if retry_generation
165
170
  deep_compact(options)
166
171
  end
@@ -378,17 +383,22 @@ module Samagotchi
378
383
  end
379
384
 
380
385
  # A generation a plugin cut (stop_generation) is an empty answer made
381
- # early: asked again with its own nudge while the retry budget lasts
382
- # (queued input goes in place of the nudge), else the turn ends as
383
- # cancelled (hook), with nothing salvaged and without a spent nudge.
384
- # A Stop that came right after the cut is a plain cancel. Returns
385
- # :retry, or the turn's result.
386
+ # early. Queued input (a user's line, a plugin's steer) goes in
387
+ # first, with or without a retry left, and spends no attempt. A
388
+ # steer's cut (Engine#cut_for_steer) with nothing queued is asked
389
+ # again as is. Else it is asked again with its own nudge while the
390
+ # retry budget lasts, else the turn ends as cancelled (hook), with
391
+ # nothing salvaged and without a spent nudge. A Stop that came right
392
+ # after the cut is a plain cancel. Returns :retry, or the turn's
393
+ # result.
386
394
  def after_cut(iteration, response)
387
395
  return canceled(iteration, @cancel_controller.reason) if @cancel_controller.cancelled?
388
396
 
389
- if @empty_retry.left?
390
- return :retry if inject_pending_input(iteration)
397
+ # The row under the cut step, above the message it was cut for.
398
+ emit(type: :steer_cut, iteration: iteration, source: response.cut[:source].to_s) if response.cut[:steer]
399
+ return :retry if inject_pending_input(iteration) || response.cut[:steer]
391
400
 
401
+ if @empty_retry.left?
392
402
  @empty_retry.nudge!(@conversation, TurnNote.cut_retry(response.cut[:by], response.cut[:reason]),
393
403
  emit: method(:emit), iteration: iteration, finish_reason: response.finish_reason,
394
404
  thinking_chars: response.reasoning.to_s.length, stopped_by: response.cut[:by])
@@ -449,10 +459,10 @@ module Samagotchi
449
459
 
450
460
  # The request runs under the generation's own controller (the turn
451
461
  # controller's child), so a plugin's stop_generation cuts it alone.
452
- def with_generation(&block)
462
+ def with_generation(&)
453
463
  return yield(nil) unless @cancel_controller
454
464
 
455
- @cancel_controller.generation(&block)
465
+ @cancel_controller.generation(&)
456
466
  end
457
467
 
458
468
  # The empty response of a cut generation: its spinners close as any
@@ -475,8 +485,11 @@ module Samagotchi
475
485
  on_delta: lambda { |content:, reasoning:, payload:|
476
486
  streamed << content
477
487
  thought << reasoning
478
- emit(type: :generation_chunk, iteration: iteration, content: reasoning + content, text: content,
479
- thinking: reasoning, payload: payload)
488
+ event = { type: :generation_chunk, iteration: iteration, content: reasoning + content, text: content,
489
+ thinking: reasoning, payload: payload }
490
+ # A tool call streaming: a steer doesn't cut it (Engine#cut_for_steer).
491
+ event[:tool_call] = true if OpenAIChat.tool_call_delta?(payload)
492
+ emit(event)
480
493
  },
481
494
  on_retry: ->(**retry_event) { emit({ type: :generation_retrying, iteration: iteration }.merge(retry_event)) }
482
495
  )
@@ -125,12 +125,12 @@ module Samagotchi
125
125
 
126
126
  # @param hint [String, nil] what to try, added to the summary
127
127
  def initialize(message = nil, context_overflow: false, tools_unsupported: false, reasoning_refused: false, hint: nil,
128
- **options)
128
+ **)
129
129
  @context_overflow = context_overflow
130
130
  @tools_unsupported = tools_unsupported
131
131
  @reasoning_refused = reasoning_refused
132
132
  @hint = hint
133
- super(message, **options)
133
+ super(message, **)
134
134
  end
135
135
 
136
136
  attr_reader :hint
@@ -138,7 +138,7 @@ module Samagotchi
138
138
  end
139
139
  end
140
140
 
141
- def stream_attempt(uri, request, current, &on_line)
141
+ def stream_attempt(uri, request, current, &)
142
142
  # Time to first token is the answering attempt's, not the retries'.
143
143
  current[:attempt_started_at] = monotonic_now
144
144
  shown = lambda do
@@ -155,11 +155,11 @@ module Samagotchi
155
155
  response.read_body do |chunk|
156
156
  buffer << chunk
157
157
  while (newline_index = buffer.index("\n"))
158
- on_line.call(buffer.slice!(0, newline_index + 1).strip, shown)
158
+ yield(buffer.slice!(0, newline_index + 1).strip, shown)
159
159
  end
160
160
  end
161
161
  # A body that doesn't end in a newline still has a last line.
162
- on_line.call(buffer.strip, shown) unless buffer.strip.empty?
162
+ yield(buffer.strip, shown) unless buffer.strip.empty?
163
163
  end
164
164
  end
165
165
  end
@@ -192,7 +192,7 @@ module Samagotchi
192
192
  cancellable(cancel_controller, current) do
193
193
  attempt.call(current.merge!(attempts: 1))
194
194
  rescue StandardError => e
195
- raise RequestCancelled.new(cancel_controller.reason) if !e.is_a?(RequestCancelled) && cancel_controller&.cancelled?
195
+ raise RequestCancelled, cancel_controller.reason if !e.is_a?(RequestCancelled) && cancel_controller&.cancelled?
196
196
 
197
197
  raise
198
198
  end
@@ -208,7 +208,7 @@ module Samagotchi
208
208
  @api_key&.authorize(request)
209
209
  end
210
210
 
211
- def start(uri, open_timeout: nil, read_timeout: nil, max_retries: nil, &block)
211
+ def start(uri, open_timeout: nil, read_timeout: nil, max_retries: nil, &)
212
212
  options = { open_timeout: open_timeout || @open_timeout, read_timeout: read_timeout || @read_timeout }
213
213
  # Net::HTTP retries an idempotent request once on its own (max_retries
214
214
  # defaults to 1) when the connection drops before an answer: chi's
@@ -218,15 +218,15 @@ module Samagotchi
218
218
  retries = max_retries || 0
219
219
  options[:max_retries] = retries
220
220
  options[:use_ssl] = true if uri.scheme == "https"
221
- Net::HTTP.start(uri.host, uri.port, **options, &block)
221
+ Net::HTTP.start(uri.host, uri.port, **options, &)
222
222
  end
223
223
 
224
224
  # Runs the block (one attempt) until it returns, retrying network
225
225
  # errors. The block gets a hash to put the attempt's Net::HTTP in, so a
226
226
  # cancel can reach its socket.
227
- def with_retries(cancel_controller, on_retry, on_network_error, current = { mutex: Mutex.new })
227
+ def with_retries(cancel_controller, on_retry, on_network_error, current = { mutex: Mutex.new }, &block)
228
228
  cancellable(cancel_controller, current) do
229
- retrying(cancel_controller, on_retry, on_network_error, current) { |state| yield(state) }
229
+ retrying(cancel_controller, on_retry, on_network_error, current, &block)
230
230
  end
231
231
  end
232
232
 
@@ -236,7 +236,7 @@ module Samagotchi
236
236
  def cancellable(cancel_controller, current)
237
237
  requesting_thread = Thread.current
238
238
  listener_id = cancel_controller&.on_cancel { |reason| abort_request(current, requesting_thread, reason) }
239
- raise RequestCancelled.new(cancel_controller.reason) if cancel_controller&.cancelled?
239
+ raise RequestCancelled, cancel_controller.reason if cancel_controller&.cancelled?
240
240
 
241
241
  yield
242
242
  ensure
@@ -257,7 +257,7 @@ module Samagotchi
257
257
  rescue RequestCancelled
258
258
  raise
259
259
  rescue ProviderError => e
260
- raise RequestCancelled.new(cancel_controller.reason) if cancel_controller&.cancelled?
260
+ raise RequestCancelled, cancel_controller.reason if cancel_controller&.cancelled?
261
261
 
262
262
  e.attempts = attempts
263
263
  raise if !e.retryable? || current[:streamed]
@@ -267,7 +267,7 @@ module Samagotchi
267
267
 
268
268
  retry_after(e, attempts, delay, on_retry, cancel_controller, current)
269
269
  rescue StandardError => e
270
- raise RequestCancelled.new(cancel_controller.reason) if cancel_controller&.cancelled?
270
+ raise RequestCancelled, cancel_controller.reason if cancel_controller&.cancelled?
271
271
  raise first_token_timeout if current[:first_token_expired]
272
272
  raise unless self.class.network_error?(e)
273
273
 
@@ -432,7 +432,7 @@ module Samagotchi
432
432
 
433
433
  remaining = seconds
434
434
  while remaining.positive?
435
- raise RequestCancelled.new(cancel_controller.reason) if cancel_controller.cancelled?
435
+ raise RequestCancelled, cancel_controller.reason if cancel_controller.cancelled?
436
436
 
437
437
  slice = [remaining, WAIT_TICK].min
438
438
  @sleeper.call(slice)
@@ -53,13 +53,31 @@ module Samagotchi
53
53
  # How much of a 200's non-stream body is read as a possible JSON error.
54
54
  PLAIN_ERROR_LIMIT = 64 * 1024
55
55
  DEFAULT_MODELS_TTL = 60
56
+ # OpenRouter holds a paid request's estimated cost against the balance
57
+ # while it runs: the input plus the output max_tokens allows (a fixed
58
+ # per-request cap when it is unset), and refuses a request with a 402
59
+ # once the running ones fill the budget. A request that names a sane
60
+ # limit holds less. 32k leaves room for any one step's answer.
61
+ OPENROUTER_MAX_TOKENS = 32_768
56
62
 
57
- attr_reader :base_url, :host_name, :api_key_env, :models_ttl, :first_token_timeout
63
+ attr_reader :base_url, :host_name, :api_key_env, :models_ttl, :first_token_timeout, :default_max_tokens
58
64
 
59
65
  # @param entry [HostRegistry::HostEntry]
60
- def self.for(entry, **options)
66
+ def self.for(entry, **)
61
67
  new(base_url: entry.openai_base_url, host_name: entry.name, api_key_env: entry.api_key_env,
62
- remote: entry.remote?, **options)
68
+ remote: entry.remote?, default_max_tokens: (OPENROUTER_MAX_TOKENS if openrouter?(entry.openai_base_url)),
69
+ **)
70
+ end
71
+
72
+ # true when a streamed +payload+ carries a tool-call delta.
73
+ def self.tool_call_delta?(payload) = Assembly.tool_call_delta?(payload)
74
+
75
+ # true when +url+ is OpenRouter's API (openrouter.ai or a subdomain).
76
+ def self.openrouter?(url)
77
+ host = URI(url.to_s).host.to_s.downcase
78
+ host == "openrouter.ai" || host.end_with?(".openrouter.ai")
79
+ rescue URI::Error
80
+ false
63
81
  end
64
82
 
65
83
  # Tool arguments as a Hash; "" is {}, invalid JSON stays a String.
@@ -87,10 +105,13 @@ module Samagotchi
87
105
  # @param remote [Boolean] a provider on the network, not a local server
88
106
  # @param first_token_timeout [Numeric, nil] seconds a streamed answer
89
107
  # may take to show something (LLM::HTTP); nil: no limit
108
+ # @param default_max_tokens [Integer, nil] max_tokens for a request
109
+ # whose options name none (OPENROUTER_MAX_TOKENS); nil sends none
90
110
  def initialize(base_url:, host_name:, api_key_env: nil, stream: true, retries: true, timeout: nil,
91
111
  env: ENV, sleeper: nil, retry_policy: nil, models_ttl: DEFAULT_MODELS_TTL, remote: false,
92
- first_token_timeout: nil, purpose: "chat")
112
+ first_token_timeout: nil, purpose: "chat", default_max_tokens: nil)
93
113
  @remote = remote
114
+ @default_max_tokens = default_max_tokens
94
115
  # What its requests are for, in the log (LLM::HTTP::LOGGED_PURPOSES).
95
116
  @purpose = purpose
96
117
  @first_token_timeout = first_token_timeout
@@ -163,7 +184,7 @@ module Samagotchi
163
184
  models = []
164
185
  after = nil
165
186
  MAX_MODEL_PAGES.times do
166
- uri = URI("#{@base_url}/models#{after ? "?after=#{URI.encode_www_form_component(after)}" : ""}")
187
+ uri = URI("#{@base_url}/models#{"?after=#{URI.encode_www_form_component(after)}" if after}")
167
188
  body = parse_json(@http.fetch(uri, get_request(uri), log_fields: { purpose: "models" }).body, "model list")
168
189
  models.concat(model_entries(body).map { |raw| model_info(raw) })
169
190
  break unless body.is_a?(Hash) && body["has_more"] && body["last_id"]
@@ -193,6 +214,18 @@ module Samagotchi
193
214
  nil
194
215
  end
195
216
 
217
+ def self.served_model(payload)
218
+ model = payload.is_a?(Hash) ? payload["model"] : nil
219
+ model.is_a?(String) && !model.strip.empty? ? model : nil
220
+ end
221
+
222
+ # OpenRouter names the provider that served a request (Baseten,
223
+ # Fireworks, …) in the body's `provider`; other servers send none.
224
+ def self.served_provider(payload)
225
+ provider = payload.is_a?(Hash) ? payload["provider"] : nil
226
+ provider.is_a?(String) && !provider.strip.empty? ? provider : nil
227
+ end
228
+
196
229
  private
197
230
 
198
231
  def timeouts(timeout)
@@ -211,11 +244,12 @@ module Samagotchi
211
244
  stream: @stream
212
245
  }
213
246
  body[:stream_options] = { include_usage: true } if @stream
247
+ body[:max_tokens] = @default_max_tokens if @default_max_tokens
214
248
  unless Array(tools).empty?
215
249
  body[:tools] = tools
216
250
  body[:tool_choice] = "auto"
217
251
  end
218
- # A configured value replaces chi's own (temperature); nil drops it.
252
+ # A configured value replaces chi's own (temperature, max_tokens); nil drops it.
219
253
  body.merge(options || {}).compact
220
254
  end
221
255
 
@@ -259,34 +293,15 @@ module Samagotchi
259
293
  function = call["function"] || {}
260
294
  ToolCall.new(id: call["id"], name: function["name"].to_s, arguments: self.class.parse_arguments(function["arguments"]))
261
295
  end
262
- ChatResponse.new(text: OpenAIChat.drop_leading_newlines(message["content"].to_s),
296
+ ChatResponse.new(text: message["content"].to_s,
263
297
  reasoning: (message["reasoning_content"] || message["reasoning"]).to_s,
264
298
  tool_calls: calls, usage: Usage.from_payload(body) || Usage.none,
265
299
  finish_reason: body.dig("choices", 0, "finish_reason"), model: served_model(body),
266
300
  provider: self.class.served_provider(body))
267
301
  end
268
302
 
269
- # Workaround for Splash 1.0.2/1.1.0, which keeps the "\n\n" the model
270
- # writes after </think> at the start of the answer
271
- # (https://github.com/incoai/splash/issues/254). Remove once Splash
272
- # strips it; llama.cpp already does. Streamed, it applies to the chunks
273
- # until the first text.
274
- def self.drop_leading_newlines(text) = text.sub(/\A[\r\n]+/, "")
275
-
276
- def self.served_model(payload)
277
- model = payload.is_a?(Hash) ? payload["model"] : nil
278
- model.is_a?(String) && !model.strip.empty? ? model : nil
279
- end
280
-
281
303
  def served_model(payload) = self.class.served_model(payload)
282
304
 
283
- # OpenRouter names the provider that served a request (Baseten,
284
- # Fireworks, …) in the body's `provider`; other servers send none.
285
- def self.served_provider(payload)
286
- provider = payload.is_a?(Hash) ? payload["provider"] : nil
287
- provider.is_a?(String) && !provider.strip.empty? ? provider : nil
288
- end
289
-
290
305
  def served_provider(payload) = self.class.served_provider(payload)
291
306
 
292
307
  # The parsed payload of a `data:` line; nil for blank lines, comments
@@ -357,7 +372,7 @@ module Samagotchi
357
372
  listed = raw["supported_parameters"] || raw["capabilities"]
358
373
  return nil unless listed.is_a?(Array)
359
374
 
360
- listed.include?("tools") ? true : nil
375
+ listed.include?("tools") || nil
361
376
  end
362
377
 
363
378
  def cached_models
@@ -405,7 +420,6 @@ module Samagotchi
405
420
  @finish_reason = choice["finish_reason"] if choice["finish_reason"]
406
421
  delta = choice["delta"].is_a?(Hash) ? choice["delta"] : {}
407
422
  content = delta["content"].to_s
408
- content = OpenAIChat.drop_leading_newlines(content) if @text.empty?
409
423
  reasoning = (delta["reasoning_content"] || delta["reasoning"]).to_s
410
424
  @text << content
411
425
  @reasoning << reasoning
@@ -55,7 +55,7 @@ module Samagotchi
55
55
  def content?(content)
56
56
  case content
57
57
  when String then !content.empty?
58
- when Array then content.any? { |part| part.is_a?(Hash) }
58
+ when Array then content.any?(Hash)
59
59
  else false
60
60
  end
61
61
  end
@@ -143,7 +143,7 @@ module Samagotchi
143
143
  def safe_value(key, value)
144
144
  return "[redacted]" if key.match?(SECRET_KEY)
145
145
 
146
- value = value.is_a?(Float) ? value.round(3) : value
146
+ value = value.round(3) if value.is_a?(Float)
147
147
  value.is_a?(String) && value.match?(URL) ? safe_url(value) : value
148
148
  end
149
149
 
@@ -185,7 +185,7 @@ module Samagotchi
185
185
  def build_state
186
186
  options = @options
187
187
  path = options.fetch(:path, :auto)
188
- path = (LogPath.resolve rescue nil) if path == :auto
188
+ path = LogPath.resolve rescue nil if path == :auto
189
189
  level = options[:level] || (Config.get("log.level") rescue nil)
190
190
  level = LEVELS.key?(level.to_s.downcase.to_sym) ? level.to_s.downcase.to_sym : DEFAULT_LEVEL
191
191
  { writer: DebugLog.new(path: path), level: LEVELS.fetch(level), mirror: options[:mirror] ? true : false }