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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -1
- data/docs/cli.md +2 -1
- data/docs/configuration.md +14 -8
- data/docs/internals/anthropic.md +80 -7
- data/docs/internals/engine.md +3 -2
- data/docs/internals/prompt-caching.md +3 -1
- data/docs/sessions.md +1 -0
- data/lib/samagotchi/broadcast/recipients.rb +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_move.rb +2 -2
- data/lib/samagotchi/child_reports.rb +2 -2
- data/lib/samagotchi/children_status.rb +2 -2
- data/lib/samagotchi/llm/adapter_common.rb +4 -2
- data/lib/samagotchi/llm/anthropic_assembly.rb +14 -0
- data/lib/samagotchi/llm/anthropic_binding_recovery.rb +195 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +74 -17
- data/lib/samagotchi/llm/anthropic_wire.rb +29 -1
- data/lib/samagotchi/llm/chat_loop.rb +61 -5
- data/lib/samagotchi/llm/errors.rb +6 -4
- data/lib/samagotchi/llm/http.rb +2 -1
- data/lib/samagotchi/llm/model_listing.rb +29 -1
- data/lib/samagotchi/llm/openai_chat.rb +12 -2
- data/lib/samagotchi/llm/refusal_memo.rb +3 -2
- data/lib/samagotchi/note_delivery.rb +2 -2
- data/lib/samagotchi/plugin/sessions.rb +2 -2
- data/lib/samagotchi/question_desk.rb +10 -3
- data/lib/samagotchi/reasoning_history.rb +2 -1
- data/lib/samagotchi/reply_wait.rb +2 -3
- data/lib/samagotchi/session_manager.rb +0 -1
- data/lib/samagotchi/session_metrics/totals.rb +4 -3
- data/lib/samagotchi/session_metrics.rb +9 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
- data/lib/samagotchi/terminal_ui.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +2 -2
- data/lib/samagotchi/tools/delegate_result.rb +2 -2
- data/lib/samagotchi/tools/list_sessions.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +61 -53
- data/lib/samagotchi/web/public/composer_fold.js +58 -0
- data/lib/samagotchi/web/public/composer_grip.js +231 -0
- data/lib/samagotchi/web/public/composer_size.js +10 -0
- data/lib/samagotchi/web/public/index.html +27 -3
- data/lib/samagotchi/web/public/model_picker.js +4 -5
- data/lib/samagotchi/web/public/stage_view.js +3 -2
- data/lib/samagotchi/web/public/storage.js +23 -0
- data/lib/samagotchi/web/public/toasts.js +5 -4
- data/lib/samagotchi/worker.rb +1 -0
- 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
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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 {
|
|
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)
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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|
|
|
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
|
-
|
|
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
|
data/lib/samagotchi/llm/http.rb
CHANGED
|
@@ -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 && (
|
|
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
|
|
10
|
-
# write takes the
|
|
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:
|
|
8
|
-
#
|
|
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:
|
|
13
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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|
|
|
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:
|
|
10
|
-
#
|
|
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,
|
|
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
|