samagotchi 0.98.0 → 0.99.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 +21 -1
- data/docs/architecture.md +1 -1
- data/docs/configuration.md +8 -0
- data/docs/guardrails.md +8 -0
- data/docs/hooks.md +5 -0
- data/docs/internals/anthropic.md +79 -8
- data/docs/internals/engine.md +13 -3
- data/docs/internals/llm-context-forget.md +2 -1
- data/docs/internals/prompt-caching.md +9 -0
- data/docs/plugins.md +16 -1
- data/docs/sessions.md +10 -0
- data/docs/testing.md +27 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +65 -8
- data/lib/samagotchi/kernel_loop.rb +15 -2
- data/lib/samagotchi/llm/anthropic_messages.rb +85 -4
- data/lib/samagotchi/llm/anthropic_wire.rb +90 -11
- data/lib/samagotchi/llm/chat_loop.rb +76 -7
- data/lib/samagotchi/llm/dialect.rb +8 -5
- data/lib/samagotchi/llm/refusal_memo.rb +6 -0
- data/lib/samagotchi/llm/turn_settings.rb +13 -5
- data/lib/samagotchi/llm_context_stale.rb +2 -2
- data/lib/samagotchi/message_parts.rb +2 -1
- data/lib/samagotchi/plugin/api.rb +1 -1
- data/lib/samagotchi/plugin/context.rb +21 -2
- data/lib/samagotchi/prompt.rb +3 -0
- data/lib/samagotchi/testing/bundle.rb +2 -0
- data/lib/samagotchi/testing/host.rb +1 -0
- data/lib/samagotchi/testing/recorder.rb +13 -2
- data/lib/samagotchi/tool_loads.rb +62 -0
- data/lib/samagotchi/tool_response.rb +5 -1
- data/lib/samagotchi/tool_runner.rb +29 -7
- data/lib/samagotchi/tools/builtin_calls.rb +16 -0
- data/lib/samagotchi/tools/deferred_tools.rb +80 -0
- data/lib/samagotchi/tools/registry.rb +7 -2
- data/lib/samagotchi/tools/tool_index.rb +88 -6
- data/lib/samagotchi/version.rb +1 -1
- metadata +3 -1
|
@@ -46,7 +46,12 @@ module Samagotchi
|
|
|
46
46
|
# - cache_control refused (Fireworks lists it unsupported): no
|
|
47
47
|
# breakpoints for the host;
|
|
48
48
|
# - max_tokens over the model's output limit: the limit the message
|
|
49
|
-
# names, per model
|
|
49
|
+
# names, per model;
|
|
50
|
+
# - deferred tools or a tool_reference refused: none deferred for the
|
|
51
|
+
# model from then on (#loads_tools? false; a load goes as text);
|
|
52
|
+
# - a deferred tool's input_schema refused: that tool leaves the list
|
|
53
|
+
# for the model (call_tool still reaches it), one fix per tool, at
|
|
54
|
+
# most MAX_SCHEMA_FIXES per request.
|
|
50
55
|
# A signature 400 (Claude 5.x preserved thinking: a replayed block bound
|
|
51
56
|
# to a prefix that changed) is recovered here too (AnthropicBindingRecovery):
|
|
52
57
|
# asked again with drop_block, or with the blocks stripped, and the
|
|
@@ -77,6 +82,16 @@ module Samagotchi
|
|
|
77
82
|
\bdisplay\W{0,4}(?:extra\s+inputs\s+are\s+not\s+permitted|is\s+not\s+(?:permitted|allowed|supported))/xi
|
|
78
83
|
# "max_tokens: 200000 > 128000, which is the maximum allowed number of output tokens for …"
|
|
79
84
|
OUTPUT_CAP_RE = /max_tokens`?:?\s*\d+\s*>\s*(\d+)/i
|
|
85
|
+
# The beta a mid-conversation system message (a tool change) needs.
|
|
86
|
+
INLINE_TOOLS_BETA = "inline-tools-2026-09-15"
|
|
87
|
+
# A 400 refusing a tool change: its block, the beta, or the system role.
|
|
88
|
+
TOOL_CHANGES_RE = /tool_addition|inline-tools|role.{0,40}system|system.{0,40}role/i
|
|
89
|
+
# A 400 refusing deferred tools or a load by reference.
|
|
90
|
+
TOOL_LOADING_RE = /defer_loading|tool_reference/i
|
|
91
|
+
# A 400 naming a tool's input schema: "tools.3.custom.input_schema: …".
|
|
92
|
+
TOOL_SCHEMA_RE = /tools\.(\d+)\.(?:custom\.)?input_schema/i
|
|
93
|
+
# The deferred tools a request drops for bad schemas, at most.
|
|
94
|
+
MAX_SCHEMA_FIXES = 5
|
|
80
95
|
# max_tokens when default.max_tokens is unset and the model list names
|
|
81
96
|
# none (a listed one is capped by it too).
|
|
82
97
|
DEFAULT_MAX_TOKENS = 64_000
|
|
@@ -136,7 +151,7 @@ module Samagotchi
|
|
|
136
151
|
begin
|
|
137
152
|
# A rebuilt body (a refusal fix's retry) keeps the recovery.
|
|
138
153
|
body, stripped = @binding.recovered_body(request_body(messages, tools, model, options), recovery)
|
|
139
|
-
headers = recovery
|
|
154
|
+
headers = beta_headers(body, recovery)
|
|
140
155
|
request = post_request("#{@base_url}/v1/messages", body, session_id: session_id, extra_headers: headers)
|
|
141
156
|
log_fields = { model: model, purpose: @purpose, sampling: sampling_summary(body), cache: cache_summary(body) }
|
|
142
157
|
response, transformations =
|
|
@@ -156,6 +171,7 @@ module Samagotchi
|
|
|
156
171
|
|
|
157
172
|
fix = refusal_fix(e, body, model)
|
|
158
173
|
raise if fix.nil? || fixed.include?(fix)
|
|
174
|
+
raise if fix.is_a?(Array) && fixed.count { |done| done.is_a?(Array) } >= MAX_SCHEMA_FIXES
|
|
159
175
|
|
|
160
176
|
fixed << fix
|
|
161
177
|
retry
|
|
@@ -196,6 +212,17 @@ module Samagotchi
|
|
|
196
212
|
# @return [Hash] frozen
|
|
197
213
|
def thinking_off_refused_fields = thinking_fields(:low)
|
|
198
214
|
|
|
215
|
+
# Whether requests for +model+ declare the tools a session reaches but
|
|
216
|
+
# doesn't declare deferred (defer_loading), loaded by reference from
|
|
217
|
+
# a find_tools result (ChatLoop#tool_definitions, AnthropicWire): the
|
|
218
|
+
# dialect's tool_loading, unless the host refused it for +model+.
|
|
219
|
+
def loads_tools?(model) = @dialect.tool_loading && !@refusals.refused?(:tool_loading, model.to_s)
|
|
220
|
+
|
|
221
|
+
# Whether requests for +model+ take a mid-conversation tool change (a
|
|
222
|
+
# system message with tool_addition blocks, INLINE_TOOLS_BETA): where
|
|
223
|
+
# tools load, unless the host refused changes for +model+.
|
|
224
|
+
def tool_changes?(model) = loads_tools?(model) && !@refusals.refused?(:tool_changes, model.to_s)
|
|
225
|
+
|
|
199
226
|
# Whether +model+ gets the budget form (it refused adaptive thinking).
|
|
200
227
|
def budget_form?(model) = @refusals.refused?(:thinking_form, model.to_s)
|
|
201
228
|
|
|
@@ -229,13 +256,15 @@ module Samagotchi
|
|
|
229
256
|
options = thinking_between_tools(options) if @refusals.refused?(:thinking_off_form, model.to_s)
|
|
230
257
|
options = without_display(options) if @refusals.refused?(:thinking_display)
|
|
231
258
|
cache = PromptCache.control if @purpose == "chat" && @dialect.cache_breakpoints && !@refusals.refused?(:cache_control)
|
|
232
|
-
|
|
259
|
+
tools = without_refused_tools(tools, model)
|
|
260
|
+
deferred = Array(tools).filter_map { |tool| tool.dig(:function, :name).to_s if tool[:defer_loading] }
|
|
261
|
+
translated = AnthropicWire.translate(messages, cache: cache, deferred: deferred)
|
|
233
262
|
max_tokens = options.delete(:max_tokens) || default_max_tokens(model)
|
|
234
263
|
output_cap = @refusals[:output_cap, model.to_s]
|
|
235
264
|
max_tokens = [max_tokens, output_cap].min if output_cap
|
|
236
265
|
body = { model: model, max_tokens: max_tokens }
|
|
237
266
|
body[:system] = translated.system unless translated.system.empty?
|
|
238
|
-
body[:messages] = translated.messages
|
|
267
|
+
body[:messages] = tool_changes?(model) ? translated.messages : translated.messages.reject { |m| m[:role] == "system" }
|
|
239
268
|
body[:stream] = @stream
|
|
240
269
|
unless Array(tools).empty?
|
|
241
270
|
body[:tools] = AnthropicWire.tools(tools)
|
|
@@ -244,6 +273,20 @@ module Samagotchi
|
|
|
244
273
|
with_budget_room(body.merge(options).compact)
|
|
245
274
|
end
|
|
246
275
|
|
|
276
|
+
# +tools+ without the deferred ones the host refused for +model+: all
|
|
277
|
+
# of them after a :tool_loading refusal, else the ones whose schema
|
|
278
|
+
# it refused. A find result that loaded one then references only the
|
|
279
|
+
# rest (AnthropicWire renders references for this request's deferred
|
|
280
|
+
# names), so no reference is left without its tool.
|
|
281
|
+
def without_refused_tools(tools, model)
|
|
282
|
+
return Array(tools).reject { |tool| tool[:defer_loading] } if @refusals.refused?(:tool_loading, model.to_s)
|
|
283
|
+
|
|
284
|
+
bad = Array(@refusals[:tool_schema, model.to_s])
|
|
285
|
+
return tools if bad.empty?
|
|
286
|
+
|
|
287
|
+
Array(tools).reject { |tool| tool[:defer_loading] && bad.include?(tool.dig(:function, :name).to_s) }
|
|
288
|
+
end
|
|
289
|
+
|
|
247
290
|
# Adaptive thinking at a level as the budget form: thinking enabled
|
|
248
291
|
# with BUDGETS' budget (medium without an effort), shown as asked (no
|
|
249
292
|
# display when it asked none), and no effort (a model without
|
|
@@ -326,6 +369,18 @@ module Samagotchi
|
|
|
326
369
|
@refusals.remember(:cache_control)
|
|
327
370
|
Log.info(:http, "cache_control_refused", host: @host_name, detail: detail[0, 200])
|
|
328
371
|
:cache_control
|
|
372
|
+
elsif changes?(body) && !@refusals.refused?(:tool_changes, model.to_s) && TOOL_CHANGES_RE.match?(detail)
|
|
373
|
+
@refusals.remember(:tool_changes, model.to_s)
|
|
374
|
+
Log.info(:http, "tool_changes_refused", host: @host_name, model: model.to_s, detail: detail[0, 200])
|
|
375
|
+
:tool_changes
|
|
376
|
+
elsif (tool = refused_schema_tool(detail, body))
|
|
377
|
+
@refusals.add(:tool_schema, model.to_s, tool)
|
|
378
|
+
Log.info(:http, "tool_schema_refused", host: @host_name, model: model.to_s, tool: tool, detail: detail[0, 200])
|
|
379
|
+
[:tool_schema, tool]
|
|
380
|
+
elsif !@refusals.refused?(:tool_loading, model.to_s) && TOOL_LOADING_RE.match?(detail) && loads?(body)
|
|
381
|
+
@refusals.remember(:tool_loading, model.to_s)
|
|
382
|
+
Log.info(:http, "tool_loading_refused", host: @host_name, model: model.to_s, detail: detail[0, 200])
|
|
383
|
+
:tool_loading
|
|
329
384
|
elsif (cap = detail[OUTPUT_CAP_RE, 1]&.to_i) && cap.positive? && body[:max_tokens].to_i > cap
|
|
330
385
|
@refusals.remember(:output_cap, model.to_s, cap)
|
|
331
386
|
Log.info(:http, "output_cap", host: @host_name, model: model.to_s, max_tokens: cap)
|
|
@@ -333,6 +388,32 @@ module Samagotchi
|
|
|
333
388
|
end
|
|
334
389
|
end
|
|
335
390
|
|
|
391
|
+
# The deferred tool a 400 names by its input_schema's path, or nil.
|
|
392
|
+
def refused_schema_tool(detail, body)
|
|
393
|
+
index = detail[TOOL_SCHEMA_RE, 1] or return nil
|
|
394
|
+
tool = Array(body[:tools])[index.to_i]
|
|
395
|
+
tool[:name] if tool.is_a?(Hash) && tool[:defer_loading]
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Whether +body+ carries a tool change (a system message).
|
|
399
|
+
def changes?(body) = Array(body[:messages]).any? { |message| message[:role] == "system" }
|
|
400
|
+
|
|
401
|
+
# The anthropic-beta header: the binding beta on a drop_block step,
|
|
402
|
+
# INLINE_TOOLS_BETA with a tool change; both comma-joined.
|
|
403
|
+
def beta_headers(body, recovery)
|
|
404
|
+
betas = []
|
|
405
|
+
betas << AnthropicBindingRecovery::BINDING_BETA if recovery.step == :drop_block
|
|
406
|
+
betas << INLINE_TOOLS_BETA if changes?(body)
|
|
407
|
+
betas.empty? ? {} : { "anthropic-beta" => betas.join(",") }
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Whether +body+ declares a deferred tool or loads one by reference.
|
|
411
|
+
def loads?(body)
|
|
412
|
+
return true if Array(body[:tools]).any? { |tool| tool[:defer_loading] }
|
|
413
|
+
|
|
414
|
+
JSON.generate(body[:messages]).include?('"tool_reference"')
|
|
415
|
+
end
|
|
416
|
+
|
|
336
417
|
# The model list's max_tokens for +model+ (capped), else the cap.
|
|
337
418
|
def default_max_tokens(model)
|
|
338
419
|
listed = listed_model(model)&.raw&.dig("max_tokens")
|
|
@@ -4,6 +4,7 @@ require "digest"
|
|
|
4
4
|
require "json"
|
|
5
5
|
require_relative "../log"
|
|
6
6
|
require_relative "utf8_scrub"
|
|
7
|
+
require_relative "../tools/tool_index"
|
|
7
8
|
|
|
8
9
|
module Samagotchi
|
|
9
10
|
module LLM
|
|
@@ -13,11 +14,18 @@ module Samagotchi
|
|
|
13
14
|
# - the head system message → the top-level system, as text blocks: with
|
|
14
15
|
# its cache_split, two blocks, the breakpoint on the first; a later
|
|
15
16
|
# system message → user text (a mid-conversation system role is
|
|
16
|
-
# model-gated)
|
|
17
|
+
# model-gated), but a plugin's tool change (tool_change:) → a system
|
|
18
|
+
# message of its own, tool_addition blocks by reference for the names
|
|
19
|
+
# deferred in this request (none: dropped), placed after the run of
|
|
20
|
+
# user-role messages it sits in, before the next assistant message;
|
|
17
21
|
# - an assistant message → its saved blocks verbatim (anthropic_content),
|
|
18
22
|
# else a text block and its tool_use blocks (arguments as the input
|
|
19
23
|
# object); one with neither is dropped;
|
|
20
|
-
# - tool messages → tool_result blocks in a user message;
|
|
24
|
+
# - tool messages → tool_result blocks in a user message; one whose
|
|
25
|
+
# loaded: names tools this request declares deferred loads them: its
|
|
26
|
+
# tool_result holds only their tool_reference blocks, and its text
|
|
27
|
+
# follows as a sibling text block (Messages takes no text beside a
|
|
28
|
+
# reference), never marked;
|
|
21
29
|
# - user parts → text blocks and image blocks (a data URL as base64, an
|
|
22
30
|
# http(s) URL as a url source).
|
|
23
31
|
# Consecutive messages of one role go as one message (so a turn's tool
|
|
@@ -47,11 +55,15 @@ module Samagotchi
|
|
|
47
55
|
# (PromptCache.control): on the head system's first block and on the
|
|
48
56
|
# last block that didn't come from a later system message (a tail
|
|
49
57
|
# note moves each request, as PromptCache's rule says); nil sets none
|
|
58
|
+
# @param deferred [Array<String>] the tools this request declares
|
|
59
|
+
# deferred (defer_loading)
|
|
50
60
|
# @return [Translated]
|
|
51
|
-
def translate(wire, cache: nil)
|
|
61
|
+
def translate(wire, cache: nil, deferred: [])
|
|
52
62
|
system = []
|
|
53
63
|
messages = []
|
|
54
64
|
notes = {}.compare_by_identity
|
|
65
|
+
# Tool changes wait for the end of the user turn they sit in.
|
|
66
|
+
changes = []
|
|
55
67
|
Array(wire).each_with_index do |raw, index|
|
|
56
68
|
message = raw.to_h.transform_keys(&:to_sym)
|
|
57
69
|
role = message[:role].to_s
|
|
@@ -60,9 +72,20 @@ module Samagotchi
|
|
|
60
72
|
next
|
|
61
73
|
end
|
|
62
74
|
|
|
75
|
+
if role == "system" && message[:tool_change]
|
|
76
|
+
blocks = tool_additions(message[:tool_change], deferred)
|
|
77
|
+
changes << { role: "system", content: blocks } unless blocks.empty?
|
|
78
|
+
next
|
|
79
|
+
end
|
|
80
|
+
|
|
63
81
|
case role
|
|
64
|
-
when "assistant"
|
|
65
|
-
|
|
82
|
+
when "assistant"
|
|
83
|
+
flush_changes(messages, changes)
|
|
84
|
+
append(messages, "assistant", assistant_blocks(message))
|
|
85
|
+
when "tool"
|
|
86
|
+
blocks = tool_blocks(message, deferred)
|
|
87
|
+
blocks.drop(1).each { |block| notes[block] = true }
|
|
88
|
+
append(messages, "user", blocks)
|
|
66
89
|
when "system"
|
|
67
90
|
blocks = content_blocks(message[:content])
|
|
68
91
|
blocks.each { |block| notes[block] = true }
|
|
@@ -70,18 +93,22 @@ module Samagotchi
|
|
|
70
93
|
else append(messages, "user", content_blocks(message[:content]))
|
|
71
94
|
end
|
|
72
95
|
end
|
|
96
|
+
flush_changes(messages, changes)
|
|
73
97
|
messages.each { |message| message[:content] << text_block(PLACEHOLDER) if message[:content].empty? }
|
|
74
98
|
mark_last(messages, cache, notes) if cache
|
|
75
99
|
Translated.new(system: system, messages: messages)
|
|
76
100
|
end
|
|
77
101
|
|
|
78
|
-
# ChatLoop's function definitions as Messages tools
|
|
102
|
+
# ChatLoop's function definitions as Messages tools; a deferred one
|
|
103
|
+
# (defer_loading: true beside its function) keeps the flag.
|
|
79
104
|
# @param tools [Array<Hash>] {type: "function", function: {name:, description:, parameters:}}
|
|
80
105
|
def tools(tools)
|
|
81
106
|
Array(tools).map do |tool|
|
|
82
107
|
function = (tool[:function] || tool["function"] || tool).to_h.transform_keys(&:to_sym)
|
|
108
|
+
deferred = tool[:defer_loading] || tool["defer_loading"]
|
|
83
109
|
{ name: function[:name].to_s, description: function[:description],
|
|
84
|
-
input_schema: function[:parameters] || { type: "object", properties: {} }
|
|
110
|
+
input_schema: function[:parameters] || { type: "object", properties: {} },
|
|
111
|
+
defer_loading: (true if deferred == true) }.compact
|
|
85
112
|
end
|
|
86
113
|
end
|
|
87
114
|
|
|
@@ -133,8 +160,27 @@ module Samagotchi
|
|
|
133
160
|
text.empty? ? "tool" : text
|
|
134
161
|
end
|
|
135
162
|
|
|
163
|
+
# A tool change's tool_addition blocks, by reference, for its names
|
|
164
|
+
# deferred in this request (a reference must name a tool in tools).
|
|
165
|
+
def tool_additions(change, deferred)
|
|
166
|
+
change = change.to_h.transform_keys(&:to_sym)
|
|
167
|
+
names = Array(change[:add]).map(&:to_s) & Array(deferred)
|
|
168
|
+
names.map { |name| { type: "tool_addition", tool: { type: "tool_reference", name: name } } }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The waiting tool changes after the messages so far (a user turn:
|
|
172
|
+
# Messages wants a system change after one), each a message of its
|
|
173
|
+
# own; none before the first user message.
|
|
174
|
+
def flush_changes(messages, changes)
|
|
175
|
+
return if changes.empty? || messages.last.nil? || messages.last[:role] != "user"
|
|
176
|
+
|
|
177
|
+
messages.concat(changes)
|
|
178
|
+
changes.clear
|
|
179
|
+
end
|
|
180
|
+
|
|
136
181
|
# A message joins the one before it when the role is the same; an
|
|
137
|
-
# assistant message with no blocks is dropped.
|
|
182
|
+
# assistant message with no blocks is dropped. A system message (a
|
|
183
|
+
# tool change) is never joined.
|
|
138
184
|
def append(messages, role, blocks)
|
|
139
185
|
return if role == "assistant" && blocks.empty?
|
|
140
186
|
|
|
@@ -215,6 +261,29 @@ module Samagotchi
|
|
|
215
261
|
block
|
|
216
262
|
end
|
|
217
263
|
|
|
264
|
+
# A tool message's blocks: its tool_result, or, when its loaded:
|
|
265
|
+
# names tools deferred in this request, a tool_result of their
|
|
266
|
+
# references and the text after it.
|
|
267
|
+
def tool_blocks(message, deferred)
|
|
268
|
+
names = Array(message[:loaded]).map(&:to_s) & Array(deferred)
|
|
269
|
+
return [tool_result(message)] if names.empty?
|
|
270
|
+
|
|
271
|
+
refs = names.map { |name| { type: "tool_reference", tool_name: name } }
|
|
272
|
+
[{ type: "tool_result", tool_use_id: tool_id(message[:tool_call_id]), content: refs },
|
|
273
|
+
text_block(loaded_text(scrub(message[:content].to_s), names))]
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# The sibling text: the load first, then the result's text without the
|
|
277
|
+
# line that points at call_tool (D11: the saved result and the prompt
|
|
278
|
+
# don't change; only this wire says it).
|
|
279
|
+
def loaded_text(text, names)
|
|
280
|
+
line = "Loaded: #{names.join(", ")}. #{LOADED_HINT}"
|
|
281
|
+
text = text.sub("#{Tools::ToolIndex::CALL_HINT}\n\n", "")
|
|
282
|
+
blank?(text) ? line : "#{line}\n\n#{text}"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
LOADED_HINT = "Call them directly by these names."
|
|
286
|
+
|
|
218
287
|
# A String or a parts list as blocks.
|
|
219
288
|
def content_blocks(content)
|
|
220
289
|
return blank?(content.to_s) ? [] : [text_block(scrub(content.to_s))] unless content.is_a?(Array)
|
|
@@ -241,14 +310,23 @@ module Samagotchi
|
|
|
241
310
|
end
|
|
242
311
|
|
|
243
312
|
# Breakpoint #2: the last user block that can carry one and didn't
|
|
244
|
-
# come from a later system message (+notes+)
|
|
313
|
+
# come from a later system message or a load's text (+notes+); on a
|
|
314
|
+
# tool_result of references, its last reference.
|
|
245
315
|
def mark_last(messages, cache, notes)
|
|
246
316
|
markable = ->(block) { !UNMARKABLE.include?(type_of(block)) && !notes.key?(block) }
|
|
247
317
|
message = messages.reverse_each.find { |m| m[:role] == "user" && m[:content].any?(&markable) } or return
|
|
248
318
|
index = message[:content].rindex(&markable)
|
|
249
319
|
block = message[:content][index]
|
|
250
320
|
key = block.key?("type") ? "cache_control" : :cache_control
|
|
251
|
-
message[:content][index] = block.merge(key => cache)
|
|
321
|
+
message[:content][index] = references?(block) ? mark_reference(block, cache) : block.merge(key => cache)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def references?(block) = type_of(block) == "tool_result" && block[:content].is_a?(Array)
|
|
325
|
+
|
|
326
|
+
def mark_reference(block, cache)
|
|
327
|
+
refs = block[:content].dup
|
|
328
|
+
refs[-1] = refs[-1].merge(cache_control: cache)
|
|
329
|
+
block.merge(content: refs)
|
|
252
330
|
end
|
|
253
331
|
|
|
254
332
|
def type_of(block) = (block[:type] || block["type"]).to_s
|
|
@@ -260,7 +338,8 @@ module Samagotchi
|
|
|
260
338
|
def scrub(text) = Utf8Scrub.call(text)
|
|
261
339
|
|
|
262
340
|
private_class_method :append, :system_blocks, :assistant_blocks, :input_of, :warn_arguments, :tool_result,
|
|
263
|
-
:content_blocks, :image_block, :mark_last, :valid_tool_use, :type_of, :text_block, :blank?, :scrub
|
|
341
|
+
:content_blocks, :image_block, :mark_last, :valid_tool_use, :type_of, :text_block, :blank?, :scrub,
|
|
342
|
+
:tool_blocks, :loaded_text, :references?, :mark_reference, :tool_additions, :flush_changes
|
|
264
343
|
end
|
|
265
344
|
end
|
|
266
345
|
end
|
|
@@ -22,6 +22,8 @@ require_relative "../tool_runner"
|
|
|
22
22
|
require_relative "../tool_response"
|
|
23
23
|
require_relative "../tool_ids"
|
|
24
24
|
require_relative "../tool_declarations"
|
|
25
|
+
require_relative "../tools/deferred_tools"
|
|
26
|
+
require_relative "../tool_loads"
|
|
25
27
|
require_relative "../vision_context"
|
|
26
28
|
require_relative "../log"
|
|
27
29
|
require_relative "../empty_answer_retry"
|
|
@@ -100,11 +102,38 @@ module Samagotchi
|
|
|
100
102
|
# Tool definitions for the request: the schemas the native prompts are
|
|
101
103
|
# rendered from (the kernel's registry, the tools the turn's LLM
|
|
102
104
|
# context layers offer), with the chat-only enums and closed
|
|
103
|
-
# parameters.
|
|
104
|
-
|
|
105
|
-
|
|
105
|
+
# parameters. On an adapter that loads tools for +model+
|
|
106
|
+
# (#loads_tools?), every tool the session reaches but doesn't declare
|
|
107
|
+
# follows, deferred (Tools::DeferredTools: +conversation+, the
|
|
108
|
+
# request's, names the ones it loaded); never without a declared one.
|
|
109
|
+
def tool_definitions(conversation = nil, model: nil)
|
|
110
|
+
declared = ToolDeclarations.chat_schemas(tools.schemas(layers: llm_context_layers)).map do |schema|
|
|
106
111
|
{ type: "function", function: schema.slice(:name, :description, :parameters) }
|
|
107
112
|
end
|
|
113
|
+
return declared if declared.empty? || !loads_tools?(model)
|
|
114
|
+
|
|
115
|
+
declared + deferred_tools(conversation).map do |tool|
|
|
116
|
+
{ type: "function", function: { name: tool.name, description: tool.description, parameters: tool.schema },
|
|
117
|
+
defer_loading: true }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The tools a request on a host that loads tools declares deferred
|
|
122
|
+
# (Tools::DeferredTools).
|
|
123
|
+
def deferred_tools(conversation)
|
|
124
|
+
Tools::DeferredTools.list(tools, conversation, layers: llm_context_layers)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Whether the adapter takes tools it doesn't declare up front for
|
|
128
|
+
# +model+ (AnthropicMessages#loads_tools?); OpenAIChat doesn't.
|
|
129
|
+
def loads_tools?(model)
|
|
130
|
+
@adapter.respond_to?(:loads_tools?) && @adapter.loads_tools?(model.to_s)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Whether the adapter takes a mid-conversation tool change for +model+
|
|
134
|
+
# (AnthropicMessages#tool_changes?: a plugin's ctx.load_tools, ToolLoads).
|
|
135
|
+
def tool_changes?(model)
|
|
136
|
+
@adapter.respond_to?(:tool_changes?) && @adapter.tool_changes?(model.to_s)
|
|
108
137
|
end
|
|
109
138
|
|
|
110
139
|
# The kernel's tools.
|
|
@@ -156,6 +185,11 @@ module Samagotchi
|
|
|
156
185
|
#
|
|
157
186
|
# The conversation goes through the kernel's LLMContextView first,
|
|
158
187
|
# as the native prompt does (it keeps every entry in place).
|
|
188
|
+
#
|
|
189
|
+
# On an adapter that loads tools (#loads_tools?), a find result's
|
|
190
|
+
# tool message carries its loaded: names (AnthropicWire renders the
|
|
191
|
+
# load); no other wire sees them. A plugin's tool change
|
|
192
|
+
# (ToolLoads) goes only to an adapter that takes tool changes.
|
|
159
193
|
def wire_messages(conversation, since: nil, reasoning: reasoning_history)
|
|
160
194
|
conversation = llm_context_view.messages(conversation)
|
|
161
195
|
paired = paired_call_ids(conversation)
|
|
@@ -172,7 +206,13 @@ module Samagotchi
|
|
|
172
206
|
items = plan.items(entry, index)
|
|
173
207
|
case entry[:role].to_s
|
|
174
208
|
when "system"
|
|
175
|
-
|
|
209
|
+
if ToolLoads.entry?(entry)
|
|
210
|
+
# Only to a host that takes tool changes; never an empty
|
|
211
|
+
# system message elsewhere.
|
|
212
|
+
wire << { role: "system", content: entry[:content].to_s, TOOL_CHANGE => entry[TOOL_CHANGE] } if tool_change_wire?
|
|
213
|
+
else
|
|
214
|
+
wire << system_message(entry, index)
|
|
215
|
+
end
|
|
176
216
|
when "user"
|
|
177
217
|
# A steer or merged input (always a String) goes with its header.
|
|
178
218
|
content = Steer.header(entry) ? Steer.wire_text(entry, entry[:content].to_s) : entry[:content]
|
|
@@ -186,7 +226,10 @@ module Samagotchi
|
|
|
186
226
|
when "tool_response"
|
|
187
227
|
id = entry[:tool_call_id]
|
|
188
228
|
if id && paired.include?(id)
|
|
189
|
-
|
|
229
|
+
message = { role: "tool", content: with_placeholders(entry[:content].to_s, items), tool_call_id: id }
|
|
230
|
+
# A find result's loads, for a wire that loads tools (AnthropicWire).
|
|
231
|
+
message[:loaded] = entry[:loaded] if entry[:loaded].is_a?(Array) && @adapter.respond_to?(:loads_tools?)
|
|
232
|
+
wire << message
|
|
190
233
|
tool_images.concat(image_parts(items))
|
|
191
234
|
else
|
|
192
235
|
text = "[tool results]\n#{entry[:content]}"
|
|
@@ -215,6 +258,9 @@ module Samagotchi
|
|
|
215
258
|
|
|
216
259
|
TOOL_IMAGES_TEXT = "[images from tool results]"
|
|
217
260
|
|
|
261
|
+
# Whether this turn's requests carry tool changes (the turn's model).
|
|
262
|
+
def tool_change_wire? = tool_changes?(@kernel.turn_settings&.model_name)
|
|
263
|
+
|
|
218
264
|
# The first index whose model entries carry their reasoning (nil:
|
|
219
265
|
# none do) under +level+. Under turn: +since+ when given, where a
|
|
220
266
|
# Run's reasoning starts (ReasoningHistory.run_start, the native
|
|
@@ -368,6 +414,9 @@ module Samagotchi
|
|
|
368
414
|
removed
|
|
369
415
|
end
|
|
370
416
|
|
|
417
|
+
# A system entry's tools a plugin loaded ({add:, by:}; ToolLoads).
|
|
418
|
+
TOOL_CHANGE = ToolLoads::KEY
|
|
419
|
+
|
|
371
420
|
# The shape the engine persists: role and content (parts stay an
|
|
372
421
|
# Array), plus a model turn's tool_calls and thinking (the host's
|
|
373
422
|
# reasoning, sent back as #wire_messages says), a result's tool_call_id, the image
|
|
@@ -376,7 +425,9 @@ module Samagotchi
|
|
|
376
425
|
# (the live row's params line and label, never sent back), an
|
|
377
426
|
# edit/write result's tool_diffs (never sent), an empty-answer
|
|
378
427
|
# note's marker (TurnNote.empty, never sent), and an answer's display
|
|
379
|
-
# and display_refs (AnswerDisplay, AnswerRefs; never sent)
|
|
428
|
+
# and display_refs (AnswerDisplay, AnswerRefs; never sent), a
|
|
429
|
+
# find_tools result's loaded names and a tool change's TOOL_CHANGE
|
|
430
|
+
# (sent only to a wire that loads tools).
|
|
380
431
|
def plain(conversation)
|
|
381
432
|
conversation.map do |entry|
|
|
382
433
|
content = entry[:content].is_a?(Array) ? entry[:content] : entry[:content].to_s
|
|
@@ -395,6 +446,8 @@ module Samagotchi
|
|
|
395
446
|
message[AnswerDisplay::KEY] = entry[AnswerDisplay::KEY] if entry[AnswerDisplay::KEY]
|
|
396
447
|
message[AnswerRefs::KEY] = entry[AnswerRefs::KEY] if entry[AnswerRefs::KEY]
|
|
397
448
|
ContextNote::KEYS.each { |key| message[key] = entry[key] if entry.key?(key) }
|
|
449
|
+
message[ToolResponse::LOADED] = entry[ToolResponse::LOADED] if entry[ToolResponse::LOADED].is_a?(Array)
|
|
450
|
+
message[TOOL_CHANGE] = entry[TOOL_CHANGE] if entry[TOOL_CHANGE].is_a?(Hash)
|
|
398
451
|
message
|
|
399
452
|
end
|
|
400
453
|
end
|
|
@@ -530,6 +583,8 @@ module Samagotchi
|
|
|
530
583
|
@request_image_tokens = nil
|
|
531
584
|
@without_reasoning = false
|
|
532
585
|
@recovery_repeated = false
|
|
586
|
+
# The names the last request declared deferred.
|
|
587
|
+
@deferred = []
|
|
533
588
|
@context = ContextStatus.new(conversation: conversation, llm_context: loop.llm_context)
|
|
534
589
|
loop.llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: @context,
|
|
535
590
|
emit: method(:emit))
|
|
@@ -753,8 +808,10 @@ module Samagotchi
|
|
|
753
808
|
end
|
|
754
809
|
|
|
755
810
|
def request(iteration, retry_generation, streamed, thought, generation_controller, streamed_any = nil)
|
|
811
|
+
definitions = @loop.tool_definitions(@conversation, model: @model_name)
|
|
812
|
+
@deferred = definitions.filter_map { |tool| tool.dig(:function, :name) if tool[:defer_loading] }
|
|
756
813
|
@loop.adapter.chat(
|
|
757
|
-
messages: @loop.wire_messages(@conversation, since: @reasoning_start, reasoning: reasoning_level), tools:
|
|
814
|
+
messages: @loop.wire_messages(@conversation, since: @reasoning_start, reasoning: reasoning_level), tools: definitions, model: @model_name,
|
|
758
815
|
cancel_controller: generation_controller || @cancel_controller, session_id: @loop.session_id,
|
|
759
816
|
options: @loop.request_options(retry_generation: retry_generation, model: @model_name),
|
|
760
817
|
on_delta: lambda { |content:, reasoning:, payload:|
|
|
@@ -938,6 +995,7 @@ module Samagotchi
|
|
|
938
995
|
# with its call (ToolResponse.single: the capped output), with the
|
|
939
996
|
# next id (ToolIds).
|
|
940
997
|
def dispatch(tool_calls, iteration, cap)
|
|
998
|
+
log_direct_deferred(tool_calls)
|
|
941
999
|
calls = tool_calls.map { |tool_call| NativeToolNormalizer.normalize(tool_call) }
|
|
942
1000
|
runs = ToolResponse.run_batch(@loop.tool_runner, calls, iteration: iteration, emit: method(:emit),
|
|
943
1001
|
on_stream_event: @on_stream_event, cap: cap) do |run, index|
|
|
@@ -947,6 +1005,17 @@ module Samagotchi
|
|
|
947
1005
|
@tool_activity.concat(ToolResponse.activities(runs))
|
|
948
1006
|
end
|
|
949
1007
|
|
|
1008
|
+
# A call of a tool the last request declared deferred: whether the
|
|
1009
|
+
# conversation had loaded it (how well a load leads to a direct
|
|
1010
|
+
# call, and how often the model calls one it only read about).
|
|
1011
|
+
def log_direct_deferred(tool_calls)
|
|
1012
|
+
direct = tool_calls.map { |call| call.name.to_s } & @deferred
|
|
1013
|
+
return if direct.empty?
|
|
1014
|
+
|
|
1015
|
+
loaded = Tools::DeferredTools.loaded_names(@conversation)
|
|
1016
|
+
direct.each { |name| Log.info(:turn, "tool_direct_deferred", tool: name, loaded: loaded.include?(name)) }
|
|
1017
|
+
end
|
|
1018
|
+
|
|
950
1019
|
# Queued input at an iteration boundary (Steer.inject!). Returns true
|
|
951
1020
|
# when there was any.
|
|
952
1021
|
def inject_pending_input(iteration, answer: nil)
|
|
@@ -15,8 +15,10 @@ module Samagotchi
|
|
|
15
15
|
# nil for none;
|
|
16
16
|
# cache_breakpoints: whether chat requests carry cache_control;
|
|
17
17
|
# session_header: a header that carries the session id (beside
|
|
18
|
-
# Session-Id), nil for none
|
|
19
|
-
|
|
18
|
+
# Session-Id), nil for none;
|
|
19
|
+
# tool_loading: whether requests declare tools deferred
|
|
20
|
+
# (defer_loading) and load them by reference (tool_reference).
|
|
21
|
+
Dialect = Data.define(:name, :auth, :thinking_display, :cache_breakpoints, :session_header, :tool_loading) do
|
|
20
22
|
# The dialect for a `dialect:` name (HostConfig#dialect); STANDARD for
|
|
21
23
|
# nil or an unknown one.
|
|
22
24
|
def self.for(name) = self::BY_NAME.fetch(name.to_s, self::STANDARD)
|
|
@@ -44,14 +46,15 @@ module Samagotchi
|
|
|
44
46
|
end
|
|
45
47
|
|
|
46
48
|
Dialect::STANDARD = Dialect.new(name: "standard", auth: :x_api_key, thinking_display: "summarized",
|
|
47
|
-
cache_breakpoints: true, session_header: nil)
|
|
49
|
+
cache_breakpoints: true, session_header: nil, tool_loading: true)
|
|
48
50
|
# Fireworks' /v1/messages (docs.fireworks.ai, anthropic-compatibility):
|
|
49
51
|
# Bearer auth, no `display` in its thinking schema (every object refuses
|
|
50
52
|
# extra fields; thinking comes back by default), an automatic prefix
|
|
51
53
|
# cache (no breakpoints), and x-session-affinity to keep a session on
|
|
52
|
-
# one replica, where its cache is.
|
|
54
|
+
# one replica, where its cache is. Its compatibility list has no
|
|
55
|
+
# deferred tools.
|
|
53
56
|
Dialect::FIREWORKS = Dialect.new(name: "fireworks", auth: :bearer, thinking_display: nil,
|
|
54
|
-
cache_breakpoints: false, session_header: "x-session-affinity")
|
|
57
|
+
cache_breakpoints: false, session_header: "x-session-affinity", tool_loading: false)
|
|
55
58
|
Dialect::BY_NAME = { "standard" => Dialect::STANDARD, "fireworks" => Dialect::FIREWORKS }.freeze
|
|
56
59
|
end
|
|
57
60
|
end
|
|
@@ -23,6 +23,12 @@ module Samagotchi
|
|
|
23
23
|
# Whether +kind+ is remembered for +model+ (nil: the host).
|
|
24
24
|
def refused?(kind, model = nil) = @mutex.synchronize { @facts.key?([kind, model]) }
|
|
25
25
|
|
|
26
|
+
# Add +item+ to the list remembered for +kind+ and +model+ (a value
|
|
27
|
+
# that grows: the deferred tools a model's host refused, one by one).
|
|
28
|
+
def add(kind, model, item)
|
|
29
|
+
@mutex.synchronize { (@facts[[kind, model]] ||= []) << item unless Array(@facts[[kind, model]]).include?(item) }
|
|
30
|
+
end
|
|
31
|
+
|
|
26
32
|
# The value remembered for +kind+ and +model+, or nil.
|
|
27
33
|
def [](kind, model = nil) = @mutex.synchronize { @facts[[kind, model]] }
|
|
28
34
|
end
|
|
@@ -15,14 +15,22 @@ module Samagotchi
|
|
|
15
15
|
# window (ContextWindow.setting, nil for none) and LLM context strategy
|
|
16
16
|
# (LLMContextStrategy.resolve, nil for none), and its price on its host
|
|
17
17
|
# (hosts.<name>.models.<id>.price, a ModelPrice; nil for none), what
|
|
18
|
-
# SessionMetrics estimates an unreported cost from
|
|
19
|
-
#
|
|
18
|
+
# SessionMetrics estimates an unreported cost from, and whether the turn's
|
|
19
|
+
# host takes tools it doesn't declare up front (+loads_tools+:
|
|
20
|
+
# AnthropicMessages#loads_tools?; a direct call of a reachable tool then
|
|
21
|
+
# runs). The Engine sets them once per turn, in Engine#generate, after
|
|
22
|
+
# the model's host is synced.
|
|
20
23
|
TurnSettings = Data.define(:vision, :sampling, :thinking, :reasoning_history, :model_name, :window_setting,
|
|
21
|
-
:llm_context, :price) do
|
|
24
|
+
:llm_context, :price, :loads_tools) do
|
|
25
|
+
def initialize(vision:, sampling:, thinking:, reasoning_history:, model_name:, window_setting:, llm_context:,
|
|
26
|
+
price:, loads_tools: false)
|
|
27
|
+
super
|
|
28
|
+
end
|
|
29
|
+
|
|
22
30
|
# No vision, no sampling, the default thinking, no reasoning sent
|
|
23
|
-
# back, no model name.
|
|
31
|
+
# back, no model name, no tools loaded.
|
|
24
32
|
def self.none = new(vision: nil, sampling: {}.freeze, thinking: Thinking::DEFAULT, reasoning_history: :drop,
|
|
25
|
-
model_name: nil, window_setting: nil, llm_context: nil, price: nil)
|
|
33
|
+
model_name: nil, window_setting: nil, llm_context: nil, price: nil, loads_tools: false)
|
|
26
34
|
end
|
|
27
35
|
end
|
|
28
36
|
end
|
|
@@ -192,7 +192,7 @@ module Samagotchi
|
|
|
192
192
|
id = entry[:tool_call_id].to_s
|
|
193
193
|
matches = id.empty? ? [] : batch.select { |call_id, _| call_id.to_s == id }
|
|
194
194
|
call = matches.one? ? matches.first.last : nil
|
|
195
|
-
call &&= Tools::BuiltinCalls.unwrap_saved(call, entry[:content])
|
|
195
|
+
call &&= Tools::BuiltinCalls.unwrap_saved(Tools::BuiltinCalls.wrap_saved(call, entry[:content]), entry[:content])
|
|
196
196
|
texts = call && refs.size == 1 ? ToolResponse.runs_named(entry[:content], [call[:name]]) : nil
|
|
197
197
|
return texts ? [[refs.first, call, texts.first.text]] : []
|
|
198
198
|
end
|
|
@@ -201,7 +201,7 @@ module Samagotchi
|
|
|
201
201
|
# built-in (BuiltinCalls.unwrap_saved).
|
|
202
202
|
calls = batch.map(&:last)
|
|
203
203
|
calls = calls.zip(ToolResponse.runs(entry[:content], calls.size)).map do |call, run|
|
|
204
|
-
Tools::BuiltinCalls.unwrap_saved(call, run&.text)
|
|
204
|
+
Tools::BuiltinCalls.unwrap_saved(Tools::BuiltinCalls.wrap_saved(call, run&.text), run&.text)
|
|
205
205
|
end
|
|
206
206
|
texts = ToolResponse.runs_named(entry[:content], calls.map { |call| call[:name] })
|
|
207
207
|
return [] unless texts && texts.size == refs.size
|
|
@@ -237,7 +237,8 @@ module Samagotchi
|
|
|
237
237
|
tool_id: nil, marks: {})
|
|
238
238
|
# A built-in reached through call_tool reloads as that built-in's
|
|
239
239
|
# row, with via (its saved params and label are the built-in's).
|
|
240
|
-
call
|
|
240
|
+
# A direct call of an MCP tool reloads as the call_tool row it ran as.
|
|
241
|
+
call = Tools::BuiltinCalls.unwrap_saved(Tools::BuiltinCalls.wrap_saved(call, output), output)
|
|
241
242
|
name = call[:name].to_s
|
|
242
243
|
params = shown.is_a?(String) ? shown : ToolActivity.tool_activity_params(name, call, registry: registry)
|
|
243
244
|
part = { tool: name, params: params.to_s }
|
|
@@ -40,7 +40,7 @@ module Samagotchi
|
|
|
40
40
|
"ctx.prompt_files" => "0.60.0", "chi.annotation" => "0.66.0", "chi.toolkit" => "0.66.0",
|
|
41
41
|
"ctx.use_toolkit" => "0.66.0", "ctx.toolkit" => "0.66.0", "ctx.declared_tools" => "0.66.0",
|
|
42
42
|
"chi.tool_source" => "0.67.0", "chi.peer_source" => "0.67.0", "ctx.http" => "0.80.0",
|
|
43
|
-
"ctx.cost" => "0.83.0", "ctx.sessions.children" => "0.34.0"
|
|
43
|
+
"ctx.cost" => "0.83.0", "ctx.sessions.children" => "0.34.0", "ctx.load_tools" => "0.99.0"
|
|
44
44
|
}.freeze
|
|
45
45
|
|
|
46
46
|
# @param bundle [String]
|