samagotchi 0.97.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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/docs/architecture.md +11 -3
  4. data/docs/bundle-authoring.md +5 -3
  5. data/docs/cli.md +8 -3
  6. data/docs/configuration.md +8 -0
  7. data/docs/guardrails.md +8 -0
  8. data/docs/hooks.md +64 -0
  9. data/docs/internals/anthropic.md +79 -8
  10. data/docs/internals/bundles.md +1 -1
  11. data/docs/internals/engine.md +31 -3
  12. data/docs/internals/llm-context-forget.md +2 -1
  13. data/docs/internals/prompt-caching.md +9 -0
  14. data/docs/plugins.md +24 -2
  15. data/docs/sessions.md +11 -1
  16. data/docs/testing.md +27 -0
  17. data/lib/samagotchi/answer_refs.rb +16 -9
  18. data/lib/samagotchi/bundle_command.rb +2 -2
  19. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +39 -5
  21. data/lib/samagotchi/bundles/source-links/manifest.yml +5 -5
  22. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  23. data/lib/samagotchi/engine.rb +91 -10
  24. data/lib/samagotchi/hooks/bundle_loader.rb +18 -15
  25. data/lib/samagotchi/hooks.rb +4 -1
  26. data/lib/samagotchi/kernel_loop.rb +15 -2
  27. data/lib/samagotchi/llm/anthropic_messages.rb +85 -4
  28. data/lib/samagotchi/llm/anthropic_wire.rb +90 -11
  29. data/lib/samagotchi/llm/chat_loop.rb +76 -7
  30. data/lib/samagotchi/llm/dialect.rb +8 -5
  31. data/lib/samagotchi/llm/errors.rb +51 -17
  32. data/lib/samagotchi/llm/refusal_memo.rb +6 -0
  33. data/lib/samagotchi/llm/turn_settings.rb +13 -5
  34. data/lib/samagotchi/llm_context_stale.rb +2 -2
  35. data/lib/samagotchi/memory_bundle/bundle_hook.rb +28 -9
  36. data/lib/samagotchi/memory_bundle/manifest.rb +17 -0
  37. data/lib/samagotchi/message_parts.rb +2 -1
  38. data/lib/samagotchi/output_formatter.rb +8 -2
  39. data/lib/samagotchi/pending_question.rb +7 -3
  40. data/lib/samagotchi/plugin/api.rb +6 -1
  41. data/lib/samagotchi/plugin/context.rb +21 -2
  42. data/lib/samagotchi/prompt.rb +3 -0
  43. data/lib/samagotchi/question_asked.rb +83 -0
  44. data/lib/samagotchi/question_desk.rb +3 -2
  45. data/lib/samagotchi/testing/bundle.rb +20 -1
  46. data/lib/samagotchi/testing/host.rb +1 -0
  47. data/lib/samagotchi/testing/recorder.rb +13 -2
  48. data/lib/samagotchi/tool_loads.rb +62 -0
  49. data/lib/samagotchi/tool_response.rb +5 -1
  50. data/lib/samagotchi/tool_runner.rb +29 -7
  51. data/lib/samagotchi/tools/builtin_calls.rb +16 -0
  52. data/lib/samagotchi/tools/deferred_tools.rb +80 -0
  53. data/lib/samagotchi/tools/registry.rb +7 -2
  54. data/lib/samagotchi/tools/tool_index.rb +88 -6
  55. data/lib/samagotchi/version.rb +1 -1
  56. data/lib/samagotchi/web/app.rb +30 -9
  57. data/lib/samagotchi/web/public/app.js +44 -11
  58. data/lib/samagotchi/web/public/index.html +36 -13
  59. data/lib/samagotchi/web/public/info_bar_model.js +0 -2
  60. data/lib/samagotchi/web/public/lists_view.js +32 -8
  61. data/lib/samagotchi/web/public/markdown_place.js +25 -8
  62. data/lib/samagotchi/web/public/question_cards.js +23 -6
  63. data/lib/samagotchi/web/public/session_rename.js +88 -15
  64. data/lib/samagotchi/web/public/session_title.js +6 -0
  65. data/lib/samagotchi/web/public/stage_frame.js +24 -14
  66. data/lib/samagotchi/web/public/stage_model.js +4 -2
  67. data/lib/samagotchi/web/public/stage_slots.js +41 -9
  68. data/lib/samagotchi/web/public/stage_view.js +149 -8
  69. data/lib/samagotchi/web/public/stream_handlers.js +5 -1
  70. data/lib/samagotchi/web/public/turn_model.js +20 -0
  71. data/lib/samagotchi/web/public/turn_view.js +22 -9
  72. metadata +4 -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.step == :drop_block ? { "anthropic-beta" => AnthropicBindingRecovery::BINDING_BETA } : {}
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
- translated = AnthropicWire.translate(messages, cache: cache)
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" then append(messages, "assistant", assistant_blocks(message))
65
- when "tool" then append(messages, "user", [tool_result(message)])
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: {} } }.compact
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
- def tool_definitions
105
- ToolDeclarations.chat_schemas(tools.schemas(layers: llm_context_layers)).map do |schema|
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
- wire << system_message(entry, index)
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
- wire << { role: "tool", content: with_placeholders(entry[:content].to_s, items), tool_call_id: id }
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: @loop.tool_definitions, model: @model_name,
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
- Dialect = Data.define(:name, :auth, :thinking_display, :cache_breakpoints, :session_header) do
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