samagotchi 0.68.0 → 0.69.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -1
  3. data/docs/architecture.md +1 -1
  4. data/docs/cli.md +6 -1
  5. data/docs/configuration.md +28 -6
  6. data/docs/guardrails.md +23 -1
  7. data/docs/hooks.md +4 -0
  8. data/docs/internals/bundles.md +1 -1
  9. data/docs/internals/engine.md +15 -8
  10. data/docs/internals/prompt-caching.md +9 -2
  11. data/docs/plugins.md +42 -19
  12. data/docs/releasing.md +14 -6
  13. data/docs/sessions.md +3 -3
  14. data/docs/testing.md +7 -1
  15. data/lib/samagotchi/bootstrap/probe.rb +1 -3
  16. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  18. data/lib/samagotchi/config.rb +0 -19
  19. data/lib/samagotchi/engine.rb +5 -2
  20. data/lib/samagotchi/guardrails/targets.rb +9 -2
  21. data/lib/samagotchi/host_config.rb +1 -29
  22. data/lib/samagotchi/host_model.rb +0 -7
  23. data/lib/samagotchi/kernel_loop.rb +24 -7
  24. data/lib/samagotchi/llm/errors.rb +47 -1
  25. data/lib/samagotchi/llm/http.rb +20 -2
  26. data/lib/samagotchi/llm_context_stale.rb +6 -0
  27. data/lib/samagotchi/memory_bundle/manifest.rb +3 -0
  28. data/lib/samagotchi/model_price.rb +2 -4
  29. data/lib/samagotchi/session_metrics.rb +4 -1
  30. data/lib/samagotchi/system_prompt.rb +19 -3
  31. data/lib/samagotchi/terminal_ui/formatting.rb +5 -2
  32. data/lib/samagotchi/tool_activity.rb +3 -0
  33. data/lib/samagotchi/tool_row_fields.rb +4 -2
  34. data/lib/samagotchi/tool_runner.rb +24 -3
  35. data/lib/samagotchi/toolkit.rb +27 -14
  36. data/lib/samagotchi/toolkit_binding.rb +8 -2
  37. data/lib/samagotchi/toolkit_command.rb +20 -4
  38. data/lib/samagotchi/tools/builtin_calls.rb +45 -1
  39. data/lib/samagotchi/tools/registry.rb +95 -8
  40. data/lib/samagotchi/tools/tool_index.rb +223 -72
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/web/app.rb +14 -9
  43. data/lib/samagotchi/web/message_parts.rb +5 -2
  44. data/lib/samagotchi/web/public/activity.js +3 -1
  45. data/lib/samagotchi/web/public/app.js +123 -22
  46. data/lib/samagotchi/web/public/chains.js +19 -2
  47. data/lib/samagotchi/web/public/families_view.js +15 -0
  48. data/lib/samagotchi/web/public/index.html +7 -5
  49. data/lib/samagotchi/web/public/model_picker.js +21 -6
  50. data/lib/samagotchi/web/public/session_state.js +5 -0
  51. data/lib/samagotchi/web/public/stream_handlers.js +5 -2
  52. data/lib/samagotchi/web/public/timing.js +3 -1
  53. data/lib/samagotchi/web/public/toast_place.js +16 -0
  54. data/lib/samagotchi/web/public/toolkit_chip.js +11 -4
  55. data/lib/samagotchi/web/public/turn_html.js +4 -2
  56. metadata +2 -1
@@ -25,17 +25,19 @@ module Samagotchi
25
25
  # The plain form the snapshot and the web carry.
26
26
  def to_h = { name: name, add: add, remove: remove }
27
27
 
28
- # This toolkit with +tool+ declared (/toolkit add): a tool it removed
29
- # comes out of +remove+ (back to its kit's), any other goes into +add+.
28
+ # This toolkit with +tool+ declared (/toolkit add): out of +remove+
29
+ # and into +add+, whatever its kit says (an add after a remove
30
+ # declares it).
30
31
  def adding(tool)
31
32
  tool = tool.to_s
32
- remove.include?(tool) ? with(remove: remove - [tool]) : with(add: (add + [tool]).uniq)
33
+ with(remove: remove - [tool], add: (add + [tool]).uniq)
33
34
  end
34
35
 
35
- # This toolkit with +tool+ left out (/toolkit remove), the other way.
36
+ # This toolkit with +tool+ removed (/toolkit remove), the other way: a
37
+ # remove is hard (call_tool can't reach it either), also after an add.
36
38
  def removing(tool)
37
39
  tool = tool.to_s
38
- add.include?(tool) ? with(add: add - [tool]) : with(remove: (remove + [tool]).uniq)
40
+ with(add: add - [tool], remove: (remove + [tool]).uniq)
39
41
  end
40
42
 
41
43
  # How the UIs name it: "lean", "lean +web_fetch -memory_write".
@@ -66,21 +68,31 @@ module Samagotchi
66
68
 
67
69
  # A toolkit as a session's next turn would declare it (Engine
68
70
  # #toolkit_explained): +toolkit+ as saved, +resolved+, the Registry
69
- # +declared+ entries and the names it leaves out (+left_out+), and
70
- # whether that isn't what the Registry declares now (+pending+: it
71
+ # +declared+ entries and the names it leaves out (+left_out+): the ones
72
+ # call_tool reaches (+reachable+) and the ones it removes (+removed+);
73
+ # and whether that isn't what the Registry declares now (+pending+: it
71
74
  # applies at the next turn's start).
72
- Explained = Data.define(:toolkit, :resolved, :declared, :left_out, :pending) do
73
- # What the prompt carries of it: the declared names and, when it
74
- # leaves tools out, the kit name the not-loaded line gives. Two equal
75
+ Explained = Data.define(:toolkit, :resolved, :declared, :left_out, :reachable, :removed, :pending) do
76
+ def initialize(toolkit:, resolved:, declared:, left_out:, pending:, reachable: [], removed: [])
77
+ super
78
+ end
79
+
80
+ # What the prompt carries of it: the declared names, the reachable
81
+ # and removed ones (the not-loaded line, find_tools' chi line) and,
82
+ # when either list has any, the kit name the line gives. Two equal
75
83
  # keys build the same prompt (/toolkit's "no change", its cost).
76
- def prompt_key = [declared.map(&:name), left_out.empty? ? nil : resolved.name]
84
+ def prompt_key
85
+ [declared.map(&:name), reachable, removed, reachable.empty? && removed.empty? ? nil : resolved.name]
86
+ end
77
87
 
78
88
  # What the UIs show (the snapshot, command_ran): nil when it declares
79
89
  # every tool on full (full, full +delegate), which has no chip.
90
+ # +left_out+ is +reachable+ and +removed+ together.
80
91
  def summary
81
92
  return nil if toolkit.name == Toolkit::FULL && left_out.empty? && resolved.fallback.nil?
82
93
 
83
- { name: toolkit.name, label: toolkit.label, left_out: left_out, fallback: resolved.fallback }
94
+ { name: toolkit.name, label: toolkit.label, left_out: left_out, reachable: reachable, removed: removed,
95
+ fallback: resolved.fallback }
84
96
  end
85
97
  end
86
98
 
@@ -111,8 +123,9 @@ module Samagotchi
111
123
  Kit.new(name: FULL, tools: :all),
112
124
  Kit.new(name: LEAN, tools: LEAN_TOOLS),
113
125
  # A delegate child: it reports to its parent (send_note; the
114
- # delegated-by line names it) and leaves memory writes to it.
115
- Kit.new(name: CHILD, base: LEAN, add: %w[send_note], remove: %w[memory_write])
126
+ # delegated-by line names it) and leaves memory writes to it, and
127
+ # starts no children of its own (removed: not even through call_tool).
128
+ Kit.new(name: CHILD, base: LEAN, add: %w[send_note], remove: %w[memory_write delegate delegate_result])
116
129
  ].to_h { |kit| [kit.name, kit] }.freeze
117
130
 
118
131
  # What a toolkit declares, the Registry's filter: every built-in
@@ -17,14 +17,17 @@ module Samagotchi
17
17
  # @param can_stage [#call] → whether a plugin may change the toolkit now
18
18
  # (no turn runs, no model reply yet)
19
19
  # @param reset_prompt [#call] → build the system prompts again
20
+ # @param refresh_index [#call] → the tool index's search tool described
21
+ # for the Registry's toolkit (Tools::ToolIndex#refresh!)
20
22
  # @param emit [#call] (sink, event) → emits to a turn's sink + observers
21
23
  # @param notify [#call] (text, level, source, extra:) → a notice
22
- def initialize(tools:, session:, state_dir:, can_stage:, reset_prompt:, emit:, notify:)
24
+ def initialize(tools:, session:, state_dir:, can_stage:, reset_prompt:, emit:, notify:, refresh_index: -> {})
23
25
  @tools = tools
24
26
  @session = session
25
27
  @state_dir = state_dir
26
28
  @can_stage = can_stage
27
29
  @reset_prompt = reset_prompt
30
+ @refresh_index = refresh_index
28
31
  @emit = emit
29
32
  @notify = notify
30
33
  # The toolkits plugins declare (chi.toolkit), beside the built-in and
@@ -63,7 +66,9 @@ module Samagotchi
63
66
  filter = resolved.everything? ? nil : resolved
64
67
  tools = @tools.call
65
68
  Toolkit::Explained.new(toolkit: toolkit, resolved: resolved, declared: tools.entries(toolkit: filter),
66
- left_out: tools.left_out_names(toolkit: filter), pending: filter != tools.toolkit)
69
+ left_out: tools.left_out_names(toolkit: filter),
70
+ reachable: tools.reachable_names(toolkit: filter),
71
+ removed: tools.removed_names(toolkit: filter), pending: filter != tools.toolkit)
67
72
  end
68
73
 
69
74
  # The session's toolkit for the UIs (the snapshot, command_ran): nil on
@@ -114,6 +119,7 @@ module Samagotchi
114
119
  return false if resolved == tools.toolkit
115
120
 
116
121
  tools.toolkit = resolved
122
+ @refresh_index.call
117
123
  @reset_prompt.call
118
124
  true
119
125
  end
@@ -7,7 +7,8 @@ require_relative "toolkit"
7
7
  module Samagotchi
8
8
  # /toolkit, the session's toolkit (Session#toolkit): with no argument,
9
9
  # the tools its next turn declares with their estimated tokens, the
10
- # toolkit and the tools it leaves out; `/toolkit NAME` puts the session
10
+ # toolkit and the tools it leaves out (the ones find_tools reaches, the
11
+ # ones it removes); `/toolkit NAME` puts the session
11
12
  # on another toolkit (its own adds and removes go), `/toolkit add TOOL`
12
13
  # and `/toolkit remove TOOL` change one tool. A change is saved at once
13
14
  # and applies at the next turn's start (Engine#apply_toolkit!). The
@@ -57,7 +58,9 @@ module Samagotchi
57
58
  [[listing(after), "From the next turn's start (a running turn keeps its tools)."].join("\n"), true, nil]
58
59
  end
59
60
 
60
- # The toolkit, its declared tools with tokens, and the ones left out.
61
+ # The toolkit, its declared tools with tokens, and the ones left out:
62
+ # reachable with find_tools / call_tool, removed (only /toolkit add
63
+ # brings one back), or, with call_tool not declared, not loaded.
61
64
  def listing(explained)
62
65
  toolkit = explained.toolkit
63
66
  lines = ["toolkit: #{toolkit.label}#{" (applies at the next turn's start)" if explained.pending}"]
@@ -68,7 +71,13 @@ module Samagotchi
68
71
  lines << " declared (#{declared.size}, ~#{format_tokens(declared.sum { |entry| tokens(entry) })} tokens): " \
69
72
  "#{declared.map { |entry| "#{entry.name} ~#{format_tokens(tokens(entry))}" }.join(", ")}"
70
73
  left_out = explained.left_out
71
- lines << " not loaded: #{left_out.empty? ? "none" : left_out.join(", ")}"
74
+ if explained.reachable.empty?
75
+ lines << " not loaded: #{left_out.empty? ? "none" : left_out.join(", ")}"
76
+ else
77
+ route = explained.removed.include?("find_tools") ? "call_tool" : "find_tools"
78
+ lines << " reachable (#{route}): #{explained.reachable.join(", ")}"
79
+ lines << " removed: #{explained.removed.empty? ? "none" : explained.removed.join(", ")}"
80
+ end
72
81
  lines << " toolkits: #{@engine.toolkit_kits.keys.join(", ")}; #{USAGE.delete_prefix("usage: ")} changes it"
73
82
  lines.join("\n")
74
83
  end
@@ -101,8 +110,15 @@ module Samagotchi
101
110
  added = names(after) - names(before)
102
111
  removed = names(before) - names(after)
103
112
  parts = []
113
+ # Removed (hard: call_tool can't reach it), declared before or not;
114
+ # left out by the kit (soft); reachable now and not before.
115
+ hard = after.removed - before.removed
116
+ left = removed - hard
117
+ soft = after.reachable - before.reachable - removed
104
118
  parts << "declares #{added.join(", ")}" unless added.empty?
105
- parts << "leaves out #{removed.join(", ")}" unless removed.empty?
119
+ parts << "leaves out #{left.join(", ")}" unless left.empty?
120
+ parts << "removes #{hard.join(", ")} (call_tool can't reach #{hard.size == 1 ? "it" : "them"})" unless hard.empty?
121
+ parts << "lets call_tool reach #{soft.join(", ")}" unless soft.empty?
106
122
  parts << "declares the same tools under its own name" if parts.empty?
107
123
  "Switching to #{after.toolkit.label} #{parts.join("; ")}."
108
124
  end
@@ -63,6 +63,14 @@ module Samagotchi
63
63
  # called_as: with the model's own spelling.
64
64
  ALIASES = { "bash" => "execute" }.freeze
65
65
 
66
+ # The tool index's call tool, whose call of a built-in runs as that
67
+ # built-in (ToolIndex#unwrap; ToolIndex::CALL_NAME is this), and the
68
+ # group it names them by. The one place the name is spelled.
69
+ VIA = "call_tool"
70
+ VIA_GROUP = "chi/"
71
+ # A saved run's "[name]" lead (ToolResponse::LEAD).
72
+ SAVED_LEAD = /\A\[([^\]\s]+)\]/
73
+
66
74
  # One built-in's mapping, resolved from its schema and OVERRIDES.
67
75
  Row = Data.define(:name, :content_keys, :path_key, :fields, :aliases, :verbatim, :fallback, :fallback_key,
68
76
  :options) do
@@ -97,7 +105,11 @@ module Samagotchi
97
105
  unknown = ([path_key].compact + Array(o[:verbatim]) + Array(o[:also]) + o.fetch(:aliases, {}).keys) - properties
98
106
  unknown << content_keys.join("/") unless content_keys.empty? || content_keys.intersect?(properties)
99
107
  raise ArgumentError, "#{name}: overrides name no schema property: #{unknown.join(", ")}" unless unknown.empty?
100
- raise ArgumentError, "#{name}: a property would replace the call's name" if fields.include?("name")
108
+ # A property can't set the call's name, nor the fields only chi sets
109
+ # (an alias's called_as, an unwrapped call's via).
110
+ if (reserved = fields & %w[name called_as via]).any?
111
+ raise ArgumentError, "#{name}: a property would replace the call's #{reserved.first}"
112
+ end
101
113
 
102
114
  Row.new(name: name, content_keys: content_keys, path_key: path_key, fields: fields,
103
115
  aliases: o.fetch(:aliases, {}), verbatim: Array(o[:verbatim]), fallback: o[:fallback],
@@ -135,6 +147,38 @@ module Samagotchi
135
147
  call
136
148
  end
137
149
 
150
+ # A saved call as it ran (a reload, the stale-read pairing): a call_tool
151
+ # of a built-in ("chi/delegate", or a bare "delegate") is that
152
+ # built-in's call with via: "call_tool" when +output+ (its saved
153
+ # result) shows it ran as that built-in: its "[name]" lead, or, with
154
+ # no lead, an error that names it (refused by its name before the
155
+ # gate); any other call as it is (a call_tool that ran, refused or
156
+ # not, leads with "[call_tool]").
157
+ # @param call [Hash] as #build gives it
158
+ # @param output [String, nil]
159
+ # @return [Hash]
160
+ def unwrap_saved(call, output)
161
+ return call unless call[:name] == VIA && call[:args].is_a?(Hash)
162
+
163
+ name = call[:args]["tool"].to_s.strip.delete_prefix(VIA_GROUP)
164
+ return call unless row(name) && ran_as?(output.to_s, name)
165
+
166
+ build(name, saved_args(call[:args]["args"])).merge(via: VIA)
167
+ end
168
+
169
+ def ran_as?(output, name)
170
+ lead = output[SAVED_LEAD, 1]
171
+ lead ? lead == name : output.start_with?("Error: #{name} ")
172
+ end
173
+
174
+ # call_tool's inner args as saved: an object, or its JSON text.
175
+ def saved_args(args)
176
+ args = JSON.parse(args) if args.is_a?(String) && !args.strip.empty?
177
+ args.is_a?(Hash) ? args : {}
178
+ rescue JSON::ParserError
179
+ {}
180
+ end
181
+
138
182
  # An execute-shaped call the model spelled +name+: built as its +target+,
139
183
  # tagged with the model's spelling for the activity event and the log.
140
184
  def aliased(name, target, args, raw)
@@ -26,18 +26,33 @@ module Samagotchi
26
26
  # @!attribute layer [Symbol, nil] the LLM context layer the tool
27
27
  # belongs to (LLMContextStrategy): offered only in a turn running
28
28
  # under it (forget_outputs, :forget); nil: always
29
- Entry = Struct.new(:name, :schema, :handler, :label, :preview, :targets, :refs, :source, :layer, keyword_init: true) do
29
+ # @!attribute unwrap [#call, nil] (call, layers) → the call it stands
30
+ # for, run in its place before the guardrail gate (call_tool on one
31
+ # of chi's own tools: Tools::ToolIndex#unwrap), or nil to run it as
32
+ # it is; nil on every other tool
33
+ Entry = Struct.new(:name, :schema, :handler, :label, :preview, :targets, :refs, :source, :layer, :unwrap,
34
+ keyword_init: true) do
30
35
  def core? = source == "core"
31
36
 
32
37
  # Offered in a turn under +layers+ (the turn's LLM context layers).
33
38
  def offered?(layers) = layer.nil? || Array(layers).include?(layer)
34
39
  end
35
40
 
41
+ # What Tools::ToolIndex#declare registered: the entries of +source+
42
+ # are the index's search and call tools, +call_name+ the call tool's
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)
46
+
36
47
  def initialize
37
48
  @entries = {}
38
49
  @toolkit = nil
50
+ @index = nil
39
51
  end
40
52
 
53
+ # The tool index's declaration (Index), nil without one.
54
+ attr_accessor :index
55
+
41
56
  # The session's resolved toolkit (Toolkit::Resolved), which the lists
42
57
  # below and #offered filter by; nil declares every tool (full). The
43
58
  # Engine sets it at a turn's start, before the prompt is built, and
@@ -45,11 +60,12 @@ module Samagotchi
45
60
  attr_accessor :toolkit
46
61
 
47
62
  # @return [Entry]
48
- def register(name, schema:, handler:, label: nil, preview: nil, targets: nil, refs: nil, source: "core", layer: nil)
63
+ def register(name, schema:, handler:, label: nil, preview: nil, targets: nil, refs: nil, source: "core", layer: nil,
64
+ unwrap: nil)
49
65
  raise ArgumentError, "tool #{name} is already registered" if @entries.key?(name)
50
66
 
51
67
  @entries[name] = Entry.new(name: name, schema: schema, handler: handler, label: label, preview: preview,
52
- targets: targets, refs: refs, source: source, layer: layer)
68
+ targets: targets, refs: refs, source: source, layer: layer, unwrap: unwrap)
53
69
  end
54
70
 
55
71
  # Remove a tool (a plugin's tool set changed after load).
@@ -70,18 +86,48 @@ module Samagotchi
70
86
  end
71
87
 
72
88
  # The entry +name+ names when the session's toolkit leaves it out (a
73
- # real tool the model can't call here), nil otherwise.
89
+ # real tool the model can't call here, by its name), nil otherwise.
74
90
  # @return [Entry, nil]
75
91
  def left_out(name, layers: [])
76
92
  entry = self[name]
77
- entry if entry&.offered?(layers) && !declared?(entry)
93
+ entry if entry && left_out?(entry, layers, @toolkit)
78
94
  end
79
95
 
80
96
  # The tools +toolkit+ (the session's by default) leaves out of a turn
81
- # under +layers+.
97
+ # under +layers+. The tool index's own aren't, unless it removes them:
98
+ # they are declared when there is something to reach.
82
99
  # @return [Array<String>] in declaration order
83
100
  def left_out_names(layers: [], toolkit: @toolkit)
84
- ordered(@entries.values.select { |entry| entry.offered?(layers) && !declared?(entry, toolkit) }).map(&:name)
101
+ ordered(@entries.values.select { |entry| left_out?(entry, layers, toolkit) }).map(&:name)
102
+ end
103
+
104
+ # The built-in +name+ names when the session's toolkit leaves it out
105
+ # but the tool index reaches it (#reachable_names).
106
+ # @return [Entry, nil]
107
+ def indexed(name, layers: [], toolkit: @toolkit)
108
+ entry = self[name]
109
+ entry if entry && call_offered?(toolkit) && soft?(entry, layers, toolkit)
110
+ end
111
+
112
+ # The built-ins +toolkit+ leaves out of a turn under +layers+ that the
113
+ # tool index reaches (call_tool): not in its kit's list, but not
114
+ # removed either (remove:, --no-tool, /toolkit remove), with call_tool
115
+ # itself declared.
116
+ # @return [Array<String>] in declaration order
117
+ def reachable_names(layers: [], toolkit: @toolkit)
118
+ return [] unless call_offered?(toolkit)
119
+
120
+ ordered(@entries.values.select { |entry| soft?(entry, layers, toolkit) }).map(&:name)
121
+ end
122
+
123
+ # The tools +toolkit+ removes from a turn under +layers+ (remove:,
124
+ # --no-tool, /toolkit remove): only the user brings them back.
125
+ # @return [Array<String>] in declaration order
126
+ def removed_names(layers: [], toolkit: @toolkit)
127
+ return [] if toolkit.nil?
128
+
129
+ ordered(@entries.values.select { |entry| entry.offered?(layers) && toolkit.removed.include?(entry.name) })
130
+ .map(&:name)
85
131
  end
86
132
 
87
133
  # Every tool +source+ registered, declared or not (a plugin's tool set
@@ -112,7 +158,48 @@ module Samagotchi
112
158
 
113
159
  private
114
160
 
115
- def declared?(entry, toolkit = @toolkit) = toolkit.nil? || toolkit.declares?(entry)
161
+ # Declared by +toolkit+ (nil: every tool, full). The tool index's
162
+ # tools are declared when they reach something, whatever the toolkit:
163
+ # a source registered at load, or a built-in it leaves out but
164
+ # doesn't remove (#index_needed?); unless it removes them.
165
+ def declared?(entry, toolkit = @toolkit)
166
+ if index_entry?(entry)
167
+ !toolkit&.removed&.include?(entry.name) && index_needed?(toolkit)
168
+ else
169
+ toolkit.nil? || toolkit.declares?(entry)
170
+ end
171
+ end
172
+
173
+ def index_entry?(entry) = !@index.nil? && entry.source == @index.source
174
+
175
+ # From what the plugins registered at load and the toolkit alone, so
176
+ # the declared tools stay the same for a session's life. A built-in
177
+ # left out counts only while call_tool can reach it (not removed).
178
+ def index_needed?(toolkit)
179
+ return true if @index.sourced
180
+ return false if toolkit&.removed&.include?(@index.call_name)
181
+
182
+ @entries.each_value.any? { |entry| soft?(entry, [], toolkit) }
183
+ end
184
+
185
+ def left_out?(entry, layers, toolkit)
186
+ return false unless entry.offered?(layers) && !declared?(entry, toolkit)
187
+
188
+ !index_entry?(entry) || toolkit&.removed&.include?(entry.name) == true
189
+ end
190
+
191
+ # A built-in +toolkit+ leaves out of a turn under +layers+ without
192
+ # removing it.
193
+ def soft?(entry, layers, toolkit)
194
+ !toolkit.nil? && entry.core? && entry.offered?(layers) && !toolkit.removed.include?(entry.name) &&
195
+ !toolkit.declares?(entry)
196
+ end
197
+
198
+ # Whether +toolkit+ declares the index's call tool.
199
+ def call_offered?(toolkit)
200
+ entry = @index && self[@index.call_name]
201
+ !entry.nil? && declared?(entry, toolkit)
202
+ end
116
203
 
117
204
  def ordered(entries)
118
205
  core, added = entries.partition(&:core?)