samagotchi 0.63.0 → 0.65.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -1
  3. data/docs/architecture.md +32 -5
  4. data/docs/cli.md +41 -9
  5. data/docs/configuration.md +143 -11
  6. data/docs/internals/engine.md +25 -6
  7. data/docs/plugins.md +7 -0
  8. data/docs/sessions.md +14 -10
  9. data/docs/sub-agent.md +21 -4
  10. data/docs/testing.md +6 -0
  11. data/lib/samagotchi/answer_command.rb +5 -1
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  13. data/lib/samagotchi/bridge.rb +29 -5
  14. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
  16. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/child_reports.rb +7 -2
  19. data/lib/samagotchi/cli/parent_wait.rb +9 -2
  20. data/lib/samagotchi/client.rb +4 -2
  21. data/lib/samagotchi/composer_route.rb +90 -0
  22. data/lib/samagotchi/config.rb +11 -7
  23. data/lib/samagotchi/continue_offer.rb +17 -3
  24. data/lib/samagotchi/delivery.rb +13 -8
  25. data/lib/samagotchi/engine.rb +41 -7
  26. data/lib/samagotchi/generation_phase.rb +17 -0
  27. data/lib/samagotchi/host_config.rb +33 -12
  28. data/lib/samagotchi/host_model.rb +2 -2
  29. data/lib/samagotchi/host_registry.rb +34 -11
  30. data/lib/samagotchi/idle_client.rb +62 -25
  31. data/lib/samagotchi/idle_recap.rb +5 -2
  32. data/lib/samagotchi/idle_target.rb +30 -7
  33. data/lib/samagotchi/interrupted_turn.rb +7 -3
  34. data/lib/samagotchi/last_turn.rb +5 -2
  35. data/lib/samagotchi/llm/adapters.rb +41 -0
  36. data/lib/samagotchi/llm/anthropic_assembly.rb +210 -0
  37. data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
  38. data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
  39. data/lib/samagotchi/llm/api_key.rb +26 -9
  40. data/lib/samagotchi/llm/chat_loop.rb +113 -14
  41. data/lib/samagotchi/llm/dialect.rb +57 -0
  42. data/lib/samagotchi/llm/errors.rb +81 -12
  43. data/lib/samagotchi/llm/model_listing.rb +111 -0
  44. data/lib/samagotchi/llm/model_result.rb +9 -2
  45. data/lib/samagotchi/llm/openai_chat.rb +46 -93
  46. data/lib/samagotchi/model_profile.rb +1 -1
  47. data/lib/samagotchi/pending_input_queue.rb +10 -3
  48. data/lib/samagotchi/plugin/sessions.rb +27 -5
  49. data/lib/samagotchi/reasoning_history.rb +14 -4
  50. data/lib/samagotchi/recap_setup.rb +2 -1
  51. data/lib/samagotchi/reply_wait.rb +171 -6
  52. data/lib/samagotchi/self_report.rb +18 -28
  53. data/lib/samagotchi/send_command.rb +63 -19
  54. data/lib/samagotchi/session.rb +3 -2
  55. data/lib/samagotchi/session_commands.rb +1 -1
  56. data/lib/samagotchi/session_inbox.rb +63 -7
  57. data/lib/samagotchi/session_manager.rb +49 -20
  58. data/lib/samagotchi/session_metrics.rb +6 -11
  59. data/lib/samagotchi/steer_cut.rb +4 -0
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +55 -28
  61. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  62. data/lib/samagotchi/terminal_ui/formatting.rb +7 -22
  63. data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
  64. data/lib/samagotchi/terminal_ui.rb +72 -57
  65. data/lib/samagotchi/tool_activity.rb +5 -5
  66. data/lib/samagotchi/tools/delegate.rb +28 -1
  67. data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
  68. data/lib/samagotchi/tools/delegate_wait.rb +17 -5
  69. data/lib/samagotchi/tools/execute.rb +11 -4
  70. data/lib/samagotchi/turn_checkpoint.rb +9 -3
  71. data/lib/samagotchi/turn_note.rb +22 -5
  72. data/lib/samagotchi/version.rb +1 -1
  73. data/lib/samagotchi/web/app.rb +2 -1
  74. data/lib/samagotchi/web/public/activity.js +3 -0
  75. data/lib/samagotchi/web/public/app.js +96 -13
  76. data/lib/samagotchi/web/public/commands.js +8 -8
  77. data/lib/samagotchi/web/public/composer.js +3 -3
  78. data/lib/samagotchi/web/public/delivery_line.js +228 -0
  79. data/lib/samagotchi/web/public/index.html +29 -0
  80. data/lib/samagotchi/web/public/live_turn.js +8 -0
  81. data/lib/samagotchi/web/public/stage_model.js +27 -7
  82. data/lib/samagotchi/web/public/stage_view.js +32 -10
  83. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  84. data/lib/samagotchi/web/public/timing.js +2 -1
  85. data/lib/samagotchi/web/public/turn_events.js +22 -8
  86. data/lib/samagotchi/web/public/turn_html.js +20 -4
  87. data/lib/samagotchi/web/public/turn_model.js +29 -12
  88. data/lib/samagotchi/web/public/turn_view.js +6 -4
  89. data/lib/samagotchi/worker.rb +7 -5
  90. metadata +9 -1
@@ -15,17 +15,24 @@ module Samagotchi
15
15
  # conversation) and .empty_retries (the retries spent): the Engine saves
16
16
  # them on the turn's note for the UIs (TurnNote.empty).
17
17
  #
18
+ # .refusal is set when the provider refused the answer (Messages'
19
+ # stop_reason refusal, OpenAI's content_filter): {category:} as the
20
+ # provider names it, {} without one; nil otherwise. Such a turn is an
21
+ # empty answer the loop never asked again.
22
+ #
18
23
  # .run_start is where the turn's run started in the conversation (the
19
24
  # length of the messages the loop got), set by the Engine after the loop
20
25
  # returns; nil from a loop called directly. A step-limit offer keeps it
21
26
  # (TurnFlow), so a Continue resumes that run (Engine#run_turn resume_from:).
22
27
  class ModelResult
23
28
  attr_reader :text, :conversation, :canceled, :cancellation_reason, :exhausted,
24
- :tool_activity, :context_status, :pending_tool_calls, :empty_steps, :empty_retries
29
+ :tool_activity, :context_status, :pending_tool_calls, :empty_steps, :empty_retries, :refusal
25
30
  attr_accessor :run_start
26
31
 
27
32
  def initialize(text:, conversation: nil, canceled: false, cancellation_reason: nil, exhausted: false,
28
- tool_activity: [], context_status: nil, pending_tool_calls: false, empty_steps: [], empty_retries: 0)
33
+ tool_activity: [], context_status: nil, pending_tool_calls: false, empty_steps: [], empty_retries: 0,
34
+ refusal: nil)
35
+ @refusal = refusal
29
36
  @empty_steps = Array(empty_steps)
30
37
  @empty_retries = empty_retries.to_i
31
38
  @text = text
@@ -4,30 +4,16 @@ require "json"
4
4
  require "uri"
5
5
  require_relative "../config"
6
6
  require_relative "../sampling_settings"
7
+ require_relative "../thinking"
7
8
  require_relative "errors"
8
9
  require_relative "http"
10
+ require_relative "model_listing"
9
11
  require_relative "prompt_cache"
10
12
  require_relative "usage"
11
13
  require_relative "utf8_scrub"
12
14
 
13
15
  module Samagotchi
14
16
  module LLM
15
- # A model a host lists. Unknown fields are nil.
16
- ModelInfo = Data.define(:id, :context_window, :supports_tools, :raw) do
17
- # true when the list says the model takes images (OpenRouter's
18
- # architecture.input_modalities, llama.cpp's "multimodal" capability),
19
- # false when it lists input modalities without "image", else nil.
20
- def image_input
21
- return nil unless raw.is_a?(Hash)
22
-
23
- modalities = raw.dig("architecture", "input_modalities")
24
- return modalities.include?("image") if modalities.is_a?(Array)
25
- return true if Array(raw["capabilities"]).include?("multimodal")
26
-
27
- nil
28
- end
29
- end
30
-
31
17
  # A tool call the model made. arguments: the parsed Hash, or the raw
32
18
  # String when it isn't valid JSON (the tool then reports the error).
33
19
  ToolCall = Data.define(:id, :name, :arguments)
@@ -43,9 +29,17 @@ module Samagotchi
43
29
  # +top_logprobs+: the first answer token's likeliest alternatives
44
30
  # (TokenLogprob) when the request asked for logprobs and the server
45
31
  # sent them (a non-streamed answer only); [] otherwise.
32
+ # +provider_content+: the response's content blocks in order, as the
33
+ # server sent them (string keys), for a wire that sends them back
34
+ # verbatim (AnthropicMessages: thinking blocks with their signatures);
35
+ # nil on the OpenAI wire.
36
+ # +stop_details+: why the provider stopped, when it says more than the
37
+ # finish reason (Messages' stop_details on a refusal: type, category,
38
+ # explanation; string keys); nil otherwise.
46
39
  ChatResponse = Data.define(:text, :reasoning, :tool_calls, :usage, :finish_reason, :model, :provider, :cut,
47
- :top_logprobs) do
48
- def initialize(model: nil, provider: nil, cut: nil, top_logprobs: [].freeze, **fields) = super
40
+ :top_logprobs, :provider_content, :stop_details) do
41
+ def initialize(model: nil, provider: nil, cut: nil, top_logprobs: [].freeze, provider_content: nil, stop_details: nil,
42
+ **fields) = super
49
43
  end
50
44
 
51
45
  # One alternative for an answer token: its text and log probability.
@@ -75,6 +69,8 @@ module Samagotchi
75
69
  # default, with errors mapped to ProviderError. It speaks the wire format
76
70
  # only; the chat loop owns the conversation and the tools.
77
71
  class OpenAIChat
72
+ include ModelListing
73
+
78
74
  MAX_MODEL_PAGES = 20
79
75
  # How much of a 200's non-stream body is read as a possible JSON error.
80
76
  PLAIN_ERROR_LIMIT = 64 * 1024
@@ -90,14 +86,11 @@ module Samagotchi
90
86
 
91
87
  # @param entry [HostRegistry::HostEntry]
92
88
  def self.for(entry, **)
93
- new(base_url: entry.openai_base_url, host_name: entry.name, api_key_env: entry.api_key_env,
94
- remote: entry.remote?, default_max_tokens: (OPENROUTER_MAX_TOKENS if openrouter?(entry.openai_base_url)),
89
+ new(base_url: entry.api_base_url, host_name: entry.name, api_key_env: entry.api_key_env, auth: entry.auth,
90
+ remote: entry.remote?, default_max_tokens: (OPENROUTER_MAX_TOKENS if openrouter?(entry.api_base_url)),
95
91
  **)
96
92
  end
97
93
 
98
- # true when a streamed +payload+ carries a tool-call delta.
99
- def self.tool_call_delta?(payload) = Assembly.tool_call_delta?(payload)
100
-
101
94
  # true when +url+ is OpenRouter's API (openrouter.ai or a subdomain).
102
95
  def self.openrouter?(url)
103
96
  host = URI(url.to_s).host.to_s.downcase
@@ -134,19 +127,23 @@ module Samagotchi
134
127
  # @param host_name [String] names the host in errors
135
128
  # @param api_key_env [String, nil] the variable holding the API key;
136
129
  # nil sends no Authorization header (a local server)
130
+ # @param auth [Symbol, nil] the header the key goes in
131
+ # (ApiKey::SCHEMES); nil: Authorization: Bearer
137
132
  # @param stream [Boolean] stream the reply (false: one JSON body)
138
133
  # @param retries [Boolean] false: one attempt
139
134
  # @param timeout [Numeric, nil] read timeout in seconds (default
140
135
  # server.read_timeout); the connect timeout is capped by it
136
+ # @param open_timeout [Numeric, nil] connect timeout in seconds, when
137
+ # shorter than that (a reachability probe's)
141
138
  # @param models_ttl [Numeric] how long #context_window reuses the list
142
139
  # @param remote [Boolean] a provider on the network, not a local server
143
140
  # @param first_token_timeout [Numeric, nil] seconds a streamed answer
144
141
  # may take to show something (LLM::HTTP); nil: no limit
145
142
  # @param default_max_tokens [Integer, nil] max_tokens for a request
146
143
  # whose options name none (OPENROUTER_MAX_TOKENS); nil sends none
147
- def initialize(base_url:, host_name:, api_key_env: nil, stream: true, retries: true, timeout: nil,
144
+ def initialize(base_url:, host_name:, api_key_env: nil, auth: nil, stream: true, retries: true, timeout: nil,
148
145
  env: ENV, sleeper: nil, retry_policy: nil, models_ttl: DEFAULT_MODELS_TTL, remote: false,
149
- first_token_timeout: nil, purpose: "chat", default_max_tokens: nil)
146
+ first_token_timeout: nil, purpose: "chat", default_max_tokens: nil, open_timeout: nil)
150
147
  @remote = remote
151
148
  @default_max_tokens = default_max_tokens
152
149
  # What its requests are for, in the log (LLM::HTTP::LOGGED_PURPOSES).
@@ -156,13 +153,12 @@ module Samagotchi
156
153
  @host_name = host_name.to_s
157
154
  @api_key_env = api_key_env
158
155
  @stream = stream
159
- @models_ttl = models_ttl
160
- @models_mutex = Mutex.new
161
- open_timeout, read_timeout = timeouts(timeout)
156
+ init_model_listing(models_ttl)
157
+ open_timeout, read_timeout = timeouts(timeout, open_timeout)
162
158
  policy = retry_policy || (retries ? nil : HTTP::RetryPolicy.none)
163
159
  @http = HTTP.new(label: @host_name, open_timeout: open_timeout, read_timeout: read_timeout,
164
160
  retry_policy: policy, sleeper: sleeper, first_token_timeout: first_token_timeout,
165
- api_key: ApiKey.for(api_key_env, host: @host_name, env: env))
161
+ api_key: ApiKey.for(api_key_env, host: @host_name, env: env, scheme: auth))
166
162
  end
167
163
 
168
164
  # @param messages [Array<Hash>] wire messages (role, content, tool_calls,
@@ -216,6 +212,25 @@ module Samagotchi
216
212
  # A provider on the network: no llama.cpp /props to ask for the window.
217
213
  def remote? = @remote
218
214
 
215
+ # Model entries go back as text, tool_calls and reasoning_content,
216
+ # never as saved provider blocks (AnthropicMessages does).
217
+ def replays_content? = false
218
+
219
+ # true when a streamed chunk's +payload+ (as #chat's on_delta gets it)
220
+ # carries part of a tool call (ChatLoop marks the chunk's event).
221
+ def tool_call_delta?(payload) = Assembly.tool_call_delta?(payload)
222
+
223
+ # The request fields a thinking +level+ (Thinking::LEVELS) adds on
224
+ # this wire: Thinking.chat_fields's OpenAI-style switches.
225
+ # @return [Hash] frozen
226
+ def thinking_fields(level) = Thinking.chat_fields(level)
227
+
228
+ # The thinking fields for a short side answer from a model that
229
+ # refused thinking off (gpt-oss: "Reasoning is mandatory"): none, the
230
+ # model's own default.
231
+ # @return [Hash] frozen
232
+ def thinking_off_refused_fields = {}.freeze
233
+
219
234
  # @return [Array<ModelInfo>] the host's models (every page)
220
235
  def list_models
221
236
  models = []
@@ -231,26 +246,6 @@ module Samagotchi
231
246
  models
232
247
  end
233
248
 
234
- # The window (tokens) the host lists for +model+, or nil. The list is
235
- # read once per models_ttl; a failed listing answers nil.
236
- def context_window(model:)
237
- info = cached_models.find { |m| m.id == model.to_s } ||
238
- cached_models.find { |m| m.id.casecmp?(model.to_s) }
239
- info&.context_window
240
- rescue StandardError
241
- nil
242
- end
243
-
244
- # Whether the host's list says +model+ takes images (ModelInfo#image_input),
245
- # or nil when it doesn't say or the listing fails.
246
- def image_input(model:)
247
- info = cached_models.find { |m| m.id == model.to_s } ||
248
- cached_models.find { |m| m.id.casecmp?(model.to_s) }
249
- info&.image_input
250
- rescue StandardError
251
- nil
252
- end
253
-
254
249
  def self.served_model(payload)
255
250
  model = payload.is_a?(Hash) ? payload["model"] : nil
256
251
  model.is_a?(String) && !model.strip.empty? ? model : nil
@@ -265,9 +260,10 @@ module Samagotchi
265
260
 
266
261
  private
267
262
 
268
- def timeouts(timeout)
263
+ def timeouts(timeout, open_limit = nil)
269
264
  open_timeout = Samagotchi::Config.positive_seconds("server.open_timeout")
270
265
  read_timeout = Samagotchi::Config.positive_seconds("server.read_timeout")
266
+ open_timeout = [open_timeout, open_limit].min if open_limit
271
267
  return [open_timeout, read_timeout] unless timeout
272
268
 
273
269
  [[open_timeout, timeout].min, timeout]
@@ -411,49 +407,6 @@ module Samagotchi
411
407
 
412
408
  def get_request(uri) = Net::HTTP::Get.new(uri)
413
409
 
414
- def model_entries(body)
415
- return [] unless body.is_a?(Hash)
416
-
417
- entries = body["data"].is_a?(Array) ? body["data"] : body["models"]
418
- Array(entries).select { |entry| entry.is_a?(Hash) }
419
- end
420
-
421
- def model_info(raw)
422
- id = (raw["id"] || raw["model"] || raw["name"]).to_s
423
- ModelInfo.new(id: id, context_window: window_of(raw), supports_tools: tools_support(raw), raw: raw)
424
- end
425
-
426
- # The running window when the list says: context_length, context_window,
427
- # max_model_len, or llama.cpp's meta.n_ctx (not n_ctx_train).
428
- def window_of(raw)
429
- [raw["context_length"], raw["context_window"], raw["max_model_len"], raw.dig("meta", "n_ctx")].each do |value|
430
- return value if value.is_a?(Integer) && value.positive?
431
- end
432
- nil
433
- end
434
-
435
- def tools_support(raw)
436
- listed = raw["supported_parameters"] || raw["capabilities"]
437
- return nil unless listed.is_a?(Array)
438
-
439
- listed.include?("tools") || nil
440
- end
441
-
442
- def cached_models
443
- @models_mutex.synchronize do
444
- fresh = @models_at && (monotonic - @models_at) < @models_ttl
445
- return @models if fresh
446
- end
447
- models = list_models
448
- @models_mutex.synchronize do
449
- @models = models
450
- @models_at = monotonic
451
- end
452
- models
453
- end
454
-
455
- def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
456
-
457
410
  # Streamed deltas put together: text, reasoning, tool calls by index,
458
411
  # the finish reason, the usage chunk, the served model and provider.
459
412
  class Assembly
@@ -484,7 +484,7 @@ module Samagotchi
484
484
  private_class_method :configured
485
485
 
486
486
  # Only a native llama.cpp host has /props with a chat template; a chat
487
- # host (api: openai) barely uses a profile, and mlx/oMLX expose none.
487
+ # host (api: openai or anthropic) barely uses a profile, and mlx/oMLX expose none.
488
488
  def self.probe?(entry, client)
489
489
  return false if client.nil? || !client.respond_to?(:server_props)
490
490
  return false if entry&.chat?
@@ -19,17 +19,24 @@ module Samagotchi
19
19
  end
20
20
 
21
21
  # Append a message to the queue. Nil/empty strings are ignored.
22
- def push(text)
22
+ # +seq+ is the sender's own order number for it (the REPL numbers every
23
+ # line it takes during a turn, so its leftovers keep send order across
24
+ # this queue and its after-turn list: #drain_entries).
25
+ def push(text, seq: nil)
23
26
  text = text.to_s
24
27
  return if text.empty?
25
28
 
26
- @mutex.synchronize { @messages << text }
29
+ @mutex.synchronize { @messages << [seq, text] }
27
30
  nil
28
31
  end
29
32
 
30
33
  # Remove and return all queued messages in FIFO order. Non-blocking.
31
34
  # @return [Array<String>]
32
- def drain
35
+ def drain = drain_entries.map(&:last)
36
+
37
+ # #drain with each message's +seq+ (nil when pushed without one).
38
+ # @return [Array(Integer|nil, String)]
39
+ def drain_entries
33
40
  @mutex.synchronize do
34
41
  drained = @messages
35
42
  @messages = []
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../client_id"
4
+ require_relative "../delivery"
4
5
  require_relative "../session"
5
6
  require_relative "../bridge_client"
6
7
  require_relative "../bridge/turn_accumulator"
@@ -62,18 +63,28 @@ module Samagotchi
62
63
  # Send +text+ to session +id+ as a user message (it runs as a turn;
63
64
  # a stopped session is woken). Waits up to 5 s for its worker, so call
64
65
  # it from an anytime command or your own thread, never a tool or hook
65
- # of a running turn.
66
+ # of a running turn. A running turn takes it at its next step; with
67
+ # +delivery: :queue+ it runs as a turn of its own after that one
68
+ # instead. A plugin never cuts the running turn's thinking.
69
+ # @param delivery [Symbol, String, nil] nil or :next_step (default),
70
+ # :queue
66
71
  # @return [String] the id of the session it went to
67
- # @raise [Error] no such session, a REPL owns it, or it wasn't taken
68
- def send(id, text)
72
+ # @raise [Error] no such session, a REPL owns it, it wasn't taken
73
+ # (a worker older than queueing refuses a queue), or delivery is
74
+ # :cut or unknown
75
+ def send(id, text, delivery: nil)
69
76
  raise Error, "nothing to send" if text.to_s.strip.empty?
70
77
 
78
+ wire = plugin_delivery(delivery)
71
79
  state_dir = self.state_dir
72
80
  sid = resolve(id, state_dir)
73
- delivered = SessionManager.deliver_turn(sid, prompt: text.to_s, client_id: ClientId::PLUGIN, state_dir: state_dir)
81
+ delivered = SessionManager.deliver_turn(sid, prompt: text.to_s, client_id: ClientId::PLUGIN, state_dir: state_dir,
82
+ delivery: wire)
74
83
  case delivered[:status]
75
84
  when :accepted then sid
76
- when :refused then raise Error, "session #{sid[0, 8]} refused the message (#{delivered.dig(:ack, "error")})"
85
+ when :refused
86
+ ack = delivered[:ack] || {}
87
+ raise Error, "session #{sid[0, 8]} refused the message (#{ack["error"]}#{": #{ack["detail"]}" if ack["detail"]})"
77
88
  when :timeout then raise Error, "session #{sid[0, 8]}'s worker did not answer in time; the message was not sent"
78
89
  else raise Error, "the message to session #{sid[0, 8]} could not be written"
79
90
  end
@@ -81,6 +92,17 @@ module Samagotchi
81
92
  raise Error, "session #{sid[0, 8]} is open in a chi REPL, which takes no messages from others"
82
93
  end
83
94
 
95
+ # The wire value for send's delivery: (nil for the default).
96
+ def plugin_delivery(delivery)
97
+ case delivery&.to_s
98
+ when nil, "", Delivery::NEXT_STEP then nil
99
+ when Delivery::QUEUE then Delivery::QUEUE
100
+ when Delivery::CUT then raise Error, "plugins never cut: send it plain (the next step) or delivery: :queue"
101
+ else raise Error, "delivery takes :next_step or :queue, not #{delivery.inspect}"
102
+ end
103
+ end
104
+ private :plugin_delivery
105
+
84
106
  # Session +id+ now: from its worker when one runs (with a running turn
85
107
  # so far), else as saved.
86
108
  # @return [Hash] {id:, title:, status:, parent_id:, running:, messages:}
@@ -16,12 +16,17 @@ module Samagotchi
16
16
  #
17
17
  # Order (#resolve): the session's own level (/reasoning-history), then
18
18
  # SAMAGOTCHI_REASONING_HISTORY, models.<key>.reasoning_history,
19
- # hosts.<name>.reasoning_history, then turn. Native hosts ignore it:
19
+ # hosts.<name>.reasoning_history, then the host's api default: all for
20
+ # api: anthropic (its thinking blocks go back verbatim, and removing
21
+ # earlier turns' would rewrite the cached prefix every turn; ChatLoop
22
+ # floors drop at turn there), turn elsewhere. Native hosts ignore it:
20
23
  # KernelLoop keeps the running turn's reasoning on its own, from the
21
24
  # run's start (.run_start).
22
25
  module ReasoningHistory
23
26
  LEVELS = %i[drop turn all].freeze
24
27
  DEFAULT = :turn
28
+ # The default for a host's api when it isn't DEFAULT.
29
+ API_DEFAULTS = { anthropic: :all }.freeze
25
30
  KEY = "reasoning_history"
26
31
  ENV_NAME = "SAMAGOTCHI_REASONING_HISTORY"
27
32
  # The source a session's own level reads as.
@@ -82,7 +87,7 @@ module Samagotchi
82
87
  host = target.entry&.reasoning_history
83
88
  return [host, "hosts.#{target.entry.name}"] if host
84
89
 
85
- [DEFAULT, nil]
90
+ [API_DEFAULTS.fetch(target.entry&.api&.to_sym, DEFAULT), nil]
86
91
  end
87
92
 
88
93
  # #resolve, as a SettingExplained with the session's own level beside it.
@@ -112,10 +117,15 @@ module Samagotchi
112
117
  end
113
118
 
114
119
  # Whether +conversation+ has a model entry with thinking (what all
115
- # sends back as reasoning_content; LLM::ChatLoop#thought?).
120
+ # sends back as reasoning_content; LLM::ChatLoop#thought?), or with a
121
+ # saved thinking block (a Messages host's, which may carry no text:
122
+ # Claude's omitted display).
116
123
  def earlier_thought?(conversation)
117
124
  Array(conversation).any? do |entry|
118
- entry[:role].to_s == "model" && entry[:thinking].is_a?(String) && !entry[:thinking].strip.empty?
125
+ next false unless entry[:role].to_s == "model"
126
+
127
+ (entry[:thinking].is_a?(String) && !entry[:thinking].strip.empty?) ||
128
+ Array(entry[:anthropic_content]).any? { |block| block.is_a?(Hash) && %w[thinking redacted_thinking].include?(block["type"].to_s) }
119
129
  end
120
130
  end
121
131
 
@@ -65,7 +65,8 @@ module Samagotchi
65
65
  min_user_turns: recap_number_setting(kwarg_config, :min_user_turns, "recap.min_user_turns", IdleRecap::DEFAULT_MIN_USER_TURNS, :int),
66
66
  timeout: recap_number_setting(kwarg_config, :timeout, "recap.timeout", IdleRecap::DEFAULT_TIMEOUT_SECONDS, :float),
67
67
  sentences: recap_sentences(string_config(kwarg_config, :sentences) || registry_string("recap.sentences")),
68
- store: RecapStore.new(session_id_lookup: -> { session_id.call }, state_dir_lookup: -> { state_dir.call })
68
+ store: RecapStore.new(session_id_lookup: -> { session_id.call }, state_dir_lookup: -> { state_dir.call }),
69
+ session_id: -> { session_id.call }
69
70
  )
70
71
  end
71
72
 
@@ -3,6 +3,7 @@
3
3
  require_relative "session"
4
4
  require_relative "turn_note"
5
5
  require_relative "session_inbox"
6
+ require_relative "worker_sidecar"
6
7
 
7
8
  module Samagotchi
8
9
  # Loaded on first use: session_manager requires terminal_ui, which
@@ -14,9 +15,15 @@ module Samagotchi
14
15
  # output/<timestamp>.txt file (one per turn that ended with visible text,
15
16
  # written just before its idle save), so "next" means a filename past the
16
17
  # cursor the caller holds: the newest one it already has, or nil for any.
18
+ # A wait for one input's turn (turn:, a queued message) looks for that
19
+ # turn's own file instead (output/<timestamp>-<enqueued_id>.txt).
17
20
  # The delegate tools and `chi send --wait` wrap it in their own words.
18
21
  module ReplyWait
19
22
  POLL_INTERVAL = 0.5
23
+ # A worker's sidecar feature (Bridge::FEATURES): its running turn's
24
+ # checkpoint mark (InterruptedTurn#enqueued_id), a continue turn and a
25
+ # Stop's last_turn carry the turn's enqueued_id (#own_turn).
26
+ TURN_IDS_FEATURE = "turn_ids"
20
27
 
21
28
  # @!attribute status [Symbol] :done, :waiting_for_answer, :no_reply,
22
29
  # :error, :stopped, :canceled, :timeout or :worker_gone
@@ -38,7 +45,9 @@ module Samagotchi
38
45
  # @!attribute retryable [Boolean, nil] whether that error may pass on a
39
46
  # retry
40
47
  # @!attribute cancel_reason [String, nil] a "canceled" turn's reason
41
- # (user, hook, ctrl_c, manual)
48
+ # (user, hook, ctrl_c, manual, session_stopped: the session was
49
+ # stopped mid-turn), passed through as the worker saved it; any
50
+ # reason is a "canceled" outcome
42
51
  # @!attribute stopped_by [String, nil] the hook that canceled it
43
52
  # (loop-guard)
44
53
  # @!attribute session [Session, nil] the session as the look that
@@ -70,18 +79,64 @@ module Samagotchi
70
79
  # @param interject [#call, nil] called once a poll, before the sleep
71
80
  # (the delegate wait relays other children's approvals there); the
72
81
  # time it takes doesn't count against +timeout+
82
+ # @param turn [String, nil] the enqueued_id of the input whose turn is
83
+ # waited for (a queued message, which runs after the turn running
84
+ # now). With it only that turn ends the wait: its reply file
85
+ # (SessionInbox.reply_for), or an idle session whose last_turn has
86
+ # that enqueued_id (failed, canceled, empty: #no_reply). Other turns'
87
+ # replies and ends are not its, and a pending question counts only
88
+ # when it is that turn's (#own_question?). A last_turn that ended
89
+ # before the baseline's (the turn whose continue offer was just
90
+ # answered) is not the end. An input a running turn merged as a
91
+ # steer (taken, never run as its own) is waited for as without turn:
92
+ # (that turn's reply or question). A turn seen running, then past
93
+ # (another turn runs or ended, last_turn not it): :no_reply, outcome
94
+ # unknown.
95
+ # @param queued [Boolean] the turn: input was sent as a queue (or with
96
+ # images), which a running turn never merges: no fallback, even when
97
+ # its file was taken before the first look
73
98
  # @return [Result]
74
99
  # @raise [ArgumentError] no such session
75
100
  def call(id, state_dir:, cursor:, timeout: nil, poll_interval: POLL_INTERVAL, cancelled: -> { false },
76
- baseline: nil, owner_grace: nil, interject: nil)
101
+ baseline: nil, owner_grace: nil, interject: nil, turn: nil, queued: false)
77
102
  deadline = timeout && (monotonic + timeout.to_f)
78
103
  seen_running = false
79
104
  gone_since = nil
105
+ turn = turn.to_s.empty? ? nil : turn.to_s
106
+ session_dir = Session.session_dir(id, state_dir: state_dir) if turn
107
+ # Whether the turn's input file has been taken (latched): looked at
108
+ # before each load, so a running session seen after it is that turn
109
+ # or a later one, never the turn that ran when the message was sent.
110
+ claimed = false
111
+ # Its input runs a turn of its own (a queue, images: +queued+, or seen
112
+ # waiting as one): never merged into another turn.
113
+ own_turn_input = queued
114
+ # The turn was seen running (its mark's id), and last_turn's ended_at
115
+ # then: a later end that isn't its is a turn after it.
116
+ seen_own = false
117
+ own_base = nil
80
118
 
81
119
  loop do
120
+ if turn && !claimed
121
+ waiting = SessionInbox.waiting_input(session_dir, turn)
122
+ own_turn_input ||= waiting == :turn_end
123
+ claimed = waiting.nil?
124
+ end
82
125
  session = Session.load(id, state_dir: state_dir)
126
+ if turn && !seen_own && session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == turn
127
+ seen_own = true
128
+ own_base = session.last_turn&.ended_at
129
+ end
130
+ # Taken but never run as a turn of its own: a running turn's drain
131
+ # merged it as a steer, so that turn's reply (or question) is its
132
+ # answer. Waited for as the next reply past the cursor, as without
133
+ # turn:, never as a turn that will not come.
134
+ if turn && claimed && !seen_own && !own_turn_input && merged_elsewhere?(session, turn, baseline, session_dir)
135
+ turn = nil
136
+ seen_running = session.status == Session::STATUS_RUNNING
137
+ end
83
138
 
84
- if (reply = reply_past(id, state_dir: state_dir, cursor: cursor))
139
+ if (reply = turn ? reply_for_turn(id, state_dir: state_dir, turn: turn) : reply_past(id, state_dir: state_dir, cursor: cursor))
85
140
  reply.session = settled(session, id, state_dir: state_dir)
86
141
  return reply
87
142
  end
@@ -98,13 +153,31 @@ module Samagotchi
98
153
  # REPL's is answered there.
99
154
  pending = session.pending_question
100
155
  if pending && !(baseline && pending[:id] == baseline[:question_id]) &&
156
+ (turn.nil? || own_question?(session, turn, claimed)) &&
101
157
  session.waiting_question(live: SessionManager.worker_live?(id, state_dir: state_dir))
102
158
  return Result.new(status: :waiting_for_answer, question: pending, session: session)
103
159
  end
104
160
 
105
161
  return Result.new(status: :canceled) if cancelled.call
106
162
 
107
- if session.status == Session::STATUS_RUNNING
163
+ if turn
164
+ # Only the turn's own end: the session runs another turn when the
165
+ # wait starts, and that one's end is not the answer. A last_turn
166
+ # with its id is its end, even with a next queued turn running
167
+ # by now (the look between the two missed the idle).
168
+ # Only an end after the baseline: a continue offer answered from
169
+ # idle leaves the exhausted turn's last_turn, with the same id,
170
+ # until the continue turn (or a Stop) records its own.
171
+ if session.last_turn&.enqueued_id == turn && ended_since_baseline?(session, baseline)
172
+ return reply_for_turn(id, state_dir: state_dir, turn: turn)&.tap { |r| r.session = session } ||
173
+ no_reply(session, baseline, fresh: true)
174
+ end
175
+ # Seen running, and now another turn runs or a later turn ended
176
+ # while last_turn (looked at first) isn't its: lost (its worker
177
+ # died), or it ended with no reply and a next turn ran and ended
178
+ # between two looks. Waiting on would sit through those turns.
179
+ return Result.new(status: :no_reply, session: session) if seen_own && past_own?(session, turn, own_base)
180
+ elsif session.status == Session::STATUS_RUNNING
108
181
  seen_running = true
109
182
  elsif (seen_running && !baseline&.key?(:last_turn)) || turn_ended_since?(session, baseline)
110
183
  # It ran and is idle again with no new reply: canceled, failed or
@@ -164,11 +237,103 @@ module Samagotchi
164
237
  !!(baseline[:messages] && session.messages.size > baseline[:messages])
165
238
  end
166
239
 
240
+ # The turn a caller that sent nothing means (chi answer, chi send --wait
241
+ # with no message): the one running now (its checkpoint mark's
242
+ # enqueued_id), or, idle, the turn whose continue offer stands (its
243
+ # last_turn's), when the worker's sidecar says it ties a continue turn
244
+ # to that id (TURN_IDS_FEATURE). nil otherwise (no id known, a worker
245
+ # from before): the caller waits for the next reply as before.
246
+ # @param session [Session] the session as loaded now
247
+ # @return [String, nil] an enqueued_id for call's turn:
248
+ def own_turn(session, session_dir:)
249
+ if session.status == Session::STATUS_RUNNING
250
+ # Only a worker that writes it has one: no feature check.
251
+ return session.interrupted_turn&.enqueued_id
252
+ end
253
+
254
+ pending = session.pending_question
255
+ return nil unless pending && pending[:kind].to_s == "continue"
256
+ return nil unless turn_ids?(session_dir)
257
+
258
+ session.last_turn&.enqueued_id
259
+ end
260
+
261
+ # Whether the session's worker sidecar names TURN_IDS_FEATURE.
262
+ def turn_ids?(session_dir)
263
+ WorkerSidecar.read(session_dir)&.features&.include?(TURN_IDS_FEATURE) || false
264
+ end
265
+
266
+ # A pending question is a turn: wait's (its enqueued_id) only when that
267
+ # turn asked it. While a turn runs its checkpoint mark says which turn
268
+ # that is (a continue turn carries its offer turn's id); a mark with no
269
+ # id (a worker from before) falls back to the claimed rule (its input
270
+ # claimed, the session running, last_turn not already it). Idle: only
271
+ # its own continue offer (last_turn has the id). Any other is another
272
+ # turn's, whose questions are the user's: the wait goes on.
273
+ def own_question?(session, turn, claimed)
274
+ ended = session.last_turn&.enqueued_id == turn
275
+ return ended unless session.status == Session::STATUS_RUNNING
276
+
277
+ running_id = session.interrupted_turn&.enqueued_id
278
+ return running_id == turn if running_id
279
+
280
+ # Ended, running again: a next turn.
281
+ !ended && claimed
282
+ end
283
+
284
+ # The turn's end, after the baseline was taken (with no baseline, or
285
+ # one without last_turn, any).
286
+ def ended_since_baseline?(session, baseline)
287
+ return true unless baseline&.key?(:last_turn)
288
+
289
+ session.last_turn&.ended_at != baseline[:last_turn]
290
+ end
291
+
292
+ # A turn: wait's taken input ran in another turn (a steer its drain
293
+ # merged): that turn runs (its mark has another id, or none from a
294
+ # worker that writes ids), or, idle, a turn not its ended since the
295
+ # baseline. last_turn or a reply file being its own is never that.
296
+ def merged_elsewhere?(session, turn, baseline, session_dir)
297
+ return false if session.last_turn&.enqueued_id == turn || SessionInbox.reply_for(session_dir, turn)
298
+
299
+ if session.status == Session::STATUS_RUNNING
300
+ running_id = session.interrupted_turn&.enqueued_id
301
+ return running_id != turn if running_id
302
+
303
+ return turn_ids?(session_dir)
304
+ end
305
+ baseline&.key?(:last_turn) ? turn_ended_since?(session, baseline) : false
306
+ end
307
+
308
+ # A turn seen running (+own_base+: last_turn's ended_at then) is past:
309
+ # last_turn isn't its, and another turn runs (a mark with another id)
310
+ # or a turn ended since.
311
+ def past_own?(session, turn, own_base)
312
+ return false if session.last_turn&.enqueued_id == turn
313
+
314
+ running_id = session.status == Session::STATUS_RUNNING ? session.interrupted_turn&.enqueued_id : nil
315
+ return true if running_id && running_id != turn
316
+
317
+ ended = session.last_turn&.ended_at
318
+ !ended.nil? && ended != own_base
319
+ end
320
+
321
+ # @return [Result, nil] :done with the reply of the turn that ran the
322
+ # input +turn+ (its enqueued_id)
323
+ def reply_for_turn(id, state_dir:, turn:)
324
+ file = SessionInbox.reply_for(Session.session_dir(id, state_dir: state_dir), turn)
325
+ return nil unless file
326
+
327
+ Result.new(status: :done, text: File.read(File.join(reply_dir(id, state_dir: state_dir), file)), file: file)
328
+ end
329
+
330
+ # @param fresh [Boolean, nil] the last_turn is known to be the turn
331
+ # waited for (a turn: wait); nil decides it from the baseline
167
332
  # @return [Result] :no_reply with the outcome the session's last_turn
168
333
  # records, when it is the turn waited for
169
- def no_reply(session, baseline)
334
+ def no_reply(session, baseline, fresh: nil)
170
335
  last = session.last_turn || LastTurn.new
171
- fresh = baseline.nil? || !baseline.key?(:last_turn) || (last.ended_at && last.ended_at != baseline[:last_turn])
336
+ fresh = baseline.nil? || !baseline.key?(:last_turn) || (last.ended_at && last.ended_at != baseline[:last_turn]) if fresh.nil?
172
337
  outcome = fresh ? last.outcome : nil
173
338
  return Result.new(status: :no_reply, outcome: "exhausted", limit: last.limit, session: session) if fresh && last.exhausted
174
339