samagotchi 0.76.0 → 0.78.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -1
  3. data/docs/cli.md +2 -1
  4. data/docs/configuration.md +10 -6
  5. data/docs/internals/anthropic.md +73 -5
  6. data/docs/internals/engine.md +2 -1
  7. data/docs/internals/prompt-caching.md +3 -1
  8. data/docs/plugins.md +42 -0
  9. data/docs/sessions.md +1 -0
  10. data/lib/samagotchi/answer_command.rb +5 -5
  11. data/lib/samagotchi/bridge.rb +3 -3
  12. data/lib/samagotchi/broadcast/recipients.rb +2 -2
  13. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +12 -10
  14. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  16. data/lib/samagotchi/child_move.rb +2 -2
  17. data/lib/samagotchi/child_reports.rb +3 -3
  18. data/lib/samagotchi/children_status.rb +2 -2
  19. data/lib/samagotchi/engine.rb +21 -12
  20. data/lib/samagotchi/guardrails/parent_approvals.rb +4 -4
  21. data/lib/samagotchi/guardrails/parent_continue.rb +2 -2
  22. data/lib/samagotchi/llm/anthropic_assembly.rb +14 -0
  23. data/lib/samagotchi/llm/anthropic_messages.rb +210 -13
  24. data/lib/samagotchi/llm/anthropic_wire.rb +22 -0
  25. data/lib/samagotchi/llm/chat_loop.rb +49 -2
  26. data/lib/samagotchi/llm/errors.rb +6 -4
  27. data/lib/samagotchi/llm/http.rb +2 -1
  28. data/lib/samagotchi/llm/openai_chat.rb +12 -2
  29. data/lib/samagotchi/memory_bundle/builder.rb +2 -0
  30. data/lib/samagotchi/message_parts.rb +272 -0
  31. data/lib/samagotchi/note_delivery.rb +2 -2
  32. data/lib/samagotchi/parent_report.rb +30 -22
  33. data/lib/samagotchi/pending_question.rb +49 -0
  34. data/lib/samagotchi/plugin/sessions.rb +2 -2
  35. data/lib/samagotchi/question_desk.rb +39 -32
  36. data/lib/samagotchi/relay.rb +8 -9
  37. data/lib/samagotchi/relay_verifier.rb +2 -2
  38. data/lib/samagotchi/relay_watcher.rb +2 -2
  39. data/lib/samagotchi/reply_wait.rb +6 -7
  40. data/lib/samagotchi/row_refs.rb +1 -1
  41. data/lib/samagotchi/session.rb +22 -14
  42. data/lib/samagotchi/session_commands.rb +4 -1
  43. data/lib/samagotchi/session_manager.rb +0 -1
  44. data/lib/samagotchi/session_metrics/totals.rb +4 -3
  45. data/lib/samagotchi/session_metrics.rb +9 -0
  46. data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
  47. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
  48. data/lib/samagotchi/terminal_ui/formatting.rb +6 -106
  49. data/lib/samagotchi/terminal_ui/model_segments_text.rb +118 -0
  50. data/lib/samagotchi/terminal_ui.rb +7 -5
  51. data/lib/samagotchi/tool_output_cut.rb +1 -1
  52. data/lib/samagotchi/tool_response.rb +13 -13
  53. data/lib/samagotchi/tool_row_fields.rb +1 -1
  54. data/lib/samagotchi/tool_runner.rb +16 -9
  55. data/lib/samagotchi/tools/delegate.rb +2 -2
  56. data/lib/samagotchi/tools/delegate_relay.rb +7 -7
  57. data/lib/samagotchi/tools/delegate_result.rb +2 -2
  58. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  59. data/lib/samagotchi/tools/list_sessions.rb +2 -2
  60. data/lib/samagotchi/tools/web_fetch.rb +3 -1
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/web/app.rb +2 -2
  63. data/lib/samagotchi/web/public/app.js +234 -74
  64. data/lib/samagotchi/web/public/composer_fold.js +58 -0
  65. data/lib/samagotchi/web/public/composer_size.js +10 -0
  66. data/lib/samagotchi/web/public/ctx.js +12 -1
  67. data/lib/samagotchi/web/public/index.html +29 -4
  68. data/lib/samagotchi/web/public/markdown_place.js +53 -0
  69. data/lib/samagotchi/web/public/stream_handlers.js +7 -3
  70. data/lib/samagotchi/web/public/toasts.js +69 -0
  71. data/lib/samagotchi/worker.rb +1 -0
  72. metadata +7 -2
  73. data/lib/samagotchi/web/message_parts.rb +0 -274
@@ -93,7 +93,7 @@ module Samagotchi
93
93
  # guardrail rules or approvals: by its rule, its paths or its command
94
94
  # (the dirs as this process's environment and config.yml name them).
95
95
  def protected?(pending)
96
- facts = fetch(pending, :approval)
96
+ facts = pending.approval
97
97
  return false unless facts.is_a?(Hash)
98
98
  return true if PROTECTED_RULES.include?(fetch(facts, :rule).to_s)
99
99
  return true if fetch(facts, :source).to_s == ProtectedPaths::SOURCE
@@ -137,13 +137,13 @@ module Samagotchi
137
137
 
138
138
  # An approval: its kind says so, or it carries approval facts.
139
139
  def approval?(pending)
140
- fetch(pending, :kind).to_s == Approval::KIND || !fetch(pending, :approval).nil?
140
+ pending.kind.to_s == Approval::KIND || !pending.approval.nil?
141
141
  end
142
142
 
143
143
  # The offered scopes, in option order, or nil when they are missing,
144
144
  # empty, not strings, or leave no option after them for Deny.
145
145
  def scopes(pending)
146
- facts = fetch(pending, :approval)
146
+ facts = pending.approval
147
147
  scopes = facts.is_a?(Hash) ? fetch(facts, :scopes) : nil
148
148
  return nil unless scopes.is_a?(Array) && !scopes.empty? && scopes.all?(String)
149
149
  return nil unless scopes.size < options(pending).size
@@ -160,7 +160,7 @@ module Samagotchi
160
160
  index == options.size - 1 && options[index].to_s == Approval::DENY
161
161
  end
162
162
 
163
- def options(pending) = Array(fetch(pending, :options))
163
+ def options(pending) = Array(pending.options)
164
164
 
165
165
  def fetch(hash, key)
166
166
  return nil unless hash.is_a?(Hash)
@@ -24,12 +24,12 @@ module Samagotchi
24
24
  end
25
25
 
26
26
  # Why a parent may not give this answer, or nil.
27
- # @param pending [Hash] the pending question (symbol or string keys)
27
+ # @param pending [PendingQuestion] the pending question
28
28
  # @param selected [Array<String>] the selected labels
29
29
  # @return [Symbol, nil] :stop_only
30
30
  def refusal(pending, selected, allowed: allowed?)
31
31
  return nil if allowed
32
- return nil unless (pending[:kind] || pending["kind"]).to_s == KIND
32
+ return nil unless pending.kind.to_s == KIND
33
33
 
34
34
  Array(selected).map(&:to_s).include?(CONTINUE) ? :stop_only : nil
35
35
  end
@@ -44,14 +44,22 @@ module Samagotchi
44
44
  @stop_reason = nil
45
45
  @stop_details = nil
46
46
  @model = nil
47
+ @input_transformations = []
47
48
  end
48
49
 
50
+ # The message's input_transformations (Claude 5.x: what the API did to
51
+ # the request before generating, e.g. the thinking blocks drop_block
52
+ # dropped), as sent (string keys); [] when it named none. The paths
53
+ # index into the request body, which only the adapter holds.
54
+ attr_reader :input_transformations
55
+
49
56
  # A whole message: its model, usage, stop reason and blocks.
50
57
  def add_message(message)
51
58
  return unless message.is_a?(Hash)
52
59
 
53
60
  @model = served(message["model"]) || @model
54
61
  merge_usage(message["usage"])
62
+ read_transformations(message)
55
63
  @stop_reason = message["stop_reason"] if message["stop_reason"]
56
64
  @stop_details = message["stop_details"] if message["stop_details"].is_a?(Hash)
57
65
  Array(message["content"]).each { |block| @blocks << block.dup if block.is_a?(Hash) }
@@ -133,9 +141,15 @@ module Samagotchi
133
141
 
134
142
  @model = served(message["model"]) || @model
135
143
  merge_usage(message["usage"])
144
+ read_transformations(message)
136
145
  ["", ""]
137
146
  end
138
147
 
148
+ def read_transformations(message)
149
+ entries = message["input_transformations"]
150
+ @input_transformations = entries.select { |entry| entry.is_a?(Hash) } if entries.is_a?(Array)
151
+ end
152
+
139
153
  def start_block(index, block)
140
154
  return ["", ""] unless block.is_a?(Hash)
141
155
 
@@ -43,6 +43,11 @@ module Samagotchi
43
43
  # breakpoints for the host;
44
44
  # - max_tokens over the model's output limit: the limit the message
45
45
  # names, per model.
46
+ # A signature 400 (Claude 5.x preserved thinking: a replayed block bound
47
+ # to a prefix that changed) is recovered here too (#binding_recovery):
48
+ # asked again with drop_block, or with the blocks stripped, and the
49
+ # dropped blocks handed to the loop (ChatResponse#thinking_drop), which
50
+ # removes them from the history.
46
51
  class AnthropicMessages
47
52
  include ModelListing
48
53
  include AdapterCommon
@@ -71,6 +76,27 @@ module Samagotchi
71
76
  # (configured sampling passes as written).
72
77
  DROPPED_OPTIONS = %i[reasoning_effort chat_template_kwargs stream_options prompt_cache_key logprobs top_logprobs
73
78
  max_completion_tokens].freeze
79
+ # The beta header thinking.block_binding needs, and the behaviour the
80
+ # recovery asks for: the API drops a thinking block whose prefix
81
+ # changed (and every thinking block after it) for that request.
82
+ BINDING_BETA = "thinking-binding-controls-2026-08-01"
83
+ DROP_BLOCK = { prefix_mismatch_behavior: "drop_block" }.freeze
84
+ # A signature 400 that drop_block can settle: the block is valid but
85
+ # bound to another prefix. Without it the signature itself is bad
86
+ # (tampered, unreadable, another provider's): only a strip helps.
87
+ BOUND_ELSEWHERE_RE = /bound to a different conversation/i
88
+ # The block a signature 400 names: an index into the body sent.
89
+ BLOCK_PATH_RE = /messages\.(\d+)\.content\.(\d+)/
90
+ THINKING_TYPES = %w[thinking redacted_thinking].freeze
91
+ # The only drop the loop settles: a model_binding_mismatch (a /model
92
+ # downgrade) keeps its blocks, a switch back reads them again.
93
+ PREFIX_DROP = "prefix_binding_mismatch"
94
+
95
+ # One request's binding recovery (#binding_recovery): the step taken
96
+ # (nil, :drop_block or :strip), the steps tried, where a strip starts
97
+ # ([message, block] in the body), the last signature 400 and the body
98
+ # it answered, and the first 400's last sentence and diagnosis for the log line.
99
+ Recovery = Struct.new(:step, :tried, :strip_from, :signature, :why, :diagnosis)
74
100
 
75
101
  attr_reader :base_url, :host_name, :api_key_env, :models_ttl, :first_token_timeout, :dialect
76
102
 
@@ -111,14 +137,29 @@ module Samagotchi
111
137
  def chat(messages:, model:, tools: [], cancel_controller: nil, on_delta: nil, on_retry: nil, options: {},
112
138
  session_id: nil)
113
139
  fixed = []
140
+ recovery = Recovery.new(tried: [])
141
+ shown = false
114
142
  begin
115
- body = request_body(messages, tools, model, options)
116
- request = post_request("#{@base_url}/v1/messages", body, session_id: session_id)
143
+ # A rebuilt body (a refusal fix's retry) keeps the recovery.
144
+ body, stripped = recovered_body(request_body(messages, tools, model, options), recovery)
145
+ headers = recovery.step == :drop_block ? { "anthropic-beta" => BINDING_BETA } : {}
146
+ request = post_request("#{@base_url}/v1/messages", body, session_id: session_id, extra_headers: headers)
117
147
  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)
148
+ response, transformations =
149
+ if @stream
150
+ chat_streamed(request, cancel_controller, on_delta, on_retry, log_fields, -> { shown = true })
151
+ else
152
+ chat_once(request, cancel_controller, log_fields)
153
+ end
154
+ recovery.step ? with_thinking_drop(response, recovery, body, stripped, transformations, model) : response
121
155
  rescue BadRequest => e
156
+ # Only before anything was shown: a mid-stream error must not
157
+ # repeat its deltas.
158
+ if !shown && binding_recovery(e, body, recovery, model)
159
+ recovery.tried << recovery.step
160
+ retry
161
+ end
162
+
122
163
  fix = refusal_fix(e, body, model)
123
164
  raise if fix.nil? || fixed.include?(fix)
124
165
 
@@ -249,6 +290,152 @@ module Samagotchi
249
290
  body.merge(max_tokens: max_tokens, thinking: thinking.merge(budget_tokens: budget))
250
291
  end
251
292
 
293
+ # The step a signature 400 for +body+ takes (set on +recovery+, once
294
+ # each per request), or nil: the error goes on as it is.
295
+ # - drop_block: the same body with thinking.block_binding and the beta
296
+ # header, for a block bound to another prefix, when the host takes
297
+ # the controls and the body's thinking can carry them (adaptive,
298
+ # enabled, or none: then adaptive; not disabled, nor none for a
299
+ # model on the budget form);
300
+ # - strip: the block the 400 names (else the first replayed thinking
301
+ # block) and every thinking block after it removed, for a bad
302
+ # signature, where drop_block can't apply, when the host refused
303
+ # the controls (remembered for the host), or after a second
304
+ # signature 400.
305
+ def binding_recovery(error, body, recovery, model)
306
+ return nil if body.nil? || error.status != 400
307
+
308
+ if error.thinking_binding_unsupported? && recovery.step == :drop_block
309
+ unless @refusals.refused?(:binding_controls)
310
+ @refusals.remember(:binding_controls)
311
+ Log.info(:http, "binding_controls_refused", host: @host_name, detail: error.detail.to_s[0, 200])
312
+ end
313
+ return strip_step(recovery)
314
+ end
315
+ return nil unless error.thinking_signature?
316
+
317
+ recovery.diagnosis ||= error.diagnosis
318
+ recovery.signature = [error.detail.to_s, body]
319
+ case recovery.step
320
+ when nil
321
+ return nil if thinking_positions(body).empty?
322
+
323
+ recovery.why = last_sentence(error.detail.to_s)
324
+ return strip_step(recovery) unless drop_block_fits?(error, body, model)
325
+
326
+ recovery.step = :drop_block
327
+ when :drop_block then strip_step(recovery)
328
+ end
329
+ end
330
+
331
+ def drop_block_fits?(error, body, model)
332
+ return false unless BOUND_ELSEWHERE_RE.match?(error.detail.to_s) && !@refusals.refused?(:binding_controls)
333
+
334
+ case body[:thinking]
335
+ when nil then !budget_form?(model)
336
+ when Hash then %w[adaptive enabled].include?(body[:thinking][:type].to_s)
337
+ else false
338
+ end
339
+ end
340
+
341
+ # The strip, from the block the last signature 400 names in the body
342
+ # it answered (a thinking block there), else the first replayed one.
343
+ def strip_step(recovery)
344
+ return nil if recovery.tried.include?(:strip)
345
+
346
+ detail, body = recovery.signature
347
+ positions = thinking_positions(body)
348
+ named = detail.to_s.match(BLOCK_PATH_RE)&.captures&.map(&:to_i)
349
+ recovery.strip_from = positions.include?(named) ? named : positions.first
350
+ recovery.step = recovery.strip_from && :strip
351
+ end
352
+
353
+ # [message, block] of every thinking or redacted_thinking block in
354
+ # +body+'s messages.
355
+ def thinking_positions(body)
356
+ Array(body[:messages]).each_with_index.flat_map do |message, n|
357
+ Array(message[:content]).each_with_index.filter_map { |block, m| [n, m] if thinking_block?(block) }
358
+ end
359
+ end
360
+
361
+ def thinking_block?(block) = block.is_a?(Hash) && THINKING_TYPES.include?((block["type"] || block[:type]).to_s)
362
+
363
+ # +body+ as the recovery step sends it, and the identities a strip
364
+ # removed (nil for none).
365
+ def recovered_body(body, recovery)
366
+ case recovery.step
367
+ when :drop_block then [body.merge(thinking: drop_block_thinking(body[:thinking])), nil]
368
+ when :strip then stripped_body(body, recovery.strip_from)
369
+ else [body, nil]
370
+ end
371
+ end
372
+
373
+ # The body's thinking with block_binding: drop_block (adaptive
374
+ # without one: the binding controls need adaptive or enabled).
375
+ def drop_block_thinking(thinking)
376
+ thinking = thinking.is_a?(Hash) ? thinking.transform_keys(&:to_sym) : {}
377
+ thinking = { type: "adaptive" } unless %w[adaptive enabled].include?(thinking[:type].to_s)
378
+ thinking.merge(block_binding: DROP_BLOCK)
379
+ end
380
+
381
+ # +body+ without the thinking blocks from +from+ on (a trailing run:
382
+ # what Messages takes; a gap in the middle it refuses), and their
383
+ # identities. A message left empty says the placeholder.
384
+ def stripped_body(body, from)
385
+ first_message, first_block = from
386
+ removed = []
387
+ messages = body[:messages].each_with_index.map do |message, n|
388
+ next message if n < first_message
389
+
390
+ content = message[:content].each_with_index.reject do |block, m|
391
+ drop = (n > first_message || m >= first_block) && thinking_block?(block)
392
+ removed << AnthropicWire.block_identity(block) if drop
393
+ drop
394
+ end.map(&:first)
395
+ next message if content.size == message[:content].size
396
+
397
+ message.merge(content: content.empty? ? [{ type: "text", text: AnthropicWire::PLACEHOLDER }] : content)
398
+ end
399
+ [body.merge(messages: messages), removed.compact.uniq.freeze]
400
+ end
401
+
402
+ # +response+ with the blocks this request went without
403
+ # (ThinkingDrop), logged: a strip's, or drop_block's
404
+ # prefix_binding_mismatch entries mapped by path into +body+ (the
405
+ # body sent); any other entry is counted as kept, never settled.
406
+ def with_thinking_drop(response, recovery, body, stripped, transformations, model)
407
+ if recovery.step == :strip
408
+ identities = stripped
409
+ kept = 0
410
+ else
411
+ drops = Array(transformations).select { |entry| entry["type"] == "thinking_dropped" }
412
+ prefix, others = drops.partition { |entry| entry["reason"] == PREFIX_DROP }
413
+ identities = prefix.filter_map { |entry| identity_at(body, entry["path"]) }.uniq.freeze
414
+ kept = others.size
415
+ end
416
+ fields = { host: @host_name, model: model.to_s, how: recovery.step.to_s, dropped: identities.size,
417
+ kept: kept, why: recovery.why }
418
+ fields[:diagnosis] = recovery.diagnosis if recovery.diagnosis
419
+ Log.info(:http, "thinking_recovered", **fields)
420
+ return response if identities.empty?
421
+
422
+ response.with(thinking_drop: ThinkingDrop.new(identities: identities, how: recovery.step))
423
+ end
424
+
425
+ def identity_at(body, path)
426
+ n, m = path.to_s.match(BLOCK_PATH_RE)&.captures&.map(&:to_i)
427
+ return nil unless n
428
+
429
+ block = Array(body[:messages])[n]&.then { |message| Array(message[:content])[m] }
430
+ thinking_block?(block) ? AnthropicWire.block_identity(block) : nil
431
+ end
432
+
433
+ # The last sentence of a 400 (what it says changed: "The `system`
434
+ # prompt differs …"), at most 200 characters.
435
+ def last_sentence(detail)
436
+ detail.strip.split(/(?<=\.)\s+/).last.to_s[0, 200]
437
+ end
438
+
252
439
  # What a 400 for +body+ changes (remembered), or nil: the error goes
253
440
  # on as it is.
254
441
  def refusal_fix(error, body, model)
@@ -309,30 +496,40 @@ module Samagotchi
309
496
  raise error if error
310
497
  raise ProtocolError.new("#{@host_name}: no content in the Messages response", host: @host_name) unless message?(body)
311
498
 
312
- AnthropicAssembly.from_message(body).response
499
+ assembly = AnthropicAssembly.from_message(body)
500
+ [assembly.response, assembly.input_transformations]
313
501
  end
314
502
 
315
503
  def message?(body) = body.is_a?(Hash) && body["content"].is_a?(Array)
316
504
 
317
505
  # The SSE stream: each `data:` event goes through the assembly (the
318
506
  # `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|
507
+ # on_delta gets every event but pings, with its text and reasoning;
508
+ # +on_shown+ hears each event that shows something. Returns the
509
+ # response and the message's input_transformations.
510
+ def chat_streamed(request, cancel_controller, on_delta, on_retry, log_fields, on_shown = nil)
511
+ last = nil
512
+ response = stream_assembled(request, cancel_controller, on_retry, log_fields, assembly_class: AnthropicAssembly,
513
+ what: "stream event") do |assembly, event, shown|
514
+ last = assembly
323
515
  content, reasoning = assembly.add(event)
324
- shown.call if AnthropicAssembly.shows?(event)
516
+ if AnthropicAssembly.shows?(event)
517
+ shown.call
518
+ on_shown&.call
519
+ end
325
520
  on_delta&.call(content: content, reasoning: reasoning, payload: assembly.with_usage(event)) unless event["type"] == "ping"
326
521
  end
522
+ [response, last&.input_transformations || []]
327
523
  end
328
524
 
329
- def post_request(url, body, session_id: nil)
525
+ # +extra_headers+ go after the defaults (the binding beta).
526
+ def post_request(url, body, session_id: nil, extra_headers: {})
330
527
  headers = { "anthropic-version" => API_VERSION }
331
528
  unless session_id.to_s.empty?
332
529
  headers["Session-Id"] = session_id.to_s
333
530
  headers[@dialect.session_header] = session_id.to_s if @dialect.session_header
334
531
  end
335
- json_post(url, body, headers)
532
+ json_post(url, body, headers.merge(extra_headers))
336
533
  end
337
534
  end
338
535
  end
@@ -83,6 +83,28 @@ module Samagotchi
83
83
  end
84
84
  end
85
85
 
86
+ # A replayed thinking block's identity, the one name the adapter and
87
+ # ChatLoop share for it: a thinking block's signature, a
88
+ # redacted_thinking block's data, or, when that is empty (Splash
89
+ # sends signature ""), "text:" + the SHA256 of its thinking text. nil
90
+ # for any other block and for one with neither (never matched).
91
+ # String or symbol keys.
92
+ def block_identity(block)
93
+ return nil unless block.is_a?(Hash)
94
+
95
+ case type_of(block)
96
+ when "thinking"
97
+ signature = (block["signature"] || block[:signature]).to_s
98
+ return signature unless signature.empty?
99
+
100
+ text = (block["thinking"] || block[:thinking]).to_s
101
+ text.empty? ? nil : "text:#{Digest::SHA256.hexdigest(text)}"
102
+ when "redacted_thinking"
103
+ data = (block["data"] || block[:data]).to_s
104
+ data.empty? ? nil : data
105
+ end
106
+ end
107
+
86
108
  # +id+ as Messages takes it (^[a-zA-Z0-9_-]+$), the same in tool_use
87
109
  # and tool_result: a valid id as it is, another with its invalid
88
110
  # 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
  #
@@ -348,6 +351,30 @@ module Samagotchi
348
351
  # edit/write result's tool_diffs (never sent), an empty-answer
349
352
  # note's marker (TurnNote.empty, never sent), and an answer's display
350
353
  # and display_refs (AnswerDisplay, AnswerRefs; never sent).
354
+ # Removes the replayed thinking blocks a response's ThinkingDrop
355
+ # names (AnthropicWire.block_identity) from the model entries'
356
+ # anthropic_content: the edit the provider made for that request, so
357
+ # the next one is valid as it is. Each changed entry is replaced in
358
+ # place, at its index (the Run, ContextStatus and the LLM context
359
+ # turn hold the array and its indexes); one left with no blocks loses
360
+ # the key. +thinking+ (the text the UIs show) stays.
361
+ # @return [Integer] the blocks removed
362
+ def settle_dropped_thinking!(conversation, identities)
363
+ wanted = Array(identities)
364
+ removed = 0
365
+ conversation.each_with_index do |entry, index|
366
+ blocks = entry[:anthropic_content]
367
+ next unless blocks.is_a?(Array)
368
+
369
+ kept = blocks.reject { |block| (identity = AnthropicWire.block_identity(block)) && wanted.include?(identity) }
370
+ next if kept.size == blocks.size
371
+
372
+ removed += blocks.size - kept.size
373
+ conversation[index] = kept.empty? ? entry.except(:anthropic_content) : entry.merge(anthropic_content: kept)
374
+ end
375
+ removed
376
+ end
377
+
351
378
  def plain(conversation)
352
379
  conversation.map do |entry|
353
380
  content = entry[:content].is_a?(Array) ? entry[:content] : entry[:content].to_s
@@ -649,11 +676,13 @@ module Samagotchi
649
676
 
650
677
  return cut_response(iteration, thought, generation_controller.detail || {})
651
678
  end
679
+ dropped = settle_thinking_drop(iteration, response.thinking_drop)
652
680
  record_context_status(response.usage, window)
653
681
  emit(type: :generation_completed, iteration: iteration, content_length: response.text.length,
654
682
  thinking_chars: response.reasoning.to_s.length, served_model: response.model,
655
683
  served_provider: response.provider, requested_model: @model_name, finish_reason: response.finish_reason,
656
- **(response.usage&.cache_fields || {}), **reprefill_field(response.usage))
684
+ **(response.usage&.cache_fields || {}), **reprefill_field(response.usage),
685
+ **(dropped.positive? ? { thinking_dropped: dropped } : {}))
657
686
  dump_response(response, iteration)
658
687
  @loop.fire_hook(:after_generation, { type: :after_generation, iteration: iteration, response: response.text,
659
688
  messages: AnswerDisplay.strip_all(@conversation).map(&:dup).freeze })
@@ -662,6 +691,24 @@ module Samagotchi
662
691
  [e.reason, streamed, thought]
663
692
  end
664
693
 
694
+ # A response the adapter got by dropping replayed thinking blocks
695
+ # (ChatResponse#thinking_drop): those blocks leave the stored
696
+ # entries (before this step adds its own, whatever its finish
697
+ # reason), and :thinking_dropped says how many. A drop that settles
698
+ # nothing (the same blocks again: they were settled, then came back)
699
+ # is logged once per run. Returns the blocks dropped.
700
+ def settle_thinking_drop(iteration, drop)
701
+ return 0 unless drop && !drop.identities.empty?
702
+
703
+ count = drop.identities.size
704
+ if @loop.settle_dropped_thinking!(@conversation, drop.identities).zero? && !@recovery_repeated
705
+ @recovery_repeated = true
706
+ Log.info(:model, "thinking_recovery_repeated", model: @model_name.to_s, how: drop.how.to_s, blocks: count)
707
+ end
708
+ emit(type: :thinking_dropped, iteration: iteration, count: count, how: drop.how)
709
+ count
710
+ end
711
+
665
712
  # The request's re-prefilled tokens (KernelLoop#reprefilled_tokens).
666
713
  def reprefill_field(usage)
667
714
  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
@@ -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
@@ -110,6 +110,8 @@ module Samagotchi
110
110
  def select_memories(target_dir, name)
111
111
  candidates = Dir.glob(File.join(target_dir, "*.md")).sort
112
112
  .reject { |p| File.basename(p) == "index.md" || File.basename(p).start_with?(".") }
113
+ symlinks, candidates = candidates.partition { |path| File.symlink?(path) }
114
+ @warnings.concat(symlinks.map { |path| "#{File.basename(path)} is a symlink (not included)" })
113
115
  if candidates.empty?
114
116
  raise BuildError, "No memories to build in #{@scope} scope (#{target_dir}): no .md files found"
115
117
  end