samagotchi 0.2.0 → 0.3.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -1
  3. data/README.md +40 -4
  4. data/bin/chi +86 -19
  5. data/docs/cli.md +108 -5
  6. data/docs/configuration.md +229 -45
  7. data/docs/desktop.md +6 -0
  8. data/docs/hooks.md +126 -5
  9. data/docs/plugins.md +68 -2
  10. data/docs/releasing.md +18 -8
  11. data/docs/sessions.md +30 -4
  12. data/lib/samagotchi/answer_display.rb +95 -0
  13. data/lib/samagotchi/archive_store.rb +90 -0
  14. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  15. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  16. data/lib/samagotchi/bootstrap_command.rb +347 -0
  17. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  18. data/lib/samagotchi/bridge/turn_accumulator.rb +14 -3
  19. data/lib/samagotchi/bridge.rb +9 -0
  20. data/lib/samagotchi/bridge_client.rb +6 -2
  21. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  22. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +358 -0
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  25. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  26. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  27. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  29. data/lib/samagotchi/client.rb +72 -13
  30. data/lib/samagotchi/config.rb +196 -36
  31. data/lib/samagotchi/desktop/macos/ChiRunner.swift +13 -7
  32. data/lib/samagotchi/desktop/macos/Panel.swift +71 -19
  33. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  34. data/lib/samagotchi/engine.rb +233 -36
  35. data/lib/samagotchi/guardrails/approval.rb +9 -0
  36. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  37. data/lib/samagotchi/guardrails.rb +1 -0
  38. data/lib/samagotchi/hooks/registry.rb +24 -5
  39. data/lib/samagotchi/host_registry.rb +4 -3
  40. data/lib/samagotchi/idle_recap.rb +5 -1
  41. data/lib/samagotchi/kernel_loop.rb +47 -15
  42. data/lib/samagotchi/llm/chat_loop.rb +59 -20
  43. data/lib/samagotchi/llm/errors.rb +21 -3
  44. data/lib/samagotchi/llm/http.rb +42 -13
  45. data/lib/samagotchi/llm/openai_chat.rb +12 -4
  46. data/lib/samagotchi/log_subscriber.rb +18 -3
  47. data/lib/samagotchi/model_profile.rb +1 -1
  48. data/lib/samagotchi/plugin/context.rb +22 -1
  49. data/lib/samagotchi/plugin/sessions.rb +3 -1
  50. data/lib/samagotchi/reply_wait.rb +126 -0
  51. data/lib/samagotchi/sampling_settings.rb +58 -0
  52. data/lib/samagotchi/self_report.rb +1 -0
  53. data/lib/samagotchi/send_command.rb +153 -7
  54. data/lib/samagotchi/session.rb +52 -11
  55. data/lib/samagotchi/session_archive_command.rb +107 -0
  56. data/lib/samagotchi/session_commands.rb +11 -2
  57. data/lib/samagotchi/session_manager.rb +114 -9
  58. data/lib/samagotchi/session_metrics.rb +222 -106
  59. data/lib/samagotchi/steer.rb +72 -0
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
  61. data/lib/samagotchi/terminal_ui/event_renderer.rb +13 -8
  62. data/lib/samagotchi/terminal_ui/formatting.rb +31 -8
  63. data/lib/samagotchi/terminal_ui/input_support.rb +3 -0
  64. data/lib/samagotchi/terminal_ui.rb +77 -4
  65. data/lib/samagotchi/tool_activity.rb +3 -1
  66. data/lib/samagotchi/tools/builtins.rb +15 -4
  67. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  68. data/lib/samagotchi/tools/execute.rb +52 -14
  69. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  70. data/lib/samagotchi/tools/task_wait.rb +27 -3
  71. data/lib/samagotchi/turn_note.rb +60 -6
  72. data/lib/samagotchi/version.rb +1 -1
  73. data/lib/samagotchi/vision_support.rb +2 -6
  74. data/lib/samagotchi/web/app.rb +88 -4
  75. data/lib/samagotchi/web/public/activity.js +10 -1
  76. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  77. data/lib/samagotchi/web/public/annotations.js +13 -0
  78. data/lib/samagotchi/web/public/app.js +437 -88
  79. data/lib/samagotchi/web/public/card.js +5 -3
  80. data/lib/samagotchi/web/public/chat_view.js +10 -1
  81. data/lib/samagotchi/web/public/copy.js +20 -4
  82. data/lib/samagotchi/web/public/ctx.js +15 -0
  83. data/lib/samagotchi/web/public/data.js +21 -6
  84. data/lib/samagotchi/web/public/format.js +9 -0
  85. data/lib/samagotchi/web/public/index.html +38 -2
  86. data/lib/samagotchi/web/public/notify.js +175 -0
  87. data/lib/samagotchi/web/public/question_card.js +2 -1
  88. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  89. data/lib/samagotchi/web/public/timing.js +39 -14
  90. data/lib/samagotchi/web/public/turn_events.js +46 -0
  91. data/lib/samagotchi/web/public/turn_view.js +47 -7
  92. data/lib/samagotchi/web/server.rb +8 -4
  93. data/lib/samagotchi/web/session_hub.rb +2 -1
  94. data/lib/samagotchi/web/session_summary.rb +24 -1
  95. data/lib/samagotchi/worker.rb +11 -0
  96. metadata +20 -1
@@ -15,14 +15,15 @@ module Samagotchi
15
15
  takes_settings ? klass.new(settings.is_a?(Hash) ? settings : {}) : klass.new
16
16
  end
17
17
 
18
- # What a hook can do beyond reading its event: the Engine's three
18
+ # What a hook can do beyond reading its event: the Engine's
19
19
  # callables, each given the hook's label. +notify+ takes
20
20
  # (text:, level:, hook:) and shows one line to the user; +ask_user+
21
21
  # takes (question:, options:, header:, allow_freeform:, hook:) and
22
22
  # returns the answer hash or nil; +stop_turn+ takes (reason:, hook:) and
23
- # cancels the running turn (true when it did). A registry without one
24
- # gives hooks no-op helpers.
25
- Runtime = Struct.new(:notify, :ask_user, :stop_turn, keyword_init: true)
23
+ # cancels the running turn (true when it did); +steer+ takes (text:,
24
+ # hook:) and puts the text into the running turn (Engine#steer, true
25
+ # when queued). A registry without one gives hooks no-op helpers.
26
+ Runtime = Struct.new(:notify, :ask_user, :stop_turn, :steer, keyword_init: true)
26
27
 
27
28
  # A thread-safe registry for named hook callbacks.
28
29
  #
@@ -172,6 +173,12 @@ module Samagotchi
172
173
  # event[:stop_turn].call(reason) -> true when the turn was cancelled;
173
174
  # in a before_tool_call event it also denies the call; from
174
175
  # after_turn / session_end it does nothing (false)
176
+ # event[:steer].call(text) -> true when the text was queued for the
177
+ # running turn's next boundary (its own user message, source: the
178
+ # hook's bundle); from after_turn / session_end false
179
+ # and after_turn's fire site adds one more (AnswerDisplay):
180
+ # event[:present].call { |text| new_text } -> the answer's display
181
+ # text after the call (chained in hook order), nil without an answer
175
182
  #
176
183
  # @param name [Symbol] the hook name to fire
177
184
  # @param event [Hash] the event payload (may be mutated by hooks)
@@ -191,6 +198,13 @@ module Samagotchi
191
198
  end
192
199
  end
193
200
 
201
+ # Whether a #fire of +name+ would call any hook.
202
+ # @param name [Symbol]
203
+ # @return [Boolean]
204
+ def any?(name)
205
+ !ordered_procs(name).empty?
206
+ end
207
+
194
208
  # Like #fire, and yields the event after each hook (a raising one
195
209
  # too), so the caller can fold what that hook did before the next one
196
210
  # runs (the guardrail gate keeps a deny sticky this way).
@@ -235,7 +249,7 @@ module Samagotchi
235
249
  end
236
250
  end
237
251
 
238
- # The three helpers, once per fire; a fire site's own keys stay.
252
+ # The helpers, once per fire; a fire site's own keys stay.
239
253
  def with_runtime(event)
240
254
  return unless event.is_a?(Hash)
241
255
 
@@ -255,6 +269,11 @@ module Samagotchi
255
269
  end
256
270
  @runtime&.stop_turn&.call(reason: reason.to_s, hook: event[:hook]) ? true : false
257
271
  }
272
+ event[:steer] ||= lambda { |text|
273
+ next false if TURN_OVER_EVENTS.include?(event[:type])
274
+
275
+ @runtime&.steer&.call(text: text.to_s, hook: event[:hook]) ? true : false
276
+ }
258
277
  end
259
278
  end
260
279
  end
@@ -28,9 +28,10 @@ module Samagotchi
28
28
  # come from it); api_key_env: the variable holding the host's API key;
29
29
  # profile: the configured prompt profile name, if any;
30
30
  # first_token_timeout: the configured first-token limit (see #first_token_limit);
31
- # vision: the configured true/false (VisionSupport), nil when unset.
31
+ # vision: the configured true/false (VisionSupport), nil when unset;
32
+ # sampling: the configured request parameters (SamplingSettings), nil when unset.
32
33
  HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :profile,
33
- :first_token_timeout, :vision, keyword_init: true) do
34
+ :first_token_timeout, :vision, :sampling, keyword_init: true) do
34
35
  # Talks the OpenAI chat API (the chat loop); nil and raw apis use the
35
36
  # raw-prompt loop.
36
37
  def chat? = api == :openai
@@ -86,7 +87,7 @@ module Samagotchi
86
87
  entry = HostEntry.new(name: key.to_s.downcase, host: cfg[:host], port: cfg[:port].to_i, transport: transport,
87
88
  api: cfg[:api]&.to_sym, scheme: cfg[:scheme], url: cfg[:url], api_key_env: cfg[:api_key_env],
88
89
  profile: cfg[:profile], first_token_timeout: cfg[:first_token_timeout],
89
- vision: cfg[:vision])
90
+ vision: cfg[:vision], sampling: cfg[:sampling])
90
91
  entry.client = Client.new(host: cfg[:host], port: cfg[:port], transport: transport, scheme: cfg[:scheme],
91
92
  first_token_timeout: entry.first_token_limit, name: entry.name)
92
93
  @entries[entry.name] = entry
@@ -7,6 +7,7 @@ require "time"
7
7
 
8
8
  require_relative "idle_client"
9
9
  require_relative "output_formatter"
10
+ require_relative "steer"
10
11
  require_relative "recap_store"
11
12
  require_relative "log"
12
13
 
@@ -60,6 +61,9 @@ module Samagotchi
60
61
 
61
62
  case message["role"]
62
63
  when "user"
64
+ # A plugin's steer is a prod to the model, not what the user said.
65
+ next nil if Steer.steer?(message)
66
+
63
67
  # An image is a line naming it (refs only, never its bytes).
64
68
  [message["content"].to_s, *image_lines(message["images"])].reject(&:empty?).join("\n")
65
69
  when "model", "assistant"
@@ -411,7 +415,7 @@ module Samagotchi
411
415
  parsed = safe_parse(@engine.messages_json_for_recap)
412
416
  return :too_short if parsed.nil?
413
417
 
414
- user_turns = parsed.count { |message| message.is_a?(Hash) && message["role"] == "user" }
418
+ user_turns = parsed.count { |message| Steer.prompt?(message) }
415
419
  if parsed.empty? || user_turns < @min_user_turns
416
420
  drop_stale(parsed)
417
421
  return :too_short
@@ -10,13 +10,16 @@ require_relative "prompt_literal_guard"
10
10
  require_relative "client"
11
11
  require_relative "llm/errors"
12
12
  require_relative "log"
13
+ require_relative "empty_answer_retry"
13
14
  require_relative "hooks"
14
15
  require_relative "pending_input_queue"
16
+ require_relative "steer"
15
17
  require_relative "thought_stream_splitter"
16
18
  require_relative "tools/builtins"
17
19
  require_relative "muted_memories"
18
20
  require_relative "tool_activity"
19
21
  require_relative "tool_runner"
22
+ require_relative "answer_display"
20
23
 
21
24
  module Samagotchi
22
25
  # The KernelLoop drives the model ↔ tool interaction cycle.
@@ -173,6 +176,9 @@ module Samagotchi
173
176
  # The turn's VisionContext (images: capability, files, limits), set by
174
177
  # the Engine per turn; nil sends no images (placeholders instead).
175
178
  attr_accessor :vision
179
+ # The turn's request parameters (SamplingSettings.for), set by the Engine
180
+ # per turn; empty or nil sends none.
181
+ attr_accessor :sampling
176
182
  # Tools::Peers (or the Engine's live view of it): the session
177
183
  # list_sessions and send_note speak for; nil outside a session.
178
184
  attr_accessor :peers
@@ -206,6 +212,9 @@ module Samagotchi
206
212
  tool_activity = []
207
213
  qwen_recovery_attempts = 0
208
214
  qwen_partial_tool_call = nil
215
+ empty_retries = 0
216
+ empty_retry_limit = EmptyAnswerRetry.limit
217
+ @retry_generation = false
209
218
  context_status = nil
210
219
  stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
211
220
  partial_assistant_buffer = +""
@@ -299,7 +308,7 @@ module Samagotchi
299
308
  # Fire :after_generation hook (after LLM returns, before tool parse),
300
309
  # with a read-only copy of the conversation as sent.
301
310
  after_gen_event = { type: :after_generation, iteration: iteration_index + 1, response: response,
302
- messages: conversation.map(&:dup).freeze }
311
+ messages: AnswerDisplay.strip_all(conversation).map(&:dup).freeze }
303
312
  fire_hook(:after_generation, after_gen_event) if @hooks
304
313
  conversation << { role: "model", content: response }
305
314
 
@@ -322,7 +331,25 @@ module Samagotchi
322
331
 
323
332
  pending_tool_calls = false
324
333
  answer = -> { PromptLiteralGuard.restore(strip_thought_blocks(response), profile: @profile) }
325
- unless inject_pending_input!(conversation, pending_input, on_stream_event, iteration_index + 1, cancel_controller, answer: answer)
334
+ empty = strip_thought_blocks(response.to_s).strip.empty?
335
+ retry_empty = empty && empty_retries < empty_retry_limit && !cancel_controller&.cancelled?
336
+ # The empty generation goes (its thinking would be sent again and
337
+ # prime the same loop); an empty answer that will be retried is no
338
+ # answer site, so a plugin's steer joins the retry.
339
+ conversation.pop if retry_empty
340
+ unless inject_pending_input!(conversation, pending_input, on_stream_event, iteration_index + 1, cancel_controller,
341
+ answer: retry_empty ? nil : answer)
342
+ if retry_empty
343
+ empty_retries += 1
344
+ @retry_generation = true
345
+ emit_stream_event(on_stream_event, type: :empty_answer_retry, iteration: iteration_index + 1,
346
+ attempt: empty_retries, of: empty_retry_limit,
347
+ thinking_chars: thinking_chars(response.to_s, streamed_thinking))
348
+ conversation << TurnNote.empty_retry
349
+ next
350
+ end
351
+ # The Engine's TurnNote.empty says it all: the spent nudge goes.
352
+ drop_last_empty_retry!(conversation) if empty && empty_retries.positive?
326
353
  break
327
354
  end
328
355
  # Queued steering keeps the turn going: loop again so the model
@@ -437,6 +464,8 @@ module Samagotchi
437
464
  # append them as ONE merged user message at the conversation tail and emit
438
465
  # :pending_input_merged. Tail-append only: head mutation would invalidate
439
466
  # the server-side prefix KV cache. Returns true when a message was injected.
467
+ # A plugin's steers (Steer) follow the user's message, each its own
468
+ # message; after an answer the Engine's drain has already dropped them.
440
469
  # After a cancel the input stays queued: it runs as the next turn instead
441
470
  # of dying with this one. +answer+ (a proc, called only on a merge) is the
442
471
  # answer the merge follows: the UIs show it, the turn summary has only the
@@ -445,24 +474,16 @@ module Samagotchi
445
474
  return false unless pending_input
446
475
  return false if cancel_controller&.cancelled?
447
476
 
448
- lines = begin
449
- pending_input.call
450
- rescue StandardError
451
- nil
452
- end
453
- return false if lines.nil? || lines.empty?
454
-
455
- content = lines.map { |line| line.to_s.strip }.reject(&:empty?).join("\n\n")
456
- return false if content.empty?
477
+ merge = Steer.merge(Steer.drain(pending_input, at_answer: !answer.nil?))
478
+ return false if merge.empty?
457
479
 
458
480
  answer = answer.call.to_s if answer
459
- conversation << { role: "user", content: content }
481
+ conversation.concat(merge.messages)
460
482
  emit_stream_event(
461
483
  on_stream_event,
462
484
  type: :pending_input_merged,
463
485
  iteration: iteration,
464
- count: lines.length,
465
- content: content,
486
+ **merge.event_fields,
466
487
  answer: answer.to_s.strip.empty? ? nil : answer
467
488
  )
468
489
  true
@@ -524,6 +545,9 @@ module Samagotchi
524
545
  kwargs[:n_predict] = n_predict if n_predict && client_supports_keyword?(:n_predict)
525
546
  resolved_model_name = completion_model_name(model_name)
526
547
  kwargs[:model] = resolved_model_name if resolved_model_name && client_supports_keyword?(:model)
548
+ sampling = @retry_generation ? EmptyAnswerRetry.sampling(@sampling) : @sampling
549
+ @retry_generation = false
550
+ kwargs[:sampling] = sampling if sampling && !sampling.empty? && client_supports_keyword?(:sampling)
527
551
  kwargs
528
552
  end
529
553
 
@@ -862,7 +886,9 @@ module Samagotchi
862
886
  def sanitize_history(messages)
863
887
  messages.map do |m|
864
888
  if m[:role] == "model"
865
- { role: m[:role], content: strip_thought_blocks(m[:content].to_s) }
889
+ # `display` rides along so the stored conversation keeps it; the
890
+ # prompt formatter never reads it (AnswerDisplay).
891
+ { role: m[:role], content: strip_thought_blocks(m[:content].to_s), display: m[:display] }.compact
866
892
  else
867
893
  m.dup
868
894
  end
@@ -882,6 +908,12 @@ module Samagotchi
882
908
  messages.map(&:dup)
883
909
  end
884
910
 
911
+ def drop_last_empty_retry!(conversation)
912
+ nudge = TurnNote.empty_retry
913
+ index = conversation.rindex { |entry| entry[:kind] == nudge[:kind] && entry[:content] == nudge[:content] }
914
+ conversation.delete_at(index) if index
915
+ end
916
+
885
917
  def last_model_content(conversation)
886
918
  message = conversation.reverse.find { |entry| entry[:role] == "model" }
887
919
  message ? message[:content].to_s : ""
@@ -8,12 +8,15 @@ require_relative "usage"
8
8
  require_relative "openai_chat"
9
9
  require_relative "native_tool_normalizer"
10
10
  require_relative "../kernel_loop"
11
+ require_relative "../answer_display"
11
12
  require_relative "../context_window"
12
13
  require_relative "../context_note"
13
14
  require_relative "../tool_runner"
14
15
  require_relative "../tool_declarations"
15
16
  require_relative "../vision_context"
16
17
  require_relative "../log"
18
+ require_relative "../empty_answer_retry"
19
+ require_relative "../turn_note"
17
20
 
18
21
  module Samagotchi
19
22
  module LLM
@@ -131,6 +134,11 @@ module Samagotchi
131
134
  @kernel.respond_to?(:vision) ? @kernel.vision : nil
132
135
  end
133
136
 
137
+ # The turn's request parameters (the Engine sets them on the kernel).
138
+ def sampling
139
+ @kernel.respond_to?(:sampling) ? @kernel.sampling || {} : {}
140
+ end
141
+
134
142
  def strip_model_thought(text)
135
143
  @kernel.respond_to?(:strip_model_thought) ? @kernel.strip_model_thought(text) : text
136
144
  end
@@ -183,6 +191,7 @@ module Samagotchi
183
191
  message[:thinking] = entry[:thinking] if entry[:thinking].is_a?(String) && !entry[:thinking].empty?
184
192
  message[:tool_params] = entry[:tool_params] if entry[:tool_params]
185
193
  message[:tool_labels] = entry[:tool_labels] if entry[:tool_labels]
194
+ message[AnswerDisplay::KEY] = entry[AnswerDisplay::KEY] if entry[AnswerDisplay::KEY]
186
195
  ContextNote::KEYS.each { |key| message[key] = entry[key] if entry.key?(key) }
187
196
  message
188
197
  end
@@ -268,6 +277,8 @@ module Samagotchi
268
277
  # usage: the text, and each image's estimate (not its base64).
269
278
  @prompt_text = conversation.sum("") { |entry| entry[:content].to_s }
270
279
  @image_tokens = ImagePlan.estimated_tokens(conversation)
280
+ @empty_retries = 0
281
+ @empty_retry_limit = EmptyAnswerRetry.limit
271
282
  end
272
283
 
273
284
  EMPTY_ANSWER = "(the model returned an empty answer)"
@@ -288,7 +299,12 @@ module Samagotchi
288
299
  # Kept before a merge too: the model answers the merged line
289
300
  # knowing what it just said.
290
301
  @conversation << with_thinking({ role: "model", content: last_text }, response) unless last_text.empty?
291
- next if inject_pending_input(iteration, answer: last_text)
302
+ retry_empty = last_text.empty? && retry_empty_answer?(iteration, response)
303
+ # An empty answer that will be retried is no answer site: a
304
+ # plugin's steer joins the retry instead of being dropped, and
305
+ # queued input (a user's line, a steer) goes in place of the nudge.
306
+ next if inject_pending_input(iteration, answer: retry_empty ? nil : last_text)
307
+ next if retry_empty && nudge_empty_answer(iteration, response)
292
308
 
293
309
  # Shown, not saved: an empty answer (content "" + stop, seen from
294
310
  # a remote host) would otherwise end the turn with nothing.
@@ -310,6 +326,31 @@ module Samagotchi
310
326
 
311
327
  private
312
328
 
329
+ # A retry is left, and the answer wasn't cut short by a full context
330
+ # (a length stop while thinking is retried: a thinking loop cut by
331
+ # the provider's output cap, not a full window).
332
+ def retry_empty_answer?(iteration, response)
333
+ return false if @empty_retries >= @empty_retry_limit || @cancel_controller&.cancelled?
334
+
335
+ if response.finish_reason.to_s == "length" &&
336
+ EmptyAnswerRetry.context_full?(response.usage&.total_tokens, @window&.tokens)
337
+ Log.info(:turn, "empty_answer_not_retried", iteration: iteration, why: "context full")
338
+ return false
339
+ end
340
+ true
341
+ end
342
+
343
+ # The hidden nudge before the next generation (EmptyAnswerRetry),
344
+ # which runs at the retry temperature. Returns true.
345
+ def nudge_empty_answer(iteration, response)
346
+ @empty_retries += 1
347
+ @retry_generation = true
348
+ emit(type: :empty_answer_retry, iteration: iteration, attempt: @empty_retries, of: @empty_retry_limit,
349
+ finish_reason: response.finish_reason, thinking_chars: response.reasoning.to_s.length)
350
+ @conversation << TurnNote.empty_retry
351
+ true
352
+ end
353
+
313
354
  # The host's reasoning, kept on the model message as +thinking+ for
314
355
  # the web turn view's reload (the whole of it, as the live view
315
356
  # shows). Only saved: #assistant_message builds the wire message from
@@ -322,14 +363,17 @@ module Samagotchi
322
363
  # One streamed request. Returns [response, nil], or [reason, partial
323
364
  # text] when it was cancelled.
324
365
  def generate(iteration)
325
- window = @loop.context_window(@model_name)
366
+ window = @window = @loop.context_window(@model_name)
367
+ options = @loop.sampling
368
+ options = EmptyAnswerRetry.sampling(options) if @retry_generation
369
+ @retry_generation = false
326
370
  emit(type: :generation_started, iteration: iteration, context_window_tokens: window&.tokens,
327
371
  context_window_source: window&.source)
328
372
  @loop.fire_hook(:before_generation, { type: :before_generation, iteration: iteration })
329
373
  streamed = +""
330
374
  response = @loop.adapter.chat(
331
375
  messages: @loop.wire_messages(@conversation), tools: @loop.tool_definitions, model: @model_name,
332
- cancel_controller: @cancel_controller, session_id: @loop.session_id,
376
+ cancel_controller: @cancel_controller, session_id: @loop.session_id, options: options,
333
377
  on_delta: lambda { |content:, reasoning:, payload:|
334
378
  streamed << content
335
379
  emit(type: :generation_chunk, iteration: iteration, content: reasoning + content, text: content,
@@ -340,10 +384,10 @@ module Samagotchi
340
384
  record_context_status(response.usage, window)
341
385
  emit(type: :generation_completed, iteration: iteration, content_length: response.text.length,
342
386
  thinking_chars: response.reasoning.to_s.length, served_model: response.model,
343
- requested_model: @model_name)
387
+ requested_model: @model_name, finish_reason: response.finish_reason)
344
388
  dump_response(response, iteration)
345
389
  @loop.fire_hook(:after_generation, { type: :after_generation, iteration: iteration, response: response.text,
346
- messages: @conversation.map(&:dup).freeze })
390
+ messages: AnswerDisplay.strip_all(@conversation).map(&:dup).freeze })
347
391
  [response, nil]
348
392
  rescue RequestCancelled => e
349
393
  [e.reason, streamed]
@@ -390,26 +434,21 @@ module Samagotchi
390
434
  emit(type: :tool_dispatch_completed, iteration: iteration, call_count: tool_calls.length)
391
435
  end
392
436
 
393
- # Queued steering joins the conversation as one user message.
394
- # Returns true when there was any. After a cancel it stays queued, so
395
- # it runs as the next turn instead of dying with this one. +answer+ is
396
- # the answer the merge follows, for the UIs.
437
+ # Queued steering joins the conversation as one user message, a
438
+ # plugin's steers each as its own after it (Steer). Returns true when
439
+ # there was any. After a cancel it stays queued, so it runs as the
440
+ # next turn instead of dying with this one. +answer+ is the answer the
441
+ # merge follows, for the UIs; given, the drain is told it is the
442
+ # after-answer site (plugin steers are dropped there).
397
443
  def inject_pending_input(iteration, answer: nil)
398
444
  return false unless @pending_input
399
445
  return false if @cancel_controller&.cancelled?
400
446
 
401
- lines = begin
402
- @pending_input.call
403
- rescue StandardError
404
- nil
405
- end
406
- return false if lines.nil? || lines.empty?
407
-
408
- content = lines.map { |line| line.to_s.strip }.reject(&:empty?).join("\n\n")
409
- return false if content.empty?
447
+ merge = Steer.merge(Steer.drain(@pending_input, at_answer: !answer.nil?))
448
+ return false if merge.empty?
410
449
 
411
- @conversation << { role: "user", content: content }
412
- emit(type: :pending_input_merged, iteration: iteration, count: lines.length, content: content,
450
+ @conversation.concat(merge.messages)
451
+ emit(type: :pending_input_merged, iteration: iteration, **merge.event_fields,
413
452
  answer: answer.to_s.empty? ? nil : answer)
414
453
  true
415
454
  end
@@ -19,7 +19,8 @@ module Samagotchi
19
19
  # (#kind): the UIs print one line per kind, and #retryable? says whether
20
20
  # asking again could help.
21
21
  #
22
- # ConnectionError no answer (refused, reset, timeout); RetryExhausted
22
+ # ConnectionError no answer (refused, reset, timeout); RetryExhausted,
23
+ # ConnectionRefused
23
24
  # RateLimited 429, with retry_after when the server says
24
25
  # ServerError 5xx or a server's error event mid-stream
25
26
  # AuthError 401/403, or an API key variable that is not set
@@ -98,12 +99,16 @@ module Samagotchi
98
99
  class BadRequest < ProviderError
99
100
  TOOLS_HINT = "this model can't use tools, and chi needs them: pick another model (/model) or host"
100
101
 
101
- def initialize(message = nil, context_overflow: false, tools_unsupported: false, **options)
102
+ # @param hint [String, nil] what to try, added to the summary
103
+ def initialize(message = nil, context_overflow: false, tools_unsupported: false, hint: nil, **options)
102
104
  @context_overflow = context_overflow
103
105
  @tools_unsupported = tools_unsupported
106
+ @hint = hint
104
107
  super(message, **options)
105
108
  end
106
109
 
110
+ attr_reader :hint
111
+
107
112
  # The prompt is larger than the model's context window.
108
113
  def context_overflow? = @context_overflow
109
114
 
@@ -119,7 +124,7 @@ module Samagotchi
119
124
  elsif tools_unsupported?
120
125
  "host #{host} rejected the request: #{tools_detail}; #{TOOLS_HINT}"
121
126
  else
122
- "host #{host} rejected the request: #{detail}"
127
+ "host #{host} rejected the request: #{detail}#{"; #{hint}" if hint}"
123
128
  end
124
129
  end
125
130
 
@@ -174,6 +179,19 @@ module Samagotchi
174
179
  def summary = "network error after #{attempts} attempts (host #{host}: #{last_error.class})"
175
180
  end
176
181
 
182
+ # A refused connection: nothing listens at the host's address. Not
183
+ # retried (a server that isn't running rarely starts within the backoff).
184
+ class ConnectionRefused < ConnectionError
185
+ attr_reader :address
186
+
187
+ def initialize(host:, address: nil, attempts: 1)
188
+ @address = address
189
+ super("#{host}: connection refused#{" at #{address}" if address}", host: host, retryable: false, attempts: attempts)
190
+ end
191
+
192
+ def summary = "can't reach host #{host}#{" at #{address}" if address} (connection refused) — is the server running?"
193
+ end
194
+
177
195
  # A stream that showed nothing (no text, reasoning or tool call) within
178
196
  # the host's first-token limit. A queued request can stay open for many
179
197
  # minutes on keep-alive comments alone, which reset the read timeout.
@@ -176,15 +176,18 @@ module Samagotchi
176
176
  private :stream_attempt
177
177
 
178
178
  # Send +request+ and return the response with its body read.
179
- # @param retries [Boolean] false: one attempt, network errors raised as is
179
+ # @param retries [Boolean] false: one attempt, network errors raised as
180
+ # is (Net::HTTP's own silent retry of an idempotent GET included)
180
181
  # @param check_status [Boolean] false: return an error response instead
181
182
  # of raising its ProviderError
183
+ # @raise [RequestCancelled] when +cancel_controller+ cancels (with or
184
+ # without retries)
182
185
  def fetch(uri, request, retries: true, check_status: true, open_timeout: nil, read_timeout: nil,
183
186
  cancel_controller: nil, log_fields: {})
184
187
  identify(request)
185
188
  current = new_attempt_state(uri, request, log_fields, stream: false)
186
189
  attempt = lambda do |state|
187
- start(uri, open_timeout: open_timeout, read_timeout: read_timeout) do |http|
190
+ start(uri, open_timeout: open_timeout, read_timeout: read_timeout, max_retries: retries ? nil : 0) do |http|
188
191
  state[:http] = http
189
192
  http.request(request).tap do |response|
190
193
  state[:status] = response.code.to_i
@@ -193,9 +196,15 @@ module Samagotchi
193
196
  end
194
197
  end
195
198
  logged(current) do
196
- next attempt.call(current.merge!(attempts: 1)) unless retries
199
+ next with_retries(cancel_controller, nil, nil, current, &attempt) if retries
197
200
 
198
- with_retries(cancel_controller, nil, nil, current, &attempt)
201
+ cancellable(cancel_controller, current) do
202
+ attempt.call(current.merge!(attempts: 1))
203
+ rescue StandardError => e
204
+ raise RequestCancelled.new(cancel_controller.reason) if !e.is_a?(RequestCancelled) && cancel_controller&.cancelled?
205
+
206
+ raise
207
+ end
199
208
  end
200
209
  end
201
210
 
@@ -205,8 +214,9 @@ module Samagotchi
205
214
  request["User-Agent"] = Samagotchi::USER_AGENT
206
215
  end
207
216
 
208
- def start(uri, open_timeout: nil, read_timeout: nil, &block)
217
+ def start(uri, open_timeout: nil, read_timeout: nil, max_retries: nil, &block)
209
218
  options = { open_timeout: open_timeout || @open_timeout, read_timeout: read_timeout || @read_timeout }
219
+ options[:max_retries] = max_retries if max_retries
210
220
  options[:use_ssl] = true if uri.scheme == "https"
211
221
  Net::HTTP.start(uri.host, uri.port, **options, &block)
212
222
  end
@@ -215,10 +225,29 @@ module Samagotchi
215
225
  # errors. The block gets a hash to put the attempt's Net::HTTP in, so a
216
226
  # cancel can reach its socket.
217
227
  def with_retries(cancel_controller, on_retry, on_network_error, current = { mutex: Mutex.new })
228
+ cancellable(cancel_controller, current) do
229
+ retrying(cancel_controller, on_retry, on_network_error, current) { |state| yield(state) }
230
+ end
231
+ end
232
+
233
+ # Runs the block with +cancel_controller+ able to abort it: a cancel
234
+ # closes the socket in current[:http] (or raises RequestCancelled in
235
+ # this thread while there is none).
236
+ def cancellable(cancel_controller, current)
218
237
  requesting_thread = Thread.current
219
238
  listener_id = cancel_controller&.on_cancel { |reason| abort_request(current, requesting_thread, reason) }
220
239
  raise RequestCancelled.new(cancel_controller.reason) if cancel_controller&.cancelled?
221
240
 
241
+ yield
242
+ ensure
243
+ # A cancel can call the listener after remove_listener returned (the
244
+ # controller calls listeners outside its lock); :done keeps it from
245
+ # raising into this thread once the request is over.
246
+ current[:mutex].synchronize { current[:done] = true }
247
+ cancel_controller&.remove_listener(listener_id)
248
+ end
249
+
250
+ def retrying(cancel_controller, on_retry, on_network_error, current)
222
251
  attempts = 0
223
252
  loop do
224
253
  attempts += 1
@@ -243,6 +272,12 @@ module Samagotchi
243
272
  raise unless self.class.network_error?(e)
244
273
 
245
274
  on_network_error&.call(e)
275
+ # Nothing is listening: asking again rarely helps, so it fails
276
+ # at once and says what to check.
277
+ if e.is_a?(Errno::ECONNREFUSED)
278
+ raise ConnectionRefused.new(host: @label, address: current[:address], attempts: attempts)
279
+ end
280
+
246
281
  delay = current[:streamed] ? nil : @retry_policy.delay_for(attempts)
247
282
  raise RetryExhausted.new(attempts: attempts, last_error: e, label: @label) if delay.nil?
248
283
 
@@ -251,12 +286,6 @@ module Samagotchi
251
286
  current.delete(:http)
252
287
  end
253
288
  end
254
- ensure
255
- # A cancel can call the listener after remove_listener returned (the
256
- # controller calls listeners outside its lock); :done keeps it from
257
- # raising into this thread once the request is over.
258
- current[:mutex].synchronize { current[:done] = true }
259
- cancel_controller&.remove_listener(listener_id)
260
289
  end
261
290
 
262
291
  # Runs one attempt (the block) under the first-token limit: a watchdog
@@ -302,7 +331,7 @@ module Samagotchi
302
331
  # closes, status, timings) and what its log line says.
303
332
  def new_attempt_state(uri, request, log_fields, stream:)
304
333
  purpose = log_fields[:purpose]&.to_s
305
- { mutex: Mutex.new, started_at: monotonic_now, stream: stream,
334
+ { mutex: Mutex.new, started_at: monotonic_now, stream: stream, address: "#{uri.host}:#{uri.port}",
306
335
  level: LOGGED_PURPOSES.include?(purpose) ? :info : :debug,
307
336
  log: { host: @label, method: request.method, url: log_url(uri),
308
337
  **log_fields.compact } }
@@ -326,7 +355,7 @@ module Samagotchi
326
355
  ms: elapsed_ms(current), attempts: retried(current))
327
356
  result
328
357
  rescue RequestCancelled => e
329
- Log.info(:http, "cancelled", **log_base(current), ms: elapsed_ms(current), reason: e.reason&.to_s)
358
+ Log.public_send(current[:level], :http, "cancelled", **log_base(current), ms: elapsed_ms(current), reason: e.reason&.to_s)
330
359
  raise
331
360
  rescue StandardError => e
332
361
  failure_level = current[:level] == :info ? :error : :debug
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "uri"
5
5
  require_relative "../config"
6
+ require_relative "../sampling_settings"
6
7
  require_relative "errors"
7
8
  require_relative "http"
8
9
  require_relative "usage"
@@ -111,9 +112,9 @@ module Samagotchi
111
112
  # @return [ChatResponse]
112
113
  def chat(messages:, model:, tools: [], cancel_controller: nil, on_delta: nil, on_retry: nil, options: {},
113
114
  session_id: nil)
114
- request = post_request("#{@base_url}/chat/completions", request_body(messages, tools, model, options),
115
- session_id: session_id)
116
- log_fields = { model: model, purpose: @purpose }
115
+ body = request_body(messages, tools, model, options)
116
+ request = post_request("#{@base_url}/chat/completions", body, session_id: session_id)
117
+ log_fields = { model: model, purpose: @purpose, sampling: sampling_summary(body) }
117
118
  return chat_once(request, cancel_controller, log_fields) unless @stream
118
119
 
119
120
  assembly = Assembly.new
@@ -209,7 +210,14 @@ module Samagotchi
209
210
  body[:tools] = tools
210
211
  body[:tool_choice] = "auto"
211
212
  end
212
- body.merge(options || {})
213
+ # A configured value replaces chi's own (temperature); nil drops it.
214
+ body.merge(options || {}).compact
215
+ end
216
+
217
+ # The body's fields beyond the conversation and the stream, for the
218
+ # log line: "temperature=0.6 presence_penalty=1.5".
219
+ def sampling_summary(body)
220
+ SamplingSettings.log_text(body.except(:model, :messages, :stream, :stream_options, :tools, :tool_choice))
213
221
  end
214
222
 
215
223
  # A String stays a String; an Array of parts passes as given.