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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -1
  3. data/docs/architecture.md +1 -1
  4. data/docs/configuration.md +8 -0
  5. data/docs/guardrails.md +8 -0
  6. data/docs/hooks.md +5 -0
  7. data/docs/internals/anthropic.md +79 -8
  8. data/docs/internals/engine.md +13 -3
  9. data/docs/internals/llm-context-forget.md +2 -1
  10. data/docs/internals/prompt-caching.md +9 -0
  11. data/docs/plugins.md +16 -1
  12. data/docs/sessions.md +10 -0
  13. data/docs/testing.md +27 -0
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/engine.rb +65 -8
  16. data/lib/samagotchi/kernel_loop.rb +15 -2
  17. data/lib/samagotchi/llm/anthropic_messages.rb +85 -4
  18. data/lib/samagotchi/llm/anthropic_wire.rb +90 -11
  19. data/lib/samagotchi/llm/chat_loop.rb +76 -7
  20. data/lib/samagotchi/llm/dialect.rb +8 -5
  21. data/lib/samagotchi/llm/refusal_memo.rb +6 -0
  22. data/lib/samagotchi/llm/turn_settings.rb +13 -5
  23. data/lib/samagotchi/llm_context_stale.rb +2 -2
  24. data/lib/samagotchi/message_parts.rb +2 -1
  25. data/lib/samagotchi/plugin/api.rb +1 -1
  26. data/lib/samagotchi/plugin/context.rb +21 -2
  27. data/lib/samagotchi/prompt.rb +3 -0
  28. data/lib/samagotchi/testing/bundle.rb +2 -0
  29. data/lib/samagotchi/testing/host.rb +1 -0
  30. data/lib/samagotchi/testing/recorder.rb +13 -2
  31. data/lib/samagotchi/tool_loads.rb +62 -0
  32. data/lib/samagotchi/tool_response.rb +5 -1
  33. data/lib/samagotchi/tool_runner.rb +29 -7
  34. data/lib/samagotchi/tools/builtin_calls.rb +16 -0
  35. data/lib/samagotchi/tools/deferred_tools.rb +80 -0
  36. data/lib/samagotchi/tools/registry.rb +7 -2
  37. data/lib/samagotchi/tools/tool_index.rb +88 -6
  38. data/lib/samagotchi/version.rb +1 -1
  39. metadata +3 -1
@@ -48,7 +48,9 @@ module Samagotchi
48
48
  # :non_interactive), which ctx.frontend names for plugins.
49
49
  # +toolkit+ returns the session's toolkit name, +declared_tools+ the
50
50
  # tool names its next turn declares, +use_toolkit+ takes a name and
51
- # returns whether it staged it (Engine#use_toolkit).
51
+ # returns whether it staged it (Engine#use_toolkit); +load_tools+ takes
52
+ # (names, bundle) and returns the wire names it staged
53
+ # (Engine#load_tools).
52
54
  # +cancel_controller+ takes (handler:) and returns the controller a
53
55
  # ctx.http call without cancel: uses, nil for none: an init task's own,
54
56
  # else the running turn's for work done for it (the turn's thread, or a
@@ -56,7 +58,7 @@ module Samagotchi
56
58
  # +cost+ returns the session's spend now (SessionMetrics::Cost).
57
59
  Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
58
60
  :ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
59
- :steer, :stop_turn, :stop_generation, :frontend, :toolkit, :declared_tools, :use_toolkit,
61
+ :steer, :stop_turn, :stop_generation, :frontend, :toolkit, :declared_tools, :use_toolkit, :load_tools,
60
62
  :cancel_controller, :cost, keyword_init: true)
61
63
 
62
64
  # ctx.ask_model failed: the model couldn't be reached, timed out, or
@@ -182,6 +184,23 @@ module Samagotchi
182
184
  # @raise [ArgumentError] a toolkit no one declared
183
185
  def use_toolkit(name) = !!@host.use_toolkit&.call(name.to_s)
184
186
 
187
+ # Load tools the session reaches but doesn't declare, so the model
188
+ # calls them by name (docs/plugins.md): built-in names, MCP tools'
189
+ # acts_as (mcp_fs_read) or <group>/<tool>. A hint, not access: the
190
+ # tools stay behind the same guardrails, and only a host that takes
191
+ # tool changes (api: anthropic) gets one; elsewhere nothing is sent.
192
+ # Called from before_turn, they load for that turn; anywhere else,
193
+ # from the next turn's start. Idempotent: a declared tool, one loaded
194
+ # before, a removed or unknown one is skipped.
195
+ # @param names [Array<String>]
196
+ # @return [Array<String>] the names staged (wire names)
197
+ # @raise [ArgumentError] +names+ isn't an Array
198
+ def load_tools(names)
199
+ raise ArgumentError, "load_tools takes an Array of tool names" unless names.is_a?(Array)
200
+
201
+ Array(@host.load_tools&.call(names.map(&:to_s), @bundle))
202
+ end
203
+
185
204
  # Whether the session is a `chi scratch` one (deleted when it ends).
186
205
  def scratch? = !!@host.scratch&.call
187
206
 
@@ -5,6 +5,7 @@ require_relative "prompt_literal_guard"
5
5
  require_relative "vision_context"
6
6
  require_relative "tool_response"
7
7
  require_relative "steer"
8
+ require_relative "tool_loads"
8
9
 
9
10
  module Samagotchi
10
11
  # Formats a message list into a prompt string ready for the /completion endpoint.
@@ -48,6 +49,8 @@ module Samagotchi
48
49
  # the model's answer (Thinking.native: Qwen's empty thought)
49
50
  # @return [Array(String, Array<String>)]
50
51
  def self.format_with_images(messages, profile: ModelProfile.default, vision: nil, prefill: "")
52
+ # A plugin's tool change (ToolLoads) is for a Messages host only.
53
+ messages = messages.reject { |message| ToolLoads.entry?(message) }
51
54
  plan = ImagePlan.new(messages, vision, data: :base64)
52
55
  images = []
53
56
  suffixes = messages.each_with_index.map { |m, index| image_lines(plan.items(m, index), profile, images) }
@@ -352,6 +352,8 @@ module Samagotchi
352
352
  def questions = @recorder.questions
353
353
  def model_requests = @recorder.model_requests
354
354
  def toolkit_uses = @recorder.toolkit_uses
355
+ # Every ctx.load_tools call (ToolLoad: names, bundle).
356
+ def tool_loads = @recorder.tool_loads
355
357
  # Queue ask_model answers: a String, an exception, or a callable.
356
358
  def model_answers = @recorder.model_answers
357
359
 
@@ -47,6 +47,7 @@ module Samagotchi
47
47
  toolkit: -> { recorder[:toolkit] },
48
48
  declared_tools: -> { recorder[:declared_tools] || tool_names.call },
49
49
  use_toolkit: ->(name) { recorder.use_toolkit(name, toolkits.call) },
50
+ load_tools: ->(names, bundle) { recorder.load_tools(names, bundle) },
50
51
  cancel_controller: ->(handler: false) { recorder[:cancelled] ? stopped : nil },
51
52
  cost: -> { cost(recorder[:cost]) }
52
53
  )
@@ -12,6 +12,8 @@ module Samagotchi
12
12
  Notice = Data.define(:text, :level, :hook, :fallback_for)
13
13
  # Text put into the running turn (ctx.steer, event[:steer]).
14
14
  Steer = Data.define(:text, :hook)
15
+ # A ctx.load_tools call: the names given, and the bundle.
16
+ ToolLoad = Data.define(:names, :bundle)
15
17
  # A stop_turn or stop_generation (+kind+ :turn or :generation).
16
18
  Stop = Data.define(:kind, :reason, :hook)
17
19
  # An ask_model request: the request Context built (SideQuestion), its
@@ -32,13 +34,13 @@ module Samagotchi
32
34
  DEFAULTS = {
33
35
  session_id: "test-session", cwd: nil, messages: [], messages_partial: false, model_name: "test:model",
34
36
  model_key: "model", toolkit: "full", declared_tools: nil, frontend: :worker, scratch: false, setup: nil,
35
- prompt_files: [], cancelled: false, state_dir: nil, running: true, toolkit_staged: true,
37
+ prompt_files: [], cancelled: false, state_dir: nil, running: true, toolkit_staged: true, load_tools_staged: nil,
36
38
  # ctx.cost: nil (zero), a Float (reported USD), a Hash of the parts
37
39
  # (reported:, estimated:, side:, known:) or a callable giving one.
38
40
  cost: nil
39
41
  }.freeze
40
42
 
41
- attr_reader :notices, :cards, :steers, :stops, :questions, :model_requests, :toolkit_uses, :answers,
43
+ attr_reader :notices, :cards, :steers, :stops, :questions, :model_requests, :toolkit_uses, :tool_loads, :answers,
42
44
  :model_answers, :values
43
45
 
44
46
  def initialize(**values)
@@ -53,6 +55,7 @@ module Samagotchi
53
55
  @questions = []
54
56
  @model_requests = []
55
57
  @toolkit_uses = []
58
+ @tool_loads = []
56
59
  @answers = []
57
60
  @model_answers = []
58
61
  @mutex = Mutex.new
@@ -127,6 +130,14 @@ module Samagotchi
127
130
  answer.respond_to?(:call) ? answer.call(entry) : answer
128
131
  end
129
132
 
133
+ # ctx.load_tools: recorded; every name is staged unless the test set
134
+ # load_tools_staged: (a list: those only).
135
+ def load_tools(names, bundle)
136
+ record(@tool_loads, ToolLoad.new(names: names, bundle: bundle))
137
+ staged = self[:load_tools_staged]
138
+ staged ? names & Array(staged) : names
139
+ end
140
+
130
141
  # ctx.use_toolkit: +known+ the toolkits chi and the plugins declare.
131
142
  # Staged (toolkit_staged:, true by default), the session is on it at
132
143
  # once, as chi saves it on the session (ToolkitBinding#use).
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # The tools plugins load (ctx.load_tools), staged until a turn's messages
5
+ # are built (Engine#turn_messages): then each bundle's names go into the
6
+ # conversation as one system entry of kind KIND, which a host that takes
7
+ # tool changes (LLM::AnthropicMessages#tool_changes?) receives as a
8
+ # tool_addition by reference. The names are wire names a request declares
9
+ # deferred (Tools::DeferredTools): a built-in's, a source tool's acts_as.
10
+ # Staging is idempotent: a name already staged is staged once.
11
+ class ToolLoads
12
+ KIND = "tool_change"
13
+ # The entry's key for what it adds: {add: [names], by: bundle}.
14
+ KEY = :tool_change
15
+
16
+ def initialize
17
+ @mutex = Mutex.new
18
+ @staged = {}
19
+ end
20
+
21
+ # Stage +names+ for bundle +by+.
22
+ # @return [Array<String>] the names newly staged
23
+ def stage(names, by:)
24
+ @mutex.synchronize do
25
+ taken = @staged.values.flatten
26
+ fresh = names.uniq - taken
27
+ (@staged[by.to_s] ||= []).concat(fresh) unless fresh.empty?
28
+ fresh
29
+ end
30
+ end
31
+
32
+ def any? = @mutex.synchronize { @staged.any? }
33
+
34
+ # The staged names per bundle, cleared.
35
+ # @return [Hash{String => Array<String>}]
36
+ def take
37
+ @mutex.synchronize do
38
+ staged = @staged
39
+ @staged = {}
40
+ staged
41
+ end
42
+ end
43
+
44
+ # The conversation entry for bundle +by+'s +names+.
45
+ def self.entry(names, by:)
46
+ { role: "system", kind: KIND, content: "[tools loaded: #{names.join(", ")} (#{by})]",
47
+ KEY => { add: names, by: by } }
48
+ end
49
+
50
+ # Whether +entry+ is one of #entry's (string or symbol keys).
51
+ def self.entry?(entry) = entry.is_a?(Hash) && (entry[:kind] || entry["kind"]).to_s == KIND
52
+
53
+ # The names +messages+' tool change entries added.
54
+ # @return [Array<String>]
55
+ def self.added(messages)
56
+ Array(messages).select { |entry| entry?(entry) }.flat_map do |entry|
57
+ change = entry[KEY] || entry[KEY.to_s] || {}
58
+ Array(change[:add] || change["add"]).map(&:to_s)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -14,6 +14,8 @@ module Samagotchi
14
14
  # run's chi-owned id, ToolIds, and a strategy's edits by id,
15
15
  # LLMContextEdit); the prompt never renders them.
16
16
  SAVED_KEYS = %i[tool_params tool_labels tool_diffs tool_refs tool_ids edits].freeze
17
+ # The tools a chat result loaded (#single), saved with it.
18
+ LOADED = :loaded
17
19
  # A run's "[name]" lead: the name, then a newline, a space or the end.
18
20
  LEAD = /\A\[([^\]\s]+)\](?:\n| |\z)/
19
21
 
@@ -90,7 +92,8 @@ module Samagotchi
90
92
 
91
93
  # The chat loop's entry for one call: its capped output, paired with the
92
94
  # call by +tool_call_id+, and its chi-owned id (a one-id +ids+, as
93
- # #joined's tool_ids).
95
+ # #joined's tool_ids); a find_tools hit's +loaded+ names (saved on every
96
+ # chat backend: a wire that loads tools renders them, LLM::AnthropicWire).
94
97
  def single(run, tool_call_id:, ids:)
95
98
  entry = { role: "tool_response", content: run.capped_output, tool_call_id: tool_call_id }
96
99
  entry[:images] = run.images if run.images&.any?
@@ -99,6 +102,7 @@ module Samagotchi
99
102
  entry[:tool_diffs] = run.diff if run.diff
100
103
  entry[:tool_refs] = run.refs if run.refs
101
104
  entry[:tool_ids] = ids
105
+ entry[LOADED] = run.loaded if run.loaded&.any?
102
106
  entry
103
107
  end
104
108
 
@@ -17,11 +17,13 @@ require_relative "memory_bundle/index_size"
17
17
  module Samagotchi
18
18
  # One call's result from ToolRunner#run, read by ToolResponse (the loops'
19
19
  # tool_response entries) and the loops' activity list. Fields a call didn't
20
- # produce (images, shown_params, shown_label, refs, diff, output_file) are nil.
20
+ # produce (images, shown_params, shown_label, refs, diff, output_file,
21
+ # loaded) are nil. +loaded+: the tools a find_tools hit loads
22
+ # (Tools::ToolIndex::FoundText#loaded), by their wire names.
21
23
  ToolRun = Data.define(:output, :capped_output, :truncated, :activity, :images, :shown_params, :shown_label, :refs, :diff,
22
- :output_file) do
24
+ :output_file, :loaded) do
23
25
  def initialize(output: nil, capped_output: nil, truncated: nil, activity: nil, images: nil, shown_params: nil,
24
- shown_label: nil, refs: nil, diff: nil, output_file: nil)
26
+ shown_label: nil, refs: nil, diff: nil, output_file: nil, loaded: nil)
25
27
  super
26
28
  end
27
29
  end
@@ -150,7 +152,8 @@ module Samagotchi
150
152
  ToolRun.new(output: output, capped_output: capped, truncated: truncated, activity: result[:activity],
151
153
  images: images&.any? ? images : nil,
152
154
  shown_params: params && plugin_tool?(call[:name]) ? params : nil,
153
- shown_label: label, refs: refs.empty? ? nil : refs, diff: diff, output_file: output_file)
155
+ shown_label: label, refs: refs.empty? ? nil : refs, diff: diff, output_file: output_file,
156
+ loaded: result[:loaded])
154
157
  end
155
158
 
156
159
  private
@@ -317,29 +320,48 @@ module Samagotchi
317
320
  def layers = @kernel.respond_to?(:llm_context_layers) ? @kernel.llm_context_layers : []
318
321
 
319
322
  # The call its tool's entry unwraps it to (call_tool on a built-in),
320
- # else the call as it is. One that fails runs as it is: call_tool then
321
- # refuses a built-in (fail closed).
323
+ # else the call as it is. On a host that loads tools, a direct call of
324
+ # a source's tool by its acts_as runs as the call_tool call first
325
+ # (Registry::Index#wrap): call_tool's row, rules and approvals. One
326
+ # that fails runs as it is: call_tool then refuses a built-in (fail
327
+ # closed).
322
328
  def unwrapped(call)
323
329
  registry = tools
324
330
  return call unless registry.respond_to?(:offered) && !call[:name].nil?
325
331
 
332
+ call = wrapped(registry, call)
326
333
  registry.offered(call[:name], layers: layers)&.unwrap&.call(call, layers) || call
327
334
  rescue StandardError => e
328
335
  Log.warn(:turn, "tool_unwrap_failed", tool: call[:name].to_s, error: "#{e.class}: #{e.message}")
329
336
  call
330
337
  end
331
338
 
339
+ # +call+ as the call_tool call it runs as when it names a deferred
340
+ # source tool, on a host that loads tools; else as it is.
341
+ def wrapped(registry, call)
342
+ wrap = registry.respond_to?(:index) ? registry.index&.wrap : nil
343
+ return call unless wrap && loads_tools? && !registry.key?(call[:name])
344
+
345
+ wrap.call(call) || call
346
+ end
347
+
332
348
  # A call that can't run in this turn as it is, by its name: a real tool
333
349
  # the session's toolkit leaves out, or one reached through call_tool
334
- # (via:) that the toolkit doesn't reach that way (a hook's rewrite).
350
+ # (via:) that the toolkit doesn't reach that way (a hook's rewrite). On
351
+ # a host that loads tools, a built-in the toolkit reaches runs by its
352
+ # own name (KernelLoop#dispatch takes it too).
335
353
  def left_out?(call)
336
354
  registry = tools
337
355
  return false unless registry.respond_to?(:left_out) && !call[:name].nil?
338
356
  return registry.indexed(call[:name], layers: layers).nil? if call[:via]
357
+ return false if loads_tools? && registry.indexed(call[:name], layers: layers)
339
358
 
340
359
  !registry.left_out(call[:name], layers: layers).nil?
341
360
  end
342
361
 
362
+ # Whether the turn's host loads tools (TurnSettings#loads_tools).
363
+ def loads_tools? = @kernel.respond_to?(:turn_settings) && @kernel.turn_settings&.loads_tools == true
364
+
343
365
  # A tool the registry has from a plugin (not core, not unknown).
344
366
  def plugin_tool?(name)
345
367
  entry = tools && !name.nil? ? tools[name] : nil
@@ -166,6 +166,22 @@ module Samagotchi
166
166
  build(name, saved_args(call[:args]["args"])).merge(via: VIA)
167
167
  end
168
168
 
169
+ # A saved direct call of a deferred source tool (an MCP tool by its
170
+ # acts_as, on a host that loads tools) as the call_tool call it ran as
171
+ # (ToolIndex#wrap): a name that is no built-in, object args, and a
172
+ # result that leads with "[call_tool]". Any other call as it is. The
173
+ # tool is named by its acts_as (the call tool takes it as well).
174
+ # @param call [Hash] as #build gives it
175
+ # @param output [String, nil]
176
+ # @return [Hash]
177
+ def wrap_saved(call, output)
178
+ name = call[:name].to_s
179
+ return call if name.empty? || name == VIA || row(name) || !call[:args].is_a?(Hash)
180
+ return call unless output.to_s[SAVED_LEAD, 1] == VIA
181
+
182
+ build(VIA, { "tool" => name, "args" => call[:args] })
183
+ end
184
+
169
185
  def ran_as?(output, name)
170
186
  lead = output[SAVED_LEAD, 1]
171
187
  lead ? lead == name : output.start_with?("Error: #{name} ")
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../tool_declarations"
5
+
6
+ module Samagotchi
7
+ module Tools
8
+ # The tools a request declares deferred (defer_loading: true) on a host
9
+ # that loads tools (LLM::AnthropicMessages#loads_tools?): every tool the
10
+ # session reaches but doesn't declare, so a find_tools hit can load it
11
+ # and the model calls it by its own name. In order:
12
+ # 1. the built-ins the toolkit leaves out but doesn't remove
13
+ # (Registry#reachable_names), in registry order;
14
+ # 2. the tool index's source tools (an MCP server's, by acts_as), only
15
+ # while call_tool is declared (ToolIndex#deferrable: sorted by name);
16
+ # 3. every other name the conversation loaded (a find result's
17
+ # loaded:, a tool change's add:) that isn't declared: a stub (its
18
+ # source is gone, or hasn't published yet), so the reference in the
19
+ # history stays valid. The runner refuses its calls as any other.
20
+ # The list goes after the declared tools, outside the cached prefix's
21
+ # marks: a tool joining it (a late server) keeps the cache.
22
+ module DeferredTools
23
+ # One deferred tool: its wire name, description and input schema.
24
+ Deferred = Data.define(:name, :description, :schema)
25
+
26
+ STUB_DESCRIPTION = "Not available right now; find_tools finds it when it is."
27
+ STUB_SCHEMA = { "type" => "object", "properties" => {} }.freeze
28
+
29
+ module_function
30
+
31
+ # @param registry [Registry]
32
+ # @param conversation [Array<Hash>, nil] the request's (engine format)
33
+ # @param layers [Array<Symbol>] the turn's LLM context layers
34
+ # @return [Array<Deferred>]
35
+ def list(registry, conversation, layers: [])
36
+ deferred = builtins(registry, layers) + sourced(registry, layers)
37
+ taken = deferred.map(&:name)
38
+ loaded_names(conversation).each do |name|
39
+ next if taken.include?(name) || registry.offered(name, layers: layers)
40
+
41
+ taken << name
42
+ deferred << Deferred.new(name: name, description: STUB_DESCRIPTION, schema: STUB_SCHEMA)
43
+ end
44
+ deferred
45
+ end
46
+
47
+ # The names +conversation+ loaded, first reference first: a find
48
+ # result's loaded: (ToolResponse::LOADED), a tool change's add:.
49
+ # @return [Array<String>]
50
+ def loaded_names(conversation)
51
+ Array(conversation).flat_map do |entry|
52
+ names = entry[:loaded] || entry["loaded"]
53
+ change = entry[:tool_change] || entry["tool_change"]
54
+ names = change[:add] || change["add"] if change.is_a?(Hash)
55
+ Array(names).map(&:to_s)
56
+ end.uniq
57
+ end
58
+
59
+ def builtins(registry, layers)
60
+ return [] unless registry.respond_to?(:reachable_names)
61
+
62
+ registry.reachable_names(layers: layers).map do |name|
63
+ schema = ToolDeclarations.chat_schemas([registry[name].schema]).first
64
+ Deferred.new(name: name, description: schema[:description].to_s, schema: schema[:parameters])
65
+ end
66
+ end
67
+
68
+ def sourced(registry, layers)
69
+ index = registry.respond_to?(:index) ? registry.index : nil
70
+ return [] unless index&.deferrable && registry.offered(index.call_name, layers: layers)
71
+
72
+ index.deferrable.call.map do |tool|
73
+ Deferred.new(name: tool.acts_as, description: tool.description, schema: tool.schema)
74
+ end
75
+ end
76
+
77
+ private_class_method :builtins, :sourced
78
+ end
79
+ end
80
+ end
@@ -41,8 +41,13 @@ module Samagotchi
41
41
  # What Tools::ToolIndex#declare registered: the entries of +source+
42
42
  # are the index's search and call tools, +call_name+ the call tool's
43
43
  # name, and +sourced+ whether a source (an MCP server's bundle)
44
- # registered when the plugins loaded.
45
- Index = Data.define(:source, :call_name, :sourced)
44
+ # registered when the plugins loaded. +deferrable+ (→ the sources'
45
+ # tools a host that loads tools may declare deferred) and +wrap+
46
+ # (call → the call_tool call a direct call of one of them runs as, or
47
+ # nil) are nil when nothing reaches them.
48
+ Index = Data.define(:source, :call_name, :sourced, :deferrable, :wrap) do
49
+ def initialize(source:, call_name:, sourced:, deferrable: nil, wrap: nil) = super
50
+ end
46
51
 
47
52
  def initialize
48
53
  @entries = {}
@@ -56,6 +56,8 @@ module Samagotchi
56
56
  group: { type: "string", description: "search only this group" }
57
57
  }.freeze
58
58
  CALL_DESCRIPTION = "Call a tool #{FIND_NAME} found.".freeze
59
+ # A keyword answer's first line.
60
+ CALL_HINT = "Call one with #{CALL_NAME}(tool: \"<group>/<tool>\", args: {…}).".freeze
59
61
  CALL_PARAMS = {
60
62
  tool: { type: "string", required: true, description: "the tool as <group>/<tool>" },
61
63
  args: { type: "object", description: "the arguments, per the inputSchema #{FIND_NAME} gave" }
@@ -84,6 +86,19 @@ module Samagotchi
84
86
  # A Group's states.
85
87
  STATES = %i[ready starting failed].freeze
86
88
 
89
+ # A search's answer (#find): its text, and the tools it loads on a host
90
+ # that loads tools (+loaded+: the wire names of a keyword answer's
91
+ # callable matches, the ones a request can declare deferred; none for
92
+ # an empty query, a miss or the names listing).
93
+ class FoundText < String
94
+ attr_reader :loaded
95
+
96
+ def initialize(text, loaded = [])
97
+ super(text)
98
+ @loaded = loaded.freeze
99
+ end
100
+ end
101
+
87
102
  # One group of a source's tools (an MCP server): +summary+ its line in
88
103
  # the search tool's description (nil: its name and tool count only),
89
104
  # +state+ :ready, :starting (its tools may come: a lookup waits) or
@@ -236,7 +251,8 @@ module Samagotchi
236
251
  registry.register(spec.name, source: SOURCE, unwrap: unwrap, **spec.entry_fields(nil))
237
252
  spec
238
253
  end
239
- registry.index = Registry::Index.new(source: SOURCE, call_name: CALL_NAME, sourced: !@sources.empty?)
254
+ registry.index = Registry::Index.new(source: SOURCE, call_name: CALL_NAME, sourced: !@sources.empty?,
255
+ deferrable: -> { deferrable }, wrap: ->(call) { wrap(call) })
240
256
  declared!(declared)
241
257
  declared.map(&:name)
242
258
  end
@@ -286,11 +302,12 @@ module Samagotchi
286
302
  # empty query every group's tool names. +server+ is the parameter's
287
303
  # name before it was +group+: a model that still gives it is told
288
304
  # every group was searched.
305
+ # @return [FoundText]
289
306
  def find(query, only = nil, server: nil)
290
307
  answer = find_in(query, only)
291
308
  return answer unless (only.nil? || only.strip.empty?) && !server.to_s.strip.empty?
292
309
 
293
- "(searched every group)\n#{answer}"
310
+ FoundText.new("(searched every group)\n#{answer}", answer.loaded)
294
311
  end
295
312
 
296
313
  # ── The call ────────────────────────────────────────────────────────
@@ -348,6 +365,56 @@ module Samagotchi
348
365
  inner && BuiltinCalls.build(entry.name, inner).merge(via: CALL_NAME)
349
366
  end
350
367
 
368
+ # The sources' tools a host that loads tools may declare deferred
369
+ # (Tools::DeferredTools), by their acts_as: callable (no refusal),
370
+ # the only tool with that acts_as, a name Messages takes, and no
371
+ # registered tool's. Sorted by acts_as, so the same index gives the
372
+ # same list in any session and after a respawn.
373
+ # @return [Array<Tool>]
374
+ def deferrable
375
+ tools = groups.reject { |group| own?(group) }.flat_map(&:tools)
376
+ counts = tools.map(&:acts_as).tally
377
+ tools.select do |tool|
378
+ tool.refusal.nil? && counts[tool.acts_as] == 1 && tool.acts_as.match?(DEFERRABLE_NAME) &&
379
+ !@registry&.key?(tool.acts_as)
380
+ end.sort_by(&:acts_as)
381
+ end
382
+
383
+ # The name a request declares +name+'s tool by when it goes out
384
+ # deferred (Tools::DeferredTools): a reachable built-in's (by its
385
+ # name or chi/<name>), a deferrable source tool's acts_as (by it or
386
+ # <group>/<tool>). Nil for any other: declared, removed, unknown, or a
387
+ # source tool that can't be deferred. Never waits for a start.
388
+ # @return [String, nil]
389
+ def deferred_name(name)
390
+ name = name.to_s.strip
391
+ bare = name.delete_prefix("#{CHI_GROUP}/")
392
+ return bare if @registry&.indexed(bare)
393
+
394
+ tool = lookup_quietly(name) or return nil
395
+ return nil if tool.entry || tool.refusal
396
+
397
+ deferrable.any? { |t| t.acts_as == tool.acts_as } ? tool.acts_as : nil
398
+ end
399
+
400
+ # What a deferred source tool's wire name may be (Messages' tool
401
+ # name rule).
402
+ DEFERRABLE_NAME = /\A[a-zA-Z0-9_-]{1,128}\z/
403
+
404
+ # A direct call of a deferrable tool (#deferrable) by its acts_as, as
405
+ # the call_tool call it runs as (ToolRunner#run, under a host that
406
+ # loads tools): the call tool's row, rules and approvals. Nil for any
407
+ # other call.
408
+ # @return [Hash, nil]
409
+ def wrap(call)
410
+ name = call[:name].to_s
411
+ return nil if name.empty? || @registry&.key?(name)
412
+
413
+ tool = deferrable.find { |t| t.acts_as == name } or return nil
414
+ args = call[:args].is_a?(Hash) ? call[:args] : {}
415
+ BuiltinCalls.build(CALL_NAME, { "tool" => tool.full_name, "args" => args })
416
+ end
417
+
351
418
  # The call tool's activity row: <group>/<tool> and its arguments.
352
419
  def call_preview(args)
353
420
  tool = lookup_quietly(args["tool"].to_s)
@@ -403,17 +470,32 @@ module Samagotchi
403
470
  searched = all
404
471
  if only && !only.strip.empty?
405
472
  searched = all.select { |group| group.name == only.strip }
406
- return "Error: no group #{only.strip}. Groups: #{all.map(&:name).join(", ")}" if searched.empty?
473
+ return FoundText.new("Error: no group #{only.strip}. Groups: #{all.map(&:name).join(", ")}") if searched.empty?
407
474
  end
408
- return names_text(searched) if words(query).empty?
475
+ return FoundText.new(names_text(searched)) if words(query).empty?
409
476
 
410
477
  matches = ranked(query, searched.flat_map(&:tools)).take(FIND_RESULTS)
411
- return "No tool matches \"#{query.strip}\".\n\n#{names_text(searched)}" if matches.empty?
478
+ return FoundText.new("No tool matches \"#{query.strip}\".\n\n#{names_text(searched)}") if matches.empty?
412
479
 
413
480
  blocks = matches.map { |tool| match_text(tool, all) }
414
481
  unsearched = searched.filter_map { |group| "#{group.name} #{group.note}" if group.tools.empty? && group.note }
415
482
  blocks << "Not searched: #{unsearched.join("; ")}." unless unsearched.empty?
416
- "Call one with #{CALL_NAME}(tool: \"<group>/<tool>\", args: {…}).\n\n#{blocks.join("\n\n")}"
483
+ FoundText.new("#{CALL_HINT}\n\n#{blocks.join("\n\n")}",
484
+ loadable(matches))
485
+ end
486
+
487
+ # The wire names of +matches+ a request can declare deferred: a
488
+ # built-in of the index's own groups by its name, a source's tool by
489
+ # its acts_as when it is #deferrable.
490
+ def loadable(matches)
491
+ sourced = nil
492
+ matches.filter_map do |tool|
493
+ next tool.name if tool.entry
494
+ next if tool.refusal
495
+
496
+ sourced ||= deferrable.map(&:acts_as)
497
+ tool.acts_as if sourced.include?(tool.acts_as)
498
+ end
417
499
  end
418
500
 
419
501
  # What the sources hear of the declared tools (/mcp's last line).
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.98.0"
4
+ VERSION = "0.99.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.98.0
4
+ version: 0.99.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -590,6 +590,7 @@ files:
590
590
  - lib/samagotchi/tool_call_parser.rb
591
591
  - lib/samagotchi/tool_declarations.rb
592
592
  - lib/samagotchi/tool_ids.rb
593
+ - lib/samagotchi/tool_loads.rb
593
594
  - lib/samagotchi/tool_output_cut.rb
594
595
  - lib/samagotchi/tool_output_files.rb
595
596
  - lib/samagotchi/tool_response.rb
@@ -605,6 +606,7 @@ files:
605
606
  - lib/samagotchi/tools/builtins.rb
606
607
  - lib/samagotchi/tools/cancel_reminder.rb
607
608
  - lib/samagotchi/tools/context_read.rb
609
+ - lib/samagotchi/tools/deferred_tools.rb
608
610
  - lib/samagotchi/tools/delegate.rb
609
611
  - lib/samagotchi/tools/delegate_cursor.rb
610
612
  - lib/samagotchi/tools/delegate_relay.rb