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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -1
- data/docs/cli.md +2 -1
- data/docs/configuration.md +10 -6
- data/docs/internals/anthropic.md +73 -5
- data/docs/internals/engine.md +2 -1
- data/docs/internals/prompt-caching.md +3 -1
- data/docs/plugins.md +42 -0
- data/docs/sessions.md +1 -0
- data/lib/samagotchi/answer_command.rb +5 -5
- data/lib/samagotchi/bridge.rb +3 -3
- data/lib/samagotchi/broadcast/recipients.rb +2 -2
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +12 -10
- data/lib/samagotchi/bundles/source-links/manifest.yml +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 +3 -3
- data/lib/samagotchi/children_status.rb +2 -2
- data/lib/samagotchi/engine.rb +21 -12
- data/lib/samagotchi/guardrails/parent_approvals.rb +4 -4
- data/lib/samagotchi/guardrails/parent_continue.rb +2 -2
- data/lib/samagotchi/llm/anthropic_assembly.rb +14 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +210 -13
- data/lib/samagotchi/llm/anthropic_wire.rb +22 -0
- data/lib/samagotchi/llm/chat_loop.rb +49 -2
- data/lib/samagotchi/llm/errors.rb +6 -4
- data/lib/samagotchi/llm/http.rb +2 -1
- data/lib/samagotchi/llm/openai_chat.rb +12 -2
- data/lib/samagotchi/memory_bundle/builder.rb +2 -0
- data/lib/samagotchi/message_parts.rb +272 -0
- data/lib/samagotchi/note_delivery.rb +2 -2
- data/lib/samagotchi/parent_report.rb +30 -22
- data/lib/samagotchi/pending_question.rb +49 -0
- data/lib/samagotchi/plugin/sessions.rb +2 -2
- data/lib/samagotchi/question_desk.rb +39 -32
- data/lib/samagotchi/relay.rb +8 -9
- data/lib/samagotchi/relay_verifier.rb +2 -2
- data/lib/samagotchi/relay_watcher.rb +2 -2
- data/lib/samagotchi/reply_wait.rb +6 -7
- data/lib/samagotchi/row_refs.rb +1 -1
- data/lib/samagotchi/session.rb +22 -14
- data/lib/samagotchi/session_commands.rb +4 -1
- 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/attach_launcher.rb +3 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +6 -106
- data/lib/samagotchi/terminal_ui/model_segments_text.rb +118 -0
- data/lib/samagotchi/terminal_ui.rb +7 -5
- data/lib/samagotchi/tool_output_cut.rb +1 -1
- data/lib/samagotchi/tool_response.rb +13 -13
- data/lib/samagotchi/tool_row_fields.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +16 -9
- data/lib/samagotchi/tools/delegate.rb +2 -2
- data/lib/samagotchi/tools/delegate_relay.rb +7 -7
- data/lib/samagotchi/tools/delegate_result.rb +2 -2
- data/lib/samagotchi/tools/delegate_wait.rb +1 -1
- data/lib/samagotchi/tools/list_sessions.rb +2 -2
- data/lib/samagotchi/tools/web_fetch.rb +3 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -2
- data/lib/samagotchi/web/public/app.js +234 -74
- data/lib/samagotchi/web/public/composer_fold.js +58 -0
- data/lib/samagotchi/web/public/composer_size.js +10 -0
- data/lib/samagotchi/web/public/ctx.js +12 -1
- data/lib/samagotchi/web/public/index.html +29 -4
- data/lib/samagotchi/web/public/markdown_place.js +53 -0
- data/lib/samagotchi/web/public/stream_handlers.js +7 -3
- data/lib/samagotchi/web/public/toasts.js +69 -0
- data/lib/samagotchi/worker.rb +1 -0
- metadata +7 -2
- 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 =
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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 [
|
|
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
|
|
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
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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)
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|