samagotchi 0.5.0 → 0.6.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -1
  3. data/bin/chi +32 -891
  4. data/docs/architecture.md +8 -1
  5. data/docs/cli.md +12 -13
  6. data/docs/configuration.md +4 -1
  7. data/docs/desktop.md +3 -1
  8. data/docs/hooks.md +69 -4
  9. data/docs/plugins.md +98 -3
  10. data/docs/sessions.md +3 -3
  11. data/lib/samagotchi/bridge/card_store.rb +30 -5
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +7 -4
  13. data/lib/samagotchi/bridge/turn_notice.rb +37 -0
  14. data/lib/samagotchi/bundle_command.rb +652 -0
  15. data/lib/samagotchi/bundles/loop-guard/manifest.yml +4 -4
  16. data/lib/samagotchi/bundles/loop-guard/plugin.rb +214 -2
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -0
  18. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  19. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  20. data/lib/samagotchi/cancellation_controller.rb +42 -1
  21. data/lib/samagotchi/config.rb +2 -2
  22. data/lib/samagotchi/desktop/macos/ChiRunner.swift +20 -5
  23. data/lib/samagotchi/desktop/macos/Panel.swift +31 -3
  24. data/lib/samagotchi/engine.rb +138 -618
  25. data/lib/samagotchi/guardrail_wiring.rb +238 -0
  26. data/lib/samagotchi/hooks/registry.rb +11 -3
  27. data/lib/samagotchi/hooks/stream_watch.rb +93 -0
  28. data/lib/samagotchi/hooks.rb +3 -0
  29. data/lib/samagotchi/kernel_loop.rb +65 -6
  30. data/lib/samagotchi/llm/chat_loop.rb +73 -11
  31. data/lib/samagotchi/llm/openai_chat.rb +16 -4
  32. data/lib/samagotchi/log_subscriber.rb +10 -4
  33. data/lib/samagotchi/plugin/api.rb +8 -0
  34. data/lib/samagotchi/plugin/context.rb +12 -3
  35. data/lib/samagotchi/plugin_tasks.rb +229 -0
  36. data/lib/samagotchi/question_desk.rb +280 -0
  37. data/lib/samagotchi/recap_setup.rb +128 -0
  38. data/lib/samagotchi/reply_wait.rb +9 -0
  39. data/lib/samagotchi/send_command.rb +3 -6
  40. data/lib/samagotchi/session.rb +9 -1
  41. data/lib/samagotchi/sessions_command.rb +255 -0
  42. data/lib/samagotchi/steer.rb +19 -2
  43. data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -5
  44. data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
  45. data/lib/samagotchi/tool_declarations.rb +2 -2
  46. data/lib/samagotchi/tools/delegate.rb +6 -2
  47. data/lib/samagotchi/tools/delegate_wait.rb +26 -8
  48. data/lib/samagotchi/turn_note.rb +20 -1
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +28 -10
  51. data/lib/samagotchi/web/public/app.js +94 -52
  52. data/lib/samagotchi/web/public/card.js +8 -1
  53. data/lib/samagotchi/web/public/format.js +1 -1
  54. data/lib/samagotchi/web/public/index.html +2 -5
  55. data/lib/samagotchi/web/public/stage_model.js +44 -0
  56. data/lib/samagotchi/web/public/stage_view.js +26 -17
  57. data/lib/samagotchi/web/public/timing.js +9 -3
  58. data/lib/samagotchi/web/public/turn_events.js +18 -5
  59. data/lib/samagotchi/web/public/turn_view.js +80 -16
  60. data/lib/samagotchi/web/server.rb +4 -3
  61. metadata +9 -2
  62. data/lib/samagotchi/web/public/chat_view.js +0 -376
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "yaml"
5
+
6
+ require_relative "config"
7
+ require_relative "log"
8
+ require_relative "guardrails"
9
+ require_relative "hooks"
10
+ require_relative "edit_preview"
11
+ require_relative "tool_activity"
12
+
13
+ module Samagotchi
14
+ # An Engine's tool guardrails: who can approve (the interface), the YAML
15
+ # rules (config.yml's and installed bundles', reloaded on a file change),
16
+ # the load failures, the protected paths, the approvals store, the context
17
+ # a tool call is judged in, the approval question, and the Gate the kernel
18
+ # asks before every tool call.
19
+ #
20
+ # What it needs from the Engine comes through lookups, read at call time.
21
+ class GuardrailWiring
22
+ # @return [Guardrails::LoadFailures] what failed to load (hooks, rules)
23
+ attr_reader :failures
24
+
25
+ # @return [Guardrails::Approvals]
26
+ attr_reader :approvals
27
+
28
+ # @param scratch [Boolean] a `chi scratch` session (writes into the memories are denied)
29
+ # @param hooks [#call] → Hooks::Registry
30
+ # @param tools [#call] → Tools::Registry
31
+ # @param session [#call] → Session, nil
32
+ # @param model_key [#call] → String, the effective model's overlay key
33
+ # @param cancelled [#call] → Boolean, whether the running turn is cancelled
34
+ # @param ask [#call] (fields) → the answer; the Engine's question flow
35
+ def initialize(scratch:, hooks:, tools:, session:, model_key:, cancelled:, ask:)
36
+ @scratch = scratch
37
+ @hooks_lookup = hooks
38
+ @tools_lookup = tools
39
+ @session_lookup = session
40
+ @model_key_lookup = model_key
41
+ @cancelled_lookup = cancelled
42
+ @ask = ask
43
+ @failures = Guardrails::LoadFailures.new
44
+ @rules_mutex = Mutex.new
45
+ @git = Guardrails::GitInfo.new
46
+ end
47
+
48
+ # Who can answer an approval: :repl, :worker or :non_interactive (the
49
+ # default, so a bare Engine denies instead of waiting for nobody). Set
50
+ # by the host (TerminalUI, Worker).
51
+ def interface
52
+ @interface || :non_interactive
53
+ end
54
+
55
+ def interface=(value)
56
+ value = value.to_sym
57
+ raise ArgumentError, "unknown interface #{value}" unless Guardrails::Context::INTERFACES.include?(value)
58
+
59
+ @interface = value
60
+ end
61
+
62
+ # Where the approval store lives: beside Session's state dir
63
+ # ($XDG_STATE_HOME/samagotchi/guardrails/).
64
+ def state_dir=(state_dir)
65
+ @approvals = Guardrails::Approvals.new(dir: Guardrails::Approvals.dir_for(state_dir))
66
+ @protected = nil
67
+ end
68
+
69
+ # A turn starts: its origin, and the git facts read afresh.
70
+ def begin_turn(origin)
71
+ @origin = origin
72
+ @git = Guardrails::GitInfo.new
73
+ end
74
+
75
+ # The gate every tool call asks first.
76
+ # @return [Guardrails::Gate]
77
+ def gate
78
+ Guardrails::Gate.new(
79
+ -> { @hooks_lookup.call },
80
+ context_lookup: -> { context },
81
+ model_key_lookup: -> { @model_key_lookup.call },
82
+ approver: ->(verdict) { request_approval(verdict) },
83
+ approvals_lookup: -> { @approvals },
84
+ checks_lookup: -> { checks },
85
+ cancelled_lookup: -> { @cancelled_lookup.call },
86
+ tools_lookup: -> { @tools_lookup.call }
87
+ )
88
+ end
89
+
90
+ # The gate's core checks, in order.
91
+ def checks
92
+ rules = self.rules
93
+ [@failures, rules.hook_asks, protected_paths, (@scratch_writes ||= Guardrails::ScratchWrites.new if @scratch),
94
+ rules].compact
95
+ end
96
+
97
+ # The YAML rules: config.yml's `guardrails:` section (rules, disable) and
98
+ # installed bundles'. One that doesn't parse is a required load failure
99
+ # (every call is denied). Read again when one of those files changed
100
+ # (a stat of each per tool call), so a long-lived worker follows edits.
101
+ # @return [Guardrails::Rules]
102
+ def rules
103
+ @rules_mutex.synchronize do
104
+ stamp = rules_stamp
105
+ if @rules.nil? || stamp != @rules_stamp
106
+ @failures.drop(:rules)
107
+ @rules = load_rules
108
+ @rules_stamp = stamp
109
+ end
110
+ @rules
111
+ end
112
+ end
113
+
114
+ # Installed bundles' guardrails/*.yml, by bundle name then file name.
115
+ # A file that is missing, changed since install (sha256) or doesn't
116
+ # parse is a required load failure.
117
+ def bundle_rules
118
+ require_relative "memory_bundle/provenance"
119
+ rules = []
120
+ MemoryBundle::Provenance.each_installed_with_guardrails do |bundle_name, data|
121
+ if data[:error]
122
+ Log.warn(:guardrails, "bundle_rules_invalid", echo: "[samagotchi:guardrails] bundle #{bundle_name}: #{data[:error]}", bundle: bundle_name)
123
+ @failures.add("rules (bundle #{bundle_name})", data[:error], required: true, group: :rules)
124
+ next
125
+ end
126
+ dir = MemoryBundle::Provenance.new(name: bundle_name).guardrails_dir
127
+ data[:guardrails].sort_by { |k, _| k.to_s }.each do |basename, meta|
128
+ what = "rules #{basename} (bundle #{bundle_name})"
129
+ path = File.join(dir, basename.to_s)
130
+ begin
131
+ raise Guardrails::Rules::ParseError, "the file is missing" unless File.file?(path)
132
+
133
+ expected = (meta.is_a?(Hash) ? meta[:sha256] : nil).to_s.sub(/\Asha256:/, "")
134
+ actual = Digest::SHA256.hexdigest(File.binread(path))
135
+ if expected != actual
136
+ raise Guardrails::Rules::ParseError, "its sha256 differs from the installed one (edited after install? reinstall the bundle)"
137
+ end
138
+
139
+ doc = YAML.safe_load(File.read(path))
140
+ raise Guardrails::Rules::ParseError, "expected a mapping with rules:" unless doc.is_a?(Hash)
141
+
142
+ rules.concat(Guardrails::Rules.parse(doc["rules"], source: "bundle #{bundle_name}"))
143
+ rescue Guardrails::Rules::ParseError, Psych::Exception => e
144
+ Log.warn(:guardrails, "rules_file_invalid", echo: "[samagotchi:guardrails] #{what}: #{e.message}", bundle: bundle_name, file: basename.to_s)
145
+ @failures.add(what, e.message, required: true, group: :rules)
146
+ end
147
+ end
148
+ end
149
+ rules
150
+ rescue StandardError => e
151
+ Log.error(:guardrails, "bundle_rules_failed", echo: "[samagotchi:guardrails] failed to read installed bundles' rules: #{e.class}: #{e.message}", error: e.class.name)
152
+ @failures.add("bundle rules", "#{e.class}: #{e.message}", required: true, group: :rules)
153
+ rules || []
154
+ end
155
+
156
+ def protected_paths
157
+ @protected ||= begin
158
+ require_relative "memory_bundle/provenance"
159
+ config = Samagotchi::ConfigFile.read_yaml(path: Samagotchi::ConfigFile.global_path)
160
+ hooks_dir = config.is_a?(Hash) && config["hooks"].is_a?(Hash) ? config["hooks"]["hooks_dir"] : nil
161
+ Guardrails::ProtectedPaths.new(
162
+ store_dir: File.dirname(@approvals.path),
163
+ bundles_dir: MemoryBundle::Provenance.bundles_dir,
164
+ config_path: Samagotchi::ConfigFile.global_path,
165
+ hooks_dir: Hooks::Loader.expand_path(hooks_dir || Hooks::Loader.default_hooks_dir)
166
+ )
167
+ end
168
+ end
169
+
170
+ # The context the gate sees for a tool call now.
171
+ # @return [Guardrails::Context]
172
+ def context
173
+ Guardrails::Context.new(cwd: Dir.pwd, session_id: @session_lookup.call&.id, interface: interface,
174
+ origin: @origin, git: @git)
175
+ end
176
+
177
+ # Ask the user to approve a call the gate voted `ask` on, through the
178
+ # question flow (REPL sync handler, attached TUI, web). Settles the
179
+ # verdict: allow with the picked scope, or deny with a note for the
180
+ # model. A --non-interactive run has no one to ask and denies at once.
181
+ # @param verdict [Guardrails::Verdict]
182
+ # @return [Guardrails::Verdict]
183
+ def request_approval(verdict)
184
+ if interface == :non_interactive
185
+ return verdict.settle!(:deny, decided_by: "no one", note: "No one to approve it (non-interactive run).")
186
+ end
187
+
188
+ # A plugin tool is asked about by its label, as its row shows it.
189
+ label = ToolActivity.plugin_label(verdict.call[:name].to_s, registry: @tools_lookup.call)
190
+ # verdict.call is the call that will run (a hook may have replaced it).
191
+ payload = Guardrails::Approval.payload(verdict, label: label, preview: approval_preview(verdict.call))
192
+ Guardrails::Approval.settle(verdict, @ask.call(payload), payload[:approval][:scopes])
193
+ end
194
+
195
+ private
196
+
197
+ # [path, mtime, size] of config.yml and every installed bundle's
198
+ # manifest.json and guardrails/ file.
199
+ def rules_stamp
200
+ require_relative "memory_bundle/provenance"
201
+ paths = [Samagotchi::ConfigFile.global_path] +
202
+ Dir[File.join(MemoryBundle::Provenance.bundles_dir, "*", "{manifest.json,guardrails/*}")]
203
+ paths.compact.sort.map do |path|
204
+ stat = File.stat(path)
205
+ [path, stat.mtime.to_r, stat.size]
206
+ rescue SystemCallError
207
+ [path]
208
+ end
209
+ end
210
+
211
+ def load_rules
212
+ section = Samagotchi::ConfigFile.read_yaml(path: Samagotchi::ConfigFile.global_path)
213
+ section = section["guardrails"] if section.is_a?(Hash)
214
+ rules = []
215
+ disable = []
216
+ begin
217
+ raise Guardrails::Rules::ParseError, "guardrails must be a mapping" unless section.nil? || section.is_a?(Hash)
218
+
219
+ rules = Guardrails::Rules.parse(section && section["rules"], source: "config")
220
+ disable = Guardrails::Rules.parse_disable(section && section["disable"])
221
+ rescue Guardrails::Rules::ParseError => e
222
+ Log.warn(:guardrails, "config_rules_invalid", echo: "[samagotchi:guardrails] config.yml guardrails rules: #{e.message}")
223
+ @failures.add("rules in config.yml", e.message, required: true, group: :rules)
224
+ end
225
+ Guardrails::Rules.new(rules + bundle_rules, disable: disable,
226
+ enabled: Samagotchi::Config.get("guardrails.enabled") != false)
227
+ end
228
+
229
+ # The dry-run diff of an edit/write call for its approval; a preview
230
+ # that fails only leaves the diff out, it never denies the call.
231
+ def approval_preview(call)
232
+ EditPreview.for(call)
233
+ rescue StandardError => e
234
+ Log.warn(:guardrails, "edit_preview_failed", error: "#{e.class}: #{e.message}")
235
+ nil
236
+ end
237
+ end
238
+ end
@@ -22,8 +22,10 @@ module Samagotchi
22
22
  # returns the answer hash or nil; +stop_turn+ takes (reason:, hook:) and
23
23
  # cancels the running turn (true when it did); +steer+ takes (text:,
24
24
  # hook:) and puts the text into the running turn (Engine#steer, true
25
- # when queued). A registry without one gives hooks no-op helpers.
26
- Runtime = Struct.new(:notify, :ask_user, :stop_turn, :steer, keyword_init: true)
25
+ # when queued); +stop_generation+ takes (reason:, hook:) and cuts the
26
+ # streaming generation while the turn goes on (true when it did). A
27
+ # registry without one gives hooks no-op helpers.
28
+ Runtime = Struct.new(:notify, :ask_user, :stop_turn, :steer, :stop_generation, keyword_init: true)
27
29
 
28
30
  # A thread-safe registry for named hook callbacks.
29
31
  #
@@ -35,7 +37,8 @@ module Samagotchi
35
37
  # Every fire puts the hook runtime on the event: +event[:hook]+ (the
36
38
  # label of the proc about to run: "<file> (bundle <name>)", a config
37
39
  # hook's label, or "turn hook"), and the helpers +event[:notify]+,
38
- # +event[:ask_user]+ and +event[:stop_turn]+ (see #fire). Keys the fire
40
+ # +event[:ask_user]+, +event[:stop_turn]+, +event[:steer]+ and
41
+ # +event[:stop_generation]+ (see #fire). Keys the fire
39
42
  # site put on the event are never overwritten.
40
43
  #
41
44
  # Thread safety is achieved via Monitor.
@@ -274,6 +277,11 @@ module Samagotchi
274
277
 
275
278
  @runtime&.steer&.call(text: text.to_s, hook: event[:hook]) ? true : false
276
279
  }
280
+ event[:stop_generation] ||= lambda { |reason|
281
+ next false if TURN_OVER_EVENTS.include?(event[:type])
282
+
283
+ @runtime&.stop_generation&.call(reason: reason.to_s, hook: event[:hook]) ? true : false
284
+ }
277
285
  end
278
286
  end
279
287
  end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../log"
4
+
5
+ module Samagotchi
6
+ module Hooks
7
+ # Fires the :generation_progress hook while a turn's model response
8
+ # streams (docs/hooks.md), batched so it can't slow the stream: when
9
+ # EVERY_CHARS new chars (thinking + text) are pending, or EVERY_SECONDS
10
+ # passed since the last fire with anything pending. Checked on each
11
+ # chunk: there is no timer thread, and the hook runs on the turn's
12
+ # thread, inside the HTTP read.
13
+ #
14
+ # The Engine builds one per turn, only when a hook listens, and feeds it
15
+ # each chunk after the UIs got it. Nothing fires once the turn or the
16
+ # generation is cancelled (lines already read still come after the
17
+ # socket closed), and there is no end-of-generation flush:
18
+ # :after_generation sees the whole response.
19
+ class StreamWatch
20
+ EVENT = :generation_progress
21
+ EVERY_CHARS = 2000
22
+ EVERY_SECONDS = 1.0
23
+ # A hook slower than this is logged (stream_hook_slow), once per turn.
24
+ SLOW_SECONDS = 0.1
25
+
26
+ # @param hooks [Registry]
27
+ # @param cancel_controller [CancellationController] the turn's
28
+ # @param clock [#call] monotonic seconds
29
+ def initialize(hooks:, cancel_controller:, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
30
+ @hooks = hooks
31
+ @cancel_controller = cancel_controller
32
+ @clock = clock
33
+ @slow_logged = false
34
+ @iteration = nil
35
+ end
36
+
37
+ # A new generation (:generation_started).
38
+ def started(iteration)
39
+ @iteration = iteration
40
+ @started_at = @last_fire_at = @clock.call
41
+ @thinking = +""
42
+ @text = +""
43
+ @thinking_chars = 0
44
+ @text_chars = 0
45
+ end
46
+
47
+ # One chunk's new thinking and visible text.
48
+ def feed(thinking:, text:)
49
+ return unless @iteration
50
+ return if @cancel_controller.cancelled? || @cancel_controller.generation_cancelled?
51
+
52
+ thinking = thinking.to_s
53
+ text = text.to_s
54
+ @thinking << thinking
55
+ @text << text
56
+ @thinking_chars += thinking.length
57
+ @text_chars += text.length
58
+ pending = @thinking.length + @text.length
59
+ return if pending.zero?
60
+
61
+ now = @clock.call
62
+ fire(now) if pending >= EVERY_CHARS || now - @last_fire_at >= EVERY_SECONDS
63
+ end
64
+
65
+ private
66
+
67
+ def fire(now)
68
+ event = { type: EVENT, iteration: @iteration, thinking: @thinking.freeze, text: @text.freeze,
69
+ thinking_chars: @thinking_chars, text_chars: @text_chars,
70
+ elapsed_ms: ((now - @started_at) * 1000).round,
71
+ # A question would hold the HTTP read for minutes.
72
+ ask_user: ->(**) {} }
73
+ @thinking = +""
74
+ @text = +""
75
+ @last_fire_at = now
76
+ hook_started = @clock.call
77
+ @hooks.fire_each(EVENT, event) do |fired|
78
+ done = @clock.call
79
+ slow(fired[:hook], done - hook_started)
80
+ hook_started = done
81
+ end
82
+ @last_fire_at = @clock.call
83
+ end
84
+
85
+ def slow(hook, seconds)
86
+ return if @slow_logged || seconds <= SLOW_SECONDS
87
+
88
+ @slow_logged = true
89
+ Log.warn(:turn, "stream_hook_slow", hook: hook.to_s, iteration: @iteration, ms: (seconds * 1000).round)
90
+ end
91
+ end
92
+ end
93
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative "hooks/registry"
4
4
  require_relative "hooks/loader"
5
5
  require_relative "hooks/bundle_loader"
6
+ require_relative "hooks/stream_watch"
6
7
 
7
8
  module Samagotchi
8
9
  # Thin wrapper that exposes the Hooks::Registry, Hooks::Loader and
@@ -18,6 +19,8 @@ module Samagotchi
18
19
  # :after_turn — after the turn completes (success or cancel)
19
20
  # :before_generation — before calling the LLM API
20
21
  # :after_generation — after LLM returns, before tool parse
22
+ # :generation_progress — while a response streams, batched (StreamWatch);
23
+ # event[:stop_generation] cuts it, the turn goes on
21
24
  # :before_tool_call — before a tool is dispatched: vote with event[:guardrail].deny!/ask!
22
25
  # (or the older event[:blocked]=true + event[:block_reason]); the
23
26
  # event also has context: and targets: (docs/guardrails.md)
@@ -255,9 +255,14 @@ module Samagotchi
255
255
  # Fire :before_generation hook
256
256
  gen_event = { type: :before_generation, iteration: iteration_index + 1 }
257
257
  fire_hook(:before_generation, gen_event) if @hooks
258
- response = @client.complete(
259
- prompt,
260
- **complete_kwargs(
258
+ # The request runs under the generation's own controller: a plugin's
259
+ # stop_generation cuts it alone, and the turn goes on (a cut).
260
+ buffer_mark = partial_assistant_buffer.length
261
+ cut = nil
262
+ response = with_generation(cancel_controller) do |generation_controller|
263
+ request_generation(
264
+ prompt,
265
+ generation_controller: generation_controller,
261
266
  cancel_controller: cancel_controller,
262
267
  model_name: resolved_model_name,
263
268
  on_chunk: lambda { |chunk|
@@ -295,7 +300,43 @@ module Samagotchi
295
300
  },
296
301
  images: images
297
302
  )
298
- )
303
+ rescue Client::RequestCancelled
304
+ raise unless cut?(generation_controller, cancel_controller)
305
+
306
+ cut = generation_controller.detail || {}
307
+ ""
308
+ end
309
+ if cut
310
+ # The cut stream may have stopped mid-block, and its visible text
311
+ # goes with it: a fresh splitter, the buffer as it was before.
312
+ stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
313
+ partial_assistant_buffer.slice!(buffer_mark..)
314
+ emit_stream_event(on_stream_event, type: :generation_completed, iteration: iteration_index + 1,
315
+ content_length: 0, thinking_chars: streamed_thinking,
316
+ served_model: served_model, requested_model: resolved_model_name,
317
+ finish_reason: "stopped", stopped_by: cut[:by], stop_reason: cut[:reason])
318
+ # A Stop that came right after the cut is a plain cancel.
319
+ raise Client::RequestCancelled.new(cancel_controller.reason) if cancel_controller.cancelled?
320
+
321
+ if empty_retries < empty_retry_limit
322
+ # Queued input (the user's line, a plugin's steer) goes in place of the nudge.
323
+ next if inject_pending_input!(conversation, pending_input, on_stream_event, iteration_index + 1, cancel_controller)
324
+
325
+ empty_retries += 1
326
+ @retry_generation = true
327
+ emit_stream_event(on_stream_event, type: :empty_answer_retry, iteration: iteration_index + 1,
328
+ attempt: empty_retries, of: empty_retry_limit,
329
+ thinking_chars: streamed_thinking, stopped_by: cut[:by])
330
+ conversation << TurnNote.cut_retry(cut[:by], cut[:reason])
331
+ next
332
+ end
333
+ # No retry left: the turn ends as cancelled (hook), with nothing
334
+ # salvaged and without the spent nudge.
335
+ drop_last_empty_retry!(conversation)
336
+ cancel_controller.cancel!(:hook)
337
+ emit_stream_event(on_stream_event, type: :generation_cancelled, iteration: iteration_index + 1, reason: :hook)
338
+ return cancelled_result(conversation, tool_activity: tool_activity, reason: :hook, partial_assistant_text: "")
339
+ end
299
340
  # What the server's prompt count covers, so the next estimate adds
300
341
  # only what the turn appended since (answer, tool results).
301
342
  context_state[:counted] = { chars: prompt.length, image_tokens: image_tokens } if generation_usage
@@ -538,6 +579,25 @@ module Samagotchi
538
579
  self.class.resolve_output_char_cap(override)
539
580
  end
540
581
 
582
+ # One request, cancelled by +generation_controller+ (the turn's child)
583
+ # when there is one.
584
+ def request_generation(prompt, generation_controller:, cancel_controller:, **options)
585
+ @client.complete(prompt, **complete_kwargs(cancel_controller: generation_controller || cancel_controller, **options))
586
+ end
587
+
588
+ # Yields the turn controller's child for one generation (nil without a
589
+ # controller).
590
+ def with_generation(cancel_controller, &block)
591
+ return yield(nil) unless cancel_controller
592
+
593
+ cancel_controller.generation(&block)
594
+ end
595
+
596
+ # The generation was cut (stop_generation) and the turn goes on.
597
+ def cut?(generation_controller, cancel_controller)
598
+ generation_controller&.cancelled? && !cancel_controller.cancelled?
599
+ end
600
+
541
601
  def complete_kwargs(cancel_controller:, model_name: nil, on_chunk: nil, on_retry: nil, images: [])
542
602
  kwargs = {}
543
603
  # Only a request with images names them: a text-only call is unchanged.
@@ -903,8 +963,7 @@ module Samagotchi
903
963
  end
904
964
 
905
965
  def drop_last_empty_retry!(conversation)
906
- nudge = TurnNote.empty_retry
907
- index = conversation.rindex { |entry| entry[:kind] == nudge[:kind] && entry[:content] == nudge[:content] }
966
+ index = conversation.rindex { |entry| TurnNote.retry_nudge?(entry) }
908
967
  conversation.delete_at(index) if index
909
968
  end
910
969
 
@@ -221,6 +221,7 @@ module Samagotchi
221
221
  message[:tool_params] = entry[:tool_params] if entry[:tool_params]
222
222
  message[:tool_labels] = entry[:tool_labels] if entry[:tool_labels]
223
223
  message[:tool_diffs] = entry[:tool_diffs] if entry[:tool_diffs]
224
+ message[TurnNote::RETRY_NUDGE] = true if TurnNote.retry_nudge?(entry)
224
225
  message[AnswerDisplay::KEY] = entry[AnswerDisplay::KEY] if entry[AnswerDisplay::KEY]
225
226
  ContextNote::KEYS.each { |key| message[key] = entry[key] if entry.key?(key) }
226
227
  message
@@ -336,6 +337,13 @@ module Samagotchi
336
337
  response, partial = generate(iteration)
337
338
  return canceled(iteration, response, partial) if partial
338
339
 
340
+ if response.cut
341
+ outcome = after_cut(iteration, response)
342
+ next if outcome == :retry
343
+
344
+ return outcome
345
+ end
346
+
339
347
  last_text = @loop.strip_model_thought(response.text)
340
348
  if response.tool_calls.empty?
341
349
  # Kept before a merge too: the model answers the merged line
@@ -393,6 +401,32 @@ module Samagotchi
393
401
  true
394
402
  end
395
403
 
404
+ # A generation a plugin cut (stop_generation) is an empty answer made
405
+ # early: asked again with its own nudge while the retry budget lasts
406
+ # (queued input goes in place of the nudge), else the turn ends as
407
+ # cancelled (hook), with nothing salvaged and without a spent nudge.
408
+ # A Stop that came right after the cut is a plain cancel. Returns
409
+ # :retry, or the turn's result.
410
+ def after_cut(iteration, response)
411
+ return canceled(iteration, @cancel_controller.reason) if @cancel_controller.cancelled?
412
+
413
+ if @empty_retries < @empty_retry_limit
414
+ return :retry if inject_pending_input(iteration)
415
+
416
+ @empty_retries += 1
417
+ @retry_generation = true
418
+ emit(type: :empty_answer_retry, iteration: iteration, attempt: @empty_retries, of: @empty_retry_limit,
419
+ finish_reason: response.finish_reason, thinking_chars: response.reasoning.to_s.length,
420
+ stopped_by: response.cut[:by])
421
+ @conversation << TurnNote.cut_retry(response.cut[:by], response.cut[:reason])
422
+ return :retry
423
+ end
424
+ index = @conversation.rindex { |entry| TurnNote.retry_nudge?(entry) }
425
+ @conversation.delete_at(index) if index
426
+ @cancel_controller.cancel!(:hook)
427
+ canceled(iteration, :hook)
428
+ end
429
+
396
430
  # The host's reasoning, kept on the model message as +thinking+ for
397
431
  # the web turn view's reload (the whole of it, as the live view
398
432
  # shows). Only saved: #assistant_message builds the wire message from
@@ -412,15 +446,22 @@ module Samagotchi
412
446
  context_window_source: window&.source)
413
447
  @loop.fire_hook(:before_generation, { type: :before_generation, iteration: iteration })
414
448
  streamed = +""
415
- response = begin
416
- request(iteration, retry_generation, streamed)
417
- rescue BadRequest => e
418
- raise unless thinking_refused?(e)
419
-
420
- # Once per model: asked again without the thinking fields.
421
- @loop.thinking_refused!(@model_name)
422
- emit(type: :thinking_refused, iteration: iteration, model: @model_name, level: @loop.thinking, detail: e.detail)
423
- request(iteration, retry_generation, streamed)
449
+ thought = +""
450
+ response = with_generation do |generation_controller|
451
+ begin
452
+ request(iteration, retry_generation, streamed, thought, generation_controller)
453
+ rescue BadRequest => e
454
+ raise unless thinking_refused?(e)
455
+
456
+ # Once per model: asked again without the thinking fields.
457
+ @loop.thinking_refused!(@model_name)
458
+ emit(type: :thinking_refused, iteration: iteration, model: @model_name, level: @loop.thinking, detail: e.detail)
459
+ request(iteration, retry_generation, streamed, thought, generation_controller)
460
+ end
461
+ rescue RequestCancelled
462
+ raise unless generation_controller&.cancelled? && !@cancel_controller.cancelled?
463
+
464
+ return cut_response(iteration, thought, generation_controller.detail || {})
424
465
  end
425
466
  record_context_status(response.usage, window)
426
467
  emit(type: :generation_completed, iteration: iteration, content_length: response.text.length,
@@ -434,13 +475,34 @@ module Samagotchi
434
475
  [e.reason, streamed]
435
476
  end
436
477
 
437
- def request(iteration, retry_generation, streamed)
478
+ # The request runs under the generation's own controller (the turn
479
+ # controller's child), so a plugin's stop_generation cuts it alone.
480
+ def with_generation(&block)
481
+ return yield(nil) unless @cancel_controller
482
+
483
+ @cancel_controller.generation(&block)
484
+ end
485
+
486
+ # The empty response of a cut generation: its spinners close as any
487
+ # generation's (finish_reason stopped, stopped_by the bundle); no
488
+ # after_generation (there is no answer to see), and no usage (the
489
+ # stream never sent its last chunk).
490
+ def cut_response(iteration, thought, cut)
491
+ emit(type: :generation_completed, iteration: iteration, content_length: 0, thinking_chars: thought.length,
492
+ requested_model: @model_name, finish_reason: "stopped", stopped_by: cut[:by], stop_reason: cut[:reason])
493
+ response = ChatResponse.new(text: "", reasoning: thought, tool_calls: [], usage: nil, finish_reason: "stopped",
494
+ cut: cut)
495
+ [response, nil]
496
+ end
497
+
498
+ def request(iteration, retry_generation, streamed, thought, generation_controller)
438
499
  @loop.adapter.chat(
439
500
  messages: @loop.wire_messages(@conversation), tools: @loop.tool_definitions, model: @model_name,
440
- cancel_controller: @cancel_controller, session_id: @loop.session_id,
501
+ cancel_controller: generation_controller || @cancel_controller, session_id: @loop.session_id,
441
502
  options: @loop.request_options(retry_generation: retry_generation, model: @model_name),
442
503
  on_delta: lambda { |content:, reasoning:, payload:|
443
504
  streamed << content
505
+ thought << reasoning
444
506
  emit(type: :generation_chunk, iteration: iteration, content: reasoning + content, text: content,
445
507
  thinking: reasoning, payload: payload)
446
508
  },
@@ -34,8 +34,10 @@ module Samagotchi
34
34
  # ([] when none), usage (never nil) and the finish reason.
35
35
  # +model+ is the model the server says answered (the body's `model`),
36
36
  # which can differ from the one asked for; nil when it says nothing.
37
- ChatResponse = Data.define(:text, :reasoning, :tool_calls, :usage, :finish_reason, :model) do
38
- def initialize(model: nil, **fields) = super
37
+ # +cut+ is set on the empty response the chat loop makes for a
38
+ # generation a plugin cut (stop_generation): {by:, reason:}.
39
+ ChatResponse = Data.define(:text, :reasoning, :tool_calls, :usage, :finish_reason, :model, :cut) do
40
+ def initialize(model: nil, cut: nil, **fields) = super
39
41
  end
40
42
 
41
43
  # The OpenAI Chat Completions API (llama.cpp's /v1, and any compatible
@@ -218,7 +220,8 @@ module Samagotchi
218
220
  SamplingSettings.log_text(body.except(:model, :messages, :stream, :stream_options, :tools, :tool_choice))
219
221
  end
220
222
 
221
- # A String stays a String; an Array of parts passes as given.
223
+ # A String stays a String; an Array of parts passes as given, its text
224
+ # parts scrubbed like a String.
222
225
  def wire_message(message)
223
226
  wire = message.to_h.transform_keys(&:to_sym)
224
227
  wire[:content] = format_content(wire[:content]) if wire.key?(:content)
@@ -226,11 +229,20 @@ module Samagotchi
226
229
  end
227
230
 
228
231
  def format_content(content)
229
- return content if content.is_a?(Array) || content.nil?
232
+ return content if content.nil?
233
+ return content.map { |part| scrub_part(part) } if content.is_a?(Array)
230
234
 
231
235
  scrub(content.to_s)
232
236
  end
233
237
 
238
+ def scrub_part(part)
239
+ return part unless part.is_a?(Hash)
240
+
241
+ part.to_h do |key, value|
242
+ [key, key.to_s == "text" && value.is_a?(String) ? scrub(value) : value]
243
+ end
244
+ end
245
+
234
246
  # Invalid UTF-8 (a tool's garbled output) would make to_json raise.
235
247
  def scrub(text)
236
248
  text.encoding == Encoding::UTF_8 && !text.valid_encoding? ? text.scrub("?") : text