samagotchi 0.2.0 → 0.4.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -1
  3. data/README.md +56 -4
  4. data/bin/chi +118 -50
  5. data/docs/cli.md +184 -9
  6. data/docs/configuration.md +333 -47
  7. data/docs/desktop.md +45 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +208 -5
  10. data/docs/plugins.md +68 -2
  11. data/docs/releasing.md +23 -13
  12. data/docs/sessions.md +45 -17
  13. data/lib/samagotchi/answer_display.rb +95 -0
  14. data/lib/samagotchi/archive_store.rb +90 -0
  15. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  16. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  17. data/lib/samagotchi/bootstrap_command.rb +347 -0
  18. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
  20. data/lib/samagotchi/bridge.rb +13 -1
  21. data/lib/samagotchi/bridge_client.rb +6 -2
  22. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  23. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  24. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
  25. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  26. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  28. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  31. data/lib/samagotchi/client.rb +81 -19
  32. data/lib/samagotchi/commands/registry.rb +8 -0
  33. data/lib/samagotchi/config.rb +252 -48
  34. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  35. data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
  36. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  37. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  38. data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
  39. data/lib/samagotchi/desktop/macos.rb +59 -8
  40. data/lib/samagotchi/desktop_command.rb +6 -3
  41. data/lib/samagotchi/edit_preview.rb +82 -0
  42. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  43. data/lib/samagotchi/engine.rb +434 -140
  44. data/lib/samagotchi/gem_update.rb +89 -0
  45. data/lib/samagotchi/guardrails/approval.rb +35 -4
  46. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  47. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  48. data/lib/samagotchi/guardrails.rb +1 -0
  49. data/lib/samagotchi/hooks/registry.rb +24 -5
  50. data/lib/samagotchi/host_registry.rb +9 -12
  51. data/lib/samagotchi/idle_client.rb +24 -15
  52. data/lib/samagotchi/idle_recap.rb +5 -1
  53. data/lib/samagotchi/idle_reminders.rb +2 -2
  54. data/lib/samagotchi/image_store.rb +10 -6
  55. data/lib/samagotchi/kernel_loop.rb +73 -94
  56. data/lib/samagotchi/live_versions.rb +59 -0
  57. data/lib/samagotchi/llm/api_key.rb +41 -0
  58. data/lib/samagotchi/llm/chat_loop.rb +132 -29
  59. data/lib/samagotchi/llm/errors.rb +41 -9
  60. data/lib/samagotchi/llm/http.rb +57 -17
  61. data/lib/samagotchi/llm/openai_chat.rb +17 -30
  62. data/lib/samagotchi/log_subscriber.rb +18 -3
  63. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  64. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  65. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  66. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  67. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  68. data/lib/samagotchi/model_profile.rb +24 -1
  69. data/lib/samagotchi/plugin/context.rb +22 -1
  70. data/lib/samagotchi/plugin/sessions.rb +3 -1
  71. data/lib/samagotchi/prompt.rb +4 -2
  72. data/lib/samagotchi/reminder_store.rb +1 -9
  73. data/lib/samagotchi/reply_wait.rb +126 -0
  74. data/lib/samagotchi/sampling_settings.rb +58 -0
  75. data/lib/samagotchi/self_report.rb +18 -3
  76. data/lib/samagotchi/send_command.rb +252 -11
  77. data/lib/samagotchi/session.rb +52 -11
  78. data/lib/samagotchi/session_archive_command.rb +107 -0
  79. data/lib/samagotchi/session_commands.rb +46 -7
  80. data/lib/samagotchi/session_manager.rb +115 -25
  81. data/lib/samagotchi/session_metrics.rb +222 -106
  82. data/lib/samagotchi/steer.rb +72 -0
  83. data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
  84. data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
  85. data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
  86. data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
  87. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  88. data/lib/samagotchi/terminal_ui.rb +134 -247
  89. data/lib/samagotchi/text_diff.rb +181 -0
  90. data/lib/samagotchi/thinking.rb +115 -0
  91. data/lib/samagotchi/tool_activity.rb +3 -1
  92. data/lib/samagotchi/tool_runner.rb +34 -1
  93. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  94. data/lib/samagotchi/tools/builtins.rb +15 -4
  95. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  96. data/lib/samagotchi/tools/edit.rb +23 -9
  97. data/lib/samagotchi/tools/execute.rb +52 -14
  98. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  99. data/lib/samagotchi/tools/task_wait.rb +27 -3
  100. data/lib/samagotchi/tools/write.rb +4 -0
  101. data/lib/samagotchi/turn_flow.rb +12 -2
  102. data/lib/samagotchi/turn_note.rb +60 -6
  103. data/lib/samagotchi/update_command.rb +308 -0
  104. data/lib/samagotchi/update_hint.rb +59 -0
  105. data/lib/samagotchi/version.rb +1 -1
  106. data/lib/samagotchi/vision_support.rb +7 -9
  107. data/lib/samagotchi/web/app.rb +91 -7
  108. data/lib/samagotchi/web/message_parts.rb +8 -3
  109. data/lib/samagotchi/web/public/activity.js +13 -1
  110. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  111. data/lib/samagotchi/web/public/annotations.js +13 -0
  112. data/lib/samagotchi/web/public/app.js +472 -111
  113. data/lib/samagotchi/web/public/card.js +5 -3
  114. data/lib/samagotchi/web/public/chat_view.js +13 -1
  115. data/lib/samagotchi/web/public/copy.js +20 -4
  116. data/lib/samagotchi/web/public/ctx.js +15 -0
  117. data/lib/samagotchi/web/public/data.js +23 -6
  118. data/lib/samagotchi/web/public/diff_view.js +58 -0
  119. data/lib/samagotchi/web/public/format.js +9 -0
  120. data/lib/samagotchi/web/public/index.html +60 -3
  121. data/lib/samagotchi/web/public/notify.js +175 -0
  122. data/lib/samagotchi/web/public/question_card.js +5 -2
  123. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  124. data/lib/samagotchi/web/public/timing.js +39 -14
  125. data/lib/samagotchi/web/public/turn_events.js +75 -5
  126. data/lib/samagotchi/web/public/turn_view.js +49 -8
  127. data/lib/samagotchi/web/server.rb +8 -4
  128. data/lib/samagotchi/web/session_hub.rb +2 -1
  129. data/lib/samagotchi/web/session_summary.rb +24 -1
  130. data/lib/samagotchi/worker.rb +16 -4
  131. metadata +31 -1
@@ -10,13 +10,17 @@ 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"
14
+ require_relative "thinking"
13
15
  require_relative "hooks"
14
16
  require_relative "pending_input_queue"
17
+ require_relative "steer"
15
18
  require_relative "thought_stream_splitter"
16
19
  require_relative "tools/builtins"
17
20
  require_relative "muted_memories"
18
21
  require_relative "tool_activity"
19
22
  require_relative "tool_runner"
23
+ require_relative "answer_display"
20
24
 
21
25
  module Samagotchi
22
26
  # The KernelLoop drives the model ↔ tool interaction cycle.
@@ -173,6 +177,12 @@ module Samagotchi
173
177
  # The turn's VisionContext (images: capability, files, limits), set by
174
178
  # the Engine per turn; nil sends no images (placeholders instead).
175
179
  attr_accessor :vision
180
+ # The turn's request parameters (SamplingSettings.for), set by the Engine
181
+ # per turn; empty or nil sends none.
182
+ attr_accessor :sampling
183
+ # The turn's thinking level (Thinking.resolve), set by the Engine per
184
+ # turn; its own accessor, since the native path sends @sampling as is.
185
+ attr_accessor :thinking
176
186
  # Tools::Peers (or the Engine's live view of it): the session
177
187
  # list_sessions and send_note speak for; nil outside a session.
178
188
  attr_accessor :peers
@@ -206,15 +216,22 @@ module Samagotchi
206
216
  tool_activity = []
207
217
  qwen_recovery_attempts = 0
208
218
  qwen_partial_tool_call = nil
219
+ empty_retries = 0
220
+ empty_retry_limit = EmptyAnswerRetry.limit
221
+ @retry_generation = false
209
222
  context_status = nil
210
223
  stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
224
+ # Qwen with thinking off: an empty thought after the cue, so the model
225
+ # answers at once. Kept in the turn's model messages, so each tool-loop
226
+ # prompt starts with what the server already has cached.
227
+ prefill = Thinking.native(@thinking || Thinking::DEFAULT, @profile).prefill
211
228
  partial_assistant_buffer = +""
212
229
 
213
230
  effective_max_iterations = @no_interrupt ? 1000 : max_iterations
214
231
  effective_max_tool_output_chars = resolve_output_char_cap(max_tool_output_chars)
215
232
  effective_max_iterations.times do |iteration_index|
216
233
  inject_pending_input!(conversation, pending_input, on_stream_event, iteration_index + 1, cancel_controller)
217
- prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @vision)
234
+ prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @vision, prefill: prefill)
218
235
  image_tokens = images.empty? ? 0 : ImagePlan.estimated_tokens(conversation)
219
236
  context_window = ContextWindow.resolve(client: @client, model: resolved_model_name)
220
237
  context_status = emit_context_status_event(on_stream_event, prompt, iteration_index: iteration_index, state: context_state, window: context_window,
@@ -223,7 +240,7 @@ module Samagotchi
223
240
  # The model's own copy, on the tail (the prompt cache keeps its
224
241
  # prefix), then the prompt again with it.
225
242
  conversation << line
226
- prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @vision)
243
+ prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @vision, prefill: prefill)
227
244
  end
228
245
  emit_stream_event(
229
246
  on_stream_event,
@@ -299,9 +316,9 @@ module Samagotchi
299
316
  # Fire :after_generation hook (after LLM returns, before tool parse),
300
317
  # with a read-only copy of the conversation as sent.
301
318
  after_gen_event = { type: :after_generation, iteration: iteration_index + 1, response: response,
302
- messages: conversation.map(&:dup).freeze }
319
+ messages: AnswerDisplay.strip_all(conversation).map(&:dup).freeze }
303
320
  fire_hook(:after_generation, after_gen_event) if @hooks
304
- conversation << { role: "model", content: response }
321
+ conversation << { role: "model", content: prefill + response.to_s }
305
322
 
306
323
  # Profile-specific parse (incl. Qwen unterminated-block recovery); the
307
324
  # returned fragment (non-nil only for Qwen) is fed back on the next
@@ -322,7 +339,25 @@ module Samagotchi
322
339
 
323
340
  pending_tool_calls = false
324
341
  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)
342
+ empty = strip_thought_blocks(response.to_s).strip.empty?
343
+ retry_empty = empty && empty_retries < empty_retry_limit && !cancel_controller&.cancelled?
344
+ # The empty generation goes (its thinking would be sent again and
345
+ # prime the same loop); an empty answer that will be retried is no
346
+ # answer site, so a plugin's steer joins the retry.
347
+ conversation.pop if retry_empty
348
+ unless inject_pending_input!(conversation, pending_input, on_stream_event, iteration_index + 1, cancel_controller,
349
+ answer: retry_empty ? nil : answer)
350
+ if retry_empty
351
+ empty_retries += 1
352
+ @retry_generation = true
353
+ emit_stream_event(on_stream_event, type: :empty_answer_retry, iteration: iteration_index + 1,
354
+ attempt: empty_retries, of: empty_retry_limit,
355
+ thinking_chars: thinking_chars(response.to_s, streamed_thinking))
356
+ conversation << TurnNote.empty_retry
357
+ next
358
+ end
359
+ # The Engine's TurnNote.empty says it all: the spent nudge goes.
360
+ drop_last_empty_retry!(conversation) if empty && empty_retries.positive?
326
361
  break
327
362
  end
328
363
  # Queued steering keeps the turn going: loop again so the model
@@ -338,6 +373,7 @@ module Samagotchi
338
373
  image_counts = []
339
374
  shown_params = []
340
375
  shown_labels = []
376
+ diffs = []
341
377
  results = calls.map.with_index do |call, call_index|
342
378
  run = tool_runner.run(call, iteration: iteration_index + 1, call_index: call_index + 1,
343
379
  call_count: calls.length, on_stream_event: on_stream_event,
@@ -347,6 +383,7 @@ module Samagotchi
347
383
  image_counts << Array(run[:images]).size
348
384
  shown_params << run[:shown_params]
349
385
  shown_labels << run[:shown_label]
386
+ diffs << run[:diff]
350
387
  run[:output]
351
388
  end.join("\n\n---\n\n")
352
389
  emit_stream_event(on_stream_event, type: :tool_dispatch_completed, iteration: iteration_index + 1, call_count: calls.length)
@@ -360,6 +397,9 @@ module Samagotchi
360
397
  # a built-in), for the web's reload; the prompt never reads it.
361
398
  tool_response[:tool_params] = shown_params if shown_params.any?
362
399
  tool_response[:tool_labels] = shown_labels if shown_labels.any?
400
+ # What each edit/write changed (nil for other calls), in call order,
401
+ # for the web's reload; the prompt never reads it.
402
+ tool_response[:tool_diffs] = diffs if diffs.any?
363
403
  conversation << tool_response
364
404
  pending_tool_calls = true
365
405
  rescue Client::RequestCancelled => e
@@ -437,6 +477,8 @@ module Samagotchi
437
477
  # append them as ONE merged user message at the conversation tail and emit
438
478
  # :pending_input_merged. Tail-append only: head mutation would invalidate
439
479
  # the server-side prefix KV cache. Returns true when a message was injected.
480
+ # A plugin's steers (Steer) follow the user's message, each its own
481
+ # message; after an answer the Engine's drain has already dropped them.
440
482
  # After a cancel the input stays queued: it runs as the next turn instead
441
483
  # of dying with this one. +answer+ (a proc, called only on a merge) is the
442
484
  # answer the merge follows: the UIs show it, the turn summary has only the
@@ -445,24 +487,16 @@ module Samagotchi
445
487
  return false unless pending_input
446
488
  return false if cancel_controller&.cancelled?
447
489
 
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?
490
+ merge = Steer.merge(Steer.drain(pending_input, at_answer: !answer.nil?))
491
+ return false if merge.empty?
457
492
 
458
493
  answer = answer.call.to_s if answer
459
- conversation << { role: "user", content: content }
494
+ conversation.concat(merge.messages)
460
495
  emit_stream_event(
461
496
  on_stream_event,
462
497
  type: :pending_input_merged,
463
498
  iteration: iteration,
464
- count: lines.length,
465
- content: content,
499
+ **merge.event_fields,
466
500
  answer: answer.to_s.strip.empty? ? nil : answer
467
501
  )
468
502
  true
@@ -524,6 +558,9 @@ module Samagotchi
524
558
  kwargs[:n_predict] = n_predict if n_predict && client_supports_keyword?(:n_predict)
525
559
  resolved_model_name = completion_model_name(model_name)
526
560
  kwargs[:model] = resolved_model_name if resolved_model_name && client_supports_keyword?(:model)
561
+ sampling = @retry_generation ? EmptyAnswerRetry.sampling(@sampling) : @sampling
562
+ @retry_generation = false
563
+ kwargs[:sampling] = sampling if sampling && !sampling.empty? && client_supports_keyword?(:sampling)
527
564
  kwargs
528
565
  end
529
566
 
@@ -804,14 +841,6 @@ module Samagotchi
804
841
  end
805
842
  end
806
843
 
807
- # Parse tool calls from raw model output using the active profile's
808
- # ToolCallParser strategy (Gemma 4 or Qwen 3.6).
809
- # Thought content is intentionally left intact while a tool-call turn is in
810
- # progress to preserve same-turn reasoning context between tool calls.
811
- def parse_tool_calls(text)
812
- parser.parse(text.to_s)
813
- end
814
-
815
844
  public
816
845
 
817
846
  # Public wrapper so other loops (e.g. the chat loop) can strip
@@ -862,7 +891,9 @@ module Samagotchi
862
891
  def sanitize_history(messages)
863
892
  messages.map do |m|
864
893
  if m[:role] == "model"
865
- { role: m[:role], content: strip_thought_blocks(m[:content].to_s) }
894
+ # `display` rides along so the stored conversation keeps it; the
895
+ # prompt formatter never reads it (AnswerDisplay).
896
+ { role: m[:role], content: strip_thought_blocks(m[:content].to_s), display: m[:display] }.compact
866
897
  else
867
898
  m.dup
868
899
  end
@@ -882,6 +913,12 @@ module Samagotchi
882
913
  messages.map(&:dup)
883
914
  end
884
915
 
916
+ def drop_last_empty_retry!(conversation)
917
+ nudge = TurnNote.empty_retry
918
+ index = conversation.rindex { |entry| entry[:kind] == nudge[:kind] && entry[:content] == nudge[:content] }
919
+ conversation.delete_at(index) if index
920
+ end
921
+
885
922
  def last_model_content(conversation)
886
923
  message = conversation.reverse.find { |entry| entry[:role] == "model" }
887
924
  message ? message[:content].to_s : ""
@@ -942,76 +979,18 @@ module Samagotchi
942
979
  }
943
980
  end
944
981
 
982
+ # ask_user_question: validate the call, then hand the payload to the
983
+ # Engine's question flow (question_handler, which blocks until the user
984
+ # answers). Without one (headless), the payload as JSON so the model sees
985
+ # the options and can ask in plain text.
945
986
  def handle_ask_user_question(call)
946
- question = (call[:question] || call[:content]).to_s.strip
947
- raw_opts = call[:options]
948
- # Dumb-model tolerant: raw may be String JSON, Array, or malformed with brackets/quotes
949
- options = Samagotchi::Tools::AskUserQuestion.normalize_options_lenient(raw_opts)
950
- # Fallback for case where raw was String like '["a","b"]' but lenient returned [] due to edge parse, try raw string of params
951
- if options.empty? && raw_opts.is_a?(String)
952
- options = Samagotchi::Tools::AskUserQuestion.normalize_options_lenient(raw_opts.to_s)
953
- end
954
- header = call[:header].to_s.strip
955
- header = nil if header.empty?
956
- multi = call[:multi_select]
957
- free = call[:allow_freeform]
958
- # Normalize booleans from string forms (Gemma passes "true"/"false" as strings)
959
- multi = normalize_ask_bool(multi)
960
- free = normalize_ask_bool(free)
961
-
962
- if question.empty?
963
- return "Error: ask_user_question requires 'question'"
964
- end
965
- # Dumb-model tolerant: salvage single-option parse glitches, but still require at least 1
966
- if options.size < 1
967
- alt = Samagotchi::Tools::AskUserQuestion.normalize_options_lenient(call[:content].to_s) if call[:content]
968
- options = alt unless alt.empty?
969
- end
970
- if options.empty?
971
- return "Error: ask_user_question requires 2-8 options (got 0). Provide e.g. options=[\"Cats\",\"Dogs\"]"
972
- end
973
- if options.size == 1
974
- # Allow single-option salvage for dumb models (will still render, user can answer or provide freeform)
975
- elsif options.size < 2 || options.size > 8
976
- return "Error: ask_user_question requires 2-8 options (got #{options.size}). Provide e.g. options=[\"Cats\",\"Dogs\"]"
977
- end
978
-
979
- # If an Engine-level blocking handler is registered (TUI/Web), delegate
980
- # there (Engine sets question_handler). Otherwise fall back to a
981
- # non-blocking JSON preview so the model can still see a structured response.
982
- handler = @question_handler
983
-
984
- payload = {
985
- question: question,
986
- options: options,
987
- header: header,
988
- multi_select: !!multi,
989
- allow_freeform: !!free
990
- }.compact
991
-
992
- if handler
993
- begin
994
- result = handler.call(payload)
995
- return result.to_s
996
- rescue => e
997
- return "Error: ask_user_question handler failed: #{e.message}"
998
- end
999
- end
1000
-
1001
- # Headless fallback: return JSON so model sees structured options and can
1002
- # fallback to plain text qualification.
1003
- JSON.pretty_generate(payload)
1004
- end
1005
-
1006
- def normalize_ask_bool(v)
1007
- return nil if v.nil?
1008
- return v if v == true || v == false
1009
-
1010
- s = v.to_s.strip.downcase
1011
- return true if %w[1 true yes on].include?(s)
1012
- return false if %w[0 false no off].include?(s)
987
+ payload = Samagotchi::Tools::AskUserQuestion.validate(call)
988
+ return payload if payload.is_a?(String)
989
+ return JSON.pretty_generate(payload) unless @question_handler
1013
990
 
1014
- nil
991
+ @question_handler.call(payload).to_s
992
+ rescue => e
993
+ "Error: ask_user_question handler failed: #{e.message}"
1015
994
  end
1016
995
  end
1017
996
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "socket"
6
+ require_relative "session"
7
+ require_relative "version"
8
+
9
+ module Samagotchi
10
+ # Which chi versions the running processes run, for `chi update`: session
11
+ # workers (their bridge.json sidecar names the version since chi update exists; an
12
+ # older one names none) and a `chi web` on its port (/api/info). Read-only:
13
+ # a dead worker's sidecar is left for the next client to clean up.
14
+ module LiveVersions
15
+ PROBE_TIMEOUT = 0.2
16
+ WEB_TIMEOUT = 0.5
17
+
18
+ # version is nil for a sidecar written before sidecars carried one.
19
+ Worker = Struct.new(:session_id, :version, keyword_init: true)
20
+
21
+ module_function
22
+
23
+ # @return [Array<Worker>] the workers whose Bridge answers, by session id
24
+ def workers(state_dir: Session.default_state_dir)
25
+ Dir[File.join(state_dir, "*", "bridge.json")].sort.filter_map do |sidecar|
26
+ data = JSON.parse(File.read(sidecar))
27
+ next unless data.is_a?(Hash) && listening?(data["port"].to_i)
28
+
29
+ Worker.new(session_id: File.basename(File.dirname(sidecar)), version: data["version"])
30
+ rescue JSON::ParserError, SystemCallError
31
+ nil
32
+ end
33
+ end
34
+
35
+ # The live workers on another version than +version+ (unknown counts).
36
+ def stale_workers(version = VERSION, state_dir: Session.default_state_dir)
37
+ workers(state_dir: state_dir).reject { |w| w.version == version }
38
+ end
39
+
40
+ # The version a chi web on host:port runs, or nil when nothing (or not
41
+ # chi web) answers.
42
+ def web_version(host, port, timeout: WEB_TIMEOUT)
43
+ response = Net::HTTP.start(host, port, open_timeout: timeout, read_timeout: timeout) { |http| http.get("/api/info") }
44
+ info = response.code.to_i == 200 ? JSON.parse(response.body.to_s) : nil
45
+ info.is_a?(Hash) && info["app"] == "chi-web" ? info["version"].to_s : nil
46
+ rescue StandardError
47
+ nil
48
+ end
49
+
50
+ def listening?(port, host: "127.0.0.1")
51
+ return false unless port.positive?
52
+
53
+ Socket.tcp(host, port, connect_timeout: PROBE_TIMEOUT).close
54
+ true
55
+ rescue StandardError
56
+ false
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Samagotchi
6
+ module LLM
7
+ # A host's API key: the environment variable its api_key_env: names,
8
+ # sent as `Authorization: Bearer <key>` on every request LLM::HTTP makes
9
+ # for the host (llama.cpp started with --api-key, or a provider). The
10
+ # key itself never goes into a message.
11
+ ApiKey = Data.define(:env_name, :host, :env) do
12
+ # nil for a host without api_key_env: its requests carry no header.
13
+ def self.for(env_name, host:, env: ENV)
14
+ name = env_name.to_s.strip
15
+ name.empty? ? nil : new(env_name: name, host: host.to_s, env: env)
16
+ end
17
+
18
+ # What to try after a 401/403 from a host that has no api_key_env.
19
+ def self.missing_hint(host)
20
+ "the server may want an API key: put it in an environment variable and name it with api_key_env: on host #{host}"
21
+ end
22
+
23
+ # Sets the header, or raises AuthError when the variable is not set.
24
+ def authorize(request)
25
+ key = env[env_name].to_s
26
+ raise AuthError.new("#{host}: set #{env_name} (the API key for host #{host})", host: host) if key.strip.empty?
27
+
28
+ request["Authorization"] = "Bearer #{key}"
29
+ end
30
+
31
+ # What to try after a 401/403 with the key sent.
32
+ def hint = "check #{env_name} (the API key for host #{host})"
33
+
34
+ # Without the environment: it holds this key and every other secret.
35
+ def inspect = "#<#{self.class.name} #{env_name} host=#{host}>"
36
+ alias_method :to_s, :inspect
37
+
38
+ def pretty_print(printer) = printer.text(inspect)
39
+ end
40
+ end
41
+ end
@@ -8,12 +8,16 @@ 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"
20
+ require_relative "../thinking"
17
21
 
18
22
  module Samagotchi
19
23
  module LLM
@@ -131,6 +135,39 @@ module Samagotchi
131
135
  @kernel.respond_to?(:vision) ? @kernel.vision : nil
132
136
  end
133
137
 
138
+ # The turn's request parameters (the Engine sets them on the kernel).
139
+ def sampling
140
+ @kernel.respond_to?(:sampling) ? @kernel.sampling || {} : {}
141
+ end
142
+
143
+ # The turn's thinking level (the Engine sets it on the kernel).
144
+ def thinking
145
+ (@kernel.respond_to?(:thinking) && @kernel.thinking) || Thinking::DEFAULT
146
+ end
147
+
148
+ # The request fields the thinking level adds (Thinking.chat_fields);
149
+ # none for a model whose host refused them (#thinking_refused!).
150
+ def thinking_fields(model = nil)
151
+ return {} if model && (@thinking_refused ||= Set.new).include?(model)
152
+
153
+ Thinking.chat_fields(thinking)
154
+ end
155
+
156
+ # The host refused +model+'s thinking fields: leave them out from now on.
157
+ def thinking_refused!(model)
158
+ (@thinking_refused ||= Set.new) << model
159
+ end
160
+
161
+ # One generation's options: the thinking fields under the sampling
162
+ # (a sampling key wins, chat_template_kwargs merges per sub-key), the
163
+ # empty-answer retry's temperature on top, then every null dropped at
164
+ # any depth (a sampling null means "don't send it").
165
+ def request_options(retry_generation: false, model: nil)
166
+ options = deep_merge(thinking_fields(model), sampling)
167
+ options = EmptyAnswerRetry.sampling(options) if retry_generation
168
+ deep_compact(options)
169
+ end
170
+
134
171
  def strip_model_thought(text)
135
172
  @kernel.respond_to?(:strip_model_thought) ? @kernel.strip_model_thought(text) : text
136
173
  end
@@ -172,7 +209,7 @@ module Samagotchi
172
209
  # reasoning, never sent back), a result's tool_call_id, the image
173
210
  # refs of a user message or a tool result, and a plugin tool result's
174
211
  # tool_params and tool_labels (the live row's params line and label,
175
- # never sent back).
212
+ # never sent back), and an edit/write result's tool_diffs (never sent).
176
213
  def plain(conversation)
177
214
  conversation.map do |entry|
178
215
  content = entry[:content].is_a?(Array) ? entry[:content] : entry[:content].to_s
@@ -183,6 +220,8 @@ module Samagotchi
183
220
  message[:thinking] = entry[:thinking] if entry[:thinking].is_a?(String) && !entry[:thinking].empty?
184
221
  message[:tool_params] = entry[:tool_params] if entry[:tool_params]
185
222
  message[:tool_labels] = entry[:tool_labels] if entry[:tool_labels]
223
+ message[:tool_diffs] = entry[:tool_diffs] if entry[:tool_diffs]
224
+ message[AnswerDisplay::KEY] = entry[AnswerDisplay::KEY] if entry[AnswerDisplay::KEY]
186
225
  ContextNote::KEYS.each { |key| message[key] = entry[key] if entry.key?(key) }
187
226
  message
188
227
  end
@@ -190,6 +229,18 @@ module Samagotchi
190
229
 
191
230
  private
192
231
 
232
+ def deep_merge(base, over)
233
+ base.merge(over) { |_key, a, b| a.is_a?(Hash) && b.is_a?(Hash) ? deep_merge(a, b) : b }
234
+ end
235
+
236
+ def deep_compact(hash)
237
+ hash.each_with_object({}) do |(key, value), out|
238
+ next if value.nil?
239
+
240
+ out[key] = value.is_a?(Hash) ? deep_compact(value) : value
241
+ end
242
+ end
243
+
193
244
  # Ids of the calls whose assistant turn is followed by a tool message
194
245
  # for every one of them (before the next non-tool message).
195
246
  def paired_call_ids(conversation)
@@ -268,6 +319,8 @@ module Samagotchi
268
319
  # usage: the text, and each image's estimate (not its base64).
269
320
  @prompt_text = conversation.sum("") { |entry| entry[:content].to_s }
270
321
  @image_tokens = ImagePlan.estimated_tokens(conversation)
322
+ @empty_retries = 0
323
+ @empty_retry_limit = EmptyAnswerRetry.limit
271
324
  end
272
325
 
273
326
  EMPTY_ANSWER = "(the model returned an empty answer)"
@@ -288,7 +341,12 @@ module Samagotchi
288
341
  # Kept before a merge too: the model answers the merged line
289
342
  # knowing what it just said.
290
343
  @conversation << with_thinking({ role: "model", content: last_text }, response) unless last_text.empty?
291
- next if inject_pending_input(iteration, answer: last_text)
344
+ retry_empty = last_text.empty? && retry_empty_answer?(iteration, response)
345
+ # An empty answer that will be retried is no answer site: a
346
+ # plugin's steer joins the retry instead of being dropped, and
347
+ # queued input (a user's line, a steer) goes in place of the nudge.
348
+ next if inject_pending_input(iteration, answer: retry_empty ? nil : last_text)
349
+ next if retry_empty && nudge_empty_answer(iteration, response)
292
350
 
293
351
  # Shown, not saved: an empty answer (content "" + stop, seen from
294
352
  # a remote host) would otherwise end the turn with nothing.
@@ -310,6 +368,31 @@ module Samagotchi
310
368
 
311
369
  private
312
370
 
371
+ # A retry is left, and the answer wasn't cut short by a full context
372
+ # (a length stop while thinking is retried: a thinking loop cut by
373
+ # the provider's output cap, not a full window).
374
+ def retry_empty_answer?(iteration, response)
375
+ return false if @empty_retries >= @empty_retry_limit || @cancel_controller&.cancelled?
376
+
377
+ if response.finish_reason.to_s == "length" &&
378
+ EmptyAnswerRetry.context_full?(response.usage&.total_tokens, @window&.tokens)
379
+ Log.info(:turn, "empty_answer_not_retried", iteration: iteration, why: "context full")
380
+ return false
381
+ end
382
+ true
383
+ end
384
+
385
+ # The hidden nudge before the next generation (EmptyAnswerRetry),
386
+ # which runs at the retry temperature. Returns true.
387
+ def nudge_empty_answer(iteration, response)
388
+ @empty_retries += 1
389
+ @retry_generation = true
390
+ emit(type: :empty_answer_retry, iteration: iteration, attempt: @empty_retries, of: @empty_retry_limit,
391
+ finish_reason: response.finish_reason, thinking_chars: response.reasoning.to_s.length)
392
+ @conversation << TurnNote.empty_retry
393
+ true
394
+ end
395
+
313
396
  # The host's reasoning, kept on the model message as +thinking+ for
314
397
  # the web turn view's reload (the whole of it, as the live view
315
398
  # shows). Only saved: #assistant_message builds the wire message from
@@ -322,14 +405,40 @@ module Samagotchi
322
405
  # One streamed request. Returns [response, nil], or [reason, partial
323
406
  # text] when it was cancelled.
324
407
  def generate(iteration)
325
- window = @loop.context_window(@model_name)
408
+ window = @window = @loop.context_window(@model_name)
409
+ retry_generation = @retry_generation
410
+ @retry_generation = false
326
411
  emit(type: :generation_started, iteration: iteration, context_window_tokens: window&.tokens,
327
412
  context_window_source: window&.source)
328
413
  @loop.fire_hook(:before_generation, { type: :before_generation, iteration: iteration })
329
414
  streamed = +""
330
- response = @loop.adapter.chat(
415
+ response = begin
416
+ request(iteration, retry_generation, streamed)
417
+ rescue BadRequest => e
418
+ raise unless thinking_refused?(e)
419
+
420
+ # Once per model: asked again without the thinking fields.
421
+ @loop.thinking_refused!(@model_name)
422
+ emit(type: :thinking_refused, iteration: iteration, model: @model_name, level: @loop.thinking, detail: e.detail)
423
+ request(iteration, retry_generation, streamed)
424
+ end
425
+ record_context_status(response.usage, window)
426
+ emit(type: :generation_completed, iteration: iteration, content_length: response.text.length,
427
+ thinking_chars: response.reasoning.to_s.length, served_model: response.model,
428
+ requested_model: @model_name, finish_reason: response.finish_reason)
429
+ dump_response(response, iteration)
430
+ @loop.fire_hook(:after_generation, { type: :after_generation, iteration: iteration, response: response.text,
431
+ messages: AnswerDisplay.strip_all(@conversation).map(&:dup).freeze })
432
+ [response, nil]
433
+ rescue RequestCancelled => e
434
+ [e.reason, streamed]
435
+ end
436
+
437
+ def request(iteration, retry_generation, streamed)
438
+ @loop.adapter.chat(
331
439
  messages: @loop.wire_messages(@conversation), tools: @loop.tool_definitions, model: @model_name,
332
440
  cancel_controller: @cancel_controller, session_id: @loop.session_id,
441
+ options: @loop.request_options(retry_generation: retry_generation, model: @model_name),
333
442
  on_delta: lambda { |content:, reasoning:, payload:|
334
443
  streamed << content
335
444
  emit(type: :generation_chunk, iteration: iteration, content: reasoning + content, text: content,
@@ -337,16 +446,13 @@ module Samagotchi
337
446
  },
338
447
  on_retry: ->(**retry_event) { emit({ type: :generation_retrying, iteration: iteration }.merge(retry_event)) }
339
448
  )
340
- record_context_status(response.usage, window)
341
- emit(type: :generation_completed, iteration: iteration, content_length: response.text.length,
342
- thinking_chars: response.reasoning.to_s.length, served_model: response.model,
343
- requested_model: @model_name)
344
- dump_response(response, iteration)
345
- @loop.fire_hook(:after_generation, { type: :after_generation, iteration: iteration, response: response.text,
346
- messages: @conversation.map(&:dup).freeze })
347
- [response, nil]
348
- rescue RequestCancelled => e
349
- [e.reason, streamed]
449
+ end
450
+
451
+ # A 400 about reasoning, for a request that carried thinking fields
452
+ # (not a missing-tools, image or context error).
453
+ def thinking_refused?(error)
454
+ error.reasoning_refused? && !error.is_a?(VisionUnsupported) && !error.tools_unsupported? &&
455
+ !error.context_overflow? && !@loop.thinking_fields(@model_name).empty?
350
456
  end
351
457
 
352
458
  # The status line's value from the server's counts for this request
@@ -385,31 +491,28 @@ module Samagotchi
385
491
  # A plugin tool's params line, for the web's reload; never sent.
386
492
  entry[:tool_params] = run[:shown_params] if run[:shown_params]
387
493
  entry[:tool_labels] = run[:shown_label] if run[:shown_label]
494
+ # What an edit/write changed, for the web's reload; never sent.
495
+ entry[:tool_diffs] = run[:diff] if run[:diff]
388
496
  @conversation << entry
389
497
  end
390
498
  emit(type: :tool_dispatch_completed, iteration: iteration, call_count: tool_calls.length)
391
499
  end
392
500
 
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.
501
+ # Queued steering joins the conversation as one user message, a
502
+ # plugin's steers each as its own after it (Steer). Returns true when
503
+ # there was any. After a cancel it stays queued, so it runs as the
504
+ # next turn instead of dying with this one. +answer+ is the answer the
505
+ # merge follows, for the UIs; given, the drain is told it is the
506
+ # after-answer site (plugin steers are dropped there).
397
507
  def inject_pending_input(iteration, answer: nil)
398
508
  return false unless @pending_input
399
509
  return false if @cancel_controller&.cancelled?
400
510
 
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?
511
+ merge = Steer.merge(Steer.drain(@pending_input, at_answer: !answer.nil?))
512
+ return false if merge.empty?
410
513
 
411
- @conversation << { role: "user", content: content }
412
- emit(type: :pending_input_merged, iteration: iteration, count: lines.length, content: content,
514
+ @conversation.concat(merge.messages)
515
+ emit(type: :pending_input_merged, iteration: iteration, **merge.event_fields,
413
516
  answer: answer.to_s.empty? ? nil : answer)
414
517
  true
415
518
  end