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
@@ -19,9 +19,11 @@ module Samagotchi
19
19
  # in +messages+ (Engine#run_turn resume_from:); the replies from there on
20
20
  # keep the model's reasoning (thought blocks), as within one run. nil:
21
21
  # the run starts here, as every other turn's does.
22
+ # +context+: the run's turn (TurnContext, Engine#generate's), handed down
23
+ # the tool path explicitly; nil: the backend's own (a bare kernel's).
22
24
  class ModelBackend
23
25
  def complete(messages:, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil,
24
- model_name: nil, max_tool_output_chars: nil, pending_input: nil, reasoning_since: nil)
26
+ model_name: nil, max_tool_output_chars: nil, pending_input: nil, reasoning_since: nil, context: nil)
25
27
  raise NotImplementedError, "#{self.class}#complete must be implemented"
26
28
  end
27
29
  end
@@ -24,7 +24,7 @@ require_relative "../tool_runner"
24
24
  require_relative "../tool_response"
25
25
  require_relative "../tool_ids"
26
26
  require_relative "../tool_declarations"
27
- require_relative "../tools/deferred_tools"
27
+ require_relative "../tools/tool_surface"
28
28
  require_relative "../tool_loads"
29
29
  require_relative "../vision_context"
30
30
  require_relative "../log"
@@ -79,8 +79,11 @@ module Samagotchi
79
79
  # prompt's part every session shares (SystemPrompt#stable_length), or nil
80
80
  # @param sleeper [#call, nil] waits the given seconds before a dropped
81
81
  # stream's step is asked again (specs pass one that doesn't sleep)
82
- def initialize(kernel:, adapter: nil, stable_length: nil, sleeper: nil)
82
+ # @param context [TurnContext, nil] the turn its helpers read before any
83
+ # #complete (a bench building wire messages); a run's replaces it
84
+ def initialize(kernel:, adapter: nil, stable_length: nil, sleeper: nil, context: nil)
83
85
  @kernel = kernel
86
+ @turn_context = context
84
87
  @adapter = adapter
85
88
  @stable_length = stable_length
86
89
  @sleeper = sleeper || ->(seconds) { sleep(seconds) }
@@ -93,10 +96,14 @@ module Samagotchi
93
96
 
94
97
  # +reasoning_since+ (ModelBackend#complete): the resumed run's start,
95
98
  # the Run's own start then (Run#initialize, #reasoning_boundary).
99
+ # +context+: the run's turn (TurnContext, Engine#generate's): its
100
+ # settings are what the requests send, its tool calls run in it; a
101
+ # bare loop's (#turn_context) when none is given.
96
102
  def complete(messages:, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil,
97
- model_name: nil, max_tool_output_chars: nil, pending_input: nil, reasoning_since: nil)
103
+ model_name: nil, max_tool_output_chars: nil, pending_input: nil, reasoning_since: nil, context: nil)
98
104
  raise ArgumentError, "ChatLoop has no adapter" unless @adapter
99
105
 
106
+ @turn_context = context if context
100
107
  conversation = Array(messages).map(&:dup)
101
108
  Run.new(self, conversation, on_stream_event, cancel_controller, model_name, pending_input, start: reasoning_since)
102
109
  .call(max_iterations: max_iterations || 1, cap: KernelLoop.resolve_output_char_cap(max_tool_output_chars))
@@ -108,14 +115,14 @@ module Samagotchi
108
115
  end
109
116
 
110
117
  # Tool definitions for the request: the schemas the native prompts are
111
- # rendered from (the kernel's registry, the tools the turn's LLM
112
- # context layers offer), with the chat-only enums and closed
113
- # parameters. On an adapter that loads tools for +model+
114
- # (#loads_tools?), every tool the session reaches but doesn't declare
115
- # follows, deferred (Tools::DeferredTools: +conversation+, the
118
+ # rendered from (the turn's ToolSurface: what it declares), with the
119
+ # chat-only enums and closed parameters. On an adapter that loads
120
+ # tools for +model+ (#loads_tools?, asked per request: a refusal turns
121
+ # it off mid-turn), every tool the session reaches but doesn't declare
122
+ # follows, deferred (ToolSurface#deferred_for: +conversation+, the
116
123
  # request's, names the ones it loaded); never without a declared one.
117
124
  def tool_definitions(conversation = nil, model: nil)
118
- declared = ToolDeclarations.chat_schemas(tools.schemas(layers: llm_context_layers)).map do |schema|
125
+ declared = ToolDeclarations.chat_schemas(surface.schemas).map do |schema|
119
126
  { type: "function", function: schema.slice(:name, :description, :parameters) }
120
127
  end
121
128
  return declared if declared.empty? || !loads_tools?(model)
@@ -127,10 +134,13 @@ module Samagotchi
127
134
  end
128
135
 
129
136
  # The tools a request on a host that loads tools declares deferred
130
- # (Tools::DeferredTools).
131
- def deferred_tools(conversation)
132
- Tools::DeferredTools.list(tools, conversation, layers: llm_context_layers)
133
- end
137
+ # (ToolSurface#deferred_for).
138
+ def deferred_tools(conversation) = surface.deferred_for(conversation)
139
+
140
+ # The turn's ToolSurface (#turn_context's), else one computed now from
141
+ # the kernel's tools (a bare loop).
142
+ # @return [Tools::ToolSurface]
143
+ def surface = Tools::ToolSurface.of(turn_context, tools)
134
144
 
135
145
  # Whether the adapter takes tools it doesn't declare up front for
136
146
  # +model+ (AnthropicMessages#loads_tools?); OpenAIChat doesn't.
@@ -147,22 +157,33 @@ module Samagotchi
147
157
  # The kernel's tools.
148
158
  def tools = @kernel.tools
149
159
 
150
- # The turn's LLM context layers (KernelLoop#llm_context_layers).
151
- def llm_context_layers = @kernel.llm_context_layers
160
+ # The TurnContext of the run in progress, else of the last run
161
+ # (#complete's context:); before any, the one it was built with, else a
162
+ # context of the kernel's own settings (a bare loop). Read on the turn's thread only.
163
+ # @return [TurnContext]
164
+ def turn_context = @turn_context || TurnContext.none(settings: @kernel.turn_settings)
165
+
166
+ # The turn's LLM::TurnSettings (#turn_context's).
167
+ def settings = turn_context.settings
168
+
169
+ # The turn's LLM context layers (TurnContext#llm_context_layers).
170
+ def llm_context_layers = turn_context.llm_context_layers
152
171
 
153
172
  # The turn's LLM context strategy (LLMContextStrategy::Resolved), nil
154
173
  # when none was resolved.
155
- def llm_context = @kernel.turn_settings&.llm_context
174
+ def llm_context = settings&.llm_context
156
175
 
157
176
  # KernelLoop#reprefilled_tokens, the kernel's request history.
158
177
  def reprefilled_tokens(model, **) = @kernel.reprefilled_tokens(model, **)
159
178
 
160
- # The kernel's LLMContextView, the turn's strategy.
161
- def llm_context_view = @kernel.llm_context_view
179
+ # The kernel's LLMContextView under the turn's strategy.
180
+ def llm_context_view = @kernel.llm_context_view(llm_context)
162
181
 
163
182
  # The strategy's own edits before a request and at turn end
164
- # (KernelLoop#apply_llm_context!).
165
- def apply_llm_context!(conversation, **) = @kernel.apply_llm_context!(conversation, **)
183
+ # (KernelLoop#apply_llm_context!), under the turn's strategy.
184
+ def apply_llm_context!(conversation, **)
185
+ @kernel.apply_llm_context!(conversation, llm_context, model: settings&.model_name, **)
186
+ end
166
187
 
167
188
  # The running turn, for forget_outputs (KernelLoop#llm_context_turn).
168
189
  def llm_context_turn=(turn)
@@ -267,7 +288,7 @@ module Samagotchi
267
288
  TOOL_IMAGES_TEXT = "[images from tool results]"
268
289
 
269
290
  # Whether this turn's requests carry tool changes (the turn's model).
270
- def tool_change_wire? = tool_changes?(@kernel.turn_settings&.model_name)
291
+ def tool_change_wire? = tool_changes?(settings&.model_name)
271
292
 
272
293
  # The first index whose model entries carry their reasoning (nil:
273
294
  # none do) under +level+. Under turn: +since+ when given, where a
@@ -298,14 +319,14 @@ module Samagotchi
298
319
  conversation.drop(boundary).sum { |entry| entry[:role].to_s == "model" && thought?(entry) ? entry[:thinking].length : 0 }
299
320
  end
300
321
 
301
- # The turn's ReasoningHistory level (the kernel's turn_settings); drop
322
+ # The turn's ReasoningHistory level (the turn's settings); drop
302
323
  # without settings, and for a +model+ whose host refused it
303
324
  # (#reasoning_history_refused!). An adapter that sends its blocks back
304
325
  # (#replays_content?) is floored at turn: the running run's thinking
305
326
  # blocks always go back (a tool loop with thinking needs the last
306
327
  # assistant turn's), whatever the level says.
307
328
  def reasoning_history(model = nil)
308
- level = model && @refusals.refused?(:reasoning_history, model) ? :drop : @kernel.turn_settings&.reasoning_history
329
+ level = model && @refusals.refused?(:reasoning_history, model) ? :drop : settings&.reasoning_history
309
330
  level ||= :drop
310
331
  level == :drop && replays_content? ? :turn : level
311
332
  end
@@ -320,18 +341,17 @@ module Samagotchi
320
341
  @refusals.remember(:reasoning_history, model)
321
342
  end
322
343
 
323
- # The turn's VisionContext (the Engine sets the kernel's
324
- # turn_settings), or nil.
325
- def vision = @kernel.turn_settings.vision
344
+ # The turn's VisionContext (its settings'), or nil.
345
+ def vision = settings.vision
326
346
 
327
- # The turn's configured price (the kernel's turn_settings), or nil.
328
- def price = (@kernel.turn_settings&.price if @kernel.respond_to?(:turn_settings))
347
+ # The turn's configured price (its settings'), or nil.
348
+ def price = settings&.price
329
349
 
330
- # The turn's request parameters (the kernel's turn_settings).
331
- def sampling = @kernel.turn_settings.sampling || {}
350
+ # The turn's request parameters (its settings').
351
+ def sampling = settings.sampling || {}
332
352
 
333
- # The turn's thinking level (the kernel's turn_settings).
334
- def thinking = @kernel.turn_settings.thinking || Thinking::DEFAULT
353
+ # The turn's thinking level (its settings').
354
+ def thinking = settings.thinking || Thinking::DEFAULT
335
355
 
336
356
  # The request fields the thinking level adds, as the adapter's wire
337
357
  # takes them (OpenAIChat#thinking_fields: Thinking.chat_fields); none
@@ -375,24 +395,24 @@ module Samagotchi
375
395
  def strip_model_thought(text) = @kernel.strip_model_thought(text)
376
396
 
377
397
  def tool_runner
378
- @tool_runner ||= ToolRunner.new(@kernel, window: -> { context_window(@kernel.turn_settings&.model_name) })
398
+ @tool_runner ||= ToolRunner.new(@kernel, window: ->(run_settings) { context_window(run_settings&.model_name, run_settings) })
379
399
  end
380
400
 
381
401
  # The window for +model+: the kernel's client probes the server it
382
402
  # points at (Engine keeps it on the effective model's host; a local
383
403
  # llama.cpp answers /props), then the host's model list, the model's
384
404
  # or host's window_tokens, config, env and the default. A remote
385
- # provider has no /props to probe.
386
- def context_window(model)
405
+ # provider has no /props to probe. +run_settings+: the LLM::TurnSettings
406
+ # whose window_setting counts (the turn's by default).
407
+ def context_window(model, run_settings = settings)
387
408
  client = @adapter.remote? ? nil : @kernel.client
388
- ContextWindow.resolve(client: client, model: model, adapter: @adapter,
389
- setting: (@kernel.turn_settings&.window_setting if @kernel.respond_to?(:turn_settings)))
409
+ ContextWindow.resolve(client: client, model: model, adapter: @adapter, setting: run_settings&.window_setting)
390
410
  rescue StandardError
391
411
  nil
392
412
  end
393
413
 
394
- # The kernel's generation guard (KernelLoop#guard_generation).
395
- def guard_generation = @kernel.respond_to?(:guard_generation) ? @kernel.guard_generation : nil
414
+ # The kernel's generation guard for the turn (KernelLoop#guard_generation).
415
+ def guard_generation = @kernel.respond_to?(:guard_generation) ? @kernel.guard_generation(turn_context) : nil
396
416
 
397
417
  # A hook failing must not break the turn (as in ToolRunner).
398
418
  def fire_hook(name, event)
@@ -609,8 +629,10 @@ module Samagotchi
609
629
  @deferred = []
610
630
  @context = ContextStatus.new(conversation: conversation, llm_context: loop.llm_context,
611
631
  counted_chars_per_token: loop.counted_chars_per_token)
632
+ # The run's turn: its tool calls run in it.
633
+ @turn_context = loop.turn_context
612
634
  loop.llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: @context,
613
- emit: method(:emit))
635
+ emit: method(:emit), turn_id: @turn_context.id)
614
636
  end
615
637
 
616
638
  def call(max_iterations:, cap:)
@@ -1094,7 +1116,7 @@ module Samagotchi
1094
1116
  def dispatch(tool_calls, iteration, cap)
1095
1117
  log_direct_deferred(tool_calls)
1096
1118
  calls = tool_calls.map { |tool_call| NativeToolNormalizer.normalize(tool_call) }
1097
- runs = ToolResponse.run_batch(@loop.tool_runner, calls, iteration: iteration, emit: method(:emit),
1119
+ runs = ToolResponse.run_batch(@loop.tool_runner, calls, context: @turn_context, iteration: iteration, emit: method(:emit),
1098
1120
  on_stream_event: @on_stream_event, cap: cap) do |run, index|
1099
1121
  @conversation << ToolResponse.single(run, tool_call_id: tool_calls[index].id,
1100
1122
  ids: ToolIds.next_ids(@conversation, 1))
@@ -17,7 +17,7 @@ module Samagotchi
17
17
  def provider = :native
18
18
 
19
19
  def complete(messages:, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil,
20
- model_name: nil, max_tool_output_chars: nil, pending_input: nil, reasoning_since: nil)
20
+ model_name: nil, max_tool_output_chars: nil, pending_input: nil, reasoning_since: nil, context: nil)
21
21
  @kernel.run(
22
22
  messages,
23
23
  max_iterations: max_iterations,
@@ -26,7 +26,8 @@ module Samagotchi
26
26
  model_name: model_name,
27
27
  max_tool_output_chars: max_tool_output_chars,
28
28
  pending_input: pending_input,
29
- reasoning_since: reasoning_since
29
+ reasoning_since: reasoning_since,
30
+ context: context || @kernel.context
30
31
  )
31
32
  end
32
33
  end
@@ -52,9 +52,11 @@ module Samagotchi
52
52
  # The turn a forget_outputs call runs in: its conversation (the
53
53
  # entries the edits are saved on), its ContextStatus (nil: none) and
54
54
  # its stream (+emit+, nil: none), which gets an applied batch's ✂ row
55
- # (LLMContextNotice).
56
- Turn = Data.define(:conversation, :context, :emit) do
57
- def initialize(emit: nil, **fields) = super
55
+ # (LLMContextNotice); +turn_id+: the Engine turn the run is for
56
+ # (TurnContext#id; nil: none), so a reader tells the running turn's
57
+ # conversation from the last one's (Engine#live_conversation).
58
+ Turn = Data.define(:conversation, :context, :emit, :turn_id) do
59
+ def initialize(emit: nil, turn_id: nil, **fields) = super
58
60
  end
59
61
 
60
62
  # One output a call may name: its entry, its ToolIds ref, its tool,
@@ -8,7 +8,7 @@ module Samagotchi
8
8
  # An Engine's model choice (ModelChoice: the model as given and as typed)
9
9
  # and the prompt profile resolved for it, under one mutex; the counterpart
10
10
  # of ToolkitBinding. The memory overlay keys follow the choice
11
- # (#sync_model_key!).
11
+ # (#sync_model_key!), which each turn's TurnContext takes.
12
12
  #
13
13
  # The choice changes in one assignment under the mutex (#switch!), so a
14
14
  # reader on another thread sees the old pair or the new one. A resolution
@@ -126,13 +126,13 @@ module Samagotchi
126
126
  # The memory overlay key follows the model sent (ModelOverlay.key_for
127
127
  # its id). A model typed as an alias used to key overlays by the alias
128
128
  # (before aliases were applied there): that key is read when the
129
- # target's overlay is missing (KernelLoop#sync_model_key!).
129
+ # target's overlay is missing. A turn takes both in its TurnContext
130
+ # (Engine#generate): memory_read and the gate read them there.
130
131
  def sync_model_key!
131
132
  choice = @choice
132
133
  @model_key = ModelOverlay.key_for(@bare_name.call(choice.name))
133
134
  typed_key = ModelOverlay.key_for(@registry.parse_qualified_model(choice.typed).last)
134
135
  @fallback_model_key = typed_key == @model_key ? nil : typed_key
135
- @kernel.call.sync_model_key!(@model_key, fallback: @fallback_model_key)
136
136
  end
137
137
  end
138
138
  end
@@ -63,6 +63,13 @@ module Samagotchi
63
63
  @mutex.synchronize { @sections.any? { |section| section.bundle == bundle.to_s && section.name == name.to_s } }
64
64
  end
65
65
 
66
+ # The names +bundle+ registered, in prompt order (what it registered, for
67
+ # Testing::Install and chi bundle check).
68
+ # @return [Array<String>]
69
+ def names(bundle)
70
+ @mutex.synchronize { @sections.select { |section| section.bundle == bundle.to_s }.map(&:name) }
71
+ end
72
+
66
73
  # Run every block for +session_id+ and keep what they gave.
67
74
  # @param session_id [String, nil] whose last good text a failure keeps
68
75
  # @return [Array<Built>] in prompt order, the sections with text
@@ -118,7 +118,7 @@ module Samagotchi
118
118
  end
119
119
 
120
120
  # Ask the model's question (ask_user_question): the kernel's
121
- # question_handler, called on the turn thread with the payload
121
+ # Services#question_handler, called on the turn thread with the payload
122
122
  # Tools::AskUserQuestion.validate made. Opens it (#open_question) and
123
123
  # returns the answer as JSON for the tool result.
124
124
  # @param payload [Hash] {question:, options:, header:, multi_select:, allow_freeform:},
@@ -5,7 +5,7 @@ require_relative "verify_anchor"
5
5
  module Samagotchi
6
6
  # The earlier turn a Revise turn rewrites the answer of, carried only by
7
7
  # a Revise turn (TurnAnchors). Its stamp on the turn's opening message
8
- # is display metadata, never in the prompt (Engine#revision_target).
8
+ # is display metadata, never in the prompt (TurnStages::VerifyAnchors#revision_target).
9
9
  ReviseAnchor = Data.define(:turn_id) do
10
10
  def self.from(value)
11
11
  return value if value.is_a?(self)
@@ -8,6 +8,7 @@ require_relative "memory_paths"
8
8
  require_relative "paths"
9
9
  require_relative "tool_declarations"
10
10
  require_relative "tools/memory"
11
+ require_relative "tools/tool_surface"
11
12
  require_relative "muted_memories"
12
13
  require_relative "model_notes"
13
14
  require_relative "bundle_needs"
@@ -46,7 +47,8 @@ module Samagotchi
46
47
  attr_reader :requested_memories
47
48
 
48
49
  # @param profile [#call] → ModelProfile
49
- # @param tools [#call] → Tools::Registry
50
+ # @param tools [#call] → Tools::Registry, nil (no tools): each build
51
+ # reads it as a Tools::ToolSurface under the build's layers
50
52
  # @param session [#call] → Session, nil
51
53
  # @param thinking [#call] → Symbol, the effective model's level (Thinking)
52
54
  # @param model [#call] → ModelIdentity, nil (the section is left out)
@@ -91,8 +93,10 @@ module Samagotchi
91
93
 
92
94
  # A prompt #build made, with what it read while it did: the memory
93
95
  # index sizes (#memory_index), the model notes, the files and the
94
- # plugins' sections it holds, and its stable part's length (chat).
95
- Built = Data.define(:prompt, :memory_index, :notes, :files, :sections, :stable_length)
96
+ # plugins' sections it holds, its stable part's length (chat) and the
97
+ # Tools::ToolSurface it declares (the tools, the not-loaded line, the
98
+ # kit's prompt text).
99
+ Built = Data.define(:prompt, :memory_index, :notes, :files, :sections, :stable_length, :surface)
96
100
 
97
101
  # The full prompt: #base wrapped with the index and the rest. Built once
98
102
  # per loop and level (until #reset!) so the prompt prefix, and the
@@ -110,7 +114,7 @@ module Samagotchi
110
114
  # @param chat [Boolean] for the chat loop
111
115
  # @param thinking [Symbol, nil] the level (Thinking); nil: the effective model's
112
116
  # @param layers [Array<Symbol>] the turn's LLM context layers: the
113
- # native prompt declares the tools they add (Tools::Registry#entries)
117
+ # native prompt declares the tools they add (Tools::ToolSurface#declared)
114
118
  def build(chat: false, thinking: nil, layers: [])
115
119
  key = [chat, thinking, layers]
116
120
  cache = @cache
@@ -172,7 +176,8 @@ module Samagotchi
172
176
  # The base prompt (specs, plugins' declarations), with Gemma's tool
173
177
  # declarations at its end as #build has them.
174
178
  def base(chat: false, layers: [])
175
- with_tail_declarations(assist_system_prompt(chat: chat, layers: layers), chat: chat, layers: layers)
179
+ surface = tool_surface(layers)
180
+ with_tail_declarations(assist_system_prompt(chat: chat, surface: surface), chat: chat, surface: surface)
176
181
  end
177
182
 
178
183
  # Names activated via preloaded --memory entries during prompt
@@ -192,12 +197,20 @@ module Samagotchi
192
197
  @section_files = []
193
198
  @section_plugins = []
194
199
  @measured_stable_length = nil
195
- prompt = system_prompt_with_index(assist_system_prompt(chat: chat, layers: layers),
196
- chat: chat, thinking: thinking, layers: layers)
200
+ surface = tool_surface(layers)
201
+ prompt = system_prompt_with_index(assist_system_prompt(chat: chat, surface: surface),
202
+ chat: chat, thinking: thinking, surface: surface)
197
203
  Built.new(prompt: prompt, memory_index: @measured_index, notes: @section_notes || [],
198
- files: @section_files.freeze, sections: @section_plugins.freeze, stable_length: @measured_stable_length)
204
+ files: @section_files.freeze, sections: @section_plugins.freeze, stable_length: @measured_stable_length,
205
+ surface: surface)
199
206
  end
200
207
 
208
+ # The session's tools as a build under +layers+ declares them, read
209
+ # from the Registry now (one per build: the declarations, the
210
+ # not-loaded line and the kit's text agree).
211
+ # @return [Tools::ToolSurface]
212
+ def tool_surface(layers) = Tools::ToolSurface.for(@tools_lookup.call, layers: layers)
213
+
201
214
  def profile = @profile_lookup.call
202
215
 
203
216
  def turn_thinking = @thinking_lookup.call
@@ -208,8 +221,8 @@ module Samagotchi
208
221
 
209
222
  # ── Tool declarations ──────────────────────────────────────────────────────
210
223
 
211
- def tool_declarations(layers)
212
- schemas = ToolDeclarations.native_schemas(@tools_lookup.call, layers: layers)
224
+ def tool_declarations(surface)
225
+ schemas = ToolDeclarations.native_schemas(surface)
213
226
  case profile.name
214
227
  when "qwen36"
215
228
  ToolDeclarations.qwen_declarations(schemas)
@@ -232,7 +245,7 @@ module Samagotchi
232
245
 
233
246
  # @param chat [Boolean] for the chat loop: no tool declarations or call
234
247
  # syntax (its tools go as schemas with each request)
235
- def assist_system_prompt(chat: false, layers: [])
248
+ def assist_system_prompt(surface:, chat: false)
236
249
  return chat_system_prompt if chat
237
250
 
238
251
  hint = tool_call_hint
@@ -241,7 +254,7 @@ module Samagotchi
241
254
  tools = if gemma_tail_declarations?
242
255
  "Your tools are declared at the end of this system prompt."
243
256
  else
244
- "You have access to the following tools:\n\n#{tool_declarations(layers)}\n"
257
+ "You have access to the following tools:\n\n#{tool_declarations(surface)}\n"
245
258
  end
246
259
 
247
260
  <<~SYS
@@ -332,7 +345,7 @@ module Samagotchi
332
345
  # memory write changes them) after all of them; the model, the location
333
346
  # and the session close the prompt (Gemma's tool declarations still
334
347
  # follow them).
335
- def system_prompt_with_index(base, chat: false, thinking: nil, layers: [])
348
+ def system_prompt_with_index(base, chat: false, thinking: nil, surface: tool_surface([]))
336
349
  thinking_token = chat ? "" : Thinking.native(thinking || turn_thinking, profile).system_token
337
350
  indexes = { project: read_memory_index("project"), system: read_memory_index("system") }
338
351
  @measured_index = measure_indexes(indexes)
@@ -340,15 +353,15 @@ module Samagotchi
340
353
  "Project memories:\n#{indexes[:project]}",
341
354
  "System memories:\n#{indexes[:system]}"
342
355
  ].join("\n\n")
343
- stable = [thinking_token + base, not_loaded_line(layers), rg_guidance, system_identity_section, model_notes_section, explicit_memory_section,
344
- project_specific_description, appended_section, kit_prompt_section, plugin_prompt_section, memory_sections]
356
+ stable = [thinking_token + base, not_loaded_line(surface), rg_guidance, system_identity_section, model_notes_section, explicit_memory_section,
357
+ project_specific_description, appended_section, kit_prompt_section(surface), plugin_prompt_section, memory_sections]
345
358
  stable_text = stable.compact.join("\n")
346
359
  volatile = [participants_section, current_model, project_location, current_session].compact
347
- return with_tail_declarations(stable_text, chat: chat, layers: layers) if volatile.empty?
360
+ return with_tail_declarations(stable_text, chat: chat, surface: surface) if volatile.empty?
348
361
 
349
362
  prompt = "#{stable_text}\n#{volatile.join("\n")}"
350
363
  @measured_stable_length = stable_text.length + 1 if chat
351
- with_tail_declarations(prompt, chat: chat, layers: layers)
364
+ with_tail_declarations(prompt, chat: chat, surface: surface)
352
365
  end
353
366
 
354
367
  # The tools the session's toolkit leaves out, named once right after the
@@ -357,15 +370,12 @@ module Samagotchi
357
370
  # the prompt is what it was before toolkits. The ones call_tool reaches
358
371
  # and the ones the toolkit removes (only the user brings those back)
359
372
  # get a sentence each; with call_tool not declared, one list.
360
- def not_loaded_line(layers)
361
- registry = @tools_lookup.call
362
- return nil unless registry.respond_to?(:left_out_names)
363
-
364
- names = registry.left_out_names(layers: layers)
373
+ def not_loaded_line(surface)
374
+ names = surface.left_out.keys
365
375
  return nil if names.empty?
366
376
 
367
- toolkit = "(toolkit #{registry.toolkit&.name})"
368
- reachable = registry.reachable_names(layers: layers)
377
+ toolkit = "(toolkit #{surface.toolkit&.name})"
378
+ reachable = surface.index_only.map(&:name)
369
379
  if reachable.empty?
370
380
  return "Not loaded in this session #{toolkit}: #{names.join(", ")}. Only the user can load one (/toolkit add <name>)."
371
381
  end
@@ -387,10 +397,7 @@ module Samagotchi
387
397
  # sessions on that kit share it, and a memory write doesn't move it.
388
398
  # nil on a kit without one and on full: the prompt is what it was
389
399
  # before kit prompts.
390
- def kit_prompt_section
391
- registry = @tools_lookup.call
392
- registry.respond_to?(:toolkit) ? registry.toolkit&.prompt : nil
393
- end
400
+ def kit_prompt_section(surface) = surface.toolkit&.prompt
394
401
 
395
402
  # The plugins' prompt sections (chi.prompt_section), right after the
396
403
  # kit prompt and before the memory indexes: a plugin's text depends on
@@ -406,8 +413,8 @@ module Samagotchi
406
413
  end
407
414
  end
408
415
 
409
- def with_tail_declarations(prompt, chat:, layers:)
410
- chat || !gemma_tail_declarations? ? prompt : prompt.rstrip + tool_declarations(layers)
416
+ def with_tail_declarations(prompt, chat:, surface:)
417
+ chat || !gemma_tail_declarations? ? prompt : prompt.rstrip + tool_declarations(surface)
411
418
  end
412
419
 
413
420
  def gemma_tail_declarations? = profile.name == "gemma4"
@@ -94,6 +94,13 @@ module Samagotchi
94
94
  "Samagotchi::AnswerRefs.new" => "spec: after_turn's refs: helper, as chi's fire site builds it",
95
95
  "Samagotchi::Log" => "spec: stubs Log to check what ctx.log recorded: a kit log recorder could replace it"
96
96
  },
97
+ "inline-widgets" => {
98
+ 'require "samagotchi/inline_widgets"' => "spec: for InlineWidgets",
99
+ "Samagotchi::InlineWidgets.parse" => "spec: each example the sections teach parses strictly, as chi web " \
100
+ "parses a block (the drawing is core's, so is the parse)",
101
+ "Samagotchi::Plugin::PromptSections::MAX" => "spec: the prompt-section limit a section over is dropped at: " \
102
+ "could be a stable constant"
103
+ },
97
104
  "loop-guard" => {
98
105
  'require "samagotchi/llm/native_tool_normalizer"' => "spec: for NativeToolNormalizer",
99
106
  "Samagotchi::LLM::NativeToolNormalizer.normalize" => "spec: replays recorded calls in the shape chi's parser gives",
@@ -11,6 +11,7 @@ require_relative "../tools/tool_index"
11
11
  require_relative "../peer_sources"
12
12
  require_relative "../toolkit"
13
13
  require_relative "../guardrails/gate"
14
+ require_relative "../turn_context"
14
15
  require_relative "../memory_bundle/manifest"
15
16
  require_relative "../memory_bundle/source"
16
17
  require_relative "../session_setup"
@@ -239,8 +240,9 @@ module Samagotchi
239
240
  dispatched = nil
240
241
  capture = Hooks::Registry.new
241
242
  capture.register(:before_tool_call) { |event| dispatched = event.dup }
242
- gate = Guardrails::Gate.new(-> { capture }, context_lookup: -> { context }, tools_lookup: -> { tools })
243
- gate.evaluate(call, iteration: iteration, params: params, verdict: verdict || Verdict.new(call: call))
243
+ gate = Guardrails::Gate.new(hooks: capture, tools: tools, context_for: ->(_turn) { context })
244
+ gate.evaluate(call, context: TurnContext.none, iteration: iteration, params: params,
245
+ verdict: verdict || Verdict.new(call: call))
244
246
  raise ArgumentError, "guardrails did not dispatch a before_tool_call event for #{call[:name].inspect}" unless dispatched
245
247
 
246
248
  dispatched.slice(:type, :iteration, :call, :params, :blocked, :block_reason, :guardrail, :context, :targets)
@@ -256,11 +258,11 @@ module Samagotchi
256
258
  def tool_call(call, params: nil, cwd: nil, context: nil, verdict: nil, iteration: 1)
257
259
  context ||= Guardrails::Context.new(cwd: cwd || @recorder[:cwd], session_id: @recorder[:session_id],
258
260
  interface: @recorder[:frontend])
259
- gate = Guardrails::Gate.new(-> { hooks }, context_lookup: -> { context }, tools_lookup: -> { tools },
260
- checks_lookup: -> { [] })
261
+ gate = Guardrails::Gate.new(hooks: hooks, tools: tools, context_for: ->(_turn) { context },
262
+ checks_for: ->(_turn) { [] })
261
263
  verdict ||= Verdict.new(call: call)
262
264
  params ||= "#{call[:name]} #{call[:content] || call[:path]}".strip
263
- failing { gate.evaluate(call, iteration: iteration, params: params, verdict: verdict) }
265
+ failing { gate.evaluate(call, context: TurnContext.none, iteration: iteration, params: params, verdict: verdict) }
264
266
  end
265
267
 
266
268
  # Run a command line ("/name args") as chi does: the first matching
@@ -31,7 +31,7 @@ module Samagotchi
31
31
  # What a bundle registered (#registered): commands, tools (names),
32
32
  # hooks per event, inits (labels), toolkits, tool sources and peer
33
33
  # sources (names).
34
- Registered = Data.define(:commands, :tools, :hooks, :inits, :toolkits, :tool_sources, :peer_sources)
34
+ Registered = Data.define(:commands, :tools, :hooks, :inits, :toolkits, :tool_sources, :peer_sources, :prompt_sections)
35
35
  # A command: its mid_turn policy and its selection-bar labels (chi.annotation).
36
36
  Command = Data.define(:name, :mid_turn, :labels)
37
37
  # A hook on an event: the bundle, the hook (its file, or the plugin's) and its priority.
@@ -98,7 +98,8 @@ module Samagotchi
98
98
  inits: inits.map { |init| init[:label] },
99
99
  toolkits: Array(registries.toolkits).map(&:name),
100
100
  tool_sources: registries.tool_index.sources.map(&:label),
101
- peer_sources: registries.peer_sources.names
101
+ peer_sources: registries.peer_sources.names,
102
+ prompt_sections: registries.prompt_sections.names(name)
102
103
  )
103
104
  end
104
105
 
@@ -58,11 +58,11 @@ module Samagotchi
58
58
  # @param registry [Tools::Registry, nil] the session's tools
59
59
  # @param cwd [String, nil] what a file tool's title is relative to (the
60
60
  # worker runs in its session's working directory)
61
- def tool_activity_event(tool_name, call, result, registry: nil, cwd: Dir.pwd)
61
+ def tool_activity_event(tool_name, call, result, registry: nil, cwd: Dir.pwd, surface: nil)
62
62
  event = {
63
63
  action: tool_activity_action(tool_name, registry: registry, call: call),
64
64
  tool: tool_name,
65
- params: tool_activity_params(tool_name, call, registry: registry),
65
+ params: tool_activity_params(tool_name, call, registry: registry, surface: surface),
66
66
  status: tool_activity_status(result, tool_name, command: call.is_a?(Hash) ? call[:content] : nil)
67
67
  }
68
68
  # A grep that found nothing: ok, and the web row says "no match".
@@ -359,7 +359,9 @@ module Samagotchi
359
359
  name == "git" && args.first == "diff" && args.intersect?(%w[--quiet --exit-code])
360
360
  end
361
361
 
362
- def tool_activity_params(tool_name, call, registry: nil)
362
+ # +surface+: the turn's Tools::ToolSurface, for a preview that reads it
363
+ # (call_tool's); nil outside a turn.
364
+ def tool_activity_params(tool_name, call, registry: nil, surface: nil)
363
365
  case tool_name
364
366
  when Tools::Execute::NAME
365
367
  "command=#{preview_tool_param(call[:content])}"
@@ -453,7 +455,7 @@ module Samagotchi
453
455
  parts << "options=#{preview_tool_param(Array(opts).join(","))}" if opts && !Array(opts).empty?
454
456
  parts.join(" ")
455
457
  else
456
- registry_params(registry_entry(registry, tool_name), call)
458
+ registry_params(registry_entry(registry, tool_name), call, surface)
457
459
  end
458
460
  end
459
461
 
@@ -472,13 +474,13 @@ module Samagotchi
472
474
  # the web's reload also has); nil for a tool the registry doesn't know
473
475
  # that has no args:, and for a built-in without its own words above
474
476
  # (the reminder tools).
475
- def registry_params(entry, call)
477
+ def registry_params(entry, call, surface = nil)
476
478
  return nil if entry&.core?
477
479
  return nil if entry.nil? && !call[:args].is_a?(Hash)
478
480
 
479
481
  if entry&.preview
480
482
  begin
481
- return entry.preview.call(call)
483
+ return entry.preview_of(call, surface)
482
484
  rescue StandardError => e
483
485
  Log.warn(:plugins, "plugin_preview_failed", tool: entry.name, error: e.class.name,
484
486
  msg: "#{entry.name} preview failed: #{e.message}")
@@ -635,10 +635,10 @@ module Samagotchi
635
635
 
636
636
  # The schemas the native (Gemma, Qwen) prompts declare: the built-ins
637
637
  # as they are, and each plugin tool's flattened (.flat_schema).
638
- # @param registry [Tools::Registry]
639
- # @param layers [Array<Symbol>] the turn's LLM context layers (Registry#entries)
640
- def native_schemas(registry, layers: [])
641
- registry.entries(layers: layers).map { |entry| entry.core? ? entry.schema : flat_schema(entry.schema) }
638
+ # @param surface [Tools::ToolSurface] the tools a turn (or a build
639
+ # under its layers) declares (ToolSurface#declared)
640
+ def native_schemas(surface)
641
+ surface.declared.map { |entry| entry.core? ? entry.schema : flat_schema(entry.schema) }
642
642
  end
643
643
 
644
644
  # A plugin tool's schema in the shape the built-ins have, which is all