samagotchi 0.154.0 → 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 (138) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -1
  3. data/docs/architecture.md +3 -2
  4. data/docs/bundles.md +21 -0
  5. data/docs/cli.md +10 -8
  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/bridge.md +18 -5
  12. data/docs/internals/engine.md +221 -100
  13. data/docs/internals/inline-widgets.md +136 -0
  14. data/docs/internals/objectives.md +8 -6
  15. data/docs/internals/participants.md +5 -5
  16. data/docs/internals/prompt-caching.md +12 -6
  17. data/docs/internals/web-frontend.md +2 -2
  18. data/docs/internals/working-set.md +2 -1
  19. data/docs/plugins.md +2 -0
  20. data/docs/releasing.md +1 -1
  21. data/docs/sessions.md +3 -3
  22. data/docs/testing.md +16 -1
  23. data/lib/samagotchi/answer_text.rb +1 -2
  24. data/lib/samagotchi/block_renderers.rb +5 -0
  25. data/lib/samagotchi/bridge/card_places.rb +56 -7
  26. data/lib/samagotchi/bridge/card_store.rb +24 -9
  27. data/lib/samagotchi/bridge.rb +9 -0
  28. data/lib/samagotchi/bridge_client.rb +9 -5
  29. data/lib/samagotchi/bundle_command/check.rb +1 -1
  30. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  31. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/btw/plugin.rb +1 -1
  33. data/lib/samagotchi/bundles/coordinator/manifest.yml +2 -2
  34. data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
  35. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  36. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  37. data/lib/samagotchi/bundles/github-pr/plugin.rb +3 -3
  38. data/lib/samagotchi/bundles/inline-widgets/manifest.yml +11 -0
  39. data/lib/samagotchi/bundles/inline-widgets/plugin.rb +76 -0
  40. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  41. data/lib/samagotchi/bundles/skills/plugin.rb +3 -2
  42. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  43. data/lib/samagotchi/desktop/macos/ChiRunner.swift +1 -1
  44. data/lib/samagotchi/engine/context_judge.rb +19 -125
  45. data/lib/samagotchi/engine/guardrails.rb +4 -2
  46. data/lib/samagotchi/engine/messages.rb +9 -6
  47. data/lib/samagotchi/engine/objectives.rb +22 -90
  48. data/lib/samagotchi/engine/participants.rb +15 -10
  49. data/lib/samagotchi/engine/plugins.rb +3 -1
  50. data/lib/samagotchi/engine/questions.rb +1 -1
  51. data/lib/samagotchi/engine/side_requests.rb +5 -2
  52. data/lib/samagotchi/engine/toolkit.rb +33 -190
  53. data/lib/samagotchi/engine/turn_end.rb +1 -1
  54. data/lib/samagotchi/engine/turn_helpers.rb +16 -39
  55. data/lib/samagotchi/engine/verify.rb +7 -143
  56. data/lib/samagotchi/engine.rb +162 -99
  57. data/lib/samagotchi/engine_peers.rb +1 -1
  58. data/lib/samagotchi/engine_snapshots.rb +9 -8
  59. data/lib/samagotchi/event_fanout.rb +18 -12
  60. data/lib/samagotchi/guardrail_wiring.rb +83 -82
  61. data/lib/samagotchi/guardrails/gate.rb +41 -40
  62. data/lib/samagotchi/guardrails/pair_boundary.rb +2 -1
  63. data/lib/samagotchi/guardrails/review_boundary.rb +2 -1
  64. data/lib/samagotchi/guardrails/targets.rb +6 -4
  65. data/lib/samagotchi/guardrails_command.rb +1 -1
  66. data/lib/samagotchi/idle_recap.rb +3 -1
  67. data/lib/samagotchi/inline_widgets.rb +325 -0
  68. data/lib/samagotchi/kernel_loop.rb +194 -216
  69. data/lib/samagotchi/llm/backend.rb +3 -1
  70. data/lib/samagotchi/llm/chat_loop.rb +66 -42
  71. data/lib/samagotchi/llm/native_backend.rb +3 -2
  72. data/lib/samagotchi/llm_context_forget.rb +5 -3
  73. data/lib/samagotchi/model_binding.rb +3 -3
  74. data/lib/samagotchi/plugin/prompt_sections.rb +7 -0
  75. data/lib/samagotchi/question_desk.rb +1 -1
  76. data/lib/samagotchi/revise_anchor.rb +1 -1
  77. data/lib/samagotchi/session_manager.rb +8 -13
  78. data/lib/samagotchi/session_retention.rb +24 -4
  79. data/lib/samagotchi/steer.rb +12 -1
  80. data/lib/samagotchi/stream_event.rb +22 -0
  81. data/lib/samagotchi/system_prompt.rb +84 -61
  82. data/lib/samagotchi/terminal_ui/attached_view.rb +0 -9
  83. data/lib/samagotchi/terminal_ui/input_support.rb +16 -15
  84. data/lib/samagotchi/terminal_ui/join_replay.rb +16 -12
  85. data/lib/samagotchi/terminal_ui/reline_seam.rb +0 -10
  86. data/lib/samagotchi/terminal_ui/status_row.rb +0 -7
  87. data/lib/samagotchi/testing/boundary.rb +7 -0
  88. data/lib/samagotchi/testing/bundle.rb +7 -5
  89. data/lib/samagotchi/testing/install.rb +3 -2
  90. data/lib/samagotchi/tool_activity.rb +8 -6
  91. data/lib/samagotchi/tool_declarations.rb +4 -4
  92. data/lib/samagotchi/tool_response.rb +4 -3
  93. data/lib/samagotchi/tool_runner.rb +93 -101
  94. data/lib/samagotchi/toolkit_binding.rb +40 -14
  95. data/lib/samagotchi/tools/deferred_tools.rb +17 -21
  96. data/lib/samagotchi/tools/registry.rb +43 -40
  97. data/lib/samagotchi/tools/tool_index.rb +135 -88
  98. data/lib/samagotchi/tools/tool_surface.rb +173 -0
  99. data/lib/samagotchi/turn_context.rb +67 -0
  100. data/lib/samagotchi/turn_flow.rb +7 -2
  101. data/lib/samagotchi/turn_stages/context_judge.rb +170 -0
  102. data/lib/samagotchi/turn_stages/objectives.rb +146 -0
  103. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +261 -0
  104. data/lib/samagotchi/turn_stages/verify_anchors.rb +169 -0
  105. data/lib/samagotchi/turn_stages/warmup.rb +83 -0
  106. data/lib/samagotchi/turn_state.rb +46 -4
  107. data/lib/samagotchi/version.rb +1 -1
  108. data/lib/samagotchi/web/app.rb +3 -1
  109. data/lib/samagotchi/web/markdown_renderer.rb +39 -14
  110. data/lib/samagotchi/web/message_display.rb +8 -1
  111. data/lib/samagotchi/web/public/app.js +5 -1
  112. data/lib/samagotchi/web/public/block_host.js +74 -199
  113. data/lib/samagotchi/web/public/block_renderers.css +2 -0
  114. data/lib/samagotchi/web/public/block_source.js +7 -4
  115. data/lib/samagotchi/web/public/block_watch.js +256 -0
  116. data/lib/samagotchi/web/public/card_stamps.js +27 -0
  117. data/lib/samagotchi/web/public/cards_place.js +5 -3
  118. data/lib/samagotchi/web/public/composer_fold.js +3 -0
  119. data/lib/samagotchi/web/public/composer_mode.js +1 -1
  120. data/lib/samagotchi/web/public/format.js +3 -2
  121. data/lib/samagotchi/web/public/index.html +28 -9
  122. data/lib/samagotchi/web/public/inline_widgets.js +146 -0
  123. data/lib/samagotchi/web/public/owner_verify.js +1 -1
  124. data/lib/samagotchi/web/public/participant.js +1 -1
  125. data/lib/samagotchi/web/public/revise_stack.js +1 -1
  126. data/lib/samagotchi/web/public/stage_watch.js +6 -2
  127. data/lib/samagotchi/web/public/text_roots.js +1 -1
  128. data/lib/samagotchi/web/public/turn_html.js +8 -4
  129. data/lib/samagotchi/web/public/verify_target.js +1 -1
  130. data/lib/samagotchi/web/public/widget_picks.js +130 -0
  131. data/lib/samagotchi/web/public/widget_walk.js +230 -0
  132. data/lib/samagotchi/web/public/widgets.css +70 -0
  133. data/lib/samagotchi/web/session_access_routes.rb +8 -0
  134. data/lib/samagotchi/web/session_show.rb +4 -0
  135. data/lib/samagotchi/worker.rb +8 -5
  136. data/lib/samagotchi.rb +0 -1
  137. metadata +19 -2
  138. data/lib/samagotchi/pending_input_queue.rb +0 -55
@@ -7,12 +7,14 @@ require_relative "file_ref"
7
7
  require_relative "guardrails"
8
8
  require_relative "vision_context"
9
9
  require_relative "degrade"
10
+ require_relative "stream_event"
10
11
  require_relative "log"
11
12
  require_relative "edit_preview"
12
13
  require_relative "tool_output_cut"
13
14
  require_relative "tool_output_files"
14
15
  require_relative "tools/output_guardrails"
15
16
  require_relative "tools/tool_path"
17
+ require_relative "tools/tool_surface"
16
18
  require_relative "tools/memory"
17
19
  require_relative "config"
18
20
  require_relative "memory_bundle/index_sync"
@@ -52,14 +54,20 @@ module Samagotchi
52
54
  NOT_AN_EDIT = Object.new.freeze
53
55
  private_constant :NOT_AN_EDIT
54
56
 
55
- # @param kernel [KernelLoop] read lazily: Engine sets its hooks after
56
- # 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.
57
64
  # @param clock [#call] monotonic seconds, for the approval wait and the
58
65
  # dispatch time (after_tool_call's elapsed_ms)
59
- # @param window [#call, nil] → ContextWindow::Resolved or nil: the
60
- # model's window, for memory_read's overflow guard. nil: the kernel's
61
- # own reader (KernelLoop#tool_output_window); a loop whose window comes
62
- # 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.
63
71
  def initialize(kernel, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, window: nil)
64
72
  @kernel = kernel
65
73
  @clock = clock
@@ -77,29 +85,30 @@ module Samagotchi
77
85
  # RowRefs; saved likewise), and diff: what an edit/write changed in its
78
86
  # file (EditPreview.change; also on tool_call_completed, never in the
79
87
  # model's output)
80
- 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:)
81
90
  # A call_tool of one of chi's own tools runs as that tool, from here
82
91
  # on (the gate, the row, the result): rules, approvals and hooks see
83
92
  # the real call, with via: (Tools::ToolIndex#unwrap).
84
- call = unwrapped(call)
85
- 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))
86
95
  # The gate runs first, so tool_call_started shows the call that runs.
87
96
  # A tool the session's toolkit leaves out skips it: no before_tool_call
88
97
  # hook, no approval card for a call that can't run (KernelLoop#dispatch
89
98
  # answers it with the not-loaded error).
90
99
  original = call.dup
91
- refusal = unreached_group_refusal(call)
100
+ refusal = unreached_group_refusal(call, context)
92
101
  verdict = if refusal
93
102
  Guardrails::Verdict.new(call: call).deny!(refusal.message,
94
103
  rule: Tools::ToolIndex::ToolkitGroupRefusal::RULE,
95
104
  source: "core", decided_by: "core")
96
- elsif left_out?(call)
105
+ elsif left_out?(call, context)
97
106
  Guardrails::Verdict.new(call: call)
98
107
  else
99
- evaluate(call, iteration, params)
108
+ evaluate(call, context, iteration, params)
100
109
  end
101
110
  call = verdict.call
102
- params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
111
+ params = ToolActivity.tool_activity_params(call[:name], call, registry: tools, surface: surface(context))
103
112
  label = plugin_label(call[:name])
104
113
  # Kept on the raw event for in-worker hooks and metrics, not sent to SSE clients.
105
114
  started = { type: :tool_call_started, iteration: iteration, call_count: call_count, call_index: call_index,
@@ -109,7 +118,7 @@ module Samagotchi
109
118
  # plugin tool's refs); the worker runs in its session's working
110
119
  # directory.
111
120
  fields = ToolRowFields.for(call[:name], call, cwd: Dir.pwd)
112
- 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))
113
122
  fields[:refs] = refs unless refs.empty?
114
123
  started.merge!(fields)
115
124
  emit(on_stream_event, started)
@@ -120,7 +129,7 @@ module Samagotchi
120
129
  waited_ms = nil
121
130
  if verdict.ask?
122
131
  asked_at = @clock.call
123
- settle_ask(verdict)
132
+ settle_ask(verdict, context)
124
133
  waited_ms = ((@clock.call - asked_at) * 1000).round
125
134
  end
126
135
  before = verdict.deny? ? NOT_AN_EDIT : file_before(call)
@@ -128,10 +137,10 @@ module Samagotchi
128
137
  entry_before = before.equal?(NOT_AN_EDIT) ? nil : MemoryBundle::EntrySize.measure_file(Tools::ToolPath.normalize(call[:path]))
129
138
  elapsed_ms = nil
130
139
  if verdict.deny?
131
- result = denied(call, verdict)
140
+ result = denied(call, verdict, context)
132
141
  else
133
142
  dispatched_at = @clock.call
134
- result = dispatch(call)
143
+ result = dispatch(call, context)
135
144
  elapsed_ms = ((@clock.call - dispatched_at) * 1000).round
136
145
  end
137
146
  diff = file_change(call, before)
@@ -140,7 +149,7 @@ module Samagotchi
140
149
  result = with_entry_note(result, entry_before, call)
141
150
  end
142
151
  result = approved(result, verdict) if verdict.allow? && verdict.decided_by
143
- result, images = attach_images(call, result) if result[:images]
152
+ result, images = attach_images(call, result, context.settings&.vision) if result[:images]
144
153
 
145
154
  output = scrub(result[:output].to_s)
146
155
  # The tool's own text, for after_tool_call: no "[tool]" lead, no
@@ -148,12 +157,12 @@ module Samagotchi
148
157
  tool_text = output.sub(/\A\[#{Regexp.escape(call[:name].to_s)}\](?:\n| )/, "")
149
158
  # The gate may have replaced the call (known-names corrects a near
150
159
  # miss): one leading line tells the model what actually ran.
151
- if (changed = changed_call_line(original, call))
160
+ if (changed = changed_call_line(original, call, context))
152
161
  output = "#{changed}\n#{output}"
153
162
  end
154
- cap = output_cap(call, max_tool_output_chars)
163
+ cap = output_cap(call, max_tool_output_chars, context)
155
164
  truncated = !cap.nil? && output.length > cap
156
- capped, saved = truncated ? cut(call, output, cap, result) : output
165
+ capped, saved = truncated ? cut(call, output, cap, result, context) : output
157
166
  output_file = saved.path if saved.is_a?(ToolOutputFiles::Saved)
158
167
 
159
168
  # The call's outcome as its activity line has it (worked out from the
@@ -179,7 +188,7 @@ module Samagotchi
179
188
  images: images&.any? ? images : nil,
180
189
  shown_params: params && plugin_tool?(call[:name]) ? params : nil,
181
190
  shown_label: label, refs: refs.empty? ? nil : refs, diff: diff, output_file: output_file,
182
- loaded: result[:loaded], stamp: status == "ok" ? file_stamp(call) : nil)
191
+ loaded: result[:loaded], stamp: status == "ok" ? file_stamp(call, context) : nil)
183
192
  end
184
193
 
185
194
  private
@@ -188,8 +197,8 @@ module Samagotchi
188
197
  # {path:, mtime_ns:, size:}, the path as FileRef has it (against the
189
198
  # session's folder, no realpath). nil for any other tool, a path it
190
199
  # can't place and a stat that fails or isn't a file's.
191
- def file_stamp(call)
192
- 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))
193
202
  return nil unless ref
194
203
 
195
204
  stat = File.stat(ref.path)
@@ -208,8 +217,9 @@ module Samagotchi
208
217
  # for task_wait and delegate; ref_stderr_path, a task's stderr log
209
218
  # @return [Array(String, Object)] the text, and the saved file (or why
210
219
  # there is none) when it was cut that way
211
- def cut(call, output, cap, result)
212
- 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) },
213
223
  line: (memory_cut_line(call) if call[:name] == MEMORY_READ),
214
224
  ref: result[:ref_path], ref_stderr: result[:ref_stderr_path],
215
225
  head_chars: Tools::OutputGuardrails.config_positive_int("execute.cut_head_chars",
@@ -222,11 +232,11 @@ module Samagotchi
222
232
  # for every tool but memory_read, which memory.read_max_chars caps (0:
223
233
  # no fixed cap) and, with none set, a known window's half (#window_guard).
224
234
  # A nil +cap+ stays nil.
225
- def output_cap(call, cap)
235
+ def output_cap(call, cap, context)
226
236
  return cap unless cap && call[:name] == MEMORY_READ
227
237
 
228
238
  fixed = Config.get("memory.read_max_chars").to_i
229
- fixed.positive? ? fixed : window_guard
239
+ fixed.positive? ? fixed : window_guard(context.settings)
230
240
  end
231
241
 
232
242
  # Half the model's context window in characters, the most one memory_read
@@ -234,9 +244,9 @@ module Samagotchi
234
244
  # and a retry repeats the read). nil when the window isn't known (the
235
245
  # default isn't a window) or can't be read. It checks this result, not the
236
246
  # context already used.
237
- def window_guard
247
+ def window_guard(settings)
238
248
  Degrade.call(:memory, "read_window_unknown") do
239
- 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))
240
250
  return nil unless window&.tokens&.positive? && window.source != :default
241
251
 
242
252
  (window.tokens * MemoryBundle::IndexSize.chars_per_token / 2).floor
@@ -253,12 +263,12 @@ module Samagotchi
253
263
  end
254
264
 
255
265
  # The whole output in the session's tool_output folder
256
- # (ToolOutputFiles), or why it isn't saved: no session (a bare kernel)
257
- # or the error's class. Never fails the call. +dry+: where it would go,
258
- # 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).
259
270
  # @return [ToolOutputFiles::Saved, String]
260
- def save_output(call, text, dry: false)
261
- dir = session_dir
271
+ def save_output(call, text, dir, dry: false)
262
272
  return "no session" if dir.to_s.empty?
263
273
  return ToolOutputFiles.planned(dir, text, tool: call[:name]) if dry
264
274
 
@@ -268,15 +278,6 @@ module Samagotchi
268
278
  e.class.name
269
279
  end
270
280
 
271
- # The session's folder (KernelLoop#session_dir), nil for a bare kernel
272
- # or a lookup that failed.
273
- def session_dir
274
- @kernel.respond_to?(:session_dir) ? @kernel.session_dir&.call : nil
275
- rescue StandardError => e
276
- Log.warn(:turn, "tool_output_save_failed", error: e.class.name)
277
- nil
278
- end
279
-
280
281
  # edit/write only: the file just before the call runs. The row diffs it
281
282
  # with the file after, whatever the result says, so an edit that fails
282
283
  # after writing still shows its change and one that wrote nothing shows
@@ -350,8 +351,8 @@ module Samagotchi
350
351
  # and keeps the text and the other images; for read, whose text only
351
352
  # says the image is attached, that line replaces the text.
352
353
  # @return [Array(Hash, Array<Hash>)] the result and its refs
353
- def attach_images(call, result)
354
- vision = @kernel.turn_settings.vision
354
+ # +vision+: the turn's VisionContext (TurnSettings#vision).
355
+ def attach_images(call, result, vision)
355
356
  reason = if vision.nil? then "images can't be attached here"
356
357
  elsif !vision.sendable? then ImagePlan::CANT_SEE
357
358
  end
@@ -396,17 +397,19 @@ module Samagotchi
396
397
  [result[:output], *notes].join("\n")
397
398
  end
398
399
 
399
- # The session's folder (the Engine's, KernelLoop#session_cwd), else the
400
+ # The session's folder (the turn's TurnContext#cwd), else the
400
401
  # process's: a bare kernel (specs) has none.
401
- def session_cwd
402
- cwd = @kernel.session_cwd&.call if @kernel.respond_to?(:session_cwd)
402
+ def session_cwd(context)
403
+ cwd = context.cwd
403
404
  cwd.to_s.empty? ? Dir.pwd : cwd
404
405
  end
405
406
 
406
407
  # The kernel's tools, for the activity line of a tool that isn't built in.
407
408
  def tools = @kernel.tools
408
409
 
409
- 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)
410
413
 
411
414
  # The call its tool's entry unwraps it to (call_tool on a built-in),
412
415
  # else the call as it is. On a host that loads tools, a direct call of
@@ -414,12 +417,12 @@ module Samagotchi
414
417
  # (Registry::Index#wrap): call_tool's row, rules and approvals. One
415
418
  # that fails runs as it is: call_tool then refuses a built-in (fail
416
419
  # closed).
417
- def unwrapped(call)
418
- registry = tools
419
- return call unless registry.respond_to?(:offered) && !call[:name].nil?
420
+ def unwrapped(call, context)
421
+ return call if tools.nil? || call[:name].nil?
420
422
 
421
- call = wrapped(registry, call)
422
- 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
423
426
  rescue StandardError => e
424
427
  Log.warn(:turn, "tool_unwrap_failed", tool: call[:name].to_s, error: "#{e.class}: #{e.message}")
425
428
  call
@@ -427,11 +430,11 @@ module Samagotchi
427
430
 
428
431
  # +call+ as the call_tool call it runs as when it names a deferred
429
432
  # source tool, on a host that loads tools; else as it is.
430
- def wrapped(registry, call)
431
- wrap = registry.respond_to?(:index) ? registry.index&.wrap : nil
432
- 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])
433
436
 
434
- wrap.call(call) || call
437
+ wrap.call(call, surface) || call
435
438
  end
436
439
 
437
440
  # A call that can't run in this turn as it is, by its name: a real tool
@@ -439,23 +442,25 @@ module Samagotchi
439
442
  # (via:) that the toolkit doesn't reach that way (a hook's rewrite). On
440
443
  # a host that loads tools, a built-in the toolkit reaches runs by its
441
444
  # own name (KernelLoop#dispatch takes it too).
442
- def left_out?(call)
443
- registry = tools
444
- return false unless registry.respond_to?(:left_out) && !call[:name].nil?
445
- return registry.indexed(call[:name], layers: layers).nil? if call[:via]
446
- 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])
447
451
 
448
- !registry.left_out(call[:name], layers: layers).nil?
452
+ surface.left_out?(call[:name])
449
453
  end
450
454
 
451
- def unreached_group_refusal(call)
455
+ def unreached_group_refusal(call, context)
452
456
  # A review turn's boundary denies it first (and words why); a pair
453
457
  # turn gets the owner's refusal.
454
- return if participant_turn? && participant_mode != ParticipantMode::PAIR
458
+ return if context.participant? && context.participant_mode != ParticipantMode::PAIR
459
+ return if tools.nil?
455
460
 
456
- registry = tools
457
- index = registry&.index if registry.respond_to?(:index)
458
- return unless index.respond_to?(:unreached_tool)
461
+ surface = surface(context)
462
+ index = surface.index
463
+ return unless index
459
464
 
460
465
  name = if call[:name] == index.call_name
461
466
  args = call[:args].is_a?(Hash) ? call[:args] : {}
@@ -463,24 +468,10 @@ module Samagotchi
463
468
  else
464
469
  call[:name]
465
470
  end
466
- tool = index.unreached_tool(name)
467
- tool && Tools::ToolIndex::ToolkitGroupRefusal.for(tool, registry)
471
+ tool = index.unreached_tool(name, surface)
472
+ tool && Tools::ToolIndex::ToolkitGroupRefusal.for(tool, surface)
468
473
  end
469
474
 
470
- def participant_turn?
471
- @kernel.respond_to?(:participant_turn?) && @kernel.participant_turn?
472
- end
473
-
474
- # The running participant turn's mode (KernelLoop#participant_mode,
475
- # GuardrailWiring#turn_mode), nil without one.
476
- def participant_mode
477
- lookup = @kernel.respond_to?(:participant_mode) ? @kernel.participant_mode : nil
478
- lookup&.call
479
- end
480
-
481
- # Whether the turn's host loads tools (TurnSettings#loads_tools).
482
- def loads_tools? = @kernel.respond_to?(:turn_settings) && @kernel.turn_settings&.loads_tools == true
483
-
484
475
  # A tool the registry has from a plugin (not core, not unknown).
485
476
  def plugin_tool?(name)
486
477
  entry = tools && !name.nil? ? tools[name] : nil
@@ -494,29 +485,30 @@ module Samagotchi
494
485
  # asked for: "ran as: <tool> <changed args>", or nil when nothing
495
486
  # changed. The args are the changed call's activity params, so the line
496
487
  # stays short.
497
- def changed_call_line(original, call)
488
+ def changed_call_line(original, call, context)
498
489
  return nil if original == call
499
490
 
500
- params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
491
+ params = ToolActivity.tool_activity_params(call[:name], call, registry: tools, surface: surface(context))
501
492
  ["ran as: #{call[:name]}", params].compact.join(" ")
502
493
  end
503
494
 
504
- # The Engine sets the kernel's gate (its context, later the approval
505
- # 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.
506
498
  def gate
507
- @kernel.guardrail_gate || (@gate ||= Guardrails::Gate.new(-> { @kernel.hooks }, tools_lookup: -> { tools }))
499
+ @kernel.guardrail_gate || Guardrails::Gate.new(hooks: @kernel.hooks, tools: tools)
508
500
  end
509
501
 
510
502
  # A gate that fails denies the call (fail closed).
511
- def evaluate(call, iteration, params)
512
- gate.evaluate(call, iteration: iteration, params: params)
503
+ def evaluate(call, context, iteration, params)
504
+ gate.evaluate(call, context: context, iteration: iteration, params: params)
513
505
  rescue StandardError => e
514
506
  Guardrails::Verdict.new(call: call).deny!("the guardrail check failed: #{e.class}: #{e.message}",
515
507
  decided_by: "core")
516
508
  end
517
509
 
518
- def settle_ask(verdict)
519
- gate.settle_ask(verdict)
510
+ def settle_ask(verdict, context)
511
+ gate.settle_ask(verdict, context: context)
520
512
  rescue StandardError => e
521
513
  verdict.settle!(:deny, decided_by: "core", note: "The approval failed (#{e.class}: #{e.message}).")
522
514
  end
@@ -530,10 +522,10 @@ module Samagotchi
530
522
 
531
523
  # A legacy veto keeps its old text; a verdict's deny tells the model
532
524
  # who decided and not to route around it.
533
- def denied(call, verdict)
525
+ def denied(call, verdict, context)
534
526
  if verdict.rule == Tools::ToolIndex::ToolkitGroupRefusal::RULE
535
527
  output = "[#{call[:name]}] #{verdict.reason}"
536
- 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))
537
529
  return { output: output, activity: activity.merge(status: "blocked", guardrail: verdict.to_activity) }
538
530
  end
539
531
 
@@ -543,14 +535,14 @@ module Samagotchi
543
535
  else
544
536
  "[#{call[:name]}] Error: #{verdict.deny_text}"
545
537
  end
546
- 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))
547
539
  { output: output, activity: activity.merge(status: "blocked", guardrail: verdict.to_activity) }
548
540
  end
549
541
 
550
542
  # KernelLoop#dispatch turns tool errors into "[name] Error: …" itself;
551
543
  # this rescue only catches a failing dispatcher.
552
- def dispatch(call)
553
- @kernel.dispatch_tool_call(call)
544
+ def dispatch(call, context)
545
+ @kernel.dispatch_tool_call(call, context)
554
546
  rescue StandardError => e
555
547
  { output: "[#{call[:name]}] Error: #{e.class}: #{e.message}", activity: nil }
556
548
  end
@@ -562,7 +554,7 @@ module Samagotchi
562
554
 
563
555
  # A failing stream callback (a UI gone) must not break the call either.
564
556
  def emit(callback, event)
565
- Degrade.call(:turn, "tool_event_callback_failed", type: (event[:type] if event.is_a?(Hash))) { callback&.call(event) }
557
+ StreamEvent.emit(callback, event, failure: "tool_event_callback_failed")
566
558
  end
567
559
  end
568
560
  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