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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +75 -1
- data/README.md +4 -1
- data/docs/context.md +8 -1
- data/docs/guardrails.md +31 -1
- data/docs/hooks.md +7 -4
- data/docs/memory.md +17 -1
- data/docs/plugins.md +79 -3
- data/docs/releasing.md +3 -2
- data/docs/sessions.md +9 -3
- data/lib/samagotchi/answer_command.rb +2 -1
- data/lib/samagotchi/archive_store.rb +1 -15
- data/lib/samagotchi/bridge.rb +8 -3
- data/lib/samagotchi/bundles/coordinator/manifest.yml +14 -0
- data/lib/samagotchi/bundles/coordinator/plugin.rb +114 -0
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +60 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +10 -11
- data/lib/samagotchi/bundles/system/delegated.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/bundles/system/self_map.md +4 -2
- data/lib/samagotchi/child_reports.rb +31 -10
- data/lib/samagotchi/children_status.rb +157 -0
- data/lib/samagotchi/client_id.rb +52 -0
- data/lib/samagotchi/context_absorber.rb +2 -1
- data/lib/samagotchi/context_fetch.rb +14 -45
- data/lib/samagotchi/context_sources.rb +20 -10
- data/lib/samagotchi/cut_policy.rb +58 -0
- data/lib/samagotchi/engine.rb +24 -108
- data/lib/samagotchi/engine_peers.rb +30 -0
- data/lib/samagotchi/guardrail_wiring.rb +27 -2
- data/lib/samagotchi/guardrails/approvals.rb +7 -0
- data/lib/samagotchi/guardrails/child_boundary.rb +103 -0
- data/lib/samagotchi/guardrails/context.rb +32 -0
- data/lib/samagotchi/guardrails/parent_approvals.rb +0 -2
- data/lib/samagotchi/guardrails/shared_git.rb +124 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/kernel_loop.rb +9 -22
- data/lib/samagotchi/llm/chat_loop.rb +11 -22
- data/lib/samagotchi/memory_bundle/builder.rb +35 -1
- data/lib/samagotchi/memory_bundle/installer.rb +6 -2
- data/lib/samagotchi/memory_bundle/manifest.rb +28 -5
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
- data/lib/samagotchi/memory_paths.rb +10 -0
- data/lib/samagotchi/plugin/api.rb +2 -2
- data/lib/samagotchi/plugin/sessions.rb +47 -2
- data/lib/samagotchi/process_group.rb +157 -0
- data/lib/samagotchi/prompt_warmup.rb +20 -1
- data/lib/samagotchi/question_desk.rb +2 -1
- data/lib/samagotchi/relay_verifier.rb +2 -4
- data/lib/samagotchi/reply_wait.rb +9 -4
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session_manager.rb +2 -4
- data/lib/samagotchi/steer.rb +27 -19
- data/lib/samagotchi/steer_cut.rb +125 -0
- data/lib/samagotchi/system_prompt.rb +20 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -4
- data/lib/samagotchi/terminal_ui/formatting.rb +21 -12
- data/lib/samagotchi/terminal_ui.rb +2 -1
- data/lib/samagotchi/tool_declarations.rb +9 -5
- data/lib/samagotchi/tools/builtins.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +105 -16
- data/lib/samagotchi/tools/delegate_wait.rb +55 -13
- data/lib/samagotchi/tools/execute.rb +10 -81
- data/lib/samagotchi/tools/task_runtime.rb +25 -38
- data/lib/samagotchi/turn_note.rb +9 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -1
- data/lib/samagotchi/web/public/app.js +19 -20
- data/lib/samagotchi/web/public/data.js +3 -0
- data/lib/samagotchi/web/public/question_card.js +3 -4
- data/lib/samagotchi/web/public/shown_texts.js +13 -0
- data/lib/samagotchi/web/public/stable_html.js +18 -0
- data/lib/samagotchi/web/public/turn_events.js +9 -2
- data/lib/samagotchi/web/public/turn_view.js +1 -1
- data/lib/samagotchi/worker.rb +40 -13
- 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 =
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
151
|
-
[out_reader, err_reader].each { |
|
|
152
|
-
stderr_text =
|
|
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:
|
|
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 { |
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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, :
|
|
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,
|
|
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),
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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,
|
|
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
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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 "
|
|
52
|
-
require_relative "
|
|
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
|
-
#
|
|
112
|
-
@
|
|
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+
|
|
346
|
-
#
|
|
347
|
-
#
|
|
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
|
-
|
|
361
|
-
|
|
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
|
-
@
|
|
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?(
|
|
1194
|
-
elsif client_id.start_with?(
|
|
1195
|
-
elsif client_id.start_with?(
|
|
1196
|
-
elsif client_id ==
|
|
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
|
-
@
|
|
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 =
|
|
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
|