samagotchi 0.34.0 → 0.36.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 +77 -1
- data/README.md +2 -0
- data/bin/chi +3 -0
- data/docs/broadcast.md +129 -0
- data/docs/cli.md +8 -0
- data/docs/configuration.md +2 -0
- data/docs/context.md +37 -0
- data/docs/guardrails.md +14 -4
- data/docs/hooks.md +25 -4
- data/docs/memory.md +3 -0
- data/docs/plugins.md +29 -3
- data/docs/sessions.md +6 -6
- data/lib/samagotchi/bridge/card_store.rb +1 -1
- data/lib/samagotchi/bridge/turn_notice.rb +1 -1
- data/lib/samagotchi/broadcast/recipients.rb +63 -0
- data/lib/samagotchi/broadcast/scope_card.rb +94 -0
- data/lib/samagotchi/broadcast/tags.rb +154 -0
- data/lib/samagotchi/broadcast_command.rb +228 -0
- data/lib/samagotchi/bundles/coordinator/manifest.yml +7 -6
- data/lib/samagotchi/bundles/coordinator/plugin.rb +72 -4
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +59 -11
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +444 -3
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +22 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +44 -11
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +32 -12
- data/lib/samagotchi/bundles/source-links/manifest.yml +4 -3
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +4 -2
- data/lib/samagotchi/bundles/system/self_map.md +4 -0
- data/lib/samagotchi/children_status.rb +2 -16
- data/lib/samagotchi/config.rb +6 -0
- data/lib/samagotchi/engine.rb +29 -5
- data/lib/samagotchi/git_head.rb +24 -0
- data/lib/samagotchi/guardrails/parent_approvals.rb +6 -0
- data/lib/samagotchi/guardrails/rules.rb +15 -4
- data/lib/samagotchi/guardrails/targets.rb +9 -2
- data/lib/samagotchi/guardrails/verdict.rb +4 -3
- data/lib/samagotchi/hooks/registry.rb +11 -5
- data/lib/samagotchi/log_subscriber.rb +1 -1
- data/lib/samagotchi/memory_bundle/index_updater.rb +6 -3
- data/lib/samagotchi/note_command.rb +13 -13
- data/lib/samagotchi/note_delivery.rb +42 -0
- data/lib/samagotchi/plugin/context.rb +4 -4
- data/lib/samagotchi/session_commands.rb +1 -0
- data/lib/samagotchi/session_manager.rb +4 -3
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +1 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -2
- data/lib/samagotchi/terminal_ui/status_row.rb +23 -2
- data/lib/samagotchi/terminal_ui.rb +14 -0
- data/lib/samagotchi/tool_activity.rb +14 -3
- data/lib/samagotchi/tool_declarations.rb +8 -4
- data/lib/samagotchi/tools/builtins.rb +1 -1
- data/lib/samagotchi/tools/memory.rb +103 -2
- data/lib/samagotchi/tools/send_note.rb +5 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -0
- data/lib/samagotchi/web/capabilities.rb +19 -0
- data/lib/samagotchi/web/public/activity.js +26 -6
- data/lib/samagotchi/web/public/app.js +515 -288
- data/lib/samagotchi/web/public/card.js +12 -3
- data/lib/samagotchi/web/public/command_view.js +2 -3
- data/lib/samagotchi/web/public/families_view.js +171 -0
- data/lib/samagotchi/web/public/format.js +52 -0
- data/lib/samagotchi/web/public/index.html +94 -21
- data/lib/samagotchi/web/public/live_timing.js +154 -0
- data/lib/samagotchi/web/public/live_turn.js +44 -0
- data/lib/samagotchi/web/public/sessions_list.js +228 -32
- data/lib/samagotchi/web/public/strip.js +5 -2
- data/lib/samagotchi/web/public/turn_html.js +107 -0
- data/lib/samagotchi/web/public/turn_model.js +5 -4
- data/lib/samagotchi/web/public/turn_view.js +43 -129
- data/lib/samagotchi/web/session_summary.rb +4 -0
- data/lib/samagotchi/worker.rb +166 -307
- data/lib/samagotchi/worker_inbound.rb +251 -0
- data/lib/samagotchi/worker_wakes.rb +120 -0
- metadata +15 -1
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
-
require "securerandom"
|
|
5
|
-
require "time"
|
|
6
4
|
|
|
7
5
|
require_relative "client_id"
|
|
8
6
|
require_relative "config"
|
|
@@ -10,9 +8,10 @@ require_relative "events"
|
|
|
10
8
|
require_relative "session"
|
|
11
9
|
require_relative "session_inbox"
|
|
12
10
|
require_relative "turn_note"
|
|
13
|
-
require_relative "context_note"
|
|
14
11
|
require_relative "steer"
|
|
15
12
|
require_relative "worker_idle_exit"
|
|
13
|
+
require_relative "worker_wakes"
|
|
14
|
+
require_relative "worker_inbound"
|
|
16
15
|
require_relative "session_manager"
|
|
17
16
|
require_relative "archive_store"
|
|
18
17
|
require_relative "log"
|
|
@@ -57,9 +56,6 @@ module Samagotchi
|
|
|
57
56
|
# gets it over the Bridge a moment after it starts, and that message's
|
|
58
57
|
# turn goes first (the reports join it).
|
|
59
58
|
WAKE_START_GRACE = 2.0
|
|
60
|
-
# One context wake per source in this long (D5): later changes inside
|
|
61
|
-
# it arrive as notes.
|
|
62
|
-
CONTEXT_WAKE_WINDOW = 600
|
|
63
59
|
# How much of a command's output goes into its :command_ran.
|
|
64
60
|
COMMAND_OUTPUT_LIMIT = 4096
|
|
65
61
|
BUSY_OUTPUT = "busy: wait for the turn to end"
|
|
@@ -115,14 +111,6 @@ module Samagotchi
|
|
|
115
111
|
@asked_parent = false
|
|
116
112
|
@owes_parent = false
|
|
117
113
|
@initial_turn = false
|
|
118
|
-
# A parent's side: turns run for delegate reports since the last human
|
|
119
|
-
# input (session.max_wakes), whether a failed one paused them until
|
|
120
|
-
# then, whether the budget notice went out, and the reports a wake
|
|
121
|
-
# turn's first boundary hands over.
|
|
122
|
-
@wakes_in_a_row = 0
|
|
123
|
-
@wakes_paused = false
|
|
124
|
-
@budget_noticed = false
|
|
125
|
-
@wake_reports = nil
|
|
126
114
|
end
|
|
127
115
|
|
|
128
116
|
# Whether the session was empty as the worker left it, so the caller
|
|
@@ -145,7 +133,32 @@ module Samagotchi
|
|
|
145
133
|
# (chi stop), :crashed when the loop raised (the session is marked
|
|
146
134
|
# errored); SessionManager.run_session_loop turns it into the exit
|
|
147
135
|
def run
|
|
148
|
-
|
|
136
|
+
start
|
|
137
|
+
serve
|
|
138
|
+
begin
|
|
139
|
+
# A session stopped before this worker took the lock (e.g. a stop
|
|
140
|
+
# right after create) must not run its initial prompt.
|
|
141
|
+
return :stopped if stopped_on_disk?
|
|
142
|
+
|
|
143
|
+
start_background
|
|
144
|
+
loop do
|
|
145
|
+
leaving = pass
|
|
146
|
+
return leaving if leaving
|
|
147
|
+
end
|
|
148
|
+
rescue StandardError => e
|
|
149
|
+
crashed(e)
|
|
150
|
+
ensure
|
|
151
|
+
teardown
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
# The session, its Engine and what runs its turns, before anyone can
|
|
158
|
+
# reach it.
|
|
159
|
+
def start
|
|
160
|
+
# The wake turns' budget (delegate reports, attached context), from now.
|
|
161
|
+
@wakes = WorkerWakes.new(grace: WAKE_START_GRACE)
|
|
149
162
|
@session = Session.load(@session_id, state_dir: @state_dir)
|
|
150
163
|
drop_dead_question
|
|
151
164
|
@engine = build_engine
|
|
@@ -180,6 +193,12 @@ module Samagotchi
|
|
|
180
193
|
# Its attached context's notes (ContextSources).
|
|
181
194
|
@context_absorber = ContextAbsorber.new(session_id: @session_id, state_dir: @state_dir,
|
|
182
195
|
project_root: @session.project_root)
|
|
196
|
+
# What comes in between turns, and how each kind is saved.
|
|
197
|
+
@inbound = WorkerInbound.new(session: @session, state_dir: @state_dir, session_dir: @session_dir, engine: @engine,
|
|
198
|
+
context_absorber: @context_absorber, wakes: @wakes,
|
|
199
|
+
awaiting_continue: -> { @turn_flow.awaiting_continue? },
|
|
200
|
+
stopped: -> { stopped_on_disk? },
|
|
201
|
+
queue_command: ->(line, client_id) { @bridge.queue_command(line, client_id: client_id) })
|
|
183
202
|
# /model's default is the config's, as in the REPL; the Engine started
|
|
184
203
|
# on the session's model.
|
|
185
204
|
@default_model = ModelProfile.required_model_name(nil)
|
|
@@ -189,7 +208,11 @@ module Samagotchi
|
|
|
189
208
|
# Start the shared idle scheduler so the worker can trigger turns when
|
|
190
209
|
# reminders are due (even with no user input).
|
|
191
210
|
@engine.start_idle
|
|
211
|
+
end
|
|
192
212
|
|
|
213
|
+
# The Bridge (UIs and clients reach the worker from here on), then the
|
|
214
|
+
# idle-exit policy.
|
|
215
|
+
def serve
|
|
193
216
|
@bridge = SessionManager.start_bridge(engine: @engine, state_dir: @state_dir, session_id: @session_id,
|
|
194
217
|
on_input: -> { @waker.wake },
|
|
195
218
|
on_command: lambda { |command|
|
|
@@ -213,88 +236,106 @@ module Samagotchi
|
|
|
213
236
|
awaiting_continue: -> { @turn_flow.awaiting_continue? },
|
|
214
237
|
running_tasks: -> { Tools::TaskRuntime.running_created_in(@engine.messages_checkpoint) }
|
|
215
238
|
)
|
|
239
|
+
end
|
|
216
240
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
241
|
+
# What runs beside the loop until it leaves.
|
|
242
|
+
def start_background
|
|
243
|
+
# Plugins' slow setup (chi.init: an MCP server's first start), in
|
|
244
|
+
# the background, shown by the UIs; a turn waits only for the ones
|
|
245
|
+
# that bring tools.
|
|
246
|
+
@engine.start_init_tasks!
|
|
247
|
+
# Attached context's commands, in the background; a new text wakes
|
|
248
|
+
# the loop to absorb it. Not activity: the idle exit stops it.
|
|
249
|
+
@context_poller = ContextPoller.new(session_id: @session_id, state_dir: @state_dir,
|
|
250
|
+
project_root: @session.project_root, cwd: @session.working_directory,
|
|
251
|
+
on_change: -> { @waker.wake }).start
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# One pass of the loop: the first work found runs (queued commands, a
|
|
255
|
+
# context wake turn, the first prompt, the queued input files) and the
|
|
256
|
+
# next pass looks again; with none, #idle_pass.
|
|
257
|
+
# @return [Symbol, nil] what #run returns as the worker leaves, nil to go on
|
|
258
|
+
def pass
|
|
259
|
+
# Check if the session was externally marked as stopped. Not
|
|
260
|
+
# stopped_on_disk?, which reads a vanished file as "not stopped":
|
|
261
|
+
# a session file deleted under a running worker crashes it here
|
|
262
|
+
# instead of being saved back by its next turn.
|
|
263
|
+
return :stopped if Session.load(@session_id, state_dir: @state_dir).status == Session::STATUS_STOPPED
|
|
264
|
+
|
|
265
|
+
# Commands queued before a prompt run first (a /continue sent
|
|
266
|
+
# before a new prompt still answers the offer).
|
|
267
|
+
return if run_queued_commands
|
|
268
|
+
|
|
269
|
+
# Between turns, so the next turn (the first one too) sees them.
|
|
270
|
+
# A change that asks to wake may start a turn here (C4), but not
|
|
271
|
+
# ahead of an exit or restart a client asked for: it is a note then.
|
|
272
|
+
@inbound.absorb_notes
|
|
273
|
+
return if absorb_context(wake: nothing_queued? && !@exit_requested)
|
|
274
|
+
|
|
275
|
+
if (prompt = @inbound.take_initial_prompt)
|
|
276
|
+
run_initial_prompt(prompt) unless @inbound.initial_command?(prompt)
|
|
277
|
+
return
|
|
278
|
+
end
|
|
221
279
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
# that bring tools.
|
|
225
|
-
@engine.start_init_tasks!
|
|
226
|
-
# Attached context's commands, in the background; a new text wakes
|
|
227
|
-
# the loop to absorb it. Not activity: the idle exit stops it.
|
|
228
|
-
@context_poller = ContextPoller.new(session_id: @session_id, state_dir: @state_dir,
|
|
229
|
-
project_root: @session.project_root, cwd: @session.working_directory,
|
|
230
|
-
on_change: -> { @waker.wake }).start
|
|
231
|
-
loop do
|
|
232
|
-
# Check if the session was externally marked as stopped. Not
|
|
233
|
-
# stopped_on_disk?, which reads a vanished file as "not stopped":
|
|
234
|
-
# a session file deleted under a running worker crashes it here
|
|
235
|
-
# instead of being saved back by its next turn.
|
|
236
|
-
return :stopped if Session.load(@session_id, state_dir: @state_dir).status == Session::STATUS_STOPPED
|
|
237
|
-
|
|
238
|
-
# Commands queued before a prompt run first (a /continue sent
|
|
239
|
-
# before a new prompt still answers the offer).
|
|
240
|
-
next if run_queued_commands
|
|
241
|
-
|
|
242
|
-
# Between turns, so the next turn (the first one too) sees them.
|
|
243
|
-
# A change that asks to wake may start a turn here (C4), but not
|
|
244
|
-
# ahead of an exit or restart a client asked for: it is a note then.
|
|
245
|
-
absorb_notes
|
|
246
|
-
next if absorb_context(wake: nothing_queued? && !@exit_requested)
|
|
247
|
-
|
|
248
|
-
if (prompt = take_initial_prompt)
|
|
249
|
-
run_initial_prompt(prompt) unless initial_command(prompt)
|
|
250
|
-
next
|
|
251
|
-
end
|
|
280
|
+
input_files = SessionInbox.find_new_input_files(@session_dir)
|
|
281
|
+
return idle_pass if input_files.empty?
|
|
252
282
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
next if run_wake_turn
|
|
256
|
-
next if run_due_reminders
|
|
257
|
-
return left(:idle_exit) if @idle_exit.due? && leave_idle
|
|
258
|
-
return left(@exit_restart ? :restart : :exit_requested) if @exit_requested && leave_on_request
|
|
283
|
+
run_input_files(input_files)
|
|
284
|
+
end
|
|
259
285
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
286
|
+
# Nothing queued: a delegate wake turn or a due reminder runs, else
|
|
287
|
+
# the worker leaves when the idle exit or a client's exit is due, else
|
|
288
|
+
# it sleeps until woken or the fallback tick.
|
|
289
|
+
# @return [Symbol, nil] as #pass
|
|
290
|
+
def idle_pass
|
|
291
|
+
return if run_wake_turn
|
|
292
|
+
return if run_due_reminders
|
|
293
|
+
return left(:idle_exit) if @idle_exit.due? && leave_idle
|
|
294
|
+
return left(@exit_restart ? :restart : :exit_requested) if @exit_requested && leave_on_request
|
|
263
295
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
296
|
+
@waker.wait(idle_wait)
|
|
297
|
+
nil
|
|
298
|
+
end
|
|
267
299
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
# A delegate's parent hears of it (best effort).
|
|
277
|
-
ChildRing.ring(@session, why: "crash", state_dir: @state_dir) if @session
|
|
278
|
-
:crashed
|
|
279
|
-
ensure
|
|
280
|
-
# The anytime commands finish and the plugins' services stop (a
|
|
281
|
-
# server process), whatever the way out; the Bridge last, so a
|
|
282
|
-
# command's command_ran still reaches its UI on a crash. A
|
|
283
|
-
# step-limit question stays in the file (a save here would write
|
|
284
|
-
# over a stop's status): with no live worker the lists don't read
|
|
285
|
-
# it as waiting, and the next worker drops it (drop_dead_question).
|
|
286
|
-
# The context poller first: a command it runs goes (its process
|
|
287
|
-
# group) before the Engine does.
|
|
288
|
-
@context_poller&.stop
|
|
289
|
-
@engine&.shutdown
|
|
290
|
-
@bridge&.stop
|
|
291
|
-
# The process exits next: a parent wake it rang for (a crash's
|
|
292
|
-
# ring comes just above) must spawn the parent's worker first.
|
|
293
|
-
ChildRing.await_wakes
|
|
300
|
+
# @return [nil]
|
|
301
|
+
def run_input_files(input_files)
|
|
302
|
+
input_files.sort.each do |input_file|
|
|
303
|
+
# A stop between two queued turns leaves the rest queued.
|
|
304
|
+
break if stopped_on_disk?
|
|
305
|
+
|
|
306
|
+
@inbound.absorb_notes
|
|
307
|
+
run_input_file(input_file)
|
|
294
308
|
end
|
|
309
|
+
nil
|
|
295
310
|
end
|
|
296
311
|
|
|
297
|
-
|
|
312
|
+
# An error outside a turn: the session is marked errored.
|
|
313
|
+
# @return [Symbol] :crashed
|
|
314
|
+
def crashed(error)
|
|
315
|
+
# Its stderr is /dev/null: the log is the only trace of why.
|
|
316
|
+
Log.exception(:worker, "crashed", error)
|
|
317
|
+
Session.mark_error(@session_id, reason: error.message, state_dir: @state_dir)
|
|
318
|
+
# A delegate's parent hears of it (best effort).
|
|
319
|
+
ChildRing.ring(@session, why: "crash", state_dir: @state_dir) if @session
|
|
320
|
+
:crashed
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# The anytime commands finish and the plugins' services stop (a
|
|
324
|
+
# server process), whatever the way out; the Bridge last, so a
|
|
325
|
+
# command's command_ran still reaches its UI on a crash. A
|
|
326
|
+
# step-limit question stays in the file (a save here would write
|
|
327
|
+
# over a stop's status): with no live worker the lists don't read
|
|
328
|
+
# it as waiting, and the next worker drops it (drop_dead_question).
|
|
329
|
+
# The context poller first: a command it runs goes (its process
|
|
330
|
+
# group) before the Engine does.
|
|
331
|
+
def teardown
|
|
332
|
+
@context_poller&.stop
|
|
333
|
+
@engine&.shutdown
|
|
334
|
+
@bridge&.stop
|
|
335
|
+
# The process exits next: a parent wake it rang for (a crash's
|
|
336
|
+
# ring comes just above) must spawn the parent's worker first.
|
|
337
|
+
ChildRing.await_wakes
|
|
338
|
+
end
|
|
298
339
|
|
|
299
340
|
def build_engine
|
|
300
341
|
waker = @waker
|
|
@@ -336,100 +377,15 @@ module Samagotchi
|
|
|
336
377
|
save_or_log(:dead_question) { @session.save(state_dir: @state_dir) }
|
|
337
378
|
end
|
|
338
379
|
|
|
339
|
-
#
|
|
340
|
-
#
|
|
341
|
-
#
|
|
342
|
-
# resumed session must not replay its last turn. Taken once.
|
|
343
|
-
# @return [String, nil]
|
|
344
|
-
def take_initial_prompt
|
|
345
|
-
return nil if @initial_prompt_taken
|
|
346
|
-
|
|
347
|
-
@initial_prompt_taken = true
|
|
348
|
-
# A context note may have come before the first prompt ran.
|
|
349
|
-
return nil unless @session.messages.all? { |m| ContextNote.note?(m) } && !@session.last_prompt.to_s.strip.empty?
|
|
350
|
-
|
|
351
|
-
prompt = @session.last_prompt
|
|
352
|
-
@session.last_prompt = ""
|
|
353
|
-
save_or_log(:initial_prompt) { @session.save(state_dir: @state_dir) }
|
|
354
|
-
prompt
|
|
355
|
-
end
|
|
356
|
-
|
|
357
|
-
# A session command sent as a message (chi send -m "/model x", a web
|
|
358
|
-
# page's first message) runs as the command, as the Bridge does for a
|
|
359
|
-
# POST /turn; an unknown /word stays a prompt. Queued: the loop's next
|
|
360
|
-
# pass runs it.
|
|
361
|
-
# @return [Boolean] whether +text+ was one
|
|
362
|
-
def queue_as_command(text, origin)
|
|
363
|
-
return false unless @engine.command_registry.command?(text.to_s)
|
|
364
|
-
|
|
365
|
-
@bridge.queue_command(text, client_id: origin&.dig(:client_id))
|
|
366
|
-
true
|
|
367
|
-
end
|
|
368
|
-
|
|
369
|
-
# The first prompt as a command: the session was saved as running for a
|
|
370
|
-
# turn that won't run.
|
|
371
|
-
# @return [Boolean] whether it was one
|
|
372
|
-
def initial_command(prompt)
|
|
373
|
-
return false unless queue_as_command(prompt, nil)
|
|
374
|
-
|
|
375
|
-
@session.status = Session::STATUS_IDLE
|
|
376
|
-
save_or_log(:initial_command) { save_session }
|
|
377
|
-
true
|
|
378
|
-
end
|
|
379
|
-
|
|
380
|
-
# Add the queued context notes to the conversation (between turns only,
|
|
381
|
-
# on this thread), save, then delete their files: a crash before the
|
|
382
|
-
# delete leaves them claimed, and Engine#add_context_note skips a note
|
|
383
|
-
# the saved conversation already holds. A failed save keeps the files
|
|
384
|
-
# too: the next pass claims them again, finds them in the conversation
|
|
385
|
-
# and saves again. Not activity: a note alone
|
|
386
|
-
# neither starts a turn nor keeps an idle worker up.
|
|
387
|
-
def absorb_notes
|
|
388
|
-
files = SessionInbox.find_new_note_files(@session_dir)
|
|
389
|
-
return if files.empty? || stopped_on_disk?
|
|
390
|
-
|
|
391
|
-
claimed = files.filter_map { |file| SessionInbox.claim_note_file(file) }
|
|
392
|
-
claimed.each do |file|
|
|
393
|
-
note = SessionInbox.read_note(file)
|
|
394
|
-
@engine.add_context_note(@session, note) if note
|
|
395
|
-
end
|
|
396
|
-
return unless save_or_log(:notes) { @session.save(state_dir: @state_dir) }
|
|
397
|
-
|
|
398
|
-
claimed.each { |file| FileUtils.rm_f(file) }
|
|
399
|
-
end
|
|
400
|
-
|
|
401
|
-
# The attached context's notes (ContextAbsorber), between turns. Not
|
|
402
|
-
# into a session with no turn yet (+before_turn+: one is about to
|
|
403
|
-
# run): auto-attached context alone doesn't make a session worth
|
|
404
|
-
# keeping. Saved, then the subscriptions written (a crash between the
|
|
405
|
-
# two re-delivers; the note ids dedupe).
|
|
406
|
-
# +wake+: the session is idle with nothing queued, so an update whose
|
|
407
|
-
# source asked to wake may start a turn (#context_wake_for): its note
|
|
408
|
-
# goes in as the turn's first message.
|
|
380
|
+
# The attached context's notes (WorkerInbound#absorb_context), and the
|
|
381
|
+
# context wake turn one may start. A failure to read or write them (or
|
|
382
|
+
# in that turn) is logged: the next pass tries again.
|
|
409
383
|
# @return [Boolean] whether a wake turn ran
|
|
410
384
|
def absorb_context(before_turn: false, wake: false)
|
|
411
|
-
|
|
412
|
-
return false
|
|
413
|
-
|
|
414
|
-
batch = @context_absorber.pending
|
|
415
|
-
return false unless batch
|
|
385
|
+
context_wake = @inbound.absorb_context(before_turn: before_turn, wake: wake)
|
|
386
|
+
return false unless context_wake
|
|
416
387
|
|
|
417
|
-
|
|
418
|
-
turn_id = SecureRandom.uuid if waking
|
|
419
|
-
batch.deliveries.each do |delivery|
|
|
420
|
-
next unless delivery.note
|
|
421
|
-
|
|
422
|
-
note = delivery == waking ? delivery.wake_note.merge(turn_start: true, turn_id: turn_id) : delivery.note
|
|
423
|
-
@engine.add_context_note(@session, note)
|
|
424
|
-
end
|
|
425
|
-
# Saved even when every note was there already: after a failed save
|
|
426
|
-
# they are in memory only.
|
|
427
|
-
return false if batch.notes.any? && !save_or_log(:context) { @session.save(state_dir: @state_dir) }
|
|
428
|
-
|
|
429
|
-
@context_absorber.commit(waking ? woken(batch, waking) : batch)
|
|
430
|
-
return false unless waking
|
|
431
|
-
|
|
432
|
-
run_context_wake_turn(waking, turn_id)
|
|
388
|
+
run_context_wake_turn(context_wake)
|
|
433
389
|
true
|
|
434
390
|
rescue SystemCallError, IOError => e
|
|
435
391
|
Log.exception(:worker, "context_failed", e)
|
|
@@ -439,123 +395,59 @@ module Samagotchi
|
|
|
439
395
|
# No input file or queued command waits: the loop would go idle.
|
|
440
396
|
def nothing_queued? = @command_queue.empty? && SessionInbox.find_new_input_files(@session_dir).empty?
|
|
441
397
|
|
|
442
|
-
# The update in +batch+ that may start a wake turn now: its source
|
|
443
|
-
# asked (wake: true), context.wake is on, the shared wake step's
|
|
444
|
-
# conditions hold (no continue offer, wakes not paused by a failed one,
|
|
445
|
-
# under session.max_wakes in a row with no human input, past the start
|
|
446
|
-
# grace) and the source hasn't woken the session in the last
|
|
447
|
-
# CONTEXT_WAKE_WINDOW. The others stay plain notes. Within the start
|
|
448
|
-
# grace a change is a note: it came while the session was away.
|
|
449
|
-
def context_wake_for(batch, now: Time.now)
|
|
450
|
-
candidates = batch.deliveries.select(&:wake_note)
|
|
451
|
-
return nil if candidates.empty? || !context_wakes_on?
|
|
452
|
-
return nil if @wakes_paused || @turn_flow.awaiting_continue? || wake_grace_left.positive?
|
|
453
|
-
|
|
454
|
-
if @wakes_in_a_row >= max_wakes
|
|
455
|
-
Log.info(:worker, "context_wake_held", reason: "max_wakes", names: candidates.map(&:name).join(","))
|
|
456
|
-
return nil
|
|
457
|
-
end
|
|
458
|
-
candidates.find { |delivery| !woke_lately?(delivery.subscription, now) }
|
|
459
|
-
end
|
|
460
|
-
|
|
461
|
-
def context_wakes_on? = Config.get("context.wake") != false
|
|
462
|
-
|
|
463
|
-
def woke_lately?(subscription, now)
|
|
464
|
-
at = subscription&.wakes_at && Time.iso8601(subscription.wakes_at)
|
|
465
|
-
at ? now - at < CONTEXT_WAKE_WINDOW : false
|
|
466
|
-
rescue ArgumentError
|
|
467
|
-
false
|
|
468
|
-
end
|
|
469
|
-
|
|
470
|
-
# +batch+ with +waking+'s subscription recording the wake (the 10-minute window).
|
|
471
|
-
def woken(batch, waking)
|
|
472
|
-
stamped = waking.with(subscription: waking.subscription.with(wakes_at: Time.now.iso8601))
|
|
473
|
-
batch.with(deliveries: batch.deliveries.map { |delivery| delivery == waking ? stamped : delivery })
|
|
474
|
-
end
|
|
475
|
-
|
|
476
398
|
# A turn nobody typed for a changed source (a context wake): a continue
|
|
477
399
|
# turn from context:<name> whose first message is the wake note already
|
|
478
400
|
# in the conversation (named by +turn_id+), on the same rules as a
|
|
479
401
|
# delegate report's wake turn: it counts toward session.max_wakes, and
|
|
480
402
|
# a failure goes back to before the turn (the note stays, as a plain
|
|
481
|
-
# one: #
|
|
482
|
-
# +
|
|
483
|
-
def run_context_wake_turn(
|
|
484
|
-
name =
|
|
485
|
-
|
|
486
|
-
Log.info(:worker, "context_wake_turn", name: name, in_a_row: @wakes_in_a_row)
|
|
403
|
+
# one: WorkerInbound#unmark_wake) and pauses wakes until a human's input.
|
|
404
|
+
# +context_wake+: a WorkerInbound::ContextWake.
|
|
405
|
+
def run_context_wake_turn(context_wake)
|
|
406
|
+
name = context_wake.name
|
|
407
|
+
Log.info(:worker, "context_wake_turn", name: name, in_a_row: @wakes.count!)
|
|
487
408
|
@turn_flow.before_prompt_turn
|
|
488
|
-
run_engine_turn(nil, continue: true, origin:
|
|
489
|
-
id: turn_id, max_iterations: IterationLimit.for) do |result, error|
|
|
409
|
+
run_engine_turn(nil, continue: true, origin: context_wake.origin,
|
|
410
|
+
id: context_wake.turn_id, max_iterations: IterationLimit.for) do |result, error|
|
|
490
411
|
if error
|
|
491
412
|
note = TurnNote.failed(error.respond_to?(:summary) ? error.summary : error.message,
|
|
492
413
|
wake: "the change in attached context #{name}")
|
|
493
414
|
@turn_flow.prompt_turn_failed(note: note)
|
|
494
|
-
|
|
495
|
-
@
|
|
415
|
+
@inbound.unmark_wake(context_wake)
|
|
416
|
+
@wakes.pause!
|
|
496
417
|
else
|
|
497
418
|
@continue_offer.after_turn(result)
|
|
498
419
|
end
|
|
499
420
|
end
|
|
500
421
|
end
|
|
501
422
|
|
|
502
|
-
# A failed wake turn kept nothing but its note: the update goes back to
|
|
503
|
-
# a plain note (no turn start, the background wording), so a reload
|
|
504
|
-
# draws no empty turn for it.
|
|
505
|
-
def unmark_wake_note(turn_id, note)
|
|
506
|
-
messages = @engine.messages_checkpoint
|
|
507
|
-
index = messages.index { |m| m[:turn_start] && m[:turn_id] == turn_id }
|
|
508
|
-
return unless index
|
|
509
|
-
|
|
510
|
-
messages[index] = ContextNote.message(**note)
|
|
511
|
-
@engine.rollback_to(messages)
|
|
512
|
-
end
|
|
513
|
-
|
|
514
|
-
# Whether the session has had a turn: a user or assistant message.
|
|
515
|
-
def conversation_started?
|
|
516
|
-
Array(@session.messages).any? { |m| %w[user assistant model].include?((m[:role] || m["role"]).to_s) }
|
|
517
|
-
end
|
|
518
|
-
|
|
519
423
|
# The first prompt (spawn_session's): a delegate child's task, which is
|
|
520
424
|
# its parent's although no client sent it.
|
|
521
425
|
def run_initial_prompt(prompt)
|
|
522
426
|
@initial_turn = true
|
|
523
|
-
run_prompt(prompt
|
|
427
|
+
run_prompt(WorkerInbound::Prompt.new(text: prompt))
|
|
524
428
|
ensure
|
|
525
429
|
@initial_turn = false
|
|
526
430
|
end
|
|
527
431
|
|
|
528
432
|
def run_input_file(input_file)
|
|
529
|
-
|
|
530
|
-
return unless claimed_file
|
|
531
|
-
|
|
532
|
-
begin
|
|
533
|
-
message, origin, no_interrupt, images = SessionInbox.read_input(claimed_file)
|
|
534
|
-
return if message.to_s.strip.empty?
|
|
535
|
-
return if Array(images).empty? && queue_as_command(message, origin)
|
|
536
|
-
|
|
537
|
-
run_prompt(message, origin, no_interrupt: !!no_interrupt, images: images || [])
|
|
538
|
-
ensure
|
|
539
|
-
FileUtils.rm_f(claimed_file)
|
|
540
|
-
end
|
|
433
|
+
@inbound.take_input(input_file) { |prompt| run_prompt(prompt) }
|
|
541
434
|
end
|
|
542
435
|
|
|
543
|
-
# @param
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
def run_prompt(prompt, origin, no_interrupt: false, images: [])
|
|
436
|
+
# @param prompt [WorkerInbound::Prompt]
|
|
437
|
+
def run_prompt(prompt)
|
|
438
|
+
origin = prompt.origin
|
|
547
439
|
# A first turn sees the context attached before it.
|
|
548
440
|
absorb_context(before_turn: true)
|
|
549
441
|
user_input(origin&.dig(:client_id))
|
|
550
442
|
@continue_offer.drop(origin)
|
|
551
443
|
@turn_flow.before_prompt_turn
|
|
552
|
-
run_engine_turn(prompt, origin: origin, max_iterations: max_iterations(no_interrupt),
|
|
553
|
-
|
|
444
|
+
run_engine_turn(prompt.text, origin: origin, max_iterations: max_iterations(prompt.no_interrupt),
|
|
445
|
+
images: prompt.images) do |result, error|
|
|
554
446
|
if error
|
|
555
447
|
# The Engine announced :turn_failed (with the error's one line).
|
|
556
|
-
restore_failed_turn([[prompt, origin, images], *@merged_this_turn], error: error)
|
|
448
|
+
restore_failed_turn([[prompt.text, origin, prompt.images], *@merged_this_turn], error: error)
|
|
557
449
|
else
|
|
558
|
-
@continue_offer.after_turn(result, no_interrupt: no_interrupt)
|
|
450
|
+
@continue_offer.after_turn(result, no_interrupt: prompt.no_interrupt)
|
|
559
451
|
end
|
|
560
452
|
end
|
|
561
453
|
end
|
|
@@ -587,10 +479,8 @@ module Samagotchi
|
|
|
587
479
|
# the budget stops it; nil otherwise.
|
|
588
480
|
def wake_state
|
|
589
481
|
return nil unless @child_reports && ChildRing.mode == "wake"
|
|
590
|
-
return nil if @wakes_paused || @turn_flow&.awaiting_continue?
|
|
591
|
-
return nil unless @child_reports.waiting?
|
|
592
482
|
|
|
593
|
-
@
|
|
483
|
+
@wakes.delegate_state(awaiting_continue: @turn_flow&.awaiting_continue?) { @child_reports.waiting? }
|
|
594
484
|
end
|
|
595
485
|
|
|
596
486
|
# Run a turn nobody typed for the reports the rings bring (a wake turn):
|
|
@@ -600,7 +490,7 @@ module Samagotchi
|
|
|
600
490
|
# doesn't loop).
|
|
601
491
|
# @return [Boolean] whether a wake turn ran
|
|
602
492
|
def run_wake_turn
|
|
603
|
-
return false if
|
|
493
|
+
return false if @wakes.in_grace?
|
|
604
494
|
|
|
605
495
|
state = wake_state
|
|
606
496
|
budget_notice if state == :budget
|
|
@@ -611,9 +501,8 @@ module Samagotchi
|
|
|
611
501
|
# exact list at its first boundary.
|
|
612
502
|
return false if reports.empty?
|
|
613
503
|
|
|
614
|
-
@
|
|
615
|
-
|
|
616
|
-
Log.info(:worker, "delegate_wake_turn", reports: reports.size, in_a_row: @wakes_in_a_row)
|
|
504
|
+
@wakes.hand_over(reports)
|
|
505
|
+
Log.info(:worker, "delegate_wake_turn", reports: reports.size, in_a_row: @wakes.count!)
|
|
617
506
|
@turn_flow.before_prompt_turn
|
|
618
507
|
run_engine_turn(nil, continue: true, origin: reports.first.origin, max_iterations: IterationLimit.for) do |result, error|
|
|
619
508
|
if error
|
|
@@ -623,44 +512,29 @@ module Samagotchi
|
|
|
623
512
|
kept: "chi keeps #{reports.size == 1 ? "the report and brings it" : "the reports and brings them"} " \
|
|
624
513
|
"again with the user's next message.")
|
|
625
514
|
@turn_flow.prompt_turn_failed(note: note)
|
|
626
|
-
@
|
|
515
|
+
@wakes.pause!
|
|
627
516
|
else
|
|
628
517
|
@continue_offer.after_turn(result)
|
|
629
518
|
end
|
|
630
519
|
end
|
|
631
520
|
true
|
|
632
521
|
ensure
|
|
633
|
-
@
|
|
634
|
-
end
|
|
635
|
-
|
|
636
|
-
def wake_grace_left
|
|
637
|
-
WAKE_START_GRACE - (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at)
|
|
522
|
+
@wakes.drop_handed
|
|
638
523
|
end
|
|
639
524
|
|
|
640
525
|
# The idle loop's sleep: the fallback tick, or less while reports wait
|
|
641
526
|
# out the start grace.
|
|
642
|
-
def idle_wait
|
|
643
|
-
left = wake_grace_left
|
|
644
|
-
left.positive? && wake_state == :due ? [left, @poll_interval].min : @poll_interval
|
|
645
|
-
end
|
|
646
|
-
|
|
647
|
-
def max_wakes
|
|
648
|
-
value = Integer(Config.get(ChildRing::MAX_WAKES_KEY), exception: false)
|
|
649
|
-
value&.positive? ? value : ChildRing::MAX_WAKES_DEFAULT
|
|
650
|
-
rescue StandardError
|
|
651
|
-
ChildRing::MAX_WAKES_DEFAULT
|
|
652
|
-
end
|
|
527
|
+
def idle_wait = @wakes.idle_wait(@poll_interval) { wake_state == :due }
|
|
653
528
|
|
|
654
529
|
# The wake budget is spent: the parent's UIs hear once that reports
|
|
655
530
|
# wait for the next message.
|
|
656
531
|
def budget_notice
|
|
657
|
-
return
|
|
532
|
+
return unless @wakes.notice_budget!
|
|
658
533
|
|
|
659
|
-
@budget_noticed = true
|
|
660
534
|
count = SessionInbox.find_ring_files(@session_dir).map { |f| SessionInbox.read_ring(f)&.dig(:child_id) }.uniq.size
|
|
661
535
|
@engine.announce(type: :hook_notice, hook: "delegate", level: :info, between_turns: true,
|
|
662
536
|
text: "#{count} delegate report#{"s" if count != 1} waiting; #{count == 1 ? "it joins" : "they join"} " \
|
|
663
|
-
"your next message (#{
|
|
537
|
+
"your next message (#{@wakes.max} turn#{"s" if @wakes.max != 1} ran for reports in a row, #{ChildRing::MAX_WAKES_KEY})")
|
|
664
538
|
end
|
|
665
539
|
|
|
666
540
|
def max_iterations(no_interrupt) = IterationLimit.for(no_interrupt: no_interrupt)
|
|
@@ -849,19 +723,8 @@ module Samagotchi
|
|
|
849
723
|
@asked_parent = true
|
|
850
724
|
end
|
|
851
725
|
|
|
852
|
-
#
|
|
853
|
-
|
|
854
|
-
# full, permissions; the next save writes it) is logged and must not
|
|
855
|
-
# take the worker down with it.
|
|
856
|
-
# @param at [Symbol] which save, for the log
|
|
857
|
-
# @return [Boolean] whether it saved
|
|
858
|
-
def save_or_log(at)
|
|
859
|
-
yield
|
|
860
|
-
true
|
|
861
|
-
rescue SystemCallError, IOError => e
|
|
862
|
-
Log.exception(:worker, "save_failed", e, at: at)
|
|
863
|
-
false
|
|
864
|
-
end
|
|
726
|
+
# WorkerInbound.save_or_log: a failed save between turns is logged.
|
|
727
|
+
def save_or_log(at, &) = WorkerInbound.save_or_log(at, &)
|
|
865
728
|
|
|
866
729
|
# Back to the conversation before the failed turn, as the REPL does (so
|
|
867
730
|
# failed prompts don't pile up as consecutive user messages), and each
|
|
@@ -942,9 +805,7 @@ module Samagotchi
|
|
|
942
805
|
# @return [Array(Array<ChildReports::Report>, Hash|nil)] the reports
|
|
943
806
|
# and the mark of their message
|
|
944
807
|
def take_child_reports
|
|
945
|
-
if @
|
|
946
|
-
reports = @wake_reports
|
|
947
|
-
@wake_reports = nil
|
|
808
|
+
if (reports = @wakes.take_handed)
|
|
948
809
|
return [reports, { turn_start: true, turn_id: @turn_id }.compact]
|
|
949
810
|
end
|
|
950
811
|
|
|
@@ -1057,9 +918,7 @@ module Samagotchi
|
|
|
1057
918
|
return unless ClientId.human?(client_id)
|
|
1058
919
|
|
|
1059
920
|
ArchiveStore.user_input(@session_id, state_dir: @state_dir)
|
|
1060
|
-
@
|
|
1061
|
-
@wakes_paused = false
|
|
1062
|
-
@budget_noticed = false
|
|
921
|
+
@wakes.human_input!
|
|
1063
922
|
end
|
|
1064
923
|
|
|
1065
924
|
def stopped_on_disk?
|