samagotchi 0.77.0 → 0.79.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -1
  3. data/docs/cli.md +2 -1
  4. data/docs/configuration.md +14 -8
  5. data/docs/internals/anthropic.md +80 -7
  6. data/docs/internals/engine.md +3 -2
  7. data/docs/internals/prompt-caching.md +3 -1
  8. data/docs/sessions.md +1 -0
  9. data/lib/samagotchi/broadcast/recipients.rb +2 -2
  10. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  11. data/lib/samagotchi/child_move.rb +2 -2
  12. data/lib/samagotchi/child_reports.rb +2 -2
  13. data/lib/samagotchi/children_status.rb +2 -2
  14. data/lib/samagotchi/llm/adapter_common.rb +4 -2
  15. data/lib/samagotchi/llm/anthropic_assembly.rb +14 -0
  16. data/lib/samagotchi/llm/anthropic_binding_recovery.rb +195 -0
  17. data/lib/samagotchi/llm/anthropic_messages.rb +74 -17
  18. data/lib/samagotchi/llm/anthropic_wire.rb +29 -1
  19. data/lib/samagotchi/llm/chat_loop.rb +61 -5
  20. data/lib/samagotchi/llm/errors.rb +6 -4
  21. data/lib/samagotchi/llm/http.rb +2 -1
  22. data/lib/samagotchi/llm/model_listing.rb +29 -1
  23. data/lib/samagotchi/llm/openai_chat.rb +12 -2
  24. data/lib/samagotchi/llm/refusal_memo.rb +3 -2
  25. data/lib/samagotchi/note_delivery.rb +2 -2
  26. data/lib/samagotchi/plugin/sessions.rb +2 -2
  27. data/lib/samagotchi/question_desk.rb +10 -3
  28. data/lib/samagotchi/reasoning_history.rb +2 -1
  29. data/lib/samagotchi/reply_wait.rb +2 -3
  30. data/lib/samagotchi/session_manager.rb +0 -1
  31. data/lib/samagotchi/session_metrics/totals.rb +4 -3
  32. data/lib/samagotchi/session_metrics.rb +9 -0
  33. data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
  34. data/lib/samagotchi/terminal_ui.rb +2 -2
  35. data/lib/samagotchi/tools/delegate.rb +2 -2
  36. data/lib/samagotchi/tools/delegate_result.rb +2 -2
  37. data/lib/samagotchi/tools/list_sessions.rb +2 -2
  38. data/lib/samagotchi/version.rb +1 -1
  39. data/lib/samagotchi/web/public/app.js +61 -53
  40. data/lib/samagotchi/web/public/composer_fold.js +58 -0
  41. data/lib/samagotchi/web/public/composer_grip.js +231 -0
  42. data/lib/samagotchi/web/public/composer_size.js +10 -0
  43. data/lib/samagotchi/web/public/index.html +27 -3
  44. data/lib/samagotchi/web/public/model_picker.js +4 -5
  45. data/lib/samagotchi/web/public/stage_view.js +3 -2
  46. data/lib/samagotchi/web/public/storage.js +23 -0
  47. data/lib/samagotchi/web/public/toasts.js +5 -4
  48. data/lib/samagotchi/worker.rb +1 -0
  49. metadata +5 -1
@@ -13,6 +13,7 @@ require_relative "openai_chat"
13
13
  require_relative "prompt_cache"
14
14
  require_relative "refusal_memo"
15
15
  require_relative "anthropic_assembly"
16
+ require_relative "anthropic_binding_recovery"
16
17
  require_relative "anthropic_wire"
17
18
  require_relative "dialect"
18
19
 
@@ -36,6 +37,9 @@ module Samagotchi
36
37
  # broader refusal paths never see them:
37
38
  # - adaptive thinking (or output_config.effort) refused: the model gets
38
39
  # the budget form instead (#budget_form), per model;
40
+ # - thinking off refused for the `between_tools` form (Claude 5.x:
41
+ # `thinking: {type: "disabled"}` is refused, the 400 names
42
+ # `between_tools`): that form from then on, per model;
39
43
  # - thinking.display refused (a provider whose thinking schema has no
40
44
  # such field, e.g. Fireworks without its dialect): no display for the
41
45
  # host, the thinking itself kept;
@@ -43,6 +47,11 @@ module Samagotchi
43
47
  # breakpoints for the host;
44
48
  # - max_tokens over the model's output limit: the limit the message
45
49
  # names, per model.
50
+ # A signature 400 (Claude 5.x preserved thinking: a replayed block bound
51
+ # to a prefix that changed) is recovered here too (AnthropicBindingRecovery):
52
+ # asked again with drop_block, or with the blocks stripped, and the
53
+ # dropped blocks handed to the loop (ChatResponse#thinking_drop), which
54
+ # removes them from the history.
46
55
  class AnthropicMessages
47
56
  include ModelListing
48
57
  include AdapterCommon
@@ -55,6 +64,10 @@ module Samagotchi
55
64
  # A 400 refusing adaptive thinking or its effort.
56
65
  ADAPTIVE_REFUSED_RE = /adaptive|output_config|effort/i
57
66
  CACHE_CONTROL_RE = /cache_control/i
67
+ # A 400 refusing `thinking: disabled` and naming the form that model
68
+ # takes for off: "To turn thinking off on this model, send "thinking":
69
+ # {"type": "between_tools"} instead of {"type": "disabled"}" (Claude 5.x).
70
+ THINKING_OFF_FORM_RE = /between_tools/i
58
71
  # A 400 refusing the thinking display field: its path (pydantic:
59
72
  # "thinking.adaptive.display"), or the field named as unknown or not
60
73
  # permitted. Not an error that only echoes the input
@@ -100,6 +113,7 @@ module Samagotchi
100
113
  # The 400 fixes (#refusal_fix): :thinking_form and :output_cap per
101
114
  # model, :thinking_display and :cache_control for the host.
102
115
  @refusals = RefusalMemo.new
116
+ @binding = AnthropicBindingRecovery.new(refusals: @refusals, host_name: @host_name, budget_form: method(:budget_form?))
103
117
  @http = build_http(timeout: timeout, open_timeout: open_timeout, retries: retries, retry_policy: retry_policy,
104
118
  sleeper: sleeper, first_token_timeout: first_token_timeout,
105
119
  api_key: ApiKey.for(api_key_env, host: @host_name, env: env, scheme: auth || @dialect.auth))
@@ -111,14 +125,29 @@ module Samagotchi
111
125
  def chat(messages:, model:, tools: [], cancel_controller: nil, on_delta: nil, on_retry: nil, options: {},
112
126
  session_id: nil)
113
127
  fixed = []
128
+ recovery = AnthropicBindingRecovery::Recovery.new(tried: [])
129
+ shown = false
114
130
  begin
115
- body = request_body(messages, tools, model, options)
116
- request = post_request("#{@base_url}/v1/messages", body, session_id: session_id)
131
+ # A rebuilt body (a refusal fix's retry) keeps the recovery.
132
+ body, stripped = @binding.recovered_body(request_body(messages, tools, model, options), recovery)
133
+ headers = recovery.step == :drop_block ? { "anthropic-beta" => AnthropicBindingRecovery::BINDING_BETA } : {}
134
+ request = post_request("#{@base_url}/v1/messages", body, session_id: session_id, extra_headers: headers)
117
135
  log_fields = { model: model, purpose: @purpose, sampling: sampling_summary(body), cache: cache_summary(body) }
118
- return chat_once(request, cancel_controller, log_fields) unless @stream
119
-
120
- chat_streamed(request, cancel_controller, on_delta, on_retry, log_fields)
136
+ response, transformations =
137
+ if @stream
138
+ chat_streamed(request, cancel_controller, on_delta, on_retry, log_fields, -> { shown = true })
139
+ else
140
+ chat_once(request, cancel_controller, log_fields)
141
+ end
142
+ recovery.step ? @binding.with_thinking_drop(response, recovery, body, stripped, transformations, model) : response
121
143
  rescue BadRequest => e
144
+ # Only before anything was shown: a mid-stream error must not
145
+ # repeat its deltas.
146
+ if !shown && @binding.advance(e, body, recovery, model)
147
+ recovery.tried << recovery.step
148
+ retry
149
+ end
150
+
122
151
  fix = refusal_fix(e, body, model)
123
152
  raise if fix.nil? || fixed.include?(fix)
124
153
 
@@ -161,16 +190,18 @@ module Samagotchi
161
190
  # @return [Hash] frozen
162
191
  def thinking_off_refused_fields = thinking_fields(:low)
163
192
 
193
+ # Whether +model+ gets the budget form (it refused adaptive thinking).
194
+ def budget_form?(model) = @refusals.refused?(:thinking_form, model.to_s)
195
+
164
196
  # The host's models, every page: GET {base}/v1/models with
165
197
  # limit=1000 and after_id paging (has_more/last_id). Anthropic's shape
166
198
  # (max_input_tokens, max_tokens, capabilities) and the OpenAI shape a
167
199
  # gateway or Splash serves here are both read (ModelListing).
168
- # Whether +model+ gets the budget form (it refused adaptive thinking).
169
- def budget_form?(model) = @refusals.refused?(:thinking_form, model.to_s)
170
-
171
200
  # @return [Array<ModelInfo>]
172
201
  def list_models
173
- list_model_pages { |after| URI("#{@base_url}/v1/models?#{URI.encode_www_form({ limit: 1000, after_id: after }.compact)}") }
202
+ list_model_pages(headers: { "anthropic-version" => API_VERSION }) do |after|
203
+ URI("#{@base_url}/v1/models?#{URI.encode_www_form({ limit: 1000, after_id: after }.compact)}")
204
+ end
174
205
  end
175
206
 
176
207
  private
@@ -189,6 +220,7 @@ module Samagotchi
189
220
  def request_body(messages, tools, model, options)
190
221
  options = (options || {}).to_h.transform_keys(&:to_sym).except(*DROPPED_OPTIONS)
191
222
  options = budget_form(options) if budget_form?(model)
223
+ options = thinking_between_tools(options) if @refusals.refused?(:thinking_off_form, model.to_s)
192
224
  options = without_display(options) if @refusals.refused?(:thinking_display)
193
225
  cache = PromptCache.control if @purpose == "chat" && @dialect.cache_breakpoints && !@refusals.refused?(:cache_control)
194
226
  translated = AnthropicWire.translate(messages, cache: cache)
@@ -221,6 +253,16 @@ module Samagotchi
221
253
  output_config: config.empty? ? nil : config)
222
254
  end
223
255
 
256
+ # +options+ with thinking off as the form such a model takes:
257
+ # `thinking: {type: "between_tools"}` where `disabled` was asked (a
258
+ # model that refused `disabled`).
259
+ def thinking_between_tools(options)
260
+ thinking = options[:thinking]
261
+ return options unless thinking.is_a?(Hash) && thinking.transform_keys(&:to_sym)[:type].to_s == "disabled"
262
+
263
+ options.merge(thinking: thinking.transform_keys(&:to_sym).merge(type: "between_tools"))
264
+ end
265
+
224
266
  # +options+ with no thinking.display (the host refused the field).
225
267
  def without_display(options)
226
268
  thinking = options[:thinking]
@@ -269,6 +311,11 @@ module Samagotchi
269
311
  @refusals.remember(:thinking_form, model.to_s)
270
312
  Log.info(:http, "thinking_form_switched", host: @host_name, model: model.to_s, to: "budget", detail: detail[0, 200])
271
313
  :thinking_form
314
+ elsif body.dig(:thinking, :type) == "disabled" && THINKING_OFF_FORM_RE.match?(detail)
315
+ @refusals.remember(:thinking_off_form, model.to_s)
316
+ Log.info(:http, "thinking_off_form_switched", host: @host_name, model: model.to_s, to: "between_tools",
317
+ detail: detail[0, 200])
318
+ :thinking_off_form
272
319
  elsif !@refusals.refused?(:cache_control) && CACHE_CONTROL_RE.match?(detail) && cache_summary(body)
273
320
  @refusals.remember(:cache_control)
274
321
  Log.info(:http, "cache_control_refused", host: @host_name, detail: detail[0, 200])
@@ -309,30 +356,40 @@ module Samagotchi
309
356
  raise error if error
310
357
  raise ProtocolError.new("#{@host_name}: no content in the Messages response", host: @host_name) unless message?(body)
311
358
 
312
- AnthropicAssembly.from_message(body).response
359
+ assembly = AnthropicAssembly.from_message(body)
360
+ [assembly.response, assembly.input_transformations]
313
361
  end
314
362
 
315
363
  def message?(body) = body.is_a?(Hash) && body["content"].is_a?(Array)
316
364
 
317
365
  # The SSE stream: each `data:` event goes through the assembly (the
318
366
  # `event:` lines are not needed, so a gateway that drops them works);
319
- # on_delta gets every event but pings, with its text and reasoning.
320
- def chat_streamed(request, cancel_controller, on_delta, on_retry, log_fields)
321
- stream_assembled(request, cancel_controller, on_retry, log_fields, assembly_class: AnthropicAssembly,
322
- what: "stream event") do |assembly, event, shown|
367
+ # on_delta gets every event but pings, with its text and reasoning;
368
+ # +on_shown+ hears each event that shows something. Returns the
369
+ # response and the message's input_transformations.
370
+ def chat_streamed(request, cancel_controller, on_delta, on_retry, log_fields, on_shown = nil)
371
+ last = nil
372
+ response = stream_assembled(request, cancel_controller, on_retry, log_fields, assembly_class: AnthropicAssembly,
373
+ what: "stream event") do |assembly, event, shown|
374
+ last = assembly
323
375
  content, reasoning = assembly.add(event)
324
- shown.call if AnthropicAssembly.shows?(event)
376
+ if AnthropicAssembly.shows?(event)
377
+ shown.call
378
+ on_shown&.call
379
+ end
325
380
  on_delta&.call(content: content, reasoning: reasoning, payload: assembly.with_usage(event)) unless event["type"] == "ping"
326
381
  end
382
+ [response, last&.input_transformations || []]
327
383
  end
328
384
 
329
- def post_request(url, body, session_id: nil)
385
+ # +extra_headers+ go after the defaults (the binding beta).
386
+ def post_request(url, body, session_id: nil, extra_headers: {})
330
387
  headers = { "anthropic-version" => API_VERSION }
331
388
  unless session_id.to_s.empty?
332
389
  headers["Session-Id"] = session_id.to_s
333
390
  headers[@dialect.session_header] = session_id.to_s if @dialect.session_header
334
391
  end
335
- json_post(url, body, headers)
392
+ json_post(url, body, headers.merge(extra_headers))
336
393
  end
337
394
  end
338
395
  end
@@ -32,8 +32,10 @@ module Samagotchi
32
32
  TOOL_ID_INVALID = /[^a-zA-Z0-9_-]/
33
33
  # What Messages takes as a tool name.
34
34
  TOOL_NAME = /\A[a-zA-Z0-9_-]{1,128}\z/
35
+ # The content block types a model's thinking is replayed in.
36
+ THINKING_TYPES = %w[thinking redacted_thinking].freeze
35
37
  # Blocks that can't carry a cache_control breakpoint.
36
- UNMARKABLE = %w[thinking redacted_thinking].freeze
38
+ UNMARKABLE = THINKING_TYPES
37
39
 
38
40
  # A request's system blocks and messages.
39
41
  Translated = Data.define(:system, :messages)
@@ -83,6 +85,32 @@ module Samagotchi
83
85
  end
84
86
  end
85
87
 
88
+ # A replayed thinking block's identity, the one name the adapter and
89
+ # ChatLoop share for it: a thinking block's signature, a
90
+ # redacted_thinking block's data, or, when that is empty (Splash
91
+ # sends signature ""), "text:" + the SHA256 of its thinking text. nil
92
+ # for any other block and for one with neither (never matched).
93
+ # String or symbol keys.
94
+ def block_identity(block)
95
+ return nil unless block.is_a?(Hash)
96
+
97
+ case type_of(block)
98
+ when "thinking"
99
+ signature = (block["signature"] || block[:signature]).to_s
100
+ return signature unless signature.empty?
101
+
102
+ text = (block["thinking"] || block[:thinking]).to_s
103
+ text.empty? ? nil : "text:#{Digest::SHA256.hexdigest(text)}"
104
+ when "redacted_thinking"
105
+ data = (block["data"] || block[:data]).to_s
106
+ data.empty? ? nil : data
107
+ end
108
+ end
109
+
110
+ # Whether +block+ is a thinking or redacted_thinking block (a Hash,
111
+ # string or symbol keys).
112
+ def thinking_block?(block) = block.is_a?(Hash) && THINKING_TYPES.include?(type_of(block))
113
+
86
114
  # +id+ as Messages takes it (^[a-zA-Z0-9_-]+$), the same in tool_use
87
115
  # and tool_result: a valid id as it is, another with its invalid
88
116
  # characters replaced and a short hash of it appended (so "a.b" and
@@ -7,6 +7,7 @@ require_relative "model_result"
7
7
  require_relative "errors"
8
8
  require_relative "usage"
9
9
  require_relative "openai_chat"
10
+ require_relative "anthropic_wire"
10
11
  require_relative "native_tool_normalizer"
11
12
  require_relative "refusal_memo"
12
13
  require_relative "stream_drops"
@@ -41,7 +42,9 @@ module Samagotchi
41
42
  #
42
43
  # Stream events match the native loop's: :generation_started (with the
43
44
  # context window), :generation_chunk per delta, :generation_completed,
44
- # :generation_retrying, the tool-call events and :pending_input_merged.
45
+ # :generation_retrying, the tool-call events and :pending_input_merged,
46
+ # and :thinking_dropped after a preserved-thinking recovery
47
+ # (#settle_dropped_thinking!).
45
48
  # A chunk carries `thinking:` (reasoning deltas), `text:` (answer deltas)
46
49
  # and `content:` (both, like native raw content) plus the raw `payload:`.
47
50
  #
@@ -339,6 +342,30 @@ module Samagotchi
339
342
  nil
340
343
  end
341
344
 
345
+ # Removes the replayed thinking blocks a response's ThinkingDrop
346
+ # names (AnthropicWire.block_identity) from the model entries'
347
+ # anthropic_content: the edit the provider made for that request, so
348
+ # the next one is valid as it is. Each changed entry is replaced in
349
+ # place, at its index (the Run, ContextStatus and the LLM context
350
+ # turn hold the array and its indexes); one left with no blocks loses
351
+ # the key. +thinking+ (the text the UIs show) stays.
352
+ # @return [Integer] the blocks removed
353
+ def settle_dropped_thinking!(conversation, identities)
354
+ wanted = Array(identities)
355
+ removed = 0
356
+ conversation.each_with_index do |entry, index|
357
+ blocks = entry[:anthropic_content]
358
+ next unless blocks.is_a?(Array)
359
+
360
+ kept = blocks.reject { |block| (identity = AnthropicWire.block_identity(block)) && wanted.include?(identity) }
361
+ next if kept.size == blocks.size
362
+
363
+ removed += blocks.size - kept.size
364
+ conversation[index] = kept.empty? ? entry.except(:anthropic_content) : entry.merge(anthropic_content: kept)
365
+ end
366
+ removed
367
+ end
368
+
342
369
  # The shape the engine persists: role and content (parts stay an
343
370
  # Array), plus a model turn's tool_calls and thinking (the host's
344
371
  # reasoning, sent back as #wire_messages says), a result's tool_call_id, the image
@@ -444,8 +471,6 @@ module Samagotchi
444
471
 
445
472
  def thought?(entry) = entry[:thinking].is_a?(String) && !entry[:thinking].strip.empty?
446
473
 
447
- THINKING_BLOCKS = %w[thinking redacted_thinking].freeze
448
-
449
474
  # A model entry's saved blocks (anthropic_content) to send back
450
475
  # verbatim, for an adapter that replays them: nil when there are none,
451
476
  # for an interrupted entry (its partial blocks have no signature), and
@@ -458,7 +483,7 @@ module Samagotchi
458
483
  return nil unless replays_content? && blocks.is_a?(Array) && !blocks.empty? && !entry[:interrupted]
459
484
  return nil unless Array(entry[:tool_calls]).all? { |call| paired.include?(call[:id]) }
460
485
 
461
- blocks = blocks.reject { |block| block.is_a?(Hash) && THINKING_BLOCKS.include?((block["type"] || block[:type]).to_s) } unless keep_thinking
486
+ blocks = blocks.reject { |block| AnthropicWire.thinking_block?(block) } unless keep_thinking
462
487
  blocks.empty? ? nil : blocks
463
488
  end
464
489
 
@@ -492,6 +517,17 @@ module Samagotchi
492
517
  # The empty generations, never saved: the UIs draw the turn's
493
518
  # steps from them (ModelResult#empty_steps).
494
519
  @empty_steps = []
520
+ # Set as the run goes: the last response's refusal, the context
521
+ # window the last request was sized for, that request's text and
522
+ # image estimates, whether a refused reasoning is being asked
523
+ # again without it, and whether a preserved-thinking recovery
524
+ # settled nothing once already.
525
+ @refusal = nil
526
+ @window = nil
527
+ @request_chars = nil
528
+ @request_image_tokens = nil
529
+ @without_reasoning = false
530
+ @recovery_repeated = false
495
531
  @context = ContextStatus.new(conversation: conversation, llm_context: loop.llm_context)
496
532
  loop.llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: @context,
497
533
  emit: method(:emit))
@@ -649,11 +685,13 @@ module Samagotchi
649
685
 
650
686
  return cut_response(iteration, thought, generation_controller.detail || {})
651
687
  end
688
+ dropped = settle_thinking_drop(iteration, response.thinking_drop)
652
689
  record_context_status(response.usage, window)
653
690
  emit(type: :generation_completed, iteration: iteration, content_length: response.text.length,
654
691
  thinking_chars: response.reasoning.to_s.length, served_model: response.model,
655
692
  served_provider: response.provider, requested_model: @model_name, finish_reason: response.finish_reason,
656
- **(response.usage&.cache_fields || {}), **reprefill_field(response.usage))
693
+ **(response.usage&.cache_fields || {}), **reprefill_field(response.usage),
694
+ **(dropped.positive? ? { thinking_dropped: dropped } : {}))
657
695
  dump_response(response, iteration)
658
696
  @loop.fire_hook(:after_generation, { type: :after_generation, iteration: iteration, response: response.text,
659
697
  messages: AnswerDisplay.strip_all(@conversation).map(&:dup).freeze })
@@ -662,6 +700,24 @@ module Samagotchi
662
700
  [e.reason, streamed, thought]
663
701
  end
664
702
 
703
+ # A response the adapter got by dropping replayed thinking blocks
704
+ # (ChatResponse#thinking_drop): those blocks leave the stored
705
+ # entries (before this step adds its own, whatever its finish
706
+ # reason), and :thinking_dropped says how many. A drop that settles
707
+ # nothing (the same blocks again: they were settled, then came back)
708
+ # is logged once per run. Returns the blocks dropped.
709
+ def settle_thinking_drop(iteration, drop)
710
+ return 0 unless drop && !drop.identities.empty?
711
+
712
+ count = drop.identities.size
713
+ if @loop.settle_dropped_thinking!(@conversation, drop.identities).zero? && !@recovery_repeated
714
+ @recovery_repeated = true
715
+ Log.info(:model, "thinking_recovery_repeated", model: @model_name.to_s, how: drop.how.to_s, blocks: count)
716
+ end
717
+ emit(type: :thinking_dropped, iteration: iteration, count: count, how: drop.how)
718
+ count
719
+ end
720
+
665
721
  # The request's re-prefilled tokens (KernelLoop#reprefilled_tokens).
666
722
  def reprefill_field(usage)
667
723
  return {} unless usage&.source == :server
@@ -38,17 +38,19 @@ module Samagotchi
38
38
  # whatever status; also raised before sending)
39
39
  # ProtocolError a body that isn't what the API promises
40
40
  class ProviderError < StandardError
41
- attr_reader :host, :status, :retry_after
41
+ attr_reader :host, :status, :retry_after, :diagnosis
42
42
  attr_accessor :attempts
43
43
 
44
44
  # @param host [String, nil] the host (or server label) that failed
45
45
  # @param status [Integer, nil] the HTTP status, when there was one
46
46
  # @param retry_after [Float, nil] seconds the server asked to wait
47
- def initialize(message = nil, host: nil, status: nil, retryable: nil, retry_after: nil, attempts: 1)
47
+ # @param diagnosis [String, nil] an optional provider diagnosis header
48
+ def initialize(message = nil, host: nil, status: nil, retryable: nil, retry_after: nil, diagnosis: nil, attempts: 1)
48
49
  @host = host
49
50
  @status = status
50
51
  @retryable = retryable.nil? ? default_retryable? : retryable
51
52
  @retry_after = retry_after
53
+ @diagnosis = diagnosis
52
54
  @attempts = attempts
53
55
  super(message)
54
56
  end
@@ -402,10 +404,10 @@ module Samagotchi
402
404
  # @param status [Integer]
403
405
  # @param body [String] the response body (JSON or text)
404
406
  # @param retry_after [String, nil] the Retry-After header
405
- def from_response(status:, body:, host:, retry_after: nil)
407
+ def from_response(status:, body:, host:, retry_after: nil, diagnosis: nil)
406
408
  message = error_message(body)
407
409
  text = "#{host}: HTTP #{status}: #{message}"
408
- options = { host: host, status: status }
410
+ options = { host: host, status: status, diagnosis: diagnosis }
409
411
  if CONTEXT_OVERFLOW_RE.match?(message) || CONTEXT_OVERFLOW_RE.match?(body.to_s)
410
412
  return BadRequest.new(text, context_overflow: true, **options)
411
413
  end
@@ -417,7 +417,8 @@ module Samagotchi
417
417
  return if status.between?(200, 299)
418
418
 
419
419
  error = ProviderErrors.from_response(status: status, body: response.body.to_s, host: @label,
420
- retry_after: response["Retry-After"])
420
+ retry_after: response["Retry-After"],
421
+ diagnosis: response["anthropic-thinking-prefix-mismatch"])
421
422
  error.hint ||= @api_key ? @api_key.hint : ApiKey.missing_hint(@label) if error.is_a?(AuthError)
422
423
  raise error
423
424
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../log"
4
+
3
5
  module Samagotchi
4
6
  module LLM
5
7
  # A model a host lists. Unknown fields are nil.
@@ -51,7 +53,10 @@ module Samagotchi
51
53
 
52
54
  def init_model_listing(models_ttl)
53
55
  @models_ttl = models_ttl
56
+ @models_failure_ttl = [models_ttl, 60].min
54
57
  @models_mutex = Mutex.new
58
+ @models_failure_at = nil
59
+ @models_failure_logged_at = nil
55
60
  end
56
61
 
57
62
  # +model+'s entry in the cached list: its exact id, else any case.
@@ -93,16 +98,39 @@ module Samagotchi
93
98
  end
94
99
 
95
100
  def cached_models
101
+ now = monotonic
96
102
  @models_mutex.synchronize do
97
- fresh = @models_at && (monotonic - @models_at) < @models_ttl
103
+ fresh = @models_at && (now - @models_at) < @models_ttl
98
104
  return @models if fresh
105
+
106
+ failed_recently = @models_failure_at && (now - @models_failure_at) < @models_failure_ttl
107
+ return [] if failed_recently
99
108
  end
100
109
  models = list_models
101
110
  @models_mutex.synchronize do
102
111
  @models = models
103
112
  @models_at = monotonic
113
+ @models_failure_at = nil
104
114
  end
105
115
  models
116
+ rescue StandardError => e
117
+ @models_mutex.synchronize { @models_failure_at = monotonic }
118
+ log_model_listing_failure(e)
119
+ raise
120
+ end
121
+
122
+ def log_model_listing_failure(error)
123
+ now = monotonic
124
+ should_log = @models_mutex.synchronize do
125
+ next false if @models_failure_logged_at && (now - @models_failure_logged_at) < @models_ttl
126
+
127
+ @models_failure_logged_at = now
128
+ true
129
+ end
130
+ return unless should_log
131
+
132
+ host = @host_name.to_s.empty? ? @base_url : @host_name
133
+ Log.warn(:model, "list_failed", host: host, error: error.class.name, msg: error.message.to_s[0, 500])
106
134
  end
107
135
 
108
136
  def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -37,12 +37,22 @@ module Samagotchi
37
37
  # +stop_details+: why the provider stopped, when it says more than the
38
38
  # finish reason (Messages' stop_details on a refusal: type, category,
39
39
  # explanation; string keys); nil otherwise.
40
+ # +thinking_drop+: the replayed thinking blocks the provider dropped (or
41
+ # the adapter stripped) to answer a request whose history it had
42
+ # refused (ThinkingDrop, AnthropicMessages' binding recovery); nil
43
+ # otherwise.
40
44
  ChatResponse = Data.define(:text, :reasoning, :tool_calls, :usage, :finish_reason, :model, :provider, :cut,
41
- :top_logprobs, :provider_content, :stop_details) do
45
+ :top_logprobs, :provider_content, :stop_details, :thinking_drop) do
42
46
  def initialize(model: nil, provider: nil, cut: nil, top_logprobs: [].freeze, provider_content: nil, stop_details: nil,
43
- **fields) = super
47
+ thinking_drop: nil, **fields) = super
44
48
  end
45
49
 
50
+ # Replayed thinking blocks a request went through without: their
51
+ # identities (AnthropicWire.block_identity, a frozen Array) and +how+:
52
+ # :drop_block (the provider dropped them, prefix_binding_mismatch) or
53
+ # :strip (the adapter removed them from the body).
54
+ ThinkingDrop = Data.define(:identities, :how)
55
+
46
56
  # One alternative for an answer token: its text and log probability.
47
57
  TokenLogprob = Data.define(:token, :logprob) do
48
58
  # The first token's alternatives in a chat completion body's
@@ -6,8 +6,9 @@ module Samagotchi
6
6
  # host's, HostRegistry keeps one per worker; ChatLoop: the worker's): a
7
7
  # +kind+ (a Symbol) for one model, or for the whole host when the model
8
8
  # is nil, with a value (true, or what the refusal named: an output cap).
9
- # The turn, side-ask and recap threads share a holder, so every read and
10
- # write takes the lock.
9
+ # The turn thread and the model-list reads share HostRegistry's adapter
10
+ # (an IdleClient's adapter is its own), so every read and write takes the
11
+ # lock.
11
12
  class RefusalMemo
12
13
  def initialize
13
14
  @mutex = Mutex.new
@@ -4,8 +4,8 @@ require_relative "session"
4
4
  require_relative "session_inbox"
5
5
 
6
6
  module Samagotchi
7
- # Loaded on first use: session_manager requires terminal_ui, which
8
- # requires KernelLoop and so the tools (a require cycle otherwise).
7
+ # Loaded on first use: this autoload keeps SessionManager and its ~80
8
+ # files unloaded until the delegate/session path needs them.
9
9
  autoload :SessionManager, File.expand_path("session_manager", __dir__)
10
10
 
11
11
  # The one rule for putting a context note into a session, shared by
@@ -9,8 +9,8 @@ require_relative "../tools/delegate"
9
9
  require_relative "../children_status"
10
10
 
11
11
  module Samagotchi
12
- # Loaded on first use: session_manager requires terminal_ui, which
13
- # requires the Engine and so the plugins (a require cycle otherwise).
12
+ # Loaded on first use: this autoload keeps SessionManager and its ~80
13
+ # files unloaded until the delegate/session path needs them.
14
14
  autoload :SessionManager, File.expand_path("../session_manager", __dir__)
15
15
 
16
16
  module Plugin
@@ -349,7 +349,7 @@ module Samagotchi
349
349
  @pending = pending = pending.with(relayed_to: relayed_to)
350
350
  if session
351
351
  session.pending_question = pending
352
- begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
352
+ save_session(session)
353
353
  end
354
354
  pending.id
355
355
  end
@@ -439,7 +439,7 @@ module Samagotchi
439
439
  end
440
440
  if session
441
441
  session.pending_question = pending
442
- begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
442
+ save_session(session)
443
443
  end
444
444
  event = { type: :question_requested, pending_question: pending.to_h }
445
445
  event[:standing] = true if standing
@@ -559,12 +559,19 @@ module Samagotchi
559
559
  def cancel_controller = @cancel_controller_lookup.call
560
560
  def emit(event) = @emit.call(event)
561
561
 
562
+ def save_session(session)
563
+ session.save(state_dir: state_dir)
564
+ rescue StandardError => e
565
+ Log.warn(:turn, "question_save_failed", sid: session.id, error: e.class.name, msg: e.message.to_s[0, 200])
566
+ nil
567
+ end
568
+
562
569
  # The session file no longer holds a pending question.
563
570
  def clear_saved_question
564
571
  return unless session
565
572
 
566
573
  session.pending_question = nil
567
- begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
574
+ save_session(session)
568
575
  end
569
576
  end
570
577
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "config"
4
4
  require_relative "setting_explained"
5
+ require_relative "llm/anthropic_wire"
5
6
 
6
7
  module Samagotchi
7
8
  # Which of its own reasoning a chat host's model gets back
@@ -125,7 +126,7 @@ module Samagotchi
125
126
  next false unless entry[:role].to_s == "model"
126
127
 
127
128
  (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) }
129
+ Array(entry[:anthropic_content]).any? { |block| LLM::AnthropicWire.thinking_block?(block) }
129
130
  end
130
131
  end
131
132
 
@@ -6,9 +6,8 @@ require_relative "session_inbox"
6
6
  require_relative "worker_sidecar"
7
7
 
8
8
  module Samagotchi
9
- # Loaded on first use: session_manager requires terminal_ui, which
10
- # requires KernelLoop and so the tools that use this (a require cycle
11
- # otherwise).
9
+ # Loaded on first use: this autoload keeps SessionManager and its ~80
10
+ # files unloaded until the delegate/session path needs them.
12
11
  autoload :SessionManager, File.expand_path("session_manager", __dir__)
13
12
 
14
13
  # Waiting for a session's next reply. A reply is the worker's
@@ -37,7 +37,6 @@ require_relative "plugin_session_state"
37
37
  require_relative "context_sources"
38
38
  require_relative "session_chain"
39
39
  require_relative "child_move"
40
- require_relative "terminal_ui"
41
40
 
42
41
  module Samagotchi
43
42
  # SessionManager coordinates background session processes.
@@ -8,7 +8,7 @@ module Samagotchi
8
8
  # mid-turn. The tool tallies (by tool, aliased, errors) are counted per
9
9
  # tool record, not per turn: SessionMetrics keeps those apart.
10
10
  class Totals < Data.define(
11
- :cancellations, :iterations, :gen_ms, :retries, :cuts, :capped,
11
+ :cancellations, :iterations, :gen_ms, :retries, :cuts, :capped, :thinking_dropped,
12
12
  :prompt_sum, :completion_sum, :cached_sum, :cache_write_sum, :reprefill_sum, :reasoning_sum,
13
13
  :cost_sum, :cost_estimate_sum, :decode_ms_sum, :decode_tokens_sum,
14
14
  # The newest speeds a record has, and where the decode one came from.
@@ -19,7 +19,8 @@ module Samagotchi
19
19
  # The record keys each sum adds up (a turn record's own names).
20
20
  RECORD_SUMS = {
21
21
  prompt_sum: :prompt_tokens_sum, completion_sum: :completion_tokens, iterations: :iterations,
22
- gen_ms: :gen_ms, retries: :retries, cuts: :cuts, capped: :capped, cached_sum: :cached_tokens_sum,
22
+ gen_ms: :gen_ms, retries: :retries, cuts: :cuts, capped: :capped, thinking_dropped: :thinking_dropped,
23
+ cached_sum: :cached_tokens_sum,
23
24
  cache_write_sum: :cache_write_tokens_sum, reprefill_sum: :reprefill_tokens_sum,
24
25
  reasoning_sum: :reasoning_tokens, cost_sum: :cost, cost_estimate_sum: :cost_estimate,
25
26
  decode_ms_sum: :decode_ms, decode_tokens_sum: :decode_tokens
@@ -28,7 +29,7 @@ module Samagotchi
28
29
  # The running turn's (TurnState) counters each sum adds while it runs.
29
30
  TURN_SUMS = {
30
31
  iterations: :iteration_count, gen_ms: :gen_latency_accum, retries: :retries, cuts: :cuts,
31
- capped: :capped, prompt_sum: :prompt_sum, completion_sum: :completion_sum, cached_sum: :cached_sum,
32
+ capped: :capped, thinking_dropped: :thinking_dropped, prompt_sum: :prompt_sum, completion_sum: :completion_sum, cached_sum: :cached_sum,
32
33
  cache_write_sum: :cache_write_sum, reprefill_sum: :reprefill_sum, reasoning_sum: :reasoning_sum,
33
34
  cost_sum: :cost_sum, cost_estimate_sum: :cost_estimate_sum, decode_ms_sum: :decode_ms_sum,
34
35
  decode_tokens_sum: :decode_tokens_sum