samagotchi 0.64.0 → 0.65.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 +41 -1
- data/docs/architecture.md +20 -3
- data/docs/cli.md +25 -1
- data/docs/configuration.md +128 -3
- data/docs/internals/engine.md +15 -3
- data/docs/sessions.md +3 -2
- data/lib/samagotchi/bridge.rb +11 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/client.rb +4 -2
- data/lib/samagotchi/config.rb +5 -4
- data/lib/samagotchi/engine.rb +39 -7
- data/lib/samagotchi/generation_phase.rb +17 -0
- data/lib/samagotchi/host_config.rb +27 -6
- data/lib/samagotchi/host_registry.rb +34 -11
- data/lib/samagotchi/idle_client.rb +62 -25
- data/lib/samagotchi/idle_recap.rb +5 -2
- data/lib/samagotchi/idle_target.rb +30 -7
- data/lib/samagotchi/llm/adapters.rb +41 -0
- data/lib/samagotchi/llm/anthropic_assembly.rb +210 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
- data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
- data/lib/samagotchi/llm/api_key.rb +26 -9
- data/lib/samagotchi/llm/chat_loop.rb +113 -14
- data/lib/samagotchi/llm/dialect.rb +57 -0
- data/lib/samagotchi/llm/errors.rb +81 -12
- data/lib/samagotchi/llm/model_listing.rb +111 -0
- data/lib/samagotchi/llm/model_result.rb +9 -2
- data/lib/samagotchi/llm/openai_chat.rb +46 -93
- data/lib/samagotchi/model_profile.rb +1 -1
- data/lib/samagotchi/reasoning_history.rb +14 -4
- data/lib/samagotchi/recap_setup.rb +2 -1
- data/lib/samagotchi/self_report.rb +18 -28
- data/lib/samagotchi/session.rb +3 -2
- data/lib/samagotchi/session_metrics.rb +6 -11
- data/lib/samagotchi/steer_cut.rb +4 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -3
- data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
- data/lib/samagotchi/turn_note.rb +22 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -1
- data/lib/samagotchi/web/public/activity.js +3 -0
- data/lib/samagotchi/web/public/app.js +90 -13
- data/lib/samagotchi/web/public/delivery_line.js +228 -0
- data/lib/samagotchi/web/public/index.html +29 -0
- data/lib/samagotchi/web/public/live_turn.js +8 -0
- data/lib/samagotchi/web/public/stage_model.js +27 -7
- data/lib/samagotchi/web/public/stage_view.js +32 -10
- data/lib/samagotchi/web/public/stream_handlers.js +8 -2
- data/lib/samagotchi/web/public/turn_events.js +22 -8
- data/lib/samagotchi/web/public/turn_html.js +20 -4
- data/lib/samagotchi/web/public/turn_model.js +16 -9
- data/lib/samagotchi/web/public/turn_view.js +6 -4
- metadata +8 -1
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "../log"
|
|
6
|
+
require_relative "utf8_scrub"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
module LLM
|
|
10
|
+
# ChatLoop's wire messages (the OpenAI shape #wire_messages builds) as a
|
|
11
|
+
# Messages request's top-level system blocks and messages
|
|
12
|
+
# (AnthropicMessages):
|
|
13
|
+
# - the head system message → the top-level system, as text blocks: with
|
|
14
|
+
# its cache_split, two blocks, the breakpoint on the first; a later
|
|
15
|
+
# system message → user text (a mid-conversation system role is
|
|
16
|
+
# model-gated);
|
|
17
|
+
# - an assistant message → its saved blocks verbatim (anthropic_content),
|
|
18
|
+
# else a text block and its tool_use blocks (arguments as the input
|
|
19
|
+
# object); one with neither is dropped;
|
|
20
|
+
# - tool messages → tool_result blocks in a user message;
|
|
21
|
+
# - user parts → text blocks and image blocks (a data URL as base64, an
|
|
22
|
+
# http(s) URL as a url source).
|
|
23
|
+
# Consecutive messages of one role go as one message (so a turn's tool
|
|
24
|
+
# results and the "[images from tool results]" message after them make
|
|
25
|
+
# one user message), its tool_result blocks first (Messages wants them
|
|
26
|
+
# before any other block). Empty or blank text blocks are never
|
|
27
|
+
# sent; a user message left with none says PLACEHOLDER. reasoning_content
|
|
28
|
+
# and cache_split never go out.
|
|
29
|
+
module AnthropicWire
|
|
30
|
+
PLACEHOLDER = "(empty)"
|
|
31
|
+
# What Messages takes as a tool_use id.
|
|
32
|
+
TOOL_ID_INVALID = /[^a-zA-Z0-9_-]/
|
|
33
|
+
# What Messages takes as a tool name.
|
|
34
|
+
TOOL_NAME = /\A[a-zA-Z0-9_-]{1,128}\z/
|
|
35
|
+
# Blocks that can't carry a cache_control breakpoint.
|
|
36
|
+
UNMARKABLE = %w[thinking redacted_thinking].freeze
|
|
37
|
+
|
|
38
|
+
# A request's system blocks and messages.
|
|
39
|
+
Translated = Data.define(:system, :messages)
|
|
40
|
+
|
|
41
|
+
module_function
|
|
42
|
+
|
|
43
|
+
# @param wire [Array<Hash>] ChatLoop's wire messages (symbol or string keys)
|
|
44
|
+
# @param cache [Hash, nil] the cache_control the breakpoints carry
|
|
45
|
+
# (PromptCache.control): on the head system's first block and on the
|
|
46
|
+
# last block that didn't come from a later system message (a tail
|
|
47
|
+
# note moves each request, as PromptCache's rule says); nil sets none
|
|
48
|
+
# @return [Translated]
|
|
49
|
+
def translate(wire, cache: nil)
|
|
50
|
+
system = []
|
|
51
|
+
messages = []
|
|
52
|
+
notes = {}.compare_by_identity
|
|
53
|
+
Array(wire).each_with_index do |raw, index|
|
|
54
|
+
message = raw.to_h.transform_keys(&:to_sym)
|
|
55
|
+
role = message[:role].to_s
|
|
56
|
+
if role == "system" && index.zero?
|
|
57
|
+
system = system_blocks(message[:content], message[:cache_split], cache)
|
|
58
|
+
next
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
case role
|
|
62
|
+
when "assistant" then append(messages, "assistant", assistant_blocks(message))
|
|
63
|
+
when "tool" then append(messages, "user", [tool_result(message)])
|
|
64
|
+
when "system"
|
|
65
|
+
blocks = content_blocks(message[:content])
|
|
66
|
+
blocks.each { |block| notes[block] = true }
|
|
67
|
+
append(messages, "user", blocks)
|
|
68
|
+
else append(messages, "user", content_blocks(message[:content]))
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
messages.each { |message| message[:content] << text_block(PLACEHOLDER) if message[:content].empty? }
|
|
72
|
+
mark_last(messages, cache, notes) if cache
|
|
73
|
+
Translated.new(system: system, messages: messages)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# ChatLoop's function definitions as Messages tools.
|
|
77
|
+
# @param tools [Array<Hash>] {type: "function", function: {name:, description:, parameters:}}
|
|
78
|
+
def tools(tools)
|
|
79
|
+
Array(tools).map do |tool|
|
|
80
|
+
function = (tool[:function] || tool["function"] || tool).to_h.transform_keys(&:to_sym)
|
|
81
|
+
{ name: function[:name].to_s, description: function[:description],
|
|
82
|
+
input_schema: function[:parameters] || { type: "object", properties: {} } }.compact
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# +id+ as Messages takes it (^[a-zA-Z0-9_-]+$), the same in tool_use
|
|
87
|
+
# and tool_result: a valid id as it is, another with its invalid
|
|
88
|
+
# characters replaced and a short hash of it appended (so "a.b" and
|
|
89
|
+
# "a_b" stay apart).
|
|
90
|
+
def tool_id(id)
|
|
91
|
+
text = id.to_s
|
|
92
|
+
return text if !text.empty? && !text.match?(TOOL_ID_INVALID)
|
|
93
|
+
|
|
94
|
+
"#{text.gsub(TOOL_ID_INVALID, "_")}_#{Digest::SHA256.hexdigest(text)[0, 8]}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# A tool name as Messages takes it (TOOL_NAME): a model's malformed one
|
|
98
|
+
# ("get_weather\n</parameter", a streaming glitch) would 400 every
|
|
99
|
+
# later request once sent back.
|
|
100
|
+
def tool_name(name)
|
|
101
|
+
text = name.to_s
|
|
102
|
+
return text if text.match?(TOOL_NAME)
|
|
103
|
+
|
|
104
|
+
text = text.gsub(TOOL_ID_INVALID, "_")[0, 128]
|
|
105
|
+
text.empty? ? "tool" : text
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# A message joins the one before it when the role is the same; an
|
|
109
|
+
# assistant message with no blocks is dropped.
|
|
110
|
+
def append(messages, role, blocks)
|
|
111
|
+
return if role == "assistant" && blocks.empty?
|
|
112
|
+
|
|
113
|
+
if messages.last && messages.last[:role] == role
|
|
114
|
+
content = messages.last[:content].concat(blocks)
|
|
115
|
+
results, rest = content.partition { |block| type_of(block) == "tool_result" }
|
|
116
|
+
messages.last[:content] = results + rest
|
|
117
|
+
else
|
|
118
|
+
messages << { role: role, content: blocks }
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def system_blocks(content, split, cache)
|
|
123
|
+
text = scrub(content.to_s)
|
|
124
|
+
return [] if blank?(text)
|
|
125
|
+
|
|
126
|
+
head = split.is_a?(Integer) && split.positive? && split < text.length ? text[0, split] : text
|
|
127
|
+
tail = head.length < text.length ? text[head.length..] : ""
|
|
128
|
+
first = text_block(head)
|
|
129
|
+
first[:cache_control] = cache if cache
|
|
130
|
+
blank?(tail) ? [first] : [first, text_block(tail)]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The saved blocks go back as they came (string keys); otherwise the
|
|
134
|
+
# text, then the calls.
|
|
135
|
+
def assistant_blocks(message)
|
|
136
|
+
saved = message[:anthropic_content]
|
|
137
|
+
return saved.map { |block| valid_tool_use(block) } if saved.is_a?(Array) && !saved.empty?
|
|
138
|
+
|
|
139
|
+
blocks = content_blocks(message[:content])
|
|
140
|
+
Array(message[:tool_calls]).each do |raw|
|
|
141
|
+
call = raw.to_h.transform_keys(&:to_sym)
|
|
142
|
+
function = (call[:function] || {}).to_h.transform_keys(&:to_sym)
|
|
143
|
+
name = function[:name].to_s
|
|
144
|
+
blocks << { type: "tool_use", id: tool_id(call[:id]), name: tool_name(name), input: input_of(function[:arguments], name) }
|
|
145
|
+
end
|
|
146
|
+
blocks
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# A saved block as it came, but a tool_use's name made valid and its
|
|
150
|
+
# id mapped as its tool_result's is (#tool_id: a provider may answer
|
|
151
|
+
# an id such as "functions.get_weather:0", which Messages refuses
|
|
152
|
+
# sent back, and the result would name another id).
|
|
153
|
+
def valid_tool_use(block)
|
|
154
|
+
return block.dup unless block.is_a?(Hash) && type_of(block) == "tool_use"
|
|
155
|
+
|
|
156
|
+
name_key = block.key?("name") ? "name" : :name
|
|
157
|
+
id_key = block.key?("id") ? "id" : :id
|
|
158
|
+
block.merge(name_key => tool_name(block[name_key]), id_key => tool_id(block[id_key]))
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# A tool call's arguments as the input object: ChatLoop sends a JSON
|
|
162
|
+
# String (raw when it was invalid, for a paired call): one that isn't
|
|
163
|
+
# an object goes as {}.
|
|
164
|
+
def input_of(arguments, name)
|
|
165
|
+
return arguments if arguments.is_a?(Hash)
|
|
166
|
+
|
|
167
|
+
text = arguments.to_s
|
|
168
|
+
return {} if text.strip.empty?
|
|
169
|
+
|
|
170
|
+
parsed = JSON.parse(text)
|
|
171
|
+
return parsed if parsed.is_a?(Hash)
|
|
172
|
+
|
|
173
|
+
warn_arguments(name)
|
|
174
|
+
rescue JSON::ParserError
|
|
175
|
+
warn_arguments(name)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def warn_arguments(name)
|
|
179
|
+
Log.warn(:model, "tool_arguments_invalid", tool: name, detail: "sent as {} to a Messages host")
|
|
180
|
+
{}
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def tool_result(message)
|
|
184
|
+
block = { type: "tool_result", tool_use_id: tool_id(message[:tool_call_id]) }
|
|
185
|
+
text = scrub(message[:content].to_s)
|
|
186
|
+
block[:content] = text unless blank?(text)
|
|
187
|
+
block
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# A String or a parts list as blocks.
|
|
191
|
+
def content_blocks(content)
|
|
192
|
+
return blank?(content.to_s) ? [] : [text_block(scrub(content.to_s))] unless content.is_a?(Array)
|
|
193
|
+
|
|
194
|
+
content.filter_map do |raw|
|
|
195
|
+
next unless raw.is_a?(Hash)
|
|
196
|
+
|
|
197
|
+
part = raw.transform_keys(&:to_sym)
|
|
198
|
+
case part[:type].to_s
|
|
199
|
+
when "text" then blank?(part[:text].to_s) ? nil : text_block(scrub(part[:text].to_s))
|
|
200
|
+
when "image_url" then image_block(part[:image_url])
|
|
201
|
+
when "image" then raw
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def image_block(image_url)
|
|
207
|
+
url = image_url.is_a?(Hash) ? (image_url[:url] || image_url["url"]).to_s : image_url.to_s
|
|
208
|
+
if (match = url.match(/\Adata:([^;,]+);base64,(.*)\z/m))
|
|
209
|
+
{ type: "image", source: { type: "base64", media_type: match[1], data: match[2] } }
|
|
210
|
+
elsif url.match?(%r{\Ahttps?://}i)
|
|
211
|
+
{ type: "image", source: { type: "url", url: url } }
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Breakpoint #2: the last user block that can carry one and didn't
|
|
216
|
+
# come from a later system message (+notes+).
|
|
217
|
+
def mark_last(messages, cache, notes)
|
|
218
|
+
markable = ->(block) { !UNMARKABLE.include?(type_of(block)) && !notes.key?(block) }
|
|
219
|
+
message = messages.reverse_each.find { |m| m[:role] == "user" && m[:content].any?(&markable) } or return
|
|
220
|
+
index = message[:content].rindex(&markable)
|
|
221
|
+
block = message[:content][index]
|
|
222
|
+
key = block.key?("type") ? "cache_control" : :cache_control
|
|
223
|
+
message[:content][index] = block.merge(key => cache)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def type_of(block) = (block[:type] || block["type"]).to_s
|
|
227
|
+
|
|
228
|
+
def text_block(text) = { type: "text", text: text }
|
|
229
|
+
|
|
230
|
+
def blank?(text) = text.strip.empty?
|
|
231
|
+
|
|
232
|
+
def scrub(text) = Utf8Scrub.call(text)
|
|
233
|
+
|
|
234
|
+
private_class_method :append, :system_blocks, :assistant_blocks, :input_of, :warn_arguments, :tool_result,
|
|
235
|
+
:content_blocks, :image_block, :mark_last, :valid_tool_use, :type_of, :text_block, :blank?, :scrub
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
@@ -5,14 +5,23 @@ require_relative "errors"
|
|
|
5
5
|
module Samagotchi
|
|
6
6
|
module LLM
|
|
7
7
|
# A host's API key: the environment variable its api_key_env: names,
|
|
8
|
-
# sent
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
|
|
8
|
+
# sent on every request LLM::HTTP makes for the host (llama.cpp started
|
|
9
|
+
# with --api-key, or a provider) in the header its +scheme+ names:
|
|
10
|
+
# :bearer `Authorization: Bearer <key>`, :x_api_key `x-api-key: <key>`
|
|
11
|
+
# (Anthropic's Messages API, which refuses a request carrying both).
|
|
12
|
+
# The key itself never goes into a message.
|
|
13
|
+
ApiKey = Data.define(:env_name, :host, :env, :scheme) do
|
|
12
14
|
# nil for a host without api_key_env: its requests carry no header.
|
|
13
|
-
|
|
15
|
+
# @param scheme [Symbol, nil] one of ApiKey::SCHEMES; nil is :bearer
|
|
16
|
+
def self.for(env_name, host:, env: ENV, scheme: nil)
|
|
14
17
|
name = env_name.to_s.strip
|
|
15
|
-
name.empty? ? nil : new(env_name: name, host: host.to_s, env: env)
|
|
18
|
+
name.empty? ? nil : new(env_name: name, host: host.to_s, env: env, scheme: scheme || :bearer)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(env_name:, host:, env:, scheme: :bearer)
|
|
22
|
+
raise ArgumentError, "unknown auth scheme #{scheme.inspect}" unless self.class::SCHEMES.include?(scheme)
|
|
23
|
+
|
|
24
|
+
super
|
|
16
25
|
end
|
|
17
26
|
|
|
18
27
|
# What to try after a 401/403 from a host that has no api_key_env.
|
|
@@ -20,22 +29,30 @@ module Samagotchi
|
|
|
20
29
|
"the server may want an API key: put it in an environment variable and name it with api_key_env: on host #{host}"
|
|
21
30
|
end
|
|
22
31
|
|
|
23
|
-
# Sets the header
|
|
32
|
+
# Sets the scheme's header (exactly one of the two), or raises
|
|
33
|
+
# AuthError when the variable is not set.
|
|
24
34
|
def authorize(request)
|
|
25
35
|
key = env[env_name].to_s
|
|
26
36
|
raise AuthError.new("#{host}: set #{env_name} (the API key for host #{host})", host: host) if key.strip.empty?
|
|
27
37
|
|
|
28
|
-
|
|
38
|
+
if scheme == :x_api_key
|
|
39
|
+
request.delete("Authorization")
|
|
40
|
+
request["x-api-key"] = key
|
|
41
|
+
else
|
|
42
|
+
request.delete("x-api-key")
|
|
43
|
+
request["Authorization"] = "Bearer #{key}"
|
|
44
|
+
end
|
|
29
45
|
end
|
|
30
46
|
|
|
31
47
|
# What to try after a 401/403 with the key sent.
|
|
32
48
|
def hint = "check #{env_name} (the API key for host #{host})"
|
|
33
49
|
|
|
34
50
|
# Without the environment: it holds this key and every other secret.
|
|
35
|
-
def inspect = "#<#{self.class.name} #{env_name} host=#{host}>"
|
|
51
|
+
def inspect = "#<#{self.class.name} #{env_name} host=#{host}#{" scheme=#{scheme}" unless scheme == :bearer}>"
|
|
36
52
|
alias_method :to_s, :inspect
|
|
37
53
|
|
|
38
54
|
def pretty_print(printer) = printer.text(inspect)
|
|
39
55
|
end
|
|
56
|
+
ApiKey::SCHEMES = %i[bearer x_api_key].freeze
|
|
40
57
|
end
|
|
41
58
|
end
|
|
@@ -49,6 +49,10 @@ module Samagotchi
|
|
|
49
49
|
class ChatLoop < ModelBackend
|
|
50
50
|
include LLM::StreamDrops
|
|
51
51
|
|
|
52
|
+
# The line after an answer an OpenAI-compatible host's content filter
|
|
53
|
+
# cut (finish_reason content_filter with text): kept, not asked again.
|
|
54
|
+
FILTERED_MARK = "[cut off by the provider's content filter]"
|
|
55
|
+
|
|
52
56
|
attr_accessor :adapter
|
|
53
57
|
# The session every request is for; Engine sets it with the adapter.
|
|
54
58
|
# Goes out as a Session-Id header (OpenAIChat#chat).
|
|
@@ -147,6 +151,11 @@ module Samagotchi
|
|
|
147
151
|
conversation = llm_context_view.messages(conversation)
|
|
148
152
|
paired = paired_call_ids(conversation)
|
|
149
153
|
boundary = reasoning_boundary(conversation, since: since, level: reasoning)
|
|
154
|
+
# The last reply with tool calls keeps its thinking blocks past the
|
|
155
|
+
# boundary: a run stopped between steps (Stop, the step limit) left
|
|
156
|
+
# its tool_use last, and Messages wants the thinking before it.
|
|
157
|
+
last_calls = conversation.rindex { |entry| entry[:role].to_s == "model" }
|
|
158
|
+
last_calls = nil unless last_calls && !Array(conversation[last_calls][:tool_calls]).empty?
|
|
150
159
|
plan = ImagePlan.new(conversation, vision)
|
|
151
160
|
tool_images = []
|
|
152
161
|
wire = []
|
|
@@ -162,6 +171,8 @@ module Samagotchi
|
|
|
162
171
|
when "model"
|
|
163
172
|
message = assistant_message(entry, paired)
|
|
164
173
|
message[:reasoning_content] = entry[:thinking] if boundary && index >= boundary && thought?(entry)
|
|
174
|
+
blocks = replay_blocks(entry, paired, keep_thinking: (boundary && index >= boundary) || index == last_calls)
|
|
175
|
+
message[:anthropic_content] = blocks if blocks
|
|
165
176
|
wire << message
|
|
166
177
|
when "tool_response"
|
|
167
178
|
id = entry[:tool_call_id]
|
|
@@ -226,13 +237,20 @@ module Samagotchi
|
|
|
226
237
|
|
|
227
238
|
# The turn's ReasoningHistory level (the kernel's turn_settings); drop
|
|
228
239
|
# without settings, and for a +model+ whose host refused it
|
|
229
|
-
# (#reasoning_history_refused!).
|
|
240
|
+
# (#reasoning_history_refused!). An adapter that sends its blocks back
|
|
241
|
+
# (#replays_content?) is floored at turn: the running run's thinking
|
|
242
|
+
# blocks always go back (a tool loop with thinking needs the last
|
|
243
|
+
# assistant turn's), whatever the level says.
|
|
230
244
|
def reasoning_history(model = nil)
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
245
|
+
level = model && @reasoning_history_refused&.include?(model) ? :drop : @kernel.turn_settings&.reasoning_history
|
|
246
|
+
level ||= :drop
|
|
247
|
+
level == :drop && replays_content? ? :turn : level
|
|
234
248
|
end
|
|
235
249
|
|
|
250
|
+
# Whether the adapter sends a model entry's saved blocks back verbatim
|
|
251
|
+
# (AnthropicMessages); the OpenAI wire doesn't.
|
|
252
|
+
def replays_content? = @adapter.respond_to?(:replays_content?) && @adapter.replays_content?
|
|
253
|
+
|
|
236
254
|
# The host refused +model+'s reasoning sent back: send none from now on
|
|
237
255
|
# (this worker's lifetime, as #thinking_refused!).
|
|
238
256
|
def reasoning_history_refused!(model)
|
|
@@ -252,12 +270,13 @@ module Samagotchi
|
|
|
252
270
|
# The turn's thinking level (the kernel's turn_settings).
|
|
253
271
|
def thinking = @kernel.turn_settings.thinking || Thinking::DEFAULT
|
|
254
272
|
|
|
255
|
-
# The request fields the thinking level adds
|
|
256
|
-
#
|
|
273
|
+
# The request fields the thinking level adds, as the adapter's wire
|
|
274
|
+
# takes them (OpenAIChat#thinking_fields: Thinking.chat_fields); none
|
|
275
|
+
# for a model whose host refused them (#thinking_refused!).
|
|
257
276
|
def thinking_fields(model = nil)
|
|
258
277
|
return {} if model && (@thinking_refused ||= Set.new).include?(model)
|
|
259
278
|
|
|
260
|
-
|
|
279
|
+
adapter.thinking_fields(thinking)
|
|
261
280
|
end
|
|
262
281
|
|
|
263
282
|
# The host refused +model+'s thinking fields: leave them out from now on.
|
|
@@ -270,14 +289,26 @@ module Samagotchi
|
|
|
270
289
|
# under the sampling (a sampling key wins, chat_template_kwargs merges
|
|
271
290
|
# per sub-key), the empty-answer retry's temperature on top, then every
|
|
272
291
|
# null dropped at any depth (a sampling null means "don't send it").
|
|
292
|
+
# A +model+ whose host refused a sampling field (#sampling_refused!)
|
|
293
|
+
# gets none of SAMPLING_KEYS.
|
|
273
294
|
def request_options(retry_generation: false, model: nil)
|
|
274
295
|
max_tokens = Samagotchi::Config.max_tokens
|
|
275
296
|
options = deep_merge(thinking_fields(model), sampling)
|
|
276
297
|
options = { max_tokens: max_tokens }.merge(options) if max_tokens
|
|
277
298
|
options = EmptyAnswerRetry.sampling(options) if retry_generation
|
|
299
|
+
options = options.except(*SAMPLING_KEYS) if model && @sampling_refused&.include?(model)
|
|
278
300
|
deep_compact(options)
|
|
279
301
|
end
|
|
280
302
|
|
|
303
|
+
# The fields a sampling refusal (BadRequest#sampling_refused?) drops.
|
|
304
|
+
SAMPLING_KEYS = %i[temperature top_p top_k].freeze
|
|
305
|
+
|
|
306
|
+
# The host refused a sampling field for +model+: send none of
|
|
307
|
+
# SAMPLING_KEYS from now on (this worker's lifetime, as #thinking_refused!).
|
|
308
|
+
def sampling_refused!(model)
|
|
309
|
+
(@sampling_refused ||= Set.new) << model
|
|
310
|
+
end
|
|
311
|
+
|
|
281
312
|
def strip_model_thought(text) = @kernel.strip_model_thought(text)
|
|
282
313
|
|
|
283
314
|
def tool_runner
|
|
@@ -323,6 +354,7 @@ module Samagotchi
|
|
|
323
354
|
message[:turn_id] = entry[:turn_id] if entry[:turn_id]
|
|
324
355
|
message[:turn_start] = true if entry[:turn_start] == true
|
|
325
356
|
message[:thinking] = entry[:thinking] if entry[:thinking].is_a?(String) && !entry[:thinking].empty?
|
|
357
|
+
message[:anthropic_content] = entry[:anthropic_content] if entry[:anthropic_content].is_a?(Array)
|
|
326
358
|
ToolResponse::SAVED_KEYS.each { |key| message[key] = entry[key] if entry[key] }
|
|
327
359
|
message[TurnNote::RETRY_NUDGE] = true if TurnNote.retry_nudge?(entry)
|
|
328
360
|
marker = TurnNote.empty_answer(entry)
|
|
@@ -408,6 +440,24 @@ module Samagotchi
|
|
|
408
440
|
|
|
409
441
|
def thought?(entry) = entry[:thinking].is_a?(String) && !entry[:thinking].strip.empty?
|
|
410
442
|
|
|
443
|
+
THINKING_BLOCKS = %w[thinking redacted_thinking].freeze
|
|
444
|
+
|
|
445
|
+
# A model entry's saved blocks (anthropic_content) to send back
|
|
446
|
+
# verbatim, for an adapter that replays them: nil when there are none,
|
|
447
|
+
# for an interrupted entry (its partial blocks have no signature), and
|
|
448
|
+
# for one with unpaired calls (it goes as flattened text: a tool_use
|
|
449
|
+
# without its tool_result would fail every later request). Before the
|
|
450
|
+
# reasoning boundary (+keep_thinking+ false) the thinking blocks are
|
|
451
|
+
# left out, which removes them only from the front of the history.
|
|
452
|
+
def replay_blocks(entry, paired, keep_thinking:)
|
|
453
|
+
blocks = entry[:anthropic_content]
|
|
454
|
+
return nil unless replays_content? && blocks.is_a?(Array) && !blocks.empty? && !entry[:interrupted]
|
|
455
|
+
return nil unless Array(entry[:tool_calls]).all? { |call| paired.include?(call[:id]) }
|
|
456
|
+
|
|
457
|
+
blocks = blocks.reject { |block| block.is_a?(Hash) && THINKING_BLOCKS.include?((block["type"] || block[:type]).to_s) } unless keep_thinking
|
|
458
|
+
blocks.empty? ? nil : blocks
|
|
459
|
+
end
|
|
460
|
+
|
|
411
461
|
def scrub(text)
|
|
412
462
|
text.encoding == Encoding::UTF_8 && !text.valid_encoding? ? text.scrub("?") : text
|
|
413
463
|
end
|
|
@@ -415,6 +465,11 @@ module Samagotchi
|
|
|
415
465
|
# One turn's state: the conversation, the stream sink and tool
|
|
416
466
|
# activity.
|
|
417
467
|
class Run
|
|
468
|
+
# Messages' refusal; OpenAI's content filter, a refusal only when
|
|
469
|
+
# it left no text (else the text stays, marked FILTERED_MARK).
|
|
470
|
+
REFUSAL = "refusal"
|
|
471
|
+
FILTERED = "content_filter"
|
|
472
|
+
|
|
418
473
|
# +start+: where the run a step-limit Continue resumes started (nil:
|
|
419
474
|
# this run starts at the conversation's end).
|
|
420
475
|
def initialize(loop, conversation, on_stream_event, cancel_controller, model_name, pending_input, start: nil)
|
|
@@ -457,13 +512,22 @@ module Samagotchi
|
|
|
457
512
|
end
|
|
458
513
|
|
|
459
514
|
last_text = @loop.strip_model_thought(response.text)
|
|
460
|
-
|
|
515
|
+
# A provider's refusal (or its content filter) ends the turn here:
|
|
516
|
+
# tool calls that came with it are not run.
|
|
517
|
+
@refusal = refusal_of(response, last_text)
|
|
518
|
+
stopped = @refusal || response.finish_reason.to_s == FILTERED
|
|
519
|
+
if response.tool_calls.empty? || stopped
|
|
520
|
+
# A refusal is no answer, never asked again; text it streamed
|
|
521
|
+
# before is not kept as one. A filtered answer's text stays,
|
|
522
|
+
# marked as cut by the filter.
|
|
523
|
+
last_text = "" if @refusal
|
|
524
|
+
last_text = "#{last_text}\n#{FILTERED_MARK}" if !@refusal && response.finish_reason.to_s == FILTERED
|
|
461
525
|
# Only whitespace is no answer either (as in the native loop).
|
|
462
526
|
empty = last_text.strip.empty?
|
|
463
527
|
# Kept before a merge too: the model answers the merged line
|
|
464
528
|
# knowing what it just said.
|
|
465
529
|
@conversation << with_thinking({ role: "model", content: last_text }, response) unless empty
|
|
466
|
-
retry_empty = empty &&
|
|
530
|
+
retry_empty = empty && !stopped &&
|
|
467
531
|
@empty_retry.retry_empty?(iteration: iteration, cancelled: @cancel_controller&.cancelled?,
|
|
468
532
|
finish_reason: response.finish_reason,
|
|
469
533
|
used_tokens: response.usage&.total_tokens, window_tokens: @window&.tokens)
|
|
@@ -495,6 +559,18 @@ module Samagotchi
|
|
|
495
559
|
|
|
496
560
|
private
|
|
497
561
|
|
|
562
|
+
# A provider's refusal as ModelResult#refusal ({category:} when its
|
|
563
|
+
# stop_details name one): finish reason refusal, or content_filter
|
|
564
|
+
# with no visible +text+.
|
|
565
|
+
def refusal_of(response, text)
|
|
566
|
+
reason = response.finish_reason.to_s
|
|
567
|
+
return nil unless reason == REFUSAL || (reason == FILTERED && text.strip.empty?)
|
|
568
|
+
|
|
569
|
+
details = response.stop_details
|
|
570
|
+
category = details["category"] || details[:category] if details.is_a?(Hash)
|
|
571
|
+
category.to_s.strip.empty? ? {} : { category: category.to_s }
|
|
572
|
+
end
|
|
573
|
+
|
|
498
574
|
# The hidden nudge before the next generation (EmptyAnswerRetry),
|
|
499
575
|
# which runs at the retry temperature. Returns true.
|
|
500
576
|
def nudge_empty_answer(iteration, response)
|
|
@@ -521,10 +597,14 @@ module Samagotchi
|
|
|
521
597
|
# The host's reasoning, kept on the model message as +thinking+ for
|
|
522
598
|
# the web turn view's reload (the whole of it, as the live view
|
|
523
599
|
# shows), and sent back to the model as ChatLoop#wire_messages says
|
|
524
|
-
# (reasoning_history).
|
|
600
|
+
# (reasoning_history). A wire that sends its blocks back keeps them
|
|
601
|
+
# as +anthropic_content+ (ChatResponse#provider_content: string
|
|
602
|
+
# keys, as received, so a resumed session sends the same bytes).
|
|
525
603
|
def with_thinking(message, response)
|
|
526
604
|
reasoning = response.reasoning.to_s
|
|
527
|
-
|
|
605
|
+
message = message.merge(thinking: reasoning) unless reasoning.strip.empty?
|
|
606
|
+
blocks = response.provider_content
|
|
607
|
+
blocks.is_a?(Array) && !blocks.empty? ? message.merge(anthropic_content: blocks) : message
|
|
528
608
|
end
|
|
529
609
|
|
|
530
610
|
# One streamed request. Returns [response, nil], or [reason, partial
|
|
@@ -551,7 +631,11 @@ module Samagotchi
|
|
|
551
631
|
begin
|
|
552
632
|
ask.call
|
|
553
633
|
rescue BadRequest => e
|
|
554
|
-
|
|
634
|
+
# A wire that replays its blocks sends them under the turn
|
|
635
|
+
# floor anyway: asking again without reasoning would resend
|
|
636
|
+
# the same body.
|
|
637
|
+
if e.sampling_refused? && sampling_sent?(retry_generation) then without_sampling(iteration, e, ask)
|
|
638
|
+
elsif refused?(e) && reasoning_sent? && !@loop.replays_content? then without_reasoning(iteration, e, ask)
|
|
555
639
|
elsif thinking_refused?(e) then without_thinking(iteration, e, ask)
|
|
556
640
|
else raise
|
|
557
641
|
end
|
|
@@ -613,7 +697,7 @@ module Samagotchi
|
|
|
613
697
|
event = { type: :generation_chunk, iteration: iteration, content: reasoning + content, text: content,
|
|
614
698
|
thinking: reasoning, payload: payload }
|
|
615
699
|
# A tool call streaming: a steer doesn't cut it (Engine#cut_for_steer).
|
|
616
|
-
tool_call =
|
|
700
|
+
tool_call = @loop.adapter.tool_call_delta?(payload)
|
|
617
701
|
event[:tool_call] = true if tool_call
|
|
618
702
|
streamed_any&.call if !content.to_s.empty? || !reasoning.to_s.empty? || tool_call
|
|
619
703
|
emit(event)
|
|
@@ -708,6 +792,21 @@ module Samagotchi
|
|
|
708
792
|
@without_reasoning = false
|
|
709
793
|
end
|
|
710
794
|
|
|
795
|
+
# Whether this step's request sends a sampling field.
|
|
796
|
+
def sampling_sent?(retry_generation)
|
|
797
|
+
options = @loop.request_options(retry_generation: retry_generation, model: @model_name)
|
|
798
|
+
SAMPLING_KEYS.any? { |key| options.key?(key) }
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
# Once per model: asked again without the sampling fields (the
|
|
802
|
+
# thinking fields stay: an effort level with the empty-answer
|
|
803
|
+
# retry's temperature keeps thinking on).
|
|
804
|
+
def without_sampling(iteration, error, ask)
|
|
805
|
+
@loop.sampling_refused!(@model_name)
|
|
806
|
+
emit(type: :sampling_refused, iteration: iteration, model: @model_name, detail: error.detail)
|
|
807
|
+
ask.call
|
|
808
|
+
end
|
|
809
|
+
|
|
711
810
|
# Once per model: asked again without the thinking fields.
|
|
712
811
|
def without_thinking(iteration, error, ask)
|
|
713
812
|
@loop.thinking_refused!(@model_name)
|
|
@@ -814,7 +913,7 @@ module Samagotchi
|
|
|
814
913
|
@loop.apply_llm_context!(@conversation, moment: :turn_end, emit: method(:emit)) unless exhausted
|
|
815
914
|
ModelResult.new(text: text, conversation: @loop.plain(@conversation), exhausted: exhausted,
|
|
816
915
|
tool_activity: @tool_activity, empty_steps: @empty_steps, empty_retries: @empty_retry.attempts,
|
|
817
|
-
context_status: @context.display)
|
|
916
|
+
context_status: @context.display, refusal: (@refusal unless exhausted))
|
|
818
917
|
end
|
|
819
918
|
|
|
820
919
|
def emit(event)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module LLM
|
|
5
|
+
# How a provider's Messages API (`api: anthropic`) departs from
|
|
6
|
+
# Anthropic's own: a small set of overrides on the standard wire, picked
|
|
7
|
+
# per host with `dialect:` (HostConfig#dialect, HostEntry#dialect_profile).
|
|
8
|
+
# A host without one is STANDARD (Anthropic, Splash, a gateway passing
|
|
9
|
+
# Claude through).
|
|
10
|
+
#
|
|
11
|
+
# name: the config value ("standard" for none);
|
|
12
|
+
# auth: the header the key goes in when the host sets no auth:
|
|
13
|
+
# (LLM::ApiKey::SCHEMES);
|
|
14
|
+
# thinking_display: the `display` sent with adaptive or budget thinking,
|
|
15
|
+
# nil for none;
|
|
16
|
+
# cache_breakpoints: whether chat requests carry cache_control;
|
|
17
|
+
# session_header: a header that carries the session id (beside
|
|
18
|
+
# Session-Id), nil for none.
|
|
19
|
+
Dialect = Data.define(:name, :auth, :thinking_display, :cache_breakpoints, :session_header) do
|
|
20
|
+
# The dialect for a `dialect:` name (HostConfig#dialect); STANDARD for
|
|
21
|
+
# nil or an unknown one.
|
|
22
|
+
def self.for(name) = self::BY_NAME.fetch(name.to_s, self::STANDARD)
|
|
23
|
+
|
|
24
|
+
# A hosts entry's `dialect:` as written: the name, or nil when unset.
|
|
25
|
+
# An unknown name, or one on a host whose api isn't anthropic, warns
|
|
26
|
+
# once (naming +where+) and is ignored.
|
|
27
|
+
# @param api [Symbol, nil] the entry's api
|
|
28
|
+
# @return [String, nil]
|
|
29
|
+
def self.parse(value, where, api:)
|
|
30
|
+
text = value.to_s.strip.downcase
|
|
31
|
+
return nil if text.empty?
|
|
32
|
+
|
|
33
|
+
require_relative "../config"
|
|
34
|
+
|
|
35
|
+
unless self::BY_NAME.key?(text)
|
|
36
|
+
ConfigFile.warn_once "Warning: #{where}: dialect must be one of #{self::BY_NAME.keys.join(", ")}; ignored"
|
|
37
|
+
return nil
|
|
38
|
+
end
|
|
39
|
+
return text if api&.to_sym == :anthropic
|
|
40
|
+
|
|
41
|
+
ConfigFile.warn_once "Warning: #{where}: dialect has no effect on api: #{api || "(none)"}; ignored"
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
Dialect::STANDARD = Dialect.new(name: "standard", auth: :x_api_key, thinking_display: "summarized",
|
|
47
|
+
cache_breakpoints: true, session_header: nil)
|
|
48
|
+
# Fireworks' /v1/messages (docs.fireworks.ai, anthropic-compatibility):
|
|
49
|
+
# Bearer auth, no `display` in its thinking schema (every object refuses
|
|
50
|
+
# extra fields; thinking comes back by default), an automatic prefix
|
|
51
|
+
# cache (no breakpoints), and x-session-affinity to keep a session on
|
|
52
|
+
# one replica, where its cache is.
|
|
53
|
+
Dialect::FIREWORKS = Dialect.new(name: "fireworks", auth: :bearer, thinking_display: nil,
|
|
54
|
+
cache_breakpoints: false, session_header: "x-session-affinity")
|
|
55
|
+
Dialect::BY_NAME = { "standard" => Dialect::STANDARD, "fireworks" => Dialect::FIREWORKS }.freeze
|
|
56
|
+
end
|
|
57
|
+
end
|