samagotchi 0.77.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -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 +1 -0
  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/anthropic_assembly.rb +14 -0
  15. data/lib/samagotchi/llm/anthropic_messages.rb +210 -13
  16. data/lib/samagotchi/llm/anthropic_wire.rb +22 -0
  17. data/lib/samagotchi/llm/chat_loop.rb +49 -2
  18. data/lib/samagotchi/llm/errors.rb +6 -4
  19. data/lib/samagotchi/llm/http.rb +2 -1
  20. data/lib/samagotchi/llm/openai_chat.rb +12 -2
  21. data/lib/samagotchi/note_delivery.rb +2 -2
  22. data/lib/samagotchi/plugin/sessions.rb +2 -2
  23. data/lib/samagotchi/reply_wait.rb +2 -3
  24. data/lib/samagotchi/session_manager.rb +0 -1
  25. data/lib/samagotchi/session_metrics/totals.rb +4 -3
  26. data/lib/samagotchi/session_metrics.rb +9 -0
  27. data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
  28. data/lib/samagotchi/terminal_ui.rb +2 -2
  29. data/lib/samagotchi/tools/delegate.rb +2 -2
  30. data/lib/samagotchi/tools/delegate_result.rb +2 -2
  31. data/lib/samagotchi/tools/list_sessions.rb +2 -2
  32. data/lib/samagotchi/version.rb +1 -1
  33. data/lib/samagotchi/web/public/app.js +217 -19
  34. data/lib/samagotchi/web/public/composer_fold.js +58 -0
  35. data/lib/samagotchi/web/public/composer_size.js +10 -0
  36. data/lib/samagotchi/web/public/index.html +27 -3
  37. data/lib/samagotchi/worker.rb +1 -0
  38. metadata +2 -1
@@ -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
@@ -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
@@ -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
@@ -144,6 +144,9 @@ module Samagotchi
144
144
  # (finish_reason length). `retries` counts the network's only.
145
145
  :cuts,
146
146
  :capped,
147
+ # Replayed thinking blocks a preserved-thinking recovery dropped
148
+ # (:generation_completed's thinking_dropped, ChatLoop).
149
+ :thinking_dropped,
147
150
  :model,
148
151
  # The model the turn ran on (asked host:model, :generation_started's
149
152
  # model_ref; the first generation's), and whether its open generation
@@ -505,6 +508,7 @@ module Samagotchi
505
508
  retries: totals.retries,
506
509
  cuts: totals.cuts,
507
510
  capped: totals.capped,
511
+ thinking_dropped: totals.thinking_dropped,
508
512
  started_at: @started_at,
509
513
  last_activity_at: @last_activity_at,
510
514
  # From the session's first start (an earlier process's too), to now.
@@ -589,6 +593,7 @@ module Samagotchi
589
593
  retries: 0,
590
594
  cuts: 0,
591
595
  capped: 0,
596
+ thinking_dropped: 0,
592
597
  model: nil,
593
598
  model_ref: nil,
594
599
  gen_switch: false,
@@ -679,12 +684,15 @@ module Samagotchi
679
684
  end
680
685
  end
681
686
 
687
+ # A generation's cut, output cap and dropped thinking blocks, into the turn.
682
688
  def count_cut(event)
683
689
  @mutex.synchronize do
684
690
  next unless @turn
685
691
 
686
692
  @turn.cuts += 1 if event[:stopped_by] && event[:stopped_by].to_s != "steer"
687
693
  @turn.capped += 1 if event[:finish_reason].to_s == "length"
694
+ dropped = event[:thinking_dropped]
695
+ @turn.thinking_dropped += dropped if dropped.is_a?(Integer) && dropped.positive?
688
696
  end
689
697
  end
690
698
 
@@ -872,6 +880,7 @@ module Samagotchi
872
880
  }
873
881
  fields[:cache_write_tokens_sum] = turn.cache_write_sum if turn.cache_write_sum.positive?
874
882
  fields[:reprefill_tokens_sum] = turn.reprefill_sum if turn.reprefill_sum.positive?
883
+ fields[:thinking_dropped] = turn.thinking_dropped if turn.thinking_dropped.positive?
875
884
  fields.merge!(cost: turn.cost_sum, cost_source: "reported") if turn.cost_sum
876
885
  fields[:cost_estimate] = turn.cost_estimate_sum if turn.cost_estimate_sum.positive?
877
886
  fields[:switch_prefill_tokens] = turn.switch_prefill_tokens if turn.switch_prefill_tokens
@@ -600,6 +600,9 @@ module Samagotchi
600
600
  # A looped session: loop-guard's cuts, or thinking that ran to the cap.
601
601
  lines << "thinking cuts: #{snapshot[:cuts].to_i}"
602
602
  lines << "output cap hits: #{snapshot[:capped].to_i}"
603
+ # Claude 5.x: replayed thinking a prefix change made the API drop.
604
+ dropped = snapshot[:thinking_dropped].to_i
605
+ lines << "thinking dropped: #{dropped} blocks (preserved-thinking recovery)" if dropped.positive?
603
606
  context = snapshot[:context] || {}
604
607
  if context[:used_tokens]
605
608
  pct = context_pct_text(context, snapshot[:llm_context])
@@ -42,8 +42,8 @@ require_relative "children_status"
42
42
  require_relative "log"
43
43
 
44
44
  module Samagotchi
45
- # Loaded on first use: session_manager requires this file (its worker
46
- # process loads the whole UI stack through it), a require cycle otherwise.
45
+ # Loaded on first use: this autoload keeps SessionManager and its ~80
46
+ # files unloaded until the delegate/session path needs them.
47
47
  autoload :SessionManager, File.expand_path("session_manager", __dir__)
48
48
 
49
49
  # The in-process terminal front end (`chi --no-shared`, `chi scratch`,
@@ -10,8 +10,8 @@ require_relative "delegate_wait"
10
10
  require_relative "../child_reports"
11
11
 
12
12
  module Samagotchi
13
- # Loaded on first use: session_manager requires terminal_ui, which
14
- # requires KernelLoop and so these tools (a require cycle otherwise).
13
+ # Loaded on first use: this autoload keeps SessionManager and its ~80
14
+ # files unloaded until the delegate/session path needs them.
15
15
  autoload :SessionManager, File.expand_path("../session_manager", __dir__)
16
16
 
17
17
  module Tools
@@ -6,8 +6,8 @@ require_relative "delegate"
6
6
  require_relative "delegate_wait"
7
7
 
8
8
  module Samagotchi
9
- # Loaded on first use: session_manager requires terminal_ui, which
10
- # requires KernelLoop and so these tools (a require cycle otherwise).
9
+ # Loaded on first use: this autoload keeps SessionManager and its ~80
10
+ # files unloaded until the delegate/session path needs them.
11
11
  autoload :SessionManager, File.expand_path("../session_manager", __dir__)
12
12
 
13
13
  module Tools
@@ -6,8 +6,8 @@ require_relative "../session"
6
6
  require_relative "peers"
7
7
 
8
8
  module Samagotchi
9
- # Loaded on first use: session_manager requires terminal_ui, which
10
- # requires KernelLoop and so these tools (a require cycle otherwise).
9
+ # Loaded on first use: this autoload keeps SessionManager and its ~80
10
+ # files unloaded until the delegate/session path needs them.
11
11
  autoload :SessionManager, File.expand_path("../session_manager", __dir__)
12
12
 
13
13
  module Tools
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.77.0"
4
+ VERSION = "0.78.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end