samagotchi 0.154.1 → 0.155.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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -1
  3. data/docs/architecture.md +3 -2
  4. data/docs/bundles.md +21 -0
  5. data/docs/cli.md +1 -1
  6. data/docs/guardrails.md +2 -2
  7. data/docs/hooks.md +1 -1
  8. data/docs/internals/anthropic.md +4 -4
  9. data/docs/internals/attached-context.md +8 -6
  10. data/docs/internals/block-renderers.md +8 -1
  11. data/docs/internals/engine.md +220 -99
  12. data/docs/internals/inline-widgets.md +136 -0
  13. data/docs/internals/objectives.md +8 -6
  14. data/docs/internals/participants.md +5 -5
  15. data/docs/internals/prompt-caching.md +9 -5
  16. data/docs/internals/web-frontend.md +1 -1
  17. data/docs/internals/working-set.md +2 -1
  18. data/docs/plugins.md +2 -0
  19. data/docs/releasing.md +1 -1
  20. data/docs/sessions.md +2 -2
  21. data/docs/testing.md +1 -1
  22. data/lib/samagotchi/block_renderers.rb +5 -0
  23. data/lib/samagotchi/bundle_command/check.rb +1 -1
  24. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  25. data/lib/samagotchi/bundles/inline-widgets/manifest.yml +11 -0
  26. data/lib/samagotchi/bundles/inline-widgets/plugin.rb +76 -0
  27. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  28. data/lib/samagotchi/engine/context_judge.rb +19 -125
  29. data/lib/samagotchi/engine/guardrails.rb +4 -2
  30. data/lib/samagotchi/engine/messages.rb +9 -6
  31. data/lib/samagotchi/engine/objectives.rb +22 -90
  32. data/lib/samagotchi/engine/participants.rb +15 -10
  33. data/lib/samagotchi/engine/plugins.rb +3 -1
  34. data/lib/samagotchi/engine/questions.rb +1 -1
  35. data/lib/samagotchi/engine/side_requests.rb +5 -2
  36. data/lib/samagotchi/engine/toolkit.rb +33 -190
  37. data/lib/samagotchi/engine/turn_end.rb +1 -1
  38. data/lib/samagotchi/engine/turn_helpers.rb +16 -39
  39. data/lib/samagotchi/engine/verify.rb +7 -143
  40. data/lib/samagotchi/engine.rb +162 -99
  41. data/lib/samagotchi/engine_peers.rb +1 -1
  42. data/lib/samagotchi/event_fanout.rb +18 -12
  43. data/lib/samagotchi/guardrail_wiring.rb +83 -82
  44. data/lib/samagotchi/guardrails/gate.rb +41 -40
  45. data/lib/samagotchi/guardrails/pair_boundary.rb +2 -1
  46. data/lib/samagotchi/guardrails/review_boundary.rb +2 -1
  47. data/lib/samagotchi/guardrails/targets.rb +6 -4
  48. data/lib/samagotchi/guardrails_command.rb +1 -1
  49. data/lib/samagotchi/inline_widgets.rb +325 -0
  50. data/lib/samagotchi/kernel_loop.rb +190 -213
  51. data/lib/samagotchi/llm/backend.rb +3 -1
  52. data/lib/samagotchi/llm/chat_loop.rb +63 -41
  53. data/lib/samagotchi/llm/native_backend.rb +3 -2
  54. data/lib/samagotchi/llm_context_forget.rb +5 -3
  55. data/lib/samagotchi/model_binding.rb +3 -3
  56. data/lib/samagotchi/plugin/prompt_sections.rb +7 -0
  57. data/lib/samagotchi/question_desk.rb +1 -1
  58. data/lib/samagotchi/revise_anchor.rb +1 -1
  59. data/lib/samagotchi/system_prompt.rb +37 -30
  60. data/lib/samagotchi/testing/boundary.rb +7 -0
  61. data/lib/samagotchi/testing/bundle.rb +7 -5
  62. data/lib/samagotchi/testing/install.rb +3 -2
  63. data/lib/samagotchi/tool_activity.rb +8 -6
  64. data/lib/samagotchi/tool_declarations.rb +4 -4
  65. data/lib/samagotchi/tool_response.rb +4 -3
  66. data/lib/samagotchi/tool_runner.rb +91 -100
  67. data/lib/samagotchi/toolkit_binding.rb +40 -14
  68. data/lib/samagotchi/tools/deferred_tools.rb +17 -21
  69. data/lib/samagotchi/tools/registry.rb +43 -40
  70. data/lib/samagotchi/tools/tool_index.rb +135 -88
  71. data/lib/samagotchi/tools/tool_surface.rb +173 -0
  72. data/lib/samagotchi/turn_context.rb +67 -0
  73. data/lib/samagotchi/turn_stages/context_judge.rb +170 -0
  74. data/lib/samagotchi/turn_stages/objectives.rb +146 -0
  75. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +261 -0
  76. data/lib/samagotchi/turn_stages/verify_anchors.rb +169 -0
  77. data/lib/samagotchi/turn_stages/warmup.rb +83 -0
  78. data/lib/samagotchi/turn_state.rb +46 -4
  79. data/lib/samagotchi/version.rb +1 -1
  80. data/lib/samagotchi/web/markdown_renderer.rb +39 -14
  81. data/lib/samagotchi/web/message_display.rb +1 -1
  82. data/lib/samagotchi/web/public/app.js +5 -1
  83. data/lib/samagotchi/web/public/block_host.js +74 -199
  84. data/lib/samagotchi/web/public/block_renderers.css +2 -0
  85. data/lib/samagotchi/web/public/block_source.js +7 -4
  86. data/lib/samagotchi/web/public/block_watch.js +256 -0
  87. data/lib/samagotchi/web/public/composer_fold.js +3 -0
  88. data/lib/samagotchi/web/public/index.html +28 -9
  89. data/lib/samagotchi/web/public/inline_widgets.js +146 -0
  90. data/lib/samagotchi/web/public/owner_verify.js +1 -1
  91. data/lib/samagotchi/web/public/participant.js +1 -1
  92. data/lib/samagotchi/web/public/revise_stack.js +1 -1
  93. data/lib/samagotchi/web/public/stage_watch.js +6 -2
  94. data/lib/samagotchi/web/public/text_roots.js +1 -1
  95. data/lib/samagotchi/web/public/verify_target.js +1 -1
  96. data/lib/samagotchi/web/public/widget_picks.js +130 -0
  97. data/lib/samagotchi/web/public/widget_walk.js +230 -0
  98. data/lib/samagotchi/web/public/widgets.css +70 -0
  99. metadata +17 -1
@@ -34,14 +34,15 @@ module Samagotchi
34
34
  # own go to +on_stream_event+. An Interrupt (Ctrl-C) mid-batch yields an
35
35
  # #interrupted run for every call left (the one it cut short too), so
36
36
  # the loop's conversation answers each call, then goes on up: the turn
37
- # keeps its progress (Engine#ctrl_c_messages).
37
+ # keeps its progress (Engine#ctrl_c_messages). +context+: the calls'
38
+ # turn (TurnContext), each run's (ToolRunner#run).
38
39
  # @return [Array<ToolRun>] the runs
39
- def run_batch(runner, calls, iteration:, emit:, on_stream_event:, cap:)
40
+ def run_batch(runner, calls, context:, iteration:, emit:, on_stream_event:, cap:)
40
41
  # Before anything can be interrupted: the rescue counts it.
41
42
  runs = []
42
43
  emit.call({ type: :tool_dispatch_started, iteration: iteration, call_count: calls.length })
43
44
  calls.each_with_index do |call, index|
44
- runs << runner.run(call, iteration: iteration, call_index: index + 1, call_count: calls.length,
45
+ runs << runner.run(call, context: context, iteration: iteration, call_index: index + 1, call_count: calls.length,
45
46
  on_stream_event: on_stream_event, max_tool_output_chars: cap)
46
47
  yield runs.last, index if block_given?
47
48
  end
@@ -14,6 +14,7 @@ require_relative "tool_output_cut"
14
14
  require_relative "tool_output_files"
15
15
  require_relative "tools/output_guardrails"
16
16
  require_relative "tools/tool_path"
17
+ require_relative "tools/tool_surface"
17
18
  require_relative "tools/memory"
18
19
  require_relative "config"
19
20
  require_relative "memory_bundle/index_sync"
@@ -53,14 +54,20 @@ module Samagotchi
53
54
  NOT_AN_EDIT = Object.new.freeze
54
55
  private_constant :NOT_AN_EDIT
55
56
 
56
- # @param kernel [KernelLoop] read lazily: Engine sets its hooks after
57
- # the kernel is built.
57
+ # What a call needs of its turn comes with it: #run's +context:+ (a
58
+ # TurnContext: the session's folders, the settings' vision, its
59
+ # ToolSurface, the participant's mode), passed on to the gate and the
60
+ # kernel's dispatch.
61
+ #
62
+ # @param kernel [KernelLoop] its services (KernelLoop::Services: hooks,
63
+ # tools, gate) are read per call: a spec's kernel may #attach new ones.
58
64
  # @param clock [#call] monotonic seconds, for the approval wait and the
59
65
  # dispatch time (after_tool_call's elapsed_ms)
60
- # @param window [#call, nil] → ContextWindow::Resolved or nil: the
61
- # model's window, for memory_read's overflow guard. nil: the kernel's
62
- # own reader (KernelLoop#tool_output_window); a loop whose window comes
63
- # from more than the kernel's client (LLM::ChatLoop's adapter) passes it.
66
+ # @param window [#call, nil] (settings) → ContextWindow::Resolved or nil:
67
+ # the model's window under the turn's LLM::TurnSettings, for
68
+ # memory_read's overflow guard. nil: the kernel's own reader
69
+ # (KernelLoop#tool_output_window); a loop whose window comes from more
70
+ # than the kernel's client (LLM::ChatLoop's adapter) passes it.
64
71
  def initialize(kernel, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, window: nil)
65
72
  @kernel = kernel
66
73
  @clock = clock
@@ -78,29 +85,30 @@ module Samagotchi
78
85
  # RowRefs; saved likewise), and diff: what an edit/write changed in its
79
86
  # file (EditPreview.change; also on tool_call_completed, never in the
80
87
  # model's output)
81
- def run(call, iteration:, call_index:, call_count:, on_stream_event:, max_tool_output_chars:)
88
+ # @param context [TurnContext] the call's turn
89
+ def run(call, context:, iteration:, call_index:, call_count:, on_stream_event:, max_tool_output_chars:)
82
90
  # A call_tool of one of chi's own tools runs as that tool, from here
83
91
  # on (the gate, the row, the result): rules, approvals and hooks see
84
92
  # the real call, with via: (Tools::ToolIndex#unwrap).
85
- call = unwrapped(call)
86
- params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
93
+ call = unwrapped(call, context)
94
+ params = ToolActivity.tool_activity_params(call[:name], call, registry: tools, surface: surface(context))
87
95
  # The gate runs first, so tool_call_started shows the call that runs.
88
96
  # A tool the session's toolkit leaves out skips it: no before_tool_call
89
97
  # hook, no approval card for a call that can't run (KernelLoop#dispatch
90
98
  # answers it with the not-loaded error).
91
99
  original = call.dup
92
- refusal = unreached_group_refusal(call)
100
+ refusal = unreached_group_refusal(call, context)
93
101
  verdict = if refusal
94
102
  Guardrails::Verdict.new(call: call).deny!(refusal.message,
95
103
  rule: Tools::ToolIndex::ToolkitGroupRefusal::RULE,
96
104
  source: "core", decided_by: "core")
97
- elsif left_out?(call)
105
+ elsif left_out?(call, context)
98
106
  Guardrails::Verdict.new(call: call)
99
107
  else
100
- evaluate(call, iteration, params)
108
+ evaluate(call, context, iteration, params)
101
109
  end
102
110
  call = verdict.call
103
- params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
111
+ params = ToolActivity.tool_activity_params(call[:name], call, registry: tools, surface: surface(context))
104
112
  label = plugin_label(call[:name])
105
113
  # Kept on the raw event for in-worker hooks and metrics, not sent to SSE clients.
106
114
  started = { type: :tool_call_started, iteration: iteration, call_count: call_count, call_index: call_index,
@@ -110,7 +118,7 @@ module Samagotchi
110
118
  # plugin tool's refs); the worker runs in its session's working
111
119
  # directory.
112
120
  fields = ToolRowFields.for(call[:name], call, cwd: Dir.pwd)
113
- refs = RowRefs.for(tools && !call[:name].nil? ? tools[call[:name]]&.refs : nil, call, cwd: session_cwd)
121
+ refs = RowRefs.for(tools && !call[:name].nil? ? tools[call[:name]]&.refs : nil, call, cwd: session_cwd(context))
114
122
  fields[:refs] = refs unless refs.empty?
115
123
  started.merge!(fields)
116
124
  emit(on_stream_event, started)
@@ -121,7 +129,7 @@ module Samagotchi
121
129
  waited_ms = nil
122
130
  if verdict.ask?
123
131
  asked_at = @clock.call
124
- settle_ask(verdict)
132
+ settle_ask(verdict, context)
125
133
  waited_ms = ((@clock.call - asked_at) * 1000).round
126
134
  end
127
135
  before = verdict.deny? ? NOT_AN_EDIT : file_before(call)
@@ -129,10 +137,10 @@ module Samagotchi
129
137
  entry_before = before.equal?(NOT_AN_EDIT) ? nil : MemoryBundle::EntrySize.measure_file(Tools::ToolPath.normalize(call[:path]))
130
138
  elapsed_ms = nil
131
139
  if verdict.deny?
132
- result = denied(call, verdict)
140
+ result = denied(call, verdict, context)
133
141
  else
134
142
  dispatched_at = @clock.call
135
- result = dispatch(call)
143
+ result = dispatch(call, context)
136
144
  elapsed_ms = ((@clock.call - dispatched_at) * 1000).round
137
145
  end
138
146
  diff = file_change(call, before)
@@ -141,7 +149,7 @@ module Samagotchi
141
149
  result = with_entry_note(result, entry_before, call)
142
150
  end
143
151
  result = approved(result, verdict) if verdict.allow? && verdict.decided_by
144
- result, images = attach_images(call, result) if result[:images]
152
+ result, images = attach_images(call, result, context.settings&.vision) if result[:images]
145
153
 
146
154
  output = scrub(result[:output].to_s)
147
155
  # The tool's own text, for after_tool_call: no "[tool]" lead, no
@@ -149,12 +157,12 @@ module Samagotchi
149
157
  tool_text = output.sub(/\A\[#{Regexp.escape(call[:name].to_s)}\](?:\n| )/, "")
150
158
  # The gate may have replaced the call (known-names corrects a near
151
159
  # miss): one leading line tells the model what actually ran.
152
- if (changed = changed_call_line(original, call))
160
+ if (changed = changed_call_line(original, call, context))
153
161
  output = "#{changed}\n#{output}"
154
162
  end
155
- cap = output_cap(call, max_tool_output_chars)
163
+ cap = output_cap(call, max_tool_output_chars, context)
156
164
  truncated = !cap.nil? && output.length > cap
157
- capped, saved = truncated ? cut(call, output, cap, result) : output
165
+ capped, saved = truncated ? cut(call, output, cap, result, context) : output
158
166
  output_file = saved.path if saved.is_a?(ToolOutputFiles::Saved)
159
167
 
160
168
  # The call's outcome as its activity line has it (worked out from the
@@ -180,7 +188,7 @@ module Samagotchi
180
188
  images: images&.any? ? images : nil,
181
189
  shown_params: params && plugin_tool?(call[:name]) ? params : nil,
182
190
  shown_label: label, refs: refs.empty? ? nil : refs, diff: diff, output_file: output_file,
183
- loaded: result[:loaded], stamp: status == "ok" ? file_stamp(call) : nil)
191
+ loaded: result[:loaded], stamp: status == "ok" ? file_stamp(call, context) : nil)
184
192
  end
185
193
 
186
194
  private
@@ -189,8 +197,8 @@ module Samagotchi
189
197
  # {path:, mtime_ns:, size:}, the path as FileRef has it (against the
190
198
  # session's folder, no realpath). nil for any other tool, a path it
191
199
  # can't place and a stat that fails or isn't a file's.
192
- def file_stamp(call)
193
- ref = FileRef.for(call[:name], call, cwd: session_cwd)
200
+ def file_stamp(call, context)
201
+ ref = FileRef.for(call[:name], call, cwd: session_cwd(context))
194
202
  return nil unless ref
195
203
 
196
204
  stat = File.stat(ref.path)
@@ -209,8 +217,9 @@ module Samagotchi
209
217
  # for task_wait and delegate; ref_stderr_path, a task's stderr log
210
218
  # @return [Array(String, Object)] the text, and the saved file (or why
211
219
  # there is none) when it was cut that way
212
- def cut(call, output, cap, result)
213
- ToolOutputCut.apply(output, cap: cap, tool: call[:name], save: ->(text, dry: false) { save_output(call, text, dry: dry) },
220
+ def cut(call, output, cap, result, context)
221
+ ToolOutputCut.apply(output, cap: cap, tool: call[:name],
222
+ save: ->(text, dry: false) { save_output(call, text, context.session_dir, dry: dry) },
214
223
  line: (memory_cut_line(call) if call[:name] == MEMORY_READ),
215
224
  ref: result[:ref_path], ref_stderr: result[:ref_stderr_path],
216
225
  head_chars: Tools::OutputGuardrails.config_positive_int("execute.cut_head_chars",
@@ -223,11 +232,11 @@ module Samagotchi
223
232
  # for every tool but memory_read, which memory.read_max_chars caps (0:
224
233
  # no fixed cap) and, with none set, a known window's half (#window_guard).
225
234
  # A nil +cap+ stays nil.
226
- def output_cap(call, cap)
235
+ def output_cap(call, cap, context)
227
236
  return cap unless cap && call[:name] == MEMORY_READ
228
237
 
229
238
  fixed = Config.get("memory.read_max_chars").to_i
230
- fixed.positive? ? fixed : window_guard
239
+ fixed.positive? ? fixed : window_guard(context.settings)
231
240
  end
232
241
 
233
242
  # Half the model's context window in characters, the most one memory_read
@@ -235,9 +244,9 @@ module Samagotchi
235
244
  # and a retry repeats the read). nil when the window isn't known (the
236
245
  # default isn't a window) or can't be read. It checks this result, not the
237
246
  # context already used.
238
- def window_guard
247
+ def window_guard(settings)
239
248
  Degrade.call(:memory, "read_window_unknown") do
240
- window = @window ? @window.call : (@kernel.tool_output_window if @kernel.respond_to?(:tool_output_window))
249
+ window = @window ? @window.call(settings) : (@kernel.tool_output_window(settings) if @kernel.respond_to?(:tool_output_window))
241
250
  return nil unless window&.tokens&.positive? && window.source != :default
242
251
 
243
252
  (window.tokens * MemoryBundle::IndexSize.chars_per_token / 2).floor
@@ -254,12 +263,12 @@ module Samagotchi
254
263
  end
255
264
 
256
265
  # The whole output in the session's tool_output folder
257
- # (ToolOutputFiles), or why it isn't saved: no session (a bare kernel)
258
- # or the error's class. Never fails the call. +dry+: where it would go,
259
- # nothing written (ToolOutputFiles.planned).
266
+ # (ToolOutputFiles; +dir+: the turn's TurnContext#session_dir), or why it
267
+ # isn't saved: no session (a bare kernel) or the error's class. Never
268
+ # fails the call. +dry+: where it would go, nothing written
269
+ # (ToolOutputFiles.planned).
260
270
  # @return [ToolOutputFiles::Saved, String]
261
- def save_output(call, text, dry: false)
262
- dir = session_dir
271
+ def save_output(call, text, dir, dry: false)
263
272
  return "no session" if dir.to_s.empty?
264
273
  return ToolOutputFiles.planned(dir, text, tool: call[:name]) if dry
265
274
 
@@ -269,15 +278,6 @@ module Samagotchi
269
278
  e.class.name
270
279
  end
271
280
 
272
- # The session's folder (KernelLoop#session_dir), nil for a bare kernel
273
- # or a lookup that failed.
274
- def session_dir
275
- @kernel.respond_to?(:session_dir) ? @kernel.session_dir&.call : nil
276
- rescue StandardError => e
277
- Log.warn(:turn, "tool_output_save_failed", error: e.class.name)
278
- nil
279
- end
280
-
281
281
  # edit/write only: the file just before the call runs. The row diffs it
282
282
  # with the file after, whatever the result says, so an edit that fails
283
283
  # after writing still shows its change and one that wrote nothing shows
@@ -351,8 +351,8 @@ module Samagotchi
351
351
  # and keeps the text and the other images; for read, whose text only
352
352
  # says the image is attached, that line replaces the text.
353
353
  # @return [Array(Hash, Array<Hash>)] the result and its refs
354
- def attach_images(call, result)
355
- vision = @kernel.turn_settings.vision
354
+ # +vision+: the turn's VisionContext (TurnSettings#vision).
355
+ def attach_images(call, result, vision)
356
356
  reason = if vision.nil? then "images can't be attached here"
357
357
  elsif !vision.sendable? then ImagePlan::CANT_SEE
358
358
  end
@@ -397,17 +397,19 @@ module Samagotchi
397
397
  [result[:output], *notes].join("\n")
398
398
  end
399
399
 
400
- # The session's folder (the Engine's, KernelLoop#session_cwd), else the
400
+ # The session's folder (the turn's TurnContext#cwd), else the
401
401
  # process's: a bare kernel (specs) has none.
402
- def session_cwd
403
- cwd = @kernel.session_cwd&.call if @kernel.respond_to?(:session_cwd)
402
+ def session_cwd(context)
403
+ cwd = context.cwd
404
404
  cwd.to_s.empty? ? Dir.pwd : cwd
405
405
  end
406
406
 
407
407
  # The kernel's tools, for the activity line of a tool that isn't built in.
408
408
  def tools = @kernel.tools
409
409
 
410
- def layers = @kernel.respond_to?(:llm_context_layers) ? @kernel.llm_context_layers : []
410
+ # The turn's Tools::ToolSurface (+context+'s), else one computed now
411
+ # from the kernel's tools (a runner outside an Engine turn).
412
+ def surface(context) = Tools::ToolSurface.of(context, tools)
411
413
 
412
414
  # The call its tool's entry unwraps it to (call_tool on a built-in),
413
415
  # else the call as it is. On a host that loads tools, a direct call of
@@ -415,12 +417,12 @@ module Samagotchi
415
417
  # (Registry::Index#wrap): call_tool's row, rules and approvals. One
416
418
  # that fails runs as it is: call_tool then refuses a built-in (fail
417
419
  # closed).
418
- def unwrapped(call)
419
- registry = tools
420
- return call unless registry.respond_to?(:offered) && !call[:name].nil?
420
+ def unwrapped(call, context)
421
+ return call if tools.nil? || call[:name].nil?
421
422
 
422
- call = wrapped(registry, call)
423
- registry.offered(call[:name], layers: layers)&.unwrap&.call(call, layers) || call
423
+ surface = surface(context)
424
+ call = wrapped(surface, call)
425
+ surface.declared_entry(call[:name])&.unwrap&.call(call, surface) || call
424
426
  rescue StandardError => e
425
427
  Log.warn(:turn, "tool_unwrap_failed", tool: call[:name].to_s, error: "#{e.class}: #{e.message}")
426
428
  call
@@ -428,11 +430,11 @@ module Samagotchi
428
430
 
429
431
  # +call+ as the call_tool call it runs as when it names a deferred
430
432
  # source tool, on a host that loads tools; else as it is.
431
- def wrapped(registry, call)
432
- wrap = registry.respond_to?(:index) ? registry.index&.wrap : nil
433
- return call unless wrap && loads_tools? && !registry.key?(call[:name])
433
+ def wrapped(surface, call)
434
+ wrap = surface.index&.wrap
435
+ return call unless wrap && surface.loads_tools && !surface.registered?(call[:name])
434
436
 
435
- wrap.call(call) || call
437
+ wrap.call(call, surface) || call
436
438
  end
437
439
 
438
440
  # A call that can't run in this turn as it is, by its name: a real tool
@@ -440,23 +442,25 @@ module Samagotchi
440
442
  # (via:) that the toolkit doesn't reach that way (a hook's rewrite). On
441
443
  # a host that loads tools, a built-in the toolkit reaches runs by its
442
444
  # own name (KernelLoop#dispatch takes it too).
443
- def left_out?(call)
444
- registry = tools
445
- return false unless registry.respond_to?(:left_out) && !call[:name].nil?
446
- return registry.indexed(call[:name], layers: layers).nil? if call[:via]
447
- return false if loads_tools? && registry.indexed(call[:name], layers: layers)
445
+ def left_out?(call, context)
446
+ return false if tools.nil? || call[:name].nil?
447
+
448
+ surface = surface(context)
449
+ return surface.indexed_entry(call[:name]).nil? if call[:via]
450
+ return false if surface.loads_tools && surface.indexed_entry(call[:name])
448
451
 
449
- !registry.left_out(call[:name], layers: layers).nil?
452
+ surface.left_out?(call[:name])
450
453
  end
451
454
 
452
- def unreached_group_refusal(call)
455
+ def unreached_group_refusal(call, context)
453
456
  # A review turn's boundary denies it first (and words why); a pair
454
457
  # turn gets the owner's refusal.
455
- return if participant_turn? && participant_mode != ParticipantMode::PAIR
458
+ return if context.participant? && context.participant_mode != ParticipantMode::PAIR
459
+ return if tools.nil?
456
460
 
457
- registry = tools
458
- index = registry&.index if registry.respond_to?(:index)
459
- return unless index.respond_to?(:unreached_tool)
461
+ surface = surface(context)
462
+ index = surface.index
463
+ return unless index
460
464
 
461
465
  name = if call[:name] == index.call_name
462
466
  args = call[:args].is_a?(Hash) ? call[:args] : {}
@@ -464,24 +468,10 @@ module Samagotchi
464
468
  else
465
469
  call[:name]
466
470
  end
467
- tool = index.unreached_tool(name)
468
- tool && Tools::ToolIndex::ToolkitGroupRefusal.for(tool, registry)
471
+ tool = index.unreached_tool(name, surface)
472
+ tool && Tools::ToolIndex::ToolkitGroupRefusal.for(tool, surface)
469
473
  end
470
474
 
471
- def participant_turn?
472
- @kernel.respond_to?(:participant_turn?) && @kernel.participant_turn?
473
- end
474
-
475
- # The running participant turn's mode (KernelLoop#participant_mode,
476
- # GuardrailWiring#turn_mode), nil without one.
477
- def participant_mode
478
- lookup = @kernel.respond_to?(:participant_mode) ? @kernel.participant_mode : nil
479
- lookup&.call
480
- end
481
-
482
- # Whether the turn's host loads tools (TurnSettings#loads_tools).
483
- def loads_tools? = @kernel.respond_to?(:turn_settings) && @kernel.turn_settings&.loads_tools == true
484
-
485
475
  # A tool the registry has from a plugin (not core, not unknown).
486
476
  def plugin_tool?(name)
487
477
  entry = tools && !name.nil? ? tools[name] : nil
@@ -495,29 +485,30 @@ module Samagotchi
495
485
  # asked for: "ran as: <tool> <changed args>", or nil when nothing
496
486
  # changed. The args are the changed call's activity params, so the line
497
487
  # stays short.
498
- def changed_call_line(original, call)
488
+ def changed_call_line(original, call, context)
499
489
  return nil if original == call
500
490
 
501
- params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
491
+ params = ToolActivity.tool_activity_params(call[:name], call, registry: tools, surface: surface(context))
502
492
  ["ran as: #{call[:name]}", params].compact.join(" ")
503
493
  end
504
494
 
505
- # The Engine sets the kernel's gate (its context, later the approval
506
- # flow); a bare kernel (specs) gets one that only runs the hooks.
495
+ # The kernel's gate (the Engine's, KernelLoop::Services#gate: its
496
+ # context, the approval flow); a bare kernel (specs) gets one that only
497
+ # runs its hooks, built per call from what the kernel holds now.
507
498
  def gate
508
- @kernel.guardrail_gate || (@gate ||= Guardrails::Gate.new(-> { @kernel.hooks }, tools_lookup: -> { tools }))
499
+ @kernel.guardrail_gate || Guardrails::Gate.new(hooks: @kernel.hooks, tools: tools)
509
500
  end
510
501
 
511
502
  # A gate that fails denies the call (fail closed).
512
- def evaluate(call, iteration, params)
513
- gate.evaluate(call, iteration: iteration, params: params)
503
+ def evaluate(call, context, iteration, params)
504
+ gate.evaluate(call, context: context, iteration: iteration, params: params)
514
505
  rescue StandardError => e
515
506
  Guardrails::Verdict.new(call: call).deny!("the guardrail check failed: #{e.class}: #{e.message}",
516
507
  decided_by: "core")
517
508
  end
518
509
 
519
- def settle_ask(verdict)
520
- gate.settle_ask(verdict)
510
+ def settle_ask(verdict, context)
511
+ gate.settle_ask(verdict, context: context)
521
512
  rescue StandardError => e
522
513
  verdict.settle!(:deny, decided_by: "core", note: "The approval failed (#{e.class}: #{e.message}).")
523
514
  end
@@ -531,10 +522,10 @@ module Samagotchi
531
522
 
532
523
  # A legacy veto keeps its old text; a verdict's deny tells the model
533
524
  # who decided and not to route around it.
534
- def denied(call, verdict)
525
+ def denied(call, verdict, context)
535
526
  if verdict.rule == Tools::ToolIndex::ToolkitGroupRefusal::RULE
536
527
  output = "[#{call[:name]}] #{verdict.reason}"
537
- activity = ToolActivity.tool_activity_event(call[:name], call, output, registry: tools)
528
+ activity = ToolActivity.tool_activity_event(call[:name], call, output, registry: tools, surface: surface(context))
538
529
  return { output: output, activity: activity.merge(status: "blocked", guardrail: verdict.to_activity) }
539
530
  end
540
531
 
@@ -544,14 +535,14 @@ module Samagotchi
544
535
  else
545
536
  "[#{call[:name]}] Error: #{verdict.deny_text}"
546
537
  end
547
- activity = ToolActivity.tool_activity_event(call[:name], call, output, registry: tools)
538
+ activity = ToolActivity.tool_activity_event(call[:name], call, output, registry: tools, surface: surface(context))
548
539
  { output: output, activity: activity.merge(status: "blocked", guardrail: verdict.to_activity) }
549
540
  end
550
541
 
551
542
  # KernelLoop#dispatch turns tool errors into "[name] Error: …" itself;
552
543
  # this rescue only catches a failing dispatcher.
553
- def dispatch(call)
554
- @kernel.dispatch_tool_call(call)
544
+ def dispatch(call, context)
545
+ @kernel.dispatch_tool_call(call, context)
555
546
  rescue StandardError => e
556
547
  { output: "[#{call[:name]}] Error: #{e.class}: #{e.message}", activity: nil }
557
548
  end
@@ -5,6 +5,7 @@ require_relative "memory_paths"
5
5
  require_relative "model_overlay"
6
6
  require_relative "muted_memories"
7
7
  require_relative "toolkit"
8
+ require_relative "tools/tool_surface"
8
9
  require "yaml"
9
10
 
10
11
  module Samagotchi
@@ -13,6 +14,12 @@ module Samagotchi
13
14
  # ctx.use_toolkit (#use) and the turn-start apply into the tool Registry
14
15
  # (#apply!), with its load warnings said once per process.
15
16
  #
17
+ # Its readers off the turn thread (#explained: /toolkit, the snapshot,
18
+ # the plugin host's declared_tools; ctx.load_tools) read #published, the
19
+ # Registry as the turn thread last published it (#publish!: the Engine's
20
+ # build, a turn's start), never the live one. #kits' lazy reads may run
21
+ # on any thread: each one stores what any other would.
22
+ #
16
23
  # What it needs from the Engine comes through lookups, called at use.
17
24
  class ToolkitBinding
18
25
  # @param tools [#call] → Tools::Registry
@@ -45,6 +52,21 @@ module Samagotchi
45
52
  # @return [Array<Toolkit::Kit>] the plugins' kits (Plugin::Registries' toolkits:)
46
53
  attr_reader :plugin_kits
47
54
 
55
+ # Publish the Registry as it is now (Tools::Registry#snapshot), on the
56
+ # turn thread or at the Engine's build only: after the tool index is
57
+ # declared, after a turn's staged tool sets and its toolkit apply.
58
+ # One assignment of a frozen value.
59
+ # @return [Tools::Registry] the snapshot
60
+ def publish!
61
+ @published = @tools.call.snapshot
62
+ end
63
+
64
+ # The Registry as last published (#publish!); before the first publish
65
+ # (a plugin reading while the Engine is built, on its thread), the
66
+ # Registry read now.
67
+ # @return [Tools::Registry]
68
+ def published = @published || @tools.call.snapshot
69
+
48
70
  # The kits a session's toolkit resolves against: built-ins, config,
49
71
  # plugins, then project and system memory kits. Config and memory kits
50
72
  # are read once per worker, like the plugins': edits take effect when a
@@ -77,17 +99,18 @@ module Samagotchi
77
99
  end
78
100
 
79
101
  # +toolkit+ (the session's by default) as its next turn would declare
80
- # it (/toolkit, the UIs' chip).
102
+ # it (/toolkit, the UIs' chip): the #published Registry read now as a
103
+ # Tools::ToolSurface under that toolkit.
81
104
  # @return [Toolkit::Explained]
82
105
  def explained(toolkit = nil)
83
106
  toolkit ||= Toolkit.of(@session.call&.toolkit)
84
107
  resolved = toolkit.resolve(kits)
85
108
  filter = resolved.everything? ? nil : resolved
86
- tools = @tools.call
87
- Toolkit::Explained.new(toolkit: toolkit, resolved: resolved, declared: tools.entries(toolkit: filter),
88
- left_out: tools.left_out_names(toolkit: filter),
89
- reachable: tools.reachable_names(toolkit: filter),
90
- removed: tools.removed_names(toolkit: filter), pending: filter != tools.toolkit)
109
+ tools = published
110
+ surface = Tools::ToolSurface.for(tools, toolkit: filter)
111
+ Toolkit::Explained.new(toolkit: toolkit, resolved: resolved, declared: surface.declared,
112
+ left_out: surface.left_out.keys, reachable: surface.index_only.map(&:name),
113
+ removed: surface.removed, pending: filter != tools.toolkit)
91
114
  end
92
115
 
93
116
  # The session's toolkit for the UIs (the snapshot, command_ran): nil on
@@ -130,7 +153,7 @@ module Samagotchi
130
153
  true
131
154
  end
132
155
 
133
- # On mode's suggestion (Engine#settle_toolkit_suggestion), on the turn
156
+ # On mode's suggestion (TurnStages::ToolkitSuggestion#settle), on the turn
134
157
  # thread before #apply!: put +session+ on kit +name+ with origin
135
158
  # suggested, saved, and tell the user through #use's notice path. Not
136
159
  # when the session's toolkit isn't the default any more (a /toolkit or
@@ -157,8 +180,8 @@ module Samagotchi
157
180
  true
158
181
  end
159
182
 
160
- # On mode's ask gave the turn no suggestion to judge (Engine
161
- # #settle_toolkit_suggestion: a timeout, no answer): say, as a warn
183
+ # On mode's ask gave the turn no suggestion to judge
184
+ # (TurnStages::ToolkitSuggestion#settle: a timeout, no answer): say, as a warn
162
185
  # notice, that +session+ runs its default (not once a toolkit was
163
186
  # chosen meanwhile). +why+ is fallback_notice's.
164
187
  def fell_back(session, why)
@@ -185,6 +208,7 @@ module Samagotchi
185
208
  # again when what it declares (or its kit's prompt text) changed. A
186
209
  # name that doesn't resolve (a typo, an uninstalled bundle) declares
187
210
  # everything for now and says so once; the saved toolkit stays as it is.
211
+ # Publishes the Registry it leaves (#publish!).
188
212
  # @return [Boolean] whether the declared tools (or the kit's prompt text) changed
189
213
  def apply!(session, on_event = nil)
190
214
  errors = []
@@ -200,6 +224,7 @@ module Samagotchi
200
224
  @refresh_index.call
201
225
  @reset_prompt.call
202
226
  end
227
+ publish!
203
228
  warn_stranded(tools)
204
229
  changed
205
230
  end
@@ -214,13 +239,14 @@ module Samagotchi
214
239
 
215
240
  # A notice (by its bundle, once per tool and worker) for each plugin
216
241
  # tool that arrived mid-session on a kit that leaves it out without
217
- # the tool index to reach it (Tools::Registry#stranded_names).
242
+ # the tool index to reach it (Tools::ToolSurface#stranded, the
243
+ # Registry read now under the toolkit #apply! set).
218
244
  def warn_stranded(tools)
219
- return unless tools.respond_to?(:stranded_names)
220
-
221
- (tools.stranded_names - @stranded).each do |name|
245
+ surface = Tools::ToolSurface.for(tools)
246
+ (surface.stranded - @stranded).each do |name|
222
247
  @stranded << name
223
- @notify.call(self.class.stranded_notice(name, tools.toolkit.name), :warn, tools[name]&.source.to_s, extra: {})
248
+ @notify.call(self.class.stranded_notice(name, surface.toolkit.name), :warn,
249
+ surface.registered[name]&.source.to_s, extra: {})
224
250
  end
225
251
  end
226
252
  private :warn_stranded
@@ -10,7 +10,7 @@ module Samagotchi
10
10
  # session reaches but doesn't declare, so a find_tools hit can load it
11
11
  # and the model calls it by its own name. In order:
12
12
  # 1. the chi tools (built-ins and plugin tools) the toolkit leaves out
13
- # but doesn't remove (Registry#reachable_names), in registry order;
13
+ # but doesn't remove (ToolSurface#index_only), in registry order;
14
14
  # 2. the tool index's source tools (an MCP server's, by acts_as), only
15
15
  # while call_tool is declared (ToolIndex#deferrable: sorted by name);
16
16
  # 3. every other name the conversation loaded (a find result's
@@ -28,18 +28,17 @@ module Samagotchi
28
28
 
29
29
  module_function
30
30
 
31
- # @param registry [Registry]
31
+ # @param surface [ToolSurface] the turn's (ToolSurface#deferred_for)
32
32
  # @param conversation [Array<Hash>, nil] the request's (engine format)
33
- # @param layers [Array<Symbol>] the turn's LLM context layers
34
33
  # @return [Array<Deferred>]
35
- def list(registry, conversation, layers: [])
36
- deferred = builtins(registry, layers) + sourced(registry, layers)
34
+ def list(surface, conversation)
35
+ deferred = builtins(surface) + sourced(surface)
37
36
  taken = deferred.map(&:name)
38
37
  loaded_names(conversation).each do |name|
39
- next if taken.include?(name) || registry.offered(name, layers: layers)
38
+ next if taken.include?(name) || surface.declared_entry(name)
40
39
 
41
40
  taken << name
42
- deferred << Deferred.new(name: name, description: stub_description(registry, name), schema: STUB_SCHEMA)
41
+ deferred << Deferred.new(name: name, description: stub_description(surface, name), schema: STUB_SCHEMA)
43
42
  end
44
43
  deferred
45
44
  end
@@ -56,30 +55,27 @@ module Samagotchi
56
55
  end.uniq
57
56
  end
58
57
 
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])
58
+ def builtins(surface)
59
+ surface.index_only.map do |entry|
60
+ schema = ToolDeclarations.chat_schemas([entry.schema]).first
61
+ Deferred.new(name: entry.name, description: schema[:description].to_s, schema: schema[:parameters])
65
62
  end
66
63
  end
67
64
 
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)
65
+ def sourced(surface)
66
+ index = surface.index
67
+ return [] unless index&.deferrable && surface.declared_entry(index.call_name)
71
68
 
72
- index.deferrable.call.map do |tool|
69
+ index.deferrable.call(surface).map do |tool|
73
70
  Deferred.new(name: tool.acts_as, description: tool.description, schema: tool.schema)
74
71
  end
75
72
  end
76
73
 
77
- def stub_description(registry, name)
78
- index = registry.respond_to?(:index) ? registry.index : nil
79
- tool = index&.unreached_tool(name)
74
+ def stub_description(surface, name)
75
+ tool = surface.index&.unreached_tool(name, surface)
80
76
  return STUB_DESCRIPTION unless tool
81
77
 
82
- ToolIndex::ToolkitGroupRefusal.for(tool, registry).stub_description
78
+ ToolIndex::ToolkitGroupRefusal.for(tool, surface).stub_description
83
79
  end
84
80
 
85
81
  private_class_method :builtins, :sourced, :stub_description