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
|
@@ -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
|
|
data/lib/samagotchi/prompt.rb
CHANGED
|
@@ -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
|
|
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.
|
|
321
|
-
#
|
|
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
|
-
|
|
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
|
-
"
|
|
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).
|
data/lib/samagotchi/version.rb
CHANGED
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.
|
|
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
|