samagotchi 0.98.0 → 0.100.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -1
  3. data/README.md +2 -2
  4. data/docs/architecture.md +6 -3
  5. data/docs/bundle-authoring.md +1 -1
  6. data/docs/cli.md +1 -1
  7. data/docs/configuration.md +8 -0
  8. data/docs/guardrails.md +10 -2
  9. data/docs/hooks.md +9 -1
  10. data/docs/internals/anthropic.md +79 -8
  11. data/docs/internals/engine.md +13 -3
  12. data/docs/internals/llm-context-forget.md +2 -1
  13. data/docs/internals/prompt-caching.md +9 -0
  14. data/docs/memory.md +1 -1
  15. data/docs/plugins.md +83 -3
  16. data/docs/releasing.md +1 -1
  17. data/docs/sessions.md +10 -0
  18. data/docs/sub-agent.md +7 -2
  19. data/docs/testing.md +27 -0
  20. data/lib/samagotchi/bundles/core/manifest.yml +4 -3
  21. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  22. data/lib/samagotchi/bundles/work-cap/manifest.yml +13 -0
  23. data/lib/samagotchi/bundles/work-cap/plugin.rb +391 -0
  24. data/lib/samagotchi/engine.rb +67 -9
  25. data/lib/samagotchi/hooks/loader.rb +17 -4
  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/refusal_memo.rb +6 -0
  32. data/lib/samagotchi/llm/turn_settings.rb +13 -5
  33. data/lib/samagotchi/llm_context_stale.rb +2 -2
  34. data/lib/samagotchi/message_parts.rb +2 -1
  35. data/lib/samagotchi/plugin/api.rb +2 -1
  36. data/lib/samagotchi/plugin/context.rb +31 -4
  37. data/lib/samagotchi/prompt.rb +3 -0
  38. data/lib/samagotchi/session_metrics/usage.rb +31 -0
  39. data/lib/samagotchi/session_metrics.rb +48 -5
  40. data/lib/samagotchi/testing/bundle.rb +2 -0
  41. data/lib/samagotchi/testing/host.rb +31 -1
  42. data/lib/samagotchi/testing/recorder.rb +18 -3
  43. data/lib/samagotchi/tool_loads.rb +62 -0
  44. data/lib/samagotchi/tool_response.rb +5 -1
  45. data/lib/samagotchi/tool_runner.rb +29 -7
  46. data/lib/samagotchi/tools/builtin_calls.rb +16 -0
  47. data/lib/samagotchi/tools/deferred_tools.rb +80 -0
  48. data/lib/samagotchi/tools/registry.rb +7 -2
  49. data/lib/samagotchi/tools/tool_index.rb +88 -6
  50. data/lib/samagotchi/version.rb +1 -1
  51. metadata +6 -1
@@ -237,7 +237,8 @@ module Samagotchi
237
237
  tool_id: nil, marks: {})
238
238
  # A built-in reached through call_tool reloads as that built-in's
239
239
  # row, with via (its saved params and label are the built-in's).
240
- call = Tools::BuiltinCalls.unwrap_saved(call, output)
240
+ # A direct call of an MCP tool reloads as the call_tool row it ran as.
241
+ call = Tools::BuiltinCalls.unwrap_saved(Tools::BuiltinCalls.wrap_saved(call, output), output)
241
242
  name = call[:name].to_s
242
243
  params = shown.is_a?(String) ? shown : ToolActivity.tool_activity_params(name, call, registry: registry)
243
244
  part = { tool: name, params: params.to_s }
@@ -40,7 +40,8 @@ module Samagotchi
40
40
  "ctx.prompt_files" => "0.60.0", "chi.annotation" => "0.66.0", "chi.toolkit" => "0.66.0",
41
41
  "ctx.use_toolkit" => "0.66.0", "ctx.toolkit" => "0.66.0", "ctx.declared_tools" => "0.66.0",
42
42
  "chi.tool_source" => "0.67.0", "chi.peer_source" => "0.67.0", "ctx.http" => "0.80.0",
43
- "ctx.cost" => "0.83.0", "ctx.sessions.children" => "0.34.0"
43
+ "ctx.cost" => "0.83.0", "ctx.sessions.children" => "0.34.0", "ctx.load_tools" => "0.99.0",
44
+ "ctx.usage" => "0.100.0"
44
45
  }.freeze
45
46
 
46
47
  # @param bundle [String]
@@ -4,6 +4,7 @@ require "fileutils"
4
4
  require_relative "../log"
5
5
  require_relative "../paths"
6
6
  require_relative "../session_metrics/cost"
7
+ require_relative "../session_metrics/usage"
7
8
  require_relative "../guardrails/context"
8
9
  require_relative "../hook_question"
9
10
  require_relative "../idle_client"
@@ -48,16 +49,19 @@ module Samagotchi
48
49
  # :non_interactive), which ctx.frontend names for plugins.
49
50
  # +toolkit+ returns the session's toolkit name, +declared_tools+ the
50
51
  # tool names its next turn declares, +use_toolkit+ takes a name and
51
- # returns whether it staged it (Engine#use_toolkit).
52
+ # returns whether it staged it (Engine#use_toolkit); +load_tools+ takes
53
+ # (names, bundle) and returns the wire names it staged
54
+ # (Engine#load_tools).
52
55
  # +cancel_controller+ takes (handler:) and returns the controller a
53
56
  # ctx.http call without cancel: uses, nil for none: an init task's own,
54
57
  # else the running turn's for work done for it (the turn's thread, or a
55
58
  # plugin handler: +handler+) until the turn has ended.
56
- # +cost+ returns the session's spend now (SessionMetrics::Cost).
59
+ # +cost+ returns the session's spend now (SessionMetrics::Cost),
60
+ # +usage+ its work now (SessionMetrics::Usage).
57
61
  Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
58
62
  :ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
59
- :steer, :stop_turn, :stop_generation, :frontend, :toolkit, :declared_tools, :use_toolkit,
60
- :cancel_controller, :cost, keyword_init: true)
63
+ :steer, :stop_turn, :stop_generation, :frontend, :toolkit, :declared_tools, :use_toolkit, :load_tools,
64
+ :cancel_controller, :cost, :usage, keyword_init: true)
61
65
 
62
66
  # ctx.ask_model failed: the model couldn't be reached, timed out, or
63
67
  # sent nothing usable. The message says why, for the user.
@@ -165,6 +169,12 @@ module Samagotchi
165
169
  # (docs/plugins.md); zero and known with no session
166
170
  def cost = @host.cost&.call || SessionMetrics::Cost.zero
167
171
 
172
+ # @return [SessionMetrics::Usage] the session's work so far, read now:
173
+ # #turns, #tool_calls, #tool_calls_by_tool, #steps, #active_ms
174
+ # (#active_minutes) and #tool_ms_by_tool (docs/plugins.md); zero
175
+ # with no session
176
+ def usage = @host.usage&.call || SessionMetrics::Usage.zero
177
+
168
178
  # @return [String, nil] the name of the session's toolkit ("full" for
169
179
  # one from before toolkits; nil with no session)
170
180
  def toolkit = @host.toolkit&.call
@@ -182,6 +192,23 @@ module Samagotchi
182
192
  # @raise [ArgumentError] a toolkit no one declared
183
193
  def use_toolkit(name) = !!@host.use_toolkit&.call(name.to_s)
184
194
 
195
+ # Load tools the session reaches but doesn't declare, so the model
196
+ # calls them by name (docs/plugins.md): built-in names, MCP tools'
197
+ # acts_as (mcp_fs_read) or <group>/<tool>. A hint, not access: the
198
+ # tools stay behind the same guardrails, and only a host that takes
199
+ # tool changes (api: anthropic) gets one; elsewhere nothing is sent.
200
+ # Called from before_turn, they load for that turn; anywhere else,
201
+ # from the next turn's start. Idempotent: a declared tool, one loaded
202
+ # before, a removed or unknown one is skipped.
203
+ # @param names [Array<String>]
204
+ # @return [Array<String>] the names staged (wire names)
205
+ # @raise [ArgumentError] +names+ isn't an Array
206
+ def load_tools(names)
207
+ raise ArgumentError, "load_tools takes an Array of tool names" unless names.is_a?(Array)
208
+
209
+ Array(@host.load_tools&.call(names.map(&:to_s), @bundle))
210
+ end
211
+
185
212
  # Whether the session is a `chi scratch` one (deleted when it ends).
186
213
  def scratch? = !!@host.scratch&.call
187
214
 
@@ -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) }
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class SessionMetrics
5
+ # The session's work so far (SessionMetrics#usage, what a bundle's
6
+ # ctx.usage reads), across its turns and workers: the same numbers
7
+ # /stats and `chi sessions stats` show.
8
+ # +turns+: finished turns plus the running one. +tool_calls+ and
9
+ # +tool_calls_by_tool+ (canonical names: an alias counts under the tool
10
+ # it resolved to, a call_tool call under the inner tool): every call,
11
+ # running ones included. +steps+: the loop rounds that dispatched tools
12
+ # (the snapshot's iterations_total). +active_ms+: work time, the
13
+ # generations' time plus the finished tool calls' time (approval waits
14
+ # left out; ask_user_question's, a wait on the user, not counted),
15
+ # never a question's wait between them; +tool_ms_by_tool+ is the tool
16
+ # part by tool (the same names), so a reader can leave a waiting tool's
17
+ # time (task_wait, a blocking delegate) out.
18
+ # Bundles use its methods only, never this constant's name.
19
+ Usage = Data.define(:turns, :tool_calls, :tool_calls_by_tool, :steps, :active_ms, :tool_ms_by_tool) do
20
+ def self.zero = new(turns: 0, tool_calls: 0, tool_calls_by_tool: {}, steps: 0, active_ms: 0, tool_ms_by_tool: {})
21
+
22
+ # Data freezes itself, not its hashes: these are copied and frozen.
23
+ def initialize(tool_calls_by_tool:, tool_ms_by_tool:, **)
24
+ super(tool_calls_by_tool: tool_calls_by_tool.dup.freeze, tool_ms_by_tool: tool_ms_by_tool.dup.freeze, **)
25
+ end
26
+
27
+ # @return [Float] active_ms in minutes
28
+ def active_minutes = active_ms / 60_000.0
29
+ end
30
+ end
31
+ end
@@ -14,6 +14,7 @@ require_relative "session"
14
14
  require_relative "session_metrics/totals"
15
15
  require_relative "session_metrics/model_segment"
16
16
  require_relative "session_metrics/cost"
17
+ require_relative "session_metrics/usage"
17
18
  require_relative "session_metrics/side_totals"
18
19
  require_relative "side_usage"
19
20
 
@@ -53,6 +54,10 @@ module Samagotchi
53
54
  SPEED_MIN_TOKENS = 8
54
55
  SPEED_MIN_MS = 100
55
56
 
57
+ # The tools whose time is a wait on the user, not work (#usage).
58
+ USER_WAIT_TOOLS = %w[ask_user_question].freeze
59
+ private_constant :USER_WAIT_TOOLS
60
+
56
61
  # The session's token totals as a saved analytics.json has them (zeros
57
62
  # where an older file lacks a count), with how full the context was.
58
63
  # A generation's decode speed (tokens per second) and where it came
@@ -377,6 +382,23 @@ module Samagotchi
377
382
  @mutex.synchronize { Cost.of(@totals.with_running(@turn), @side) }
378
383
  end
379
384
 
385
+ # The session's work now (ctx.usage): turns, tool calls (by tool), steps
386
+ # and active (work) time, earlier workers' loaded records and the
387
+ # running turn included. Safe from any thread.
388
+ # @return [Usage]
389
+ def usage
390
+ @mutex.synchronize do
391
+ turn = @turn
392
+ totals = @totals.with_running(turn)
393
+ calls, by_tool = tool_call_counts(turn)
394
+ tool_ms = @tool_tallies[:ms]
395
+ # Finished calls only: a running call's approval wait isn't known
396
+ # until it completes (waited_ms), so its time counts then.
397
+ Usage.new(turns: turn_count(turn), tool_calls: calls, tool_calls_by_tool: by_tool, steps: totals.iterations,
398
+ active_ms: (totals.gen_ms + tool_ms.values.sum).round, tool_ms_by_tool: tool_ms.transform_values(&:round))
399
+ end
400
+ end
401
+
380
402
  # Event sink. Safe to call from any thread; errors are isolated by the
381
403
  # caller (SessionObserver / TerminalUI) but we still guard internally.
382
404
  # @param event [Hash]
@@ -542,10 +564,10 @@ module Samagotchi
542
564
  def build_snapshot(records_mode)
543
565
  turn = @turn
544
566
  totals = @totals.with_running(turn)
545
- in_flight = turn ? turn.tool_calls_by_id.values : []
567
+ calls, by_tool = tool_call_counts(turn)
546
568
  {
547
569
  session_id: @session_id,
548
- turns: @turn_records.size + (turn ? 1 : 0),
570
+ turns: turn_count(turn),
549
571
  cancellations: totals.cancellations,
550
572
  tokens: totals.tokens_block,
551
573
  # The tokens, cost and speeds per model, in order (ModelSegment.rows:
@@ -557,8 +579,8 @@ module Samagotchi
557
579
  profile_source: @profile_source,
558
580
  served_model: @served_model,
559
581
  served_model_for: @served_model_for,
560
- tool_calls_total: @tool_records.size + in_flight.size,
561
- tool_calls_by_tool: tally_into(@tool_tallies[:by_tool].dup, in_flight),
582
+ tool_calls_total: calls,
583
+ tool_calls_by_tool: by_tool,
562
584
  # The calls the model made under an alias's name (bash run as
563
585
  # execute), by its own spelling; only when there are any.
564
586
  **(@tool_tallies[:aliased].empty? ? {} : { tool_calls_aliased: @tool_tallies[:aliased].dup }),
@@ -582,6 +604,17 @@ module Samagotchi
582
604
  }
583
605
  end
584
606
 
607
+ # The finished turns and the running one. Caller holds the mutex.
608
+ def turn_count(turn) = @turn_records.size + (turn ? 1 : 0)
609
+
610
+ # Every tool call (loaded records, this process's, the running ones)
611
+ # and the calls by tool, a copy. Caller holds the mutex.
612
+ # @return [Array(Integer, Hash)]
613
+ def tool_call_counts(turn)
614
+ in_flight = turn ? turn.tool_calls_by_id.values : []
615
+ [@tool_records.size + in_flight.size, tally_into(@tool_tallies[:by_tool].dup, in_flight)]
616
+ end
617
+
585
618
  # The model segments with the running turn in: the last one's when it
586
619
  # runs on that model or names none yet, else a new one. Caller holds
587
620
  # the mutex.
@@ -1199,7 +1232,8 @@ module Samagotchi
1199
1232
  @totals = Totals.zero
1200
1233
  @segments = []
1201
1234
  @last_model_ref = nil
1202
- @tool_tallies = { by_tool: {}, aliased: {}, errors: 0 }
1235
+ # ms: the finished calls' time by tool (#usage's active_ms).
1236
+ @tool_tallies = { by_tool: {}, aliased: {}, errors: 0, ms: {} }
1203
1237
  end
1204
1238
 
1205
1239
  def count_turn(record)
@@ -1219,6 +1253,15 @@ module Samagotchi
1219
1253
  tally_into(@tool_tallies[:by_tool], [record])
1220
1254
  called_as = record[:called_as].to_s
1221
1255
  @tool_tallies[:aliased][called_as] = @tool_tallies[:aliased].fetch(called_as, 0) + 1 unless called_as.empty?
1256
+ count_tool_ms(record)
1257
+ end
1258
+
1259
+ def count_tool_ms(record)
1260
+ name = record[:tool].to_s
1261
+ ms = record[:duration_ms]
1262
+ return if name.empty? || !ms.is_a?(Numeric) || USER_WAIT_TOOLS.include?(name)
1263
+
1264
+ @tool_tallies[:ms][name] = @tool_tallies[:ms].fetch(name, 0) + ms
1222
1265
  end
1223
1266
 
1224
1267
  def tally_into(counts, tools)
@@ -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
 
@@ -3,6 +3,7 @@
3
3
  require_relative "../cancellation_controller"
4
4
  require_relative "../plugin/context"
5
5
  require_relative "../session_metrics/cost"
6
+ require_relative "../session_metrics/usage"
6
7
  require_relative "recorder"
7
8
 
8
9
  module Samagotchi
@@ -47,8 +48,10 @@ module Samagotchi
47
48
  toolkit: -> { recorder[:toolkit] },
48
49
  declared_tools: -> { recorder[:declared_tools] || tool_names.call },
49
50
  use_toolkit: ->(name) { recorder.use_toolkit(name, toolkits.call) },
51
+ load_tools: ->(names, bundle) { recorder.load_tools(names, bundle) },
50
52
  cancel_controller: ->(handler: false) { recorder[:cancelled] ? stopped : nil },
51
- cost: -> { cost(recorder[:cost]) }
53
+ cost: -> { cost(recorder[:cost]) },
54
+ usage: -> { usage(recorder[:usage]) }
52
55
  )
53
56
  end
54
57
 
@@ -74,6 +77,33 @@ module Samagotchi
74
77
  else raise ArgumentError, "cost: a number, a Hash or a callable, not #{value.inspect}"
75
78
  end
76
79
  end
80
+
81
+ # The parts a test may give bundle.set(usage: {...}).
82
+ USAGE_PARTS = %i[turns tool_calls tool_calls_by_tool steps active_ms tool_ms_by_tool].freeze
83
+
84
+ # The SessionMetrics::Usage ctx.usage returns for the recorder's
85
+ # +usage+ value (Recorder::DEFAULTS): zeros for the parts not given;
86
+ # tool_calls defaults to the sum of tool_calls_by_tool, active_ms to
87
+ # the sum of tool_ms_by_tool.
88
+ # @return [SessionMetrics::Usage]
89
+ def usage(value)
90
+ value = value.call while value.respond_to?(:call)
91
+ case value
92
+ when nil then SessionMetrics::Usage.zero
93
+ when SessionMetrics::Usage then value
94
+ when Hash
95
+ parts = value.transform_keys(&:to_sym)
96
+ unknown = parts.keys - USAGE_PARTS
97
+ raise ArgumentError, "unknown usage parts: #{unknown.join(", ")}" unless unknown.empty?
98
+
99
+ by_tool = parts.fetch(:tool_calls_by_tool, {}).transform_keys(&:to_s)
100
+ ms_by_tool = parts.fetch(:tool_ms_by_tool, {}).transform_keys(&:to_s)
101
+ SessionMetrics::Usage.new(turns: parts.fetch(:turns, 0), tool_calls: parts.fetch(:tool_calls, by_tool.values.sum),
102
+ tool_calls_by_tool: by_tool, steps: parts.fetch(:steps, 0),
103
+ active_ms: parts.fetch(:active_ms, ms_by_tool.values.sum), tool_ms_by_tool: ms_by_tool)
104
+ else raise ArgumentError, "usage: a Hash or a callable, not #{value.inspect}"
105
+ end
106
+ end
77
107
  end
78
108
 
79
109
  # The hook runtime (Hooks::Runtime: event[:notify] and the other
@@ -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,17 @@ 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
- cost: nil
40
+ cost: nil,
41
+ # ctx.usage: nil (zero), a Hash of the parts (turns:, tool_calls:,
42
+ # tool_calls_by_tool:, steps:, active_ms:, tool_ms_by_tool:) or a
43
+ # callable giving one.
44
+ usage: nil
39
45
  }.freeze
40
46
 
41
- attr_reader :notices, :cards, :steers, :stops, :questions, :model_requests, :toolkit_uses, :answers,
47
+ attr_reader :notices, :cards, :steers, :stops, :questions, :model_requests, :toolkit_uses, :tool_loads, :answers,
42
48
  :model_answers, :values
43
49
 
44
50
  def initialize(**values)
@@ -53,6 +59,7 @@ module Samagotchi
53
59
  @questions = []
54
60
  @model_requests = []
55
61
  @toolkit_uses = []
62
+ @tool_loads = []
56
63
  @answers = []
57
64
  @model_answers = []
58
65
  @mutex = Mutex.new
@@ -127,6 +134,14 @@ module Samagotchi
127
134
  answer.respond_to?(:call) ? answer.call(entry) : answer
128
135
  end
129
136
 
137
+ # ctx.load_tools: recorded; every name is staged unless the test set
138
+ # load_tools_staged: (a list: those only).
139
+ def load_tools(names, bundle)
140
+ record(@tool_loads, ToolLoad.new(names: names, bundle: bundle))
141
+ staged = self[:load_tools_staged]
142
+ staged ? names & Array(staged) : names
143
+ end
144
+
130
145
  # ctx.use_toolkit: +known+ the toolkits chi and the plugins declare.
131
146
  # Staged (toolkit_staged:, true by default), the session is on it at
132
147
  # 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 = {}