samagotchi 0.32.0 → 0.34.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +75 -1
  3. data/README.md +4 -1
  4. data/docs/context.md +8 -1
  5. data/docs/guardrails.md +31 -1
  6. data/docs/hooks.md +7 -4
  7. data/docs/memory.md +17 -1
  8. data/docs/plugins.md +79 -3
  9. data/docs/releasing.md +3 -2
  10. data/docs/sessions.md +9 -3
  11. data/lib/samagotchi/answer_command.rb +2 -1
  12. data/lib/samagotchi/archive_store.rb +1 -15
  13. data/lib/samagotchi/bridge.rb +8 -3
  14. data/lib/samagotchi/bundles/coordinator/manifest.yml +14 -0
  15. data/lib/samagotchi/bundles/coordinator/plugin.rb +114 -0
  16. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +60 -0
  17. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  18. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  19. data/lib/samagotchi/bundles/mcp/plugin.rb +10 -11
  20. data/lib/samagotchi/bundles/system/delegated.md +1 -1
  21. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  22. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  23. data/lib/samagotchi/bundles/system/self_map.md +4 -2
  24. data/lib/samagotchi/child_reports.rb +31 -10
  25. data/lib/samagotchi/children_status.rb +157 -0
  26. data/lib/samagotchi/client_id.rb +52 -0
  27. data/lib/samagotchi/context_absorber.rb +2 -1
  28. data/lib/samagotchi/context_fetch.rb +14 -45
  29. data/lib/samagotchi/context_sources.rb +20 -10
  30. data/lib/samagotchi/cut_policy.rb +58 -0
  31. data/lib/samagotchi/engine.rb +24 -108
  32. data/lib/samagotchi/engine_peers.rb +30 -0
  33. data/lib/samagotchi/guardrail_wiring.rb +27 -2
  34. data/lib/samagotchi/guardrails/approvals.rb +7 -0
  35. data/lib/samagotchi/guardrails/child_boundary.rb +103 -0
  36. data/lib/samagotchi/guardrails/context.rb +32 -0
  37. data/lib/samagotchi/guardrails/parent_approvals.rb +0 -2
  38. data/lib/samagotchi/guardrails/shared_git.rb +124 -0
  39. data/lib/samagotchi/guardrails.rb +1 -0
  40. data/lib/samagotchi/kernel_loop.rb +9 -22
  41. data/lib/samagotchi/llm/chat_loop.rb +11 -22
  42. data/lib/samagotchi/memory_bundle/builder.rb +35 -1
  43. data/lib/samagotchi/memory_bundle/installer.rb +6 -2
  44. data/lib/samagotchi/memory_bundle/manifest.rb +28 -5
  45. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  46. data/lib/samagotchi/memory_paths.rb +10 -0
  47. data/lib/samagotchi/plugin/api.rb +2 -2
  48. data/lib/samagotchi/plugin/sessions.rb +47 -2
  49. data/lib/samagotchi/process_group.rb +157 -0
  50. data/lib/samagotchi/prompt_warmup.rb +20 -1
  51. data/lib/samagotchi/question_desk.rb +2 -1
  52. data/lib/samagotchi/relay_verifier.rb +2 -4
  53. data/lib/samagotchi/reply_wait.rb +9 -4
  54. data/lib/samagotchi/send_command.rb +2 -1
  55. data/lib/samagotchi/session_manager.rb +2 -4
  56. data/lib/samagotchi/steer.rb +27 -19
  57. data/lib/samagotchi/steer_cut.rb +125 -0
  58. data/lib/samagotchi/system_prompt.rb +20 -8
  59. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -4
  60. data/lib/samagotchi/terminal_ui/formatting.rb +21 -12
  61. data/lib/samagotchi/terminal_ui.rb +2 -1
  62. data/lib/samagotchi/tool_declarations.rb +9 -5
  63. data/lib/samagotchi/tools/builtins.rb +2 -2
  64. data/lib/samagotchi/tools/delegate.rb +105 -16
  65. data/lib/samagotchi/tools/delegate_wait.rb +55 -13
  66. data/lib/samagotchi/tools/execute.rb +10 -81
  67. data/lib/samagotchi/tools/task_runtime.rb +25 -38
  68. data/lib/samagotchi/turn_note.rb +9 -1
  69. data/lib/samagotchi/version.rb +1 -1
  70. data/lib/samagotchi/web/app.rb +2 -1
  71. data/lib/samagotchi/web/public/app.js +19 -20
  72. data/lib/samagotchi/web/public/data.js +3 -0
  73. data/lib/samagotchi/web/public/question_card.js +3 -4
  74. data/lib/samagotchi/web/public/shown_texts.js +13 -0
  75. data/lib/samagotchi/web/public/stable_html.js +18 -0
  76. data/lib/samagotchi/web/public/turn_events.js +9 -2
  77. data/lib/samagotchi/web/public/turn_view.js +1 -1
  78. data/lib/samagotchi/worker.rb +40 -13
  79. metadata +14 -1
@@ -4,6 +4,7 @@ require "fileutils"
4
4
  require_relative "context_sources"
5
5
  require_relative "context_providers"
6
6
  require_relative "log"
7
+ require_relative "process_group"
7
8
 
8
9
  module Samagotchi
9
10
  # Runs an attached context source's command once and records the result
@@ -117,47 +118,26 @@ module Samagotchi
117
118
 
118
119
  out_r, out_w = IO.pipe
119
120
  err_r, err_w = IO.pipe
120
- pid = Process.spawn(env, "sh", "-c", cmd, chdir: cwd, pgroup: true, in: File::NULL, out: out_w, err: err_w)
121
+ pid = ProcessGroup.spawn(env, "sh", "-c", cmd, chdir: cwd, in: File::NULL, out: out_w, err: err_w)
121
122
  out_w.close
122
123
  err_w.close
123
- output = +""
124
- over = false
125
- out_reader = Thread.new do
126
- loop do
127
- chunk = out_r.readpartial(READ_CHUNK)
128
- if output.bytesize + chunk.bytesize > ContextSources::TEXT_MAX_BYTES
129
- over = true
130
- break
131
- end
132
- output << chunk
133
- end
134
- rescue IOError # EOFError too
135
- nil
136
- end
137
- stderr = +""
138
- err_reader = Thread.new do
139
- loop do
140
- stderr << err_r.readpartial(READ_CHUNK)
141
- stderr = stderr.byteslice(-STDERR_TAIL_BYTES, STDERR_TAIL_BYTES) if stderr.bytesize > STDERR_TAIL_BYTES
142
- end
143
- rescue IOError # EOFError too
144
- nil
145
- end
124
+ out_reader = ProcessGroup::PipeReader.new(out_r, cap: ContextSources::TEXT_MAX_BYTES, chunk: READ_CHUNK)
125
+ err_reader = ProcessGroup::PipeReader.new(err_r, cap: STDERR_TAIL_BYTES, keep: :tail, chunk: READ_CHUNK)
146
126
 
147
- error, status = wait(pid, timeout: timeout, over: -> { over }, cancelled: cancelled)
127
+ error, status = wait(pid, timeout: timeout, over: -> { out_reader.over? }, cancelled: cancelled)
148
128
  reaped = true
149
129
  # Whatever the command left running in its group goes with it.
150
- signal_group(pid, "KILL")
151
- [out_reader, err_reader].each { |thread| thread.join(KILL_GRACE_SECONDS) || thread.kill }
152
- stderr_text = stderr.dup.force_encoding(Encoding::UTF_8).scrub
130
+ ProcessGroup.signal(pid, "KILL")
131
+ [out_reader, err_reader].each { |reader| reader.wait(KILL_GRACE_SECONDS) || reader.kill }
132
+ stderr_text = err_reader.text(Encoding::UTF_8).scrub
153
133
  Log.debug(:context, "stderr", text: stderr_text[-1000..] || stderr_text) unless stderr_text.strip.empty?
154
134
  return Run.new(output: nil, error: nil, stderr: stderr_text, cancelled: true) if error == :cancelled
155
135
 
156
136
  # Checked again once the reader is done: a command that exits before
157
137
  # wait's next tick never gets an over from it.
158
- error = OVER_CAP if over
138
+ error = OVER_CAP if out_reader.over?
159
139
  error ||= exit_error(status, stderr_text)
160
- Run.new(output: output, error: error, stderr: stderr_text, cancelled: false)
140
+ Run.new(output: out_reader.text, error: error, stderr: stderr_text, cancelled: false)
161
141
  rescue SystemCallError => e
162
142
  Run.new(output: nil, error: "couldn't run it: #{e.message}", stderr: "", cancelled: false)
163
143
  ensure
@@ -188,33 +168,22 @@ module Samagotchi
188
168
 
189
169
  def abandon(pid, readers)
190
170
  stop_group(pid)
191
- readers.each { |thread| thread&.kill }
171
+ readers.each { |reader| reader&.kill }
192
172
  rescue StandardError => e
193
173
  Log.warn(:context, "stop_failed", pid: pid, error: e.class.name)
194
174
  end
195
175
 
196
176
  # TERM, then KILL after a grace; @return [Process::Status, nil]
197
177
  def stop_group(pid)
198
- signal_group(pid, "TERM")
199
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + KILL_GRACE_SECONDS
200
- until Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
201
- _, status = Process.wait2(pid, Process::WNOHANG)
202
- return status if status
178
+ status = nil
179
+ reaped = -> { status = Process.wait2(pid, Process::WNOHANG)&.last }
180
+ return status if ProcessGroup.stop(pid, grace: KILL_GRACE_SECONDS, poll: 0.05, stopped: reaped)
203
181
 
204
- sleep(0.05)
205
- end
206
- signal_group(pid, "KILL")
207
182
  Process.wait2(pid).last
208
183
  rescue Errno::ECHILD
209
184
  nil
210
185
  end
211
186
 
212
- def signal_group(pid, signal)
213
- Process.kill(signal, -pid)
214
- rescue Errno::ESRCH, Errno::EPERM
215
- nil
216
- end
217
-
218
187
  def exit_error(status, stderr)
219
188
  return nil if status.nil? || status.success?
220
189
 
@@ -67,26 +67,35 @@ module Samagotchi
67
67
  # +serial+ counts the revisions (an absorb that missed some says how
68
68
  # many); +error+ is the last failure, +error_since+ when the failures
69
69
  # began (cleared by a success), so a session hears about each run of
70
- # failures once. +summary+, +wake+ and +hint+ come with the revision.
70
+ # failures once. +summary+, +wake+ and +hint+ come with the revision;
71
+ # +wake_serial+ is the serial of the newest revision that asked to wake
72
+ # (nil: none), so a change that doesn't ask, recorded before an absorb,
73
+ # doesn't hide a wake asked since the session's last one.
71
74
  # +fetched_at+ is the last success, +checked_at+ the last try (the
72
75
  # poller's clock: a failing source isn't run again every tick).
73
- Snapshot = Data.define(:text, :summary, :revision, :serial, :fetched_at, :checked_at, :wake, :hint, :error,
74
- :error_since) do
76
+ Snapshot = Data.define(:text, :summary, :revision, :serial, :fetched_at, :checked_at, :wake, :wake_serial, :hint,
77
+ :error, :error_since) do
75
78
  def self.empty
76
- new(text: nil, summary: nil, revision: nil, serial: 0, fetched_at: nil, checked_at: nil, wake: false, hint: nil,
77
- error: nil, error_since: nil)
79
+ new(text: nil, summary: nil, revision: nil, serial: 0, fetched_at: nil, checked_at: nil, wake: false,
80
+ wake_serial: nil, hint: nil, error: nil, error_since: nil)
78
81
  end
79
82
 
80
83
  def to_h
81
84
  { "text" => text, "summary" => summary, "revision" => revision, "serial" => serial,
82
- "fetched_at" => fetched_at, "checked_at" => checked_at, "wake" => (wake ? true : nil), "hint" => hint,
85
+ "fetched_at" => fetched_at, "checked_at" => checked_at, "wake" => (wake ? true : nil),
86
+ "wake_serial" => wake_serial, "hint" => hint,
83
87
  "error" => error, "error_since" => error_since }.compact
84
88
  end
85
89
 
90
+ # A snapshot 0.32.0 wrote has no wake_serial: its wake is the latest
91
+ # revision's.
86
92
  def self.from_h(data)
87
- new(text: data["text"], summary: data["summary"], revision: data["revision"], serial: data["serial"].to_i,
88
- fetched_at: data["fetched_at"], checked_at: data["checked_at"] || data["fetched_at"],
89
- wake: data["wake"] == true, hint: data["hint"], error: data["error"], error_since: data["error_since"])
93
+ serial = data["serial"].to_i
94
+ wake = data["wake"] == true
95
+ new(text: data["text"], summary: data["summary"], revision: data["revision"], serial: serial,
96
+ fetched_at: data["fetched_at"], checked_at: data["checked_at"] || data["fetched_at"], wake: wake,
97
+ wake_serial: data["wake_serial"]&.to_i || (serial if wake), hint: data["hint"], error: data["error"],
98
+ error_since: data["error_since"])
90
99
  end
91
100
 
92
101
  def text? = !text.nil?
@@ -230,7 +239,8 @@ module Samagotchi
230
239
  else
231
240
  Snapshot.new(text: fetched.text, revision: revision, serial: previous.serial + 1, fetched_at: stamp,
232
241
  checked_at: stamp, summary: fetched.summary || ContextSources.plain_summary(previous.text, fetched.text),
233
- wake: fetched.wake, hint: fetched.hint || previous.hint, error: nil, error_since: nil)
242
+ wake: fetched.wake, wake_serial: fetched.wake ? previous.serial + 1 : previous.wake_serial,
243
+ hint: fetched.hint || previous.hint, error: nil, error_since: nil)
234
244
  end
235
245
  write_snapshot(name, written)
236
246
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "turn_note"
4
+
5
+ module Samagotchi
6
+ # What a turn does after a generation was cut (stop_generation: a
7
+ # plugin's cut, or Engine#cut_for_steer's), shared by the native loop
8
+ # (KernelLoop) and the chat loop (ChatLoop::Run); each maps the outcome to
9
+ # its own ending.
10
+ #
11
+ # A generation a plugin cut is an empty answer made early. Queued input
12
+ # (a user's line, a plugin's steer) goes in first, with or without a
13
+ # retry left, and spends no attempt: the model answers it. A steer's cut
14
+ # with nothing queued (its message was taken at a boundary already) is
15
+ # asked again as is. Else it is asked again with its own nudge while the
16
+ # retry budget lasts, else the turn ends as cancelled (hook), with
17
+ # nothing salvaged and without the spent nudge. A Stop that came right
18
+ # after the cut is a plain cancel.
19
+ module CutPolicy
20
+ # :stopped (a Stop after the cut), :again (the turn goes on) or :hook
21
+ # (the turn ends as cancelled by the cut's bundle).
22
+ Outcome = Data.define(:kind) do
23
+ def stopped? = kind == :stopped
24
+ def again? = kind == :again
25
+ def hook? = kind == :hook
26
+ end
27
+
28
+ STOPPED = Outcome.new(kind: :stopped)
29
+ AGAIN = Outcome.new(kind: :again)
30
+ HOOK = Outcome.new(kind: :hook)
31
+
32
+ module_function
33
+
34
+ # +cut+: the cut's detail (the stop_generation payload). +emit+ takes an
35
+ # event Hash; +inject+ puts queued input in (true when there was any).
36
+ # +finish_reason+ and +thinking_chars+: the cut generation's, for the
37
+ # retry's row.
38
+ # On :hook the turn controller is cancelled (:hook, +cut+) already.
39
+ def decide(cut:, cancel_controller:, empty_retry:, conversation:, iteration:, emit:, inject:, finish_reason:,
40
+ thinking_chars:)
41
+ return STOPPED if cancel_controller.cancelled?
42
+
43
+ # The row under the cut step, above the message it was cut for.
44
+ emit.call({ type: :steer_cut, iteration: iteration, source: cut[:source].to_s }) if cut[:steer]
45
+ return AGAIN if inject.call || cut[:steer]
46
+
47
+ if empty_retry.left?
48
+ empty_retry.nudge!(conversation, TurnNote.cut_retry(cut[:by], cut[:reason]),
49
+ emit: emit, iteration: iteration, finish_reason: finish_reason,
50
+ thinking_chars: thinking_chars, stopped_by: cut[:by])
51
+ return AGAIN
52
+ end
53
+ empty_retry.drop_nudge!(conversation)
54
+ cancel_controller.cancel!(:hook, cut)
55
+ HOOK
56
+ end
57
+ end
58
+ end
@@ -6,6 +6,7 @@ require "securerandom"
6
6
  require "time"
7
7
  require "yaml"
8
8
 
9
+ require_relative "client_id"
9
10
  require_relative "iteration_limit"
10
11
  require_relative "config"
11
12
  require_relative "context_note"
@@ -48,8 +49,8 @@ require_relative "tools/memory"
48
49
  require_relative "muted_memories"
49
50
  require_relative "used_memories"
50
51
  require_relative "turn_state"
51
- require_relative "generation_phase"
52
- require_relative "waiting_steer"
52
+ require_relative "steer_cut"
53
+ require_relative "engine_peers"
53
54
  require_relative "bundle_needs"
54
55
  require_relative "model_overlay"
55
56
  require_relative "served_model"
@@ -108,10 +109,8 @@ module Samagotchi
108
109
  # steers) and the idle layer's activity clock. Built first: a plugin
109
110
  # may steer or ask whether a turn runs while it loads.
110
111
  @turn_state = TurnState.new(clock: -> { monotonic_now })
111
- # What the running generation streams: whether a steer may cut it.
112
- @generation_phase = GenerationPhase.new(clock: -> { monotonic_now })
113
- # A message that may cut it but came too early: it cuts later.
114
- @waiting_steer = WaitingSteer.new
112
+ # A message for the running turn cutting its generation.
113
+ @steer_cut = SteerCut.new(clock: -> { monotonic_now }, controller: -> { active_cancel_controller })
115
114
  @scratch = scratch
116
115
  @no_interrupt = no_interrupt
117
116
  @chat_backend = nil
@@ -342,68 +341,15 @@ module Samagotchi
342
341
  @turn_state.steer(text, source: source)
343
342
  end
344
343
 
345
- # A message for the running turn from +source+ (Steer.source_for_client:
346
- # nil for the user, "chi_send", "parent_agent") cuts the streaming
347
- # generation when it has streamed only thinking for steer.cut_after
348
- # seconds (GenerationPhase): the loops' cut path then starts the step
349
- # again with the message, no nudge, no retry spent. Too early, the
350
- # message waits (WaitingSteer) and cuts once the thinking passes it
351
- # (#recheck_waiting_steer, on each thinking chunk), unless a boundary
352
- # hands it to the model first. A plugin's message never cuts.
353
- # Call it after the message is queued, with the #input_epoch read
354
- # before queueing it. Outside any lock; a cut that lands just after the
355
- # generation ended is harmless (the loop re-asks).
344
+ # A message for the running turn from +source+ cuts the generation
345
+ # streaming only thinking (SteerCut#cut_for_steer). Call it after the
346
+ # message is queued, with the #input_epoch read before queueing it.
356
347
  # @return [Boolean] whether a generation was cut now
357
- def cut_for_steer(source, epoch: nil)
358
- return false unless Steer.cuts?(source)
348
+ def cut_for_steer(source, epoch: nil) = @steer_cut.cut_for_steer(source, epoch: epoch)
359
349
 
360
- after = Config.get("steer.cut_after").to_i
361
- return false unless after.positive?
362
-
363
- ctrl = active_cancel_controller
364
- return false unless ctrl
365
- return steer_cut!(ctrl, source.to_s) if @generation_phase.cuttable?(after)
366
-
367
- Log.info(:turn, "steer_cut_waits", source: source.to_s) if @waiting_steer.wait!(source, epoch: epoch)
368
- false
369
- rescue StandardError
370
- false
371
- end
372
-
373
- # The drains that took input so far (WaitingSteer#epoch): read it before
374
- # queueing a message for #cut_for_steer.
375
- def input_epoch
376
- @waiting_steer.epoch
377
- end
378
-
379
- # A thinking chunk streamed: a waiting message cuts once the thinking
380
- # passes steer.cut_after.
381
- def recheck_waiting_steer
382
- return unless @waiting_steer.waiting?
383
-
384
- after = Config.get("steer.cut_after").to_i
385
- return unless after.positive? && @generation_phase.cuttable?(after)
386
-
387
- source = @waiting_steer.take
388
- ctrl = active_cancel_controller
389
- steer_cut!(ctrl, source, waited: true) if source && ctrl
390
- rescue StandardError
391
- nil
392
- end
393
- private :recheck_waiting_steer
394
-
395
- def steer_cut!(ctrl, source, waited: false)
396
- age = @generation_phase.age
397
- cut = ctrl.cancel_generation!(:steer, { by: "steer", steer: true, source: source, reason: "a new message" })
398
- if cut
399
- @waiting_steer.clear!
400
- fields = { source: source, age: age&.round(1) }
401
- fields[:waited] = true if waited
402
- Log.info(:turn, "steer_cut", **fields)
403
- end
404
- cut
405
- end
406
- private :steer_cut!
350
+ # The drains that took input so far: read it before queueing a message
351
+ # for #cut_for_steer.
352
+ def input_epoch = @steer_cut.input_epoch
407
353
 
408
354
  # Like #steer, for the turn that begins next (a continue turn answered
409
355
  # with a text): it joins that turn at its first boundary. Callable from
@@ -430,7 +376,7 @@ module Samagotchi
430
376
  lambda do |at_answer: false|
431
377
  lines = pending_input ? Array(pending_input.call) : []
432
378
  # The input went to the model: a message that waited to cut is in.
433
- @waiting_steer.delivered! unless lines.empty?
379
+ @steer_cut.delivered! unless lines.empty?
434
380
  lines + take_steers(at_answer)
435
381
  end
436
382
  end
@@ -811,6 +757,7 @@ module Samagotchi
811
757
  # #stop_idle's work (the scheduler's stop is idempotent), where the
812
758
  # callers haven't stopped it already.
813
759
  @idle_scheduler&.stop
760
+ @prompt_warmup.stop
814
761
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + join_timeout
815
762
  threads.each do |thread|
816
763
  next if thread == Thread.current
@@ -1183,17 +1130,18 @@ module Samagotchi
1183
1130
  private :hook_messages
1184
1131
 
1185
1132
  # How the turn ended, on the session (Session#last_turn): the caller's
1186
- # save puts it in the file the session hub watches.
1133
+ # save puts it in the file the session hub watches. Public for the
1134
+ # Worker's turn that failed before it began (no #end_turn ran).
1187
1135
  # @param limit [Integer, nil] the turn ran out of iterations at this
1188
1136
  # limit: "exhausted" => true, "limit" => N, so a wait with nobody to
1189
1137
  # answer the continue offer says so (ReplyWait, ParentReport "limit")
1190
1138
  # @param event [Hash] the turn's end event (#stop_facts)
1191
1139
  def record_last_turn(session, outcome, seconds, origin, event, limit: nil)
1192
1140
  client_id = origin.is_a?(Hash) ? origin[:client_id].to_s : ""
1193
- source = if client_id.start_with?("#{Tools::Delegate::CLIENT_PREFIX}:") then "delegate"
1194
- elsif client_id.start_with?(ChildReports::CLIENT_PREFIX) then "delegate_report"
1195
- elsif client_id.start_with?(Steer::CONTEXT_CLIENT_PREFIX) then "context"
1196
- elsif client_id == SessionManager::REMINDER_CLIENT_ID then "reminder"
1141
+ source = if client_id.start_with?(ClientId::DELEGATE_PREFIX) then "delegate"
1142
+ elsif client_id.start_with?(ClientId::CHILD_PREFIX) then "delegate_report"
1143
+ elsif client_id.start_with?(ClientId::CONTEXT_PREFIX) then "context"
1144
+ elsif client_id == ClientId::REMINDER then "reminder"
1197
1145
  else "client"
1198
1146
  end
1199
1147
  session.last_turn = { "outcome" => outcome, "ended_at" => Time.now.iso8601(3),
@@ -1201,7 +1149,6 @@ module Samagotchi
1201
1149
  session.last_turn.merge!("exhausted" => true, "limit" => limit) if limit
1202
1150
  session.last_turn.merge!(stop_facts(event))
1203
1151
  end
1204
- private :record_last_turn
1205
1152
 
1206
1153
  # Why a turn stopped, from its end event, for a parent agent's wait
1207
1154
  # (ReplyWait): a provider error's kind and whether a retry may help, a
@@ -1456,28 +1403,6 @@ module Samagotchi
1456
1403
  # @return [Session] current session (Engine owns create/resume)
1457
1404
  attr_reader :session
1458
1405
 
1459
- # The kernel's Tools::Peers, following the current session. cancelled?
1460
- # is the running turn's cancel, for a tool that waits (delegate_result):
1461
- # the controller is set from another thread and a tool gets no other
1462
- # way to see it.
1463
- PeerView = Struct.new(:engine) do
1464
- def session_id = engine.session&.id
1465
- def cwd = engine.session&.working_directory
1466
- def project_root = engine.session&.project_root
1467
- def state_dir = engine.peer_state_dir
1468
- def cancelled? = !!engine.active_cancel_controller&.cancelled?
1469
- def relay = engine.relay_peer
1470
- def model_ref = engine.effective_model_ref
1471
- end
1472
-
1473
- # What the approval relay needs from the parent's Engine (Peers#relay):
1474
- # its own question flow, which every UI attached to it answers.
1475
- RelayPeer = Struct.new(:engine) do
1476
- def open_question(fields, watch: nil) = engine.open_question(fields, watch: watch)
1477
- def interface = engine.interface
1478
- def relay_desk = engine.relay_desk
1479
- end
1480
-
1481
1406
  # This worker's relays of its delegates' approvals (memory only).
1482
1407
  # @return [RelayDesk]
1483
1408
  attr_reader :relay_desk
@@ -2012,8 +1937,7 @@ module Samagotchi
2012
1937
  # shared inactivity clock so the idle recap detector (shared with the REPL)
2013
1938
  # treats the just-finished turn as activity and re-arms its window.
2014
1939
  left = @turn_state.finish!
2015
- @generation_phase.finished!
2016
- @waiting_steer.clear!
1940
+ @steer_cut.finished!
2017
1941
  Client.swap_probe_cancel(probe_cancel_before)
2018
1942
  # The next turn's window and served model are asked again: the cache
2019
1943
  # (this host's, process-wide) is dropped here, at the turn's end,
@@ -2470,21 +2394,13 @@ module Samagotchi
2470
2394
  def build_stream_event_handler(on_event, cancel_controller: nil)
2471
2395
  watch = stream_watch(cancel_controller)
2472
2396
  proc do |event|
2473
- progress = nil
2397
+ progress = SteerCut.lanes(event) if event[:type] == :generation_chunk
2398
+ @steer_cut.observe(event, progress)
2474
2399
  case event[:type]
2475
2400
  when :generation_started
2476
- @generation_phase.started!
2477
2401
  @generation_thought = nil
2478
2402
  watch&.started(event[:iteration])
2479
- when :generation_chunk
2480
- # A chunk without the lanes (no loop of ours sends one) counts as text.
2481
- text = event.key?(:text) ? event[:text] : event[:content]
2482
- progress = { thinking: event[:thinking].to_s, text: text.to_s }
2483
- @generation_phase.chunk!(**progress, tool_call: event[:tool_call])
2484
- recheck_waiting_steer unless progress[:thinking].empty?
2485
- note_generation_thought(progress[:thinking])
2486
- when :generation_retrying then @generation_phase.retrying!
2487
- when :generation_completed, :generation_cancelled then @generation_phase.finished!
2403
+ when :generation_chunk then note_generation_thought(progress[:thinking])
2488
2404
  end
2489
2405
  # The chat loop asked again without the thinking fields: a notice,
2490
2406
  # not an event of its own.
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # The views of an Engine its peers get: the kernel's tools
5
+ # (Engine#initialize sets @kernel.peers) and a delegate's approval relay
6
+ # (Engine#relay_peer).
7
+ class Engine
8
+ # The kernel's Tools::Peers, following the current session. cancelled?
9
+ # is the running turn's cancel, for a tool that waits (delegate_result):
10
+ # the controller is set from another thread and a tool gets no other
11
+ # way to see it.
12
+ PeerView = Struct.new(:engine) do
13
+ def session_id = engine.session&.id
14
+ def cwd = engine.session&.working_directory
15
+ def project_root = engine.session&.project_root
16
+ def state_dir = engine.peer_state_dir
17
+ def cancelled? = !!engine.active_cancel_controller&.cancelled?
18
+ def relay = engine.relay_peer
19
+ def model_ref = engine.effective_model_ref
20
+ end
21
+
22
+ # What the approval relay needs from the parent's Engine (Peers#relay):
23
+ # its own question flow, which every UI attached to it answers.
24
+ RelayPeer = Struct.new(:engine) do
25
+ def open_question(fields, watch: nil) = engine.open_question(fields, watch: watch)
26
+ def interface = engine.interface
27
+ def relay_desk = engine.relay_desk
28
+ end
29
+ end
30
+ end
@@ -104,11 +104,26 @@ module Samagotchi
104
104
  )
105
105
  end
106
106
 
107
- # The gate's core checks, in order.
107
+ # The gate's core checks, in order. A delegate child's boundary comes
108
+ # before the rules, so its ask keeps the child-boundary rule id (and
109
+ # its once/session scopes) when a strict rule asks too.
108
110
  def checks
109
111
  rules = self.rules
110
112
  [@failures, rules.hook_asks, protected_paths, (@scratch_writes ||= Guardrails::ScratchWrites.new if @scratch),
111
- rules].compact
113
+ child_boundary, rules].compact
114
+ end
115
+
116
+ # The delegate child's boundary (Guardrails::ChildBoundary); nil for
117
+ # any other session.
118
+ def child_boundary
119
+ return nil unless @session_lookup.call&.delegate?
120
+
121
+ @child_boundary ||= Guardrails::ChildBoundary.new(
122
+ root: -> { boundary_root },
123
+ worktrees: -> { (root = boundary_root) ? @git.worktrees(root) : [] },
124
+ common_dir: -> { (root = boundary_root) && @git.common_dir(root) },
125
+ branch: -> { (root = boundary_root) && @git.branch(root) }
126
+ )
112
127
  end
113
128
 
114
129
  # The YAML rules: config.yml's `guardrails:` section (rules, disable) and
@@ -221,6 +236,16 @@ module Samagotchi
221
236
 
222
237
  private
223
238
 
239
+ # The work tree top of the session's working_directory (the folder
240
+ # itself outside a repo, or when it's gone); never Dir.pwd, which is
241
+ # the waker's cwd for a worker whose folder is gone.
242
+ def boundary_root
243
+ dir = @session_lookup.call&.working_directory.to_s
244
+ return nil if dir.empty?
245
+
246
+ @git.root(dir) || File.expand_path(dir)
247
+ end
248
+
224
249
  # The rules of a bundle that needs a newer chi: the ones last loaded
225
250
  # from it, with one notice per bundle version (restart the session to
226
251
  # load the new ones). Never loaded (a chi older than the bundle it
@@ -26,6 +26,10 @@ module Samagotchi
26
26
  class Approvals
27
27
  FILE = "approvals.json"
28
28
  STORED_SCOPES = %w[session repo rule].freeze
29
+ # Rules a stored answer allows only for its session: their asks offer
30
+ # once/session, and a repo entry for the same call (from another
31
+ # rule) would hold in every worktree, for every child.
32
+ SESSION_ONLY_RULES = %w[child-boundary].freeze
29
33
 
30
34
  # @param sessions_dir [String] Session's state dir
31
35
  # ($XDG_STATE_HOME/samagotchi/sessions): the store sits beside it,
@@ -69,7 +73,10 @@ module Samagotchi
69
73
  repo = verdict.targets&.repo
70
74
  here = ->(e) { e["repo"] ? e["repo"] == repo : e["repo_root"] == place }
71
75
  session_id = verdict.context&.session_id
76
+ session_only = SESSION_ONLY_RULES.include?(verdict.rule.to_s)
72
77
  list.find do |e|
78
+ next false if session_only && e["scope"] != "session"
79
+
73
80
  case e["scope"]
74
81
  when "session" then session_id && e["session_id"] == session_id && e["key"] == key
75
82
  when "repo" then here.call(e) && e["key"] == key
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "outside"
4
+ require_relative "protected_paths"
5
+ require_relative "shell_lex"
6
+ require_relative "shell_paths"
7
+ require_relative "shared_git"
8
+
9
+ module Samagotchi
10
+ module Guardrails
11
+ # A delegate child asks before it changes anything outside its folder
12
+ # (+root+: the work tree top of its working_directory, never Dir.pwd,
13
+ # so a child whose worktree is gone still has one). A core check, on in
14
+ # every guardrails mode and with guardrails.enabled: false, for delegate
15
+ # sessions only (GuardrailWiring#checks), so ordinary sessions and a
16
+ # parent's own flows never get it. It asks, once or for the session
17
+ # (a repo or rule answer would hold in every worktree, for every child),
18
+ # when:
19
+ # write, edit: a path is outside root (Outside: tmp dirs and memory
20
+ # files never are);
21
+ # execute, task_create: mutating git runs in a dir outside root
22
+ # (ShellGitDirs), or a command that isn't read-only starts outside
23
+ # root (its cwd:, else Dir.pwd) or names a path in the repository's
24
+ # other checkouts (the main one and sibling worktrees) or its
25
+ # common git dir; or it runs git that changes what all checkouts
26
+ # share, from anywhere (SharedGit: config, update-ref, worktree add,
27
+ # stash clear, tags, another branch's delete).
28
+ # Paths elsewhere in a command (/usr/bin, ~/.gem) don't count, and
29
+ # read-only commands never ask. Text heuristics: sh -c bodies, scripts
30
+ # and $(…) aren't looked into.
31
+ class ChildBoundary
32
+ RULE = "child-boundary"
33
+ SOURCE = "core"
34
+ SCOPES = %w[once session].freeze
35
+ SHELL_TOOLS = %w[execute task_create].freeze
36
+
37
+ # @param root [#call] → String, nil: the child's folder (nil: no check)
38
+ # @param worktrees [#call] → Array<String>: every checkout of root's
39
+ # repository (GitInfo#worktrees); root's own is left out here
40
+ # @param common_dir [#call] → String, nil: root's common git dir (in
41
+ # a bare layout it is in no checkout)
42
+ # @param branch [#call] → String, nil: the branch checked out in root
43
+ def initialize(root:, worktrees: -> { [] }, common_dir: -> {}, branch: -> {})
44
+ @root = root
45
+ @worktrees = worktrees
46
+ @common_dir = common_dir
47
+ @branch = branch
48
+ end
49
+
50
+ # Vote on +verdict+ (its targets).
51
+ def check(verdict)
52
+ targets = verdict.targets
53
+ root = targets && @root.call
54
+ return verdict unless root && !root.to_s.empty?
55
+
56
+ crossing = crosses?(targets, root) or return verdict
57
+
58
+ what = crossing == :shared ? "git state every checkout of the repository shares, from its folder" : "something outside its folder"
59
+ verdict.ask!("a delegate child changes #{what} #{root}: ask the user",
60
+ scopes: SCOPES, rule: RULE, source: SOURCE, decided_by: "core")
61
+ end
62
+
63
+ private
64
+
65
+ def crosses?(targets, root)
66
+ case targets.tool
67
+ when "write", "edit" then targets.paths.any? { |path| Outside.outside?(path, root: root) }
68
+ when *SHELL_TOOLS then shell_crosses?(targets, root)
69
+ else false
70
+ end
71
+ end
72
+
73
+ def shell_crosses?(targets, root)
74
+ return true if targets.git_dirs.any? { |dir| dir.is_a?(String) && Outside.outside?(dir, root: root) }
75
+ return :shared if SharedGit.changes?(targets.command, own_branch: @branch.call)
76
+ return false if targets.read_only?
77
+ return true if targets.cwd && Outside.outside?(targets.cwd, root: root)
78
+
79
+ names_other_checkout?(targets.command, targets.cwd, root)
80
+ end
81
+
82
+ # Whether a word of +command+ resolves into another checkout of the
83
+ # repository (and not into root, which may sit inside the main one).
84
+ def names_other_checkout?(command, cwd, root)
85
+ real_root = ProtectedPaths.real(root)
86
+ others = [*@worktrees.call, @common_dir.call].compact.map { |dir| ProtectedPaths.real(dir) } - [real_root]
87
+ return false if others.empty?
88
+
89
+ walk = ShellPaths::Walk.new(cwd, Dir.home, ENV)
90
+ ShellLex.simple_commands(ShellLex.lex(command.to_s)).any? do |words|
91
+ walk.step(words).any? do |_word, path|
92
+ next false unless path
93
+
94
+ real = ProtectedPaths.real(path)
95
+ !ShellPaths.within?(real, real_root) && others.any? { |dir| ShellPaths.within?(real, dir) }
96
+ end
97
+ end
98
+ rescue ArgumentError, EncodingError
99
+ false
100
+ end
101
+ end
102
+ end
103
+ end