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
@@ -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)
@@ -432,7 +452,8 @@ module Samagotchi
432
452
  # Array), plus a model turn's tool_calls and thinking (the host's
433
453
  # reasoning, sent back as #wire_messages says), a result's tool_call_id, the image
434
454
  # refs of a user message or a tool result, a prompt's turn_id and its
435
- # Verify/Revise anchors (Engine, never sent) and a wake turn's report's turn_start (Steer, never sent), and a plugin tool result's tool_params and tool_labels
455
+ # Verify/Revise anchors (Engine, never sent), an opening's opening_id
456
+ # (TurnFlow#abort_continue!'s reason line, never sent) and a wake turn's report's turn_start (Steer, never sent), and a plugin tool result's tool_params and tool_labels
436
457
  # (the live row's params line and label, never sent back), an
437
458
  # edit/write result's tool_diffs (never sent), an empty-answer
438
459
  # note's marker (TurnNote.empty, never sent), and an answer's display
@@ -448,6 +469,7 @@ module Samagotchi
448
469
  message[:tool_call_id] = entry[:tool_call_id] if entry[:tool_call_id]
449
470
  message[:images] = entry[:images] if entry[:images].is_a?(Array) && !entry[:images].empty?
450
471
  message[:turn_id] = entry[:turn_id] if entry[:turn_id]
472
+ message[:opening_id] = entry[:opening_id] if entry[:opening_id].is_a?(String)
451
473
  message[:turn_start] = true if entry[:turn_start] == true
452
474
  message[:verify] = entry[:verify] if entry[:verify].is_a?(Hash)
453
475
  message[:revises] = entry[:revises] if entry[:revises].is_a?(Hash)
@@ -607,8 +629,10 @@ module Samagotchi
607
629
  @deferred = []
608
630
  @context = ContextStatus.new(conversation: conversation, llm_context: loop.llm_context,
609
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
610
634
  loop.llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: @context,
611
- emit: method(:emit))
635
+ emit: method(:emit), turn_id: @turn_context.id)
612
636
  end
613
637
 
614
638
  def call(max_iterations:, cap:)
@@ -1092,7 +1116,7 @@ module Samagotchi
1092
1116
  def dispatch(tool_calls, iteration, cap)
1093
1117
  log_direct_deferred(tool_calls)
1094
1118
  calls = tool_calls.map { |tool_call| NativeToolNormalizer.normalize(tool_call) }
1095
- 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),
1096
1120
  on_stream_event: @on_stream_event, cap: cap) do |run, index|
1097
1121
  @conversation << ToolResponse.single(run, tool_call_id: tool_calls[index].id,
1098
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)
@@ -33,6 +33,7 @@ require_relative "worker_idle_exit"
33
33
  require_relative "worker_signals"
34
34
  require_relative "chi_invocation"
35
35
  require_relative "log"
36
+ require_relative "degrade"
36
37
  require_relative "log_path"
37
38
  require_relative "paths"
38
39
  require_relative "project_env"
@@ -282,7 +283,7 @@ module Samagotchi
282
283
  child_env[ModelProfile::MODEL_FROM_CLI_ENV] = "1" if child_env.key?(ModelProfile::MODEL_ENV)
283
284
  # And the spawner's debug log, absolute: a relative log.file would
284
285
  # otherwise land in the worker's (the session's) directory.
285
- log_path = begin LogPath.resolve rescue nil end
286
+ log_path = begin LogPath.resolve rescue nil end # degrade-ok: no log to say it in; the worker's is off (below)
286
287
  if log_path
287
288
  child_env["SAMAGOTCHI_LOG_FILE"] = log_path
288
289
  else
@@ -936,9 +937,7 @@ module Samagotchi
936
937
  # session.keep_empty off: sessions left with nothing in them are deleted
937
938
  # (the worker as it exits, the retention sweep).
938
939
  def self.discard_empty?
939
- Samagotchi::Config.get("session.keep_empty") != true
940
- rescue StandardError
941
- false
940
+ Degrade.call(:config, "keep_empty_unreadable", fallback: false) { Samagotchi::Config.get("session.keep_empty") != true }
942
941
  end
943
942
 
944
943
  # Whether a session being left (or found left) is deleted: session.keep_empty
@@ -977,9 +976,9 @@ module Samagotchi
977
976
  return true if one.to_s == other.to_s
978
977
  return false if one.nil? || other.nil?
979
978
 
980
- ConfigFile.model_ref(one).ref == ConfigFile.model_ref(other).ref
981
- rescue StandardError
982
- false
979
+ Degrade.call(:config, "model_ref_unresolved", fallback: false) do
980
+ ConfigFile.model_ref(one).ref == ConfigFile.model_ref(other).ref
981
+ end
983
982
  end
984
983
 
985
984
  # Only the system prompt (an Engine seeds one, a saved session keeps
@@ -1281,9 +1280,7 @@ module Samagotchi
1281
1280
  end
1282
1281
 
1283
1282
  def self.config_idle_exit_minutes
1284
- Samagotchi::Config.get("session.idle_exit_minutes")
1285
- rescue StandardError
1286
- nil
1283
+ Degrade.call(:config, "idle_exit_minutes_unreadable") { Samagotchi::Config.get("session.idle_exit_minutes") }
1287
1284
  end
1288
1285
 
1289
1286
  private_class_method def self.setup_session_directory(session_dir, session, state_dir:)
@@ -1640,9 +1637,7 @@ module Samagotchi
1640
1637
  end
1641
1638
 
1642
1639
  private_class_method def self.delivery_owner(manager, session_id, state_dir)
1643
- manager.session_owner(session_id, state_dir: state_dir)
1644
- rescue StandardError
1645
- nil
1640
+ Degrade.call(:worker, "owner_unreadable", sid: session_id) { manager.session_owner(session_id, state_dir: state_dir) }
1646
1641
  end
1647
1642
 
1648
1643
  def self.stopped_on_disk?(session_id, state_dir:)
@@ -18,7 +18,7 @@ module Samagotchi
18
18
  # (session.retention_days), count (session.max_count, counting only the
19
19
  # sessions that stay), statuses kept (session.keep_status), sessions left
20
20
  # empty (SessionManager.discardable?, an hour on), leftover scratch
21
- # sessions and skeleton-only directories; a session with a live owner
21
+ # sessions (once past SCRATCH_START_GRACE) and skeleton-only directories; a session with a live owner
22
22
  # always stays.
23
23
  module SessionRetention
24
24
  # The session.retention_days / max_count / keep_status defaults.
@@ -93,6 +93,16 @@ module Samagotchi
93
93
  end
94
94
  end
95
95
 
96
+ # How long after its save an unowned scratch session may still be
97
+ # waiting for the worker just spawned for it to take its owner lock.
98
+ SCRATCH_START_GRACE = SessionManager::WORKER_START_GRACE
99
+
100
+ private_class_method def self.scratch_starting?(path, now)
101
+ now - File.mtime(path) < SCRATCH_START_GRACE
102
+ rescue SystemCallError
103
+ false
104
+ end
105
+
96
106
  # Lazy sweep guard: runs prune at most once per SWEEP_INTERVAL_HOURS
97
107
  # (session.sweep_interval_hours).
98
108
  MARKER = ".last_retention"
@@ -162,7 +172,9 @@ module Samagotchi
162
172
  # count (`chi sessions clean` with no --days: test runs are throwaway).
163
173
  #
164
174
  # A scratch session nobody owns (its terminal and worker were killed) goes whatever its
165
- # age, its status and the count; +test_only+ takes it too.
175
+ # age, its status and the count; +test_only+ takes it too. One saved
176
+ # within SCRATCH_START_GRACE (its file's mtime, against +now+) stays: its
177
+ # worker may not have taken the owner lock yet.
166
178
  #
167
179
  # +max_count+ counts the sessions that stay: one deleted whatever its
168
180
  # place (scratch, left empty, expired, +any_age+) takes no slot.
@@ -173,7 +185,7 @@ module Samagotchi
173
185
  # @return [Hash] { deleted: [ids], kept: [ids], skipped: [ids] }
174
186
  def self.apply(state_dir: Session.default_state_dir, days: DEFAULT_RETENTION_DAYS, max_count: DEFAULT_MAX_COUNT,
175
187
  keep_status: DEFAULT_KEEP_STATUS, dry_run: false, test_only: false, alive_check: nil,
176
- empty_check: nil, any_age: false)
188
+ empty_check: nil, any_age: false, now: Time.now)
177
189
  keep_status = Array(keep_status).map(&:to_s)
178
190
  # Fetch all sessions sorted newest-first for count logic
179
191
  all = Session.list(state_dir: state_dir, sort: "updated_at", order: "desc")
@@ -182,7 +194,6 @@ module Samagotchi
182
194
  all = all.select { |session| session.test_run || session.scratch }
183
195
  end
184
196
 
185
- now = Time.now
186
197
  cutoff = days.to_i.positive? ? now - (days.to_i * 86_400) : nil
187
198
  max = max_count.to_i
188
199
 
@@ -217,6 +228,15 @@ module Samagotchi
217
228
  end
218
229
  end
219
230
 
231
+ # A scratch session saved within SCRATCH_START_GRACE: `chi scratch`
232
+ # saves it, then spawns the worker that takes its owner lock, so no
233
+ # owner yet doesn't mean a leftover. Kept, it takes no count slot.
234
+ if session.scratch && scratch_starting?(path, now)
235
+ kept << session.id
236
+ passed_over += 1
237
+ next
238
+ end
239
+
220
240
  left_empty = begin
221
241
  empty_check&.call(session.id)
222
242
  rescue StandardError
@@ -28,6 +28,12 @@ module Samagotchi
28
28
  module Steer
29
29
  KIND = "steer"
30
30
  INPUT_KIND = "input"
31
+ # KernelLoop's line asking a Qwen model to finish the tool call it left
32
+ # open: a user line inside the running turn, the model's alone. No turn
33
+ # opens with it (#turn_prompt?), and the page draws nothing for it, as it
34
+ # showed nothing live. Its kind adds no header (#header): its bytes go
35
+ # out as before it had one.
36
+ RECOVERY_KIND = "recovery"
31
37
 
32
38
  # A drain item for an input line with its sender (source nil = the user).
33
39
  # A plain String is still a valid item: the user's line. +mark+: fields
@@ -317,6 +323,11 @@ module Samagotchi
317
323
  message.is_a?(Hash) && (message[:role] || message["role"]).to_s == "user" && !steer?(message)
318
324
  end
319
325
 
326
+ # KernelLoop's recovery line (RECOVERY_KIND).
327
+ def recovery?(message)
328
+ message.is_a?(Hash) && (message[:kind] || message["kind"]).to_s == RECOVERY_KIND
329
+ end
330
+
320
331
  # The user's lines merged into a running turn (Merge#messages).
321
332
  def input?(message)
322
333
  message.is_a?(Hash) && (message[:kind] || message["kind"]).to_s == INPUT_KIND
@@ -333,7 +344,7 @@ module Samagotchi
333
344
  start = (message[:turn_start] || message["turn_start"]) == true
334
345
  return start if (message[:role] || message["role"]).to_s == "system"
335
346
 
336
- prompt?(message) && (!input?(message) || start)
347
+ prompt?(message) && !recovery?(message) && (!input?(message) || start)
337
348
  end
338
349
 
339
350
  # A stored `!shell` line: "!(cmd)\n<output>", no turn id. A user message,
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "degrade"
4
+
5
+ module Samagotchi
6
+ # The one rule for handing a loop event to its on_stream_event callback
7
+ # (KernelLoop#emit_stream_event, ToolRunner#emit): no callback is fine, and
8
+ # a failing one (a UI gone) must not break the turn or the tool call. Its
9
+ # failure is a Degrade record under :turn, named by the caller (+failure+,
10
+ # so a log still says which side lost the event), with the event's type.
11
+ module StreamEvent
12
+ module_function
13
+
14
+ # @param callback [#call, nil] the loop's on_stream_event
15
+ # @param event [Hash] the event
16
+ # @param failure [String] the Degrade event slug when +callback+ raises
17
+ # @return the callback's value, nil when it raised or there is none
18
+ def emit(callback, event, failure:)
19
+ Degrade.call(:turn, failure, type: (event[:type] if event.is_a?(Hash))) { callback&.call(event) }
20
+ end
21
+ end
22
+ end