samagotchi 0.30.0 → 0.32.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 +87 -1
- data/README.md +13 -0
- data/bin/chi +3 -0
- data/docs/cli.md +1 -0
- data/docs/configuration.md +7 -1
- data/docs/context.md +218 -0
- data/docs/guardrails.md +12 -4
- data/docs/hooks.md +1 -0
- data/docs/internals/engine.md +1 -1
- data/docs/plugins.md +77 -1
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +9 -2
- data/lib/samagotchi/bootstrap_command.rb +4 -1
- data/lib/samagotchi/bridge/card_store.rb +1 -1
- data/lib/samagotchi/bridge/turn_notice.rb +1 -1
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
- data/lib/samagotchi/bundles/github-pr/manifest.yml +22 -0
- data/lib/samagotchi/bundles/github-pr/plugin.rb +65 -0
- data/lib/samagotchi/bundles/github-pr/scripts/pr_context.rb +199 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +15 -2
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
- data/lib/samagotchi/bundles/system/delegated.md +1 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +8 -1
- data/lib/samagotchi/child_reports.rb +212 -0
- data/lib/samagotchi/client.rb +35 -4
- data/lib/samagotchi/config.rb +11 -0
- data/lib/samagotchi/context_absorber.rb +179 -0
- data/lib/samagotchi/context_command.rb +473 -0
- data/lib/samagotchi/context_fetch.rb +226 -0
- data/lib/samagotchi/context_note.rb +11 -5
- data/lib/samagotchi/context_poller.rb +100 -0
- data/lib/samagotchi/context_providers.rb +159 -0
- data/lib/samagotchi/context_sources.rb +452 -0
- data/lib/samagotchi/engine.rb +12 -5
- data/lib/samagotchi/guardrail_wiring.rb +3 -1
- data/lib/samagotchi/guardrails/parent_approvals.rb +9 -7
- data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
- data/lib/samagotchi/guardrails/shell_lex.rb +0 -9
- data/lib/samagotchi/guardrails/verdict.rb +21 -3
- data/lib/samagotchi/idle_recap.rb +4 -0
- data/lib/samagotchi/kernel_loop.rb +44 -5
- data/lib/samagotchi/llm/chat_loop.rb +2 -1
- data/lib/samagotchi/llm/errors.rb +9 -0
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/log_subscriber.rb +1 -1
- data/lib/samagotchi/memory_bundle/installer.rb +43 -1
- data/lib/samagotchi/memory_bundle/manifest.rb +39 -3
- data/lib/samagotchi/memory_bundle/provenance.rb +17 -1
- data/lib/samagotchi/plugin/attached_context.rb +100 -0
- data/lib/samagotchi/plugin/context.rb +22 -0
- data/lib/samagotchi/prompt_warmup.rb +30 -5
- data/lib/samagotchi/question_desk.rb +0 -3
- data/lib/samagotchi/reply_wait.rb +39 -8
- data/lib/samagotchi/session.rb +30 -7
- data/lib/samagotchi/session_commands.rb +18 -1
- data/lib/samagotchi/session_inbox.rb +34 -1
- data/lib/samagotchi/session_manager.rb +37 -5
- data/lib/samagotchi/session_retention.rb +3 -0
- data/lib/samagotchi/steer.rb +48 -7
- data/lib/samagotchi/system_prompt.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +10 -4
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +45 -7
- data/lib/samagotchi/terminal_ui.rb +9 -3
- data/lib/samagotchi/tool_activity.rb +8 -0
- data/lib/samagotchi/tool_call_parser.rb +40 -0
- data/lib/samagotchi/tool_declarations.rb +28 -7
- data/lib/samagotchi/tools/builtin_calls.rb +1 -2
- data/lib/samagotchi/tools/builtins.rb +5 -0
- data/lib/samagotchi/tools/context_read.rb +163 -0
- data/lib/samagotchi/tools/delegate.rb +13 -4
- data/lib/samagotchi/tools/delegate_cursor.rb +91 -0
- data/lib/samagotchi/tools/delegate_wait.rb +50 -31
- data/lib/samagotchi/tools/task_runtime.rb +21 -2
- data/lib/samagotchi/tools/task_wait.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +117 -3
- data/lib/samagotchi/web/public/app.js +52 -14
- data/lib/samagotchi/web/public/composer_size.js +7 -6
- data/lib/samagotchi/web/public/context_chips.js +285 -0
- data/lib/samagotchi/web/public/data.js +21 -0
- data/lib/samagotchi/web/public/format.js +8 -1
- data/lib/samagotchi/web/public/index.html +49 -3
- data/lib/samagotchi/web/public/notify.js +6 -2
- data/lib/samagotchi/web/public/timing.js +7 -3
- data/lib/samagotchi/web/public/turn_events.js +34 -10
- data/lib/samagotchi/web/public/turn_view.js +26 -4
- data/lib/samagotchi/worker.rb +343 -8
- metadata +16 -1
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "time"
|
|
4
6
|
|
|
7
|
+
require_relative "config"
|
|
5
8
|
require_relative "events"
|
|
6
9
|
require_relative "session"
|
|
7
10
|
require_relative "session_inbox"
|
|
@@ -17,6 +20,9 @@ require_relative "continue_offer"
|
|
|
17
20
|
require_relative "iteration_limit"
|
|
18
21
|
require_relative "session_commands"
|
|
19
22
|
require_relative "model_profile"
|
|
23
|
+
require_relative "child_reports"
|
|
24
|
+
require_relative "context_absorber"
|
|
25
|
+
require_relative "context_poller"
|
|
20
26
|
require_relative "tools/task_runtime"
|
|
21
27
|
|
|
22
28
|
module Samagotchi
|
|
@@ -45,6 +51,14 @@ module Samagotchi
|
|
|
45
51
|
# turn) is refused as busy.
|
|
46
52
|
class Worker
|
|
47
53
|
FALLBACK_TICK_SECONDS = 5
|
|
54
|
+
# A worker just started runs no wake turn for delegate reports this
|
|
55
|
+
# long: a worker spawned for a message (chi send to a stopped parent)
|
|
56
|
+
# gets it over the Bridge a moment after it starts, and that message's
|
|
57
|
+
# turn goes first (the reports join it).
|
|
58
|
+
WAKE_START_GRACE = 2.0
|
|
59
|
+
# One context wake per source in this long (D5): later changes inside
|
|
60
|
+
# it arrive as notes.
|
|
61
|
+
CONTEXT_WAKE_WINDOW = 600
|
|
48
62
|
# How much of a command's output goes into its :command_ran.
|
|
49
63
|
COMMAND_OUTPUT_LIMIT = 4096
|
|
50
64
|
BUSY_OUTPUT = "busy: wait for the turn to end"
|
|
@@ -89,6 +103,25 @@ module Samagotchi
|
|
|
89
103
|
@discard = false
|
|
90
104
|
# The exit asked for is a restart (POST /exit restart: true).
|
|
91
105
|
@exit_restart = false
|
|
106
|
+
# The lines the running turn's drain merged ([prompt, origin]), for a
|
|
107
|
+
# failed prompt turn to hand back; reset as each turn begins.
|
|
108
|
+
@merged_this_turn = []
|
|
109
|
+
# A delegate child's side (ChildRing): whether the running turn is
|
|
110
|
+
# its parent's (it rings when it ends), whether that turn rang a
|
|
111
|
+
# question, and whether the next turn rings whatever its origin (it
|
|
112
|
+
# follows a question the parent was rung about: D10).
|
|
113
|
+
@reporting_turn = false
|
|
114
|
+
@asked_parent = false
|
|
115
|
+
@owes_parent = false
|
|
116
|
+
@initial_turn = false
|
|
117
|
+
# A parent's side: turns run for delegate reports since the last human
|
|
118
|
+
# input (session.max_wakes), whether a failed one paused them until
|
|
119
|
+
# then, whether the budget notice went out, and the reports a wake
|
|
120
|
+
# turn's first boundary hands over.
|
|
121
|
+
@wakes_in_a_row = 0
|
|
122
|
+
@wakes_paused = false
|
|
123
|
+
@budget_noticed = false
|
|
124
|
+
@wake_reports = nil
|
|
92
125
|
end
|
|
93
126
|
|
|
94
127
|
# Whether the session was empty as the worker left it, so the caller
|
|
@@ -96,6 +129,12 @@ module Samagotchi
|
|
|
96
129
|
# checks again then).
|
|
97
130
|
def discard? = @discard
|
|
98
131
|
|
|
132
|
+
# Whether delegate reports wait that an idle parent runs a turn for
|
|
133
|
+
# (#wake_state): SessionManager.run_session_loop starts a new worker
|
|
134
|
+
# for them after this one leaves. Reports the budget or a failure held
|
|
135
|
+
# back wait for the next human input instead.
|
|
136
|
+
def wake_due? = wake_state == :due
|
|
137
|
+
|
|
99
138
|
# What a new session starts on (and /model resets to); nil before #run.
|
|
100
139
|
attr_reader :default_model
|
|
101
140
|
|
|
@@ -105,6 +144,7 @@ module Samagotchi
|
|
|
105
144
|
# (chi stop), :crashed when the loop raised (the session is marked
|
|
106
145
|
# errored); SessionManager.run_session_loop turns it into the exit
|
|
107
146
|
def run
|
|
147
|
+
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
108
148
|
@session = Session.load(@session_id, state_dir: @state_dir)
|
|
109
149
|
drop_dead_question
|
|
110
150
|
@engine = build_engine
|
|
@@ -114,7 +154,7 @@ module Samagotchi
|
|
|
114
154
|
@engine.session = @session
|
|
115
155
|
# Input already waiting in the inbox starts the next turn at once: no
|
|
116
156
|
# turn-end warm-up then.
|
|
117
|
-
@engine.next_turn_waiting = -> { SessionInbox.find_new_input_files(@session_dir).any? }
|
|
157
|
+
@engine.next_turn_waiting = -> { SessionInbox.find_new_input_files(@session_dir).any? || wake_due? }
|
|
118
158
|
@turn_flow = TurnFlow.new(engine: @engine)
|
|
119
159
|
# Its question's answer is queued as the matching /continue (the
|
|
120
160
|
# Bridge exists by the time anyone answers).
|
|
@@ -131,7 +171,14 @@ module Samagotchi
|
|
|
131
171
|
@turn_end_seq = 0
|
|
132
172
|
@engine.subscribe(observer: lambda { |event|
|
|
133
173
|
@turn_end_seq = event[:event_seq] if Events::TURN_END.include?(event[:type])
|
|
174
|
+
@turn_id = event[:turn_id] if event[:type] == :turn_started
|
|
175
|
+
ring_question(event) if event[:type] == :question_requested
|
|
134
176
|
})
|
|
177
|
+
# This session's own delegate children's news (rings in children/).
|
|
178
|
+
@child_reports = ChildReports.new(session_id: @session_id, state_dir: @state_dir)
|
|
179
|
+
# Its attached context's notes (ContextSources).
|
|
180
|
+
@context_absorber = ContextAbsorber.new(session_id: @session_id, state_dir: @state_dir,
|
|
181
|
+
project_root: @session.project_root)
|
|
135
182
|
# /model's default is the config's, as in the REPL; the Engine started
|
|
136
183
|
# on the session's model.
|
|
137
184
|
@default_model = ModelProfile.required_model_name(nil)
|
|
@@ -175,6 +222,11 @@ module Samagotchi
|
|
|
175
222
|
# the background, shown by the UIs; a turn waits only for the ones
|
|
176
223
|
# that bring tools.
|
|
177
224
|
@engine.start_init_tasks!
|
|
225
|
+
# Attached context's commands, in the background; a new text wakes
|
|
226
|
+
# the loop to absorb it. Not activity: the idle exit stops it.
|
|
227
|
+
@context_poller = ContextPoller.new(session_id: @session_id, state_dir: @state_dir,
|
|
228
|
+
project_root: @session.project_root, cwd: @session.working_directory,
|
|
229
|
+
on_change: -> { @waker.wake }).start
|
|
178
230
|
loop do
|
|
179
231
|
# Check if the session was externally marked as stopped. Not
|
|
180
232
|
# stopped_on_disk?, which reads a vanished file as "not stopped":
|
|
@@ -187,20 +239,24 @@ module Samagotchi
|
|
|
187
239
|
next if run_queued_commands
|
|
188
240
|
|
|
189
241
|
# Between turns, so the next turn (the first one too) sees them.
|
|
242
|
+
# A change that asks to wake may start a turn here (C4), but not
|
|
243
|
+
# ahead of an exit or restart a client asked for: it is a note then.
|
|
190
244
|
absorb_notes
|
|
245
|
+
next if absorb_context(wake: nothing_queued? && !@exit_requested)
|
|
191
246
|
|
|
192
247
|
if (prompt = take_initial_prompt)
|
|
193
|
-
|
|
248
|
+
run_initial_prompt(prompt) unless initial_command(prompt)
|
|
194
249
|
next
|
|
195
250
|
end
|
|
196
251
|
|
|
197
252
|
input_files = SessionInbox.find_new_input_files(@session_dir)
|
|
198
253
|
if input_files.empty?
|
|
254
|
+
next if run_wake_turn
|
|
199
255
|
next if run_due_reminders
|
|
200
256
|
return left(:idle_exit) if @idle_exit.due? && leave_idle
|
|
201
257
|
return left(@exit_restart ? :restart : :exit_requested) if @exit_requested && leave_on_request
|
|
202
258
|
|
|
203
|
-
@waker.wait(
|
|
259
|
+
@waker.wait(idle_wait)
|
|
204
260
|
next
|
|
205
261
|
end
|
|
206
262
|
|
|
@@ -216,6 +272,8 @@ module Samagotchi
|
|
|
216
272
|
# Its stderr is /dev/null: the log is the only trace of why.
|
|
217
273
|
Log.exception(:worker, "crashed", e)
|
|
218
274
|
Session.mark_error(@session_id, reason: e.message, state_dir: @state_dir)
|
|
275
|
+
# A delegate's parent hears of it (best effort).
|
|
276
|
+
ChildRing.ring(@session, why: "crash", state_dir: @state_dir) if @session
|
|
219
277
|
:crashed
|
|
220
278
|
ensure
|
|
221
279
|
# The anytime commands finish and the plugins' services stop (a
|
|
@@ -224,6 +282,9 @@ module Samagotchi
|
|
|
224
282
|
# step-limit question stays in the file (a save here would write
|
|
225
283
|
# over a stop's status): with no live worker the lists don't read
|
|
226
284
|
# it as waiting, and the next worker drops it (drop_dead_question).
|
|
285
|
+
# The context poller first: a command it runs goes (its process
|
|
286
|
+
# group) before the Engine does.
|
|
287
|
+
@context_poller&.stop
|
|
227
288
|
@engine&.shutdown
|
|
228
289
|
@bridge&.stop
|
|
229
290
|
end
|
|
@@ -333,6 +394,117 @@ module Samagotchi
|
|
|
333
394
|
claimed.each { |file| FileUtils.rm_f(file) }
|
|
334
395
|
end
|
|
335
396
|
|
|
397
|
+
# The attached context's notes (ContextAbsorber), between turns. Not
|
|
398
|
+
# into a session with no turn yet (+before_turn+: one is about to
|
|
399
|
+
# run): auto-attached context alone doesn't make a session worth
|
|
400
|
+
# keeping. Saved, then the subscriptions written (a crash between the
|
|
401
|
+
# two re-delivers; the note ids dedupe).
|
|
402
|
+
# +wake+: the session is idle with nothing queued, so an update whose
|
|
403
|
+
# source asked to wake may start a turn (#context_wake_for): its note
|
|
404
|
+
# goes in as the turn's first message.
|
|
405
|
+
# @return [Boolean] whether a wake turn ran
|
|
406
|
+
def absorb_context(before_turn: false, wake: false)
|
|
407
|
+
return false unless before_turn || conversation_started?
|
|
408
|
+
return false if stopped_on_disk?
|
|
409
|
+
|
|
410
|
+
batch = @context_absorber.pending
|
|
411
|
+
return false unless batch
|
|
412
|
+
|
|
413
|
+
waking = wake ? context_wake_for(batch) : nil
|
|
414
|
+
turn_id = SecureRandom.uuid if waking
|
|
415
|
+
batch.deliveries.each do |delivery|
|
|
416
|
+
next unless delivery.note
|
|
417
|
+
|
|
418
|
+
note = delivery == waking ? delivery.wake_note.merge(turn_start: true, turn_id: turn_id) : delivery.note
|
|
419
|
+
@engine.add_context_note(@session, note)
|
|
420
|
+
end
|
|
421
|
+
# Saved even when every note was there already: after a failed save
|
|
422
|
+
# they are in memory only.
|
|
423
|
+
return false if batch.notes.any? && !save_or_log(:context) { @session.save(state_dir: @state_dir) }
|
|
424
|
+
|
|
425
|
+
@context_absorber.commit(waking ? woken(batch, waking) : batch)
|
|
426
|
+
return false unless waking
|
|
427
|
+
|
|
428
|
+
run_context_wake_turn(waking.name, turn_id)
|
|
429
|
+
true
|
|
430
|
+
rescue SystemCallError, IOError => e
|
|
431
|
+
Log.exception(:worker, "context_failed", e)
|
|
432
|
+
false
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# No input file or queued command waits: the loop would go idle.
|
|
436
|
+
def nothing_queued? = @command_queue.empty? && SessionInbox.find_new_input_files(@session_dir).empty?
|
|
437
|
+
|
|
438
|
+
# The update in +batch+ that may start a wake turn now: its source
|
|
439
|
+
# asked (wake: true), context.wake is on, the shared wake step's
|
|
440
|
+
# conditions hold (no continue offer, wakes not paused by a failed one,
|
|
441
|
+
# under session.max_wakes in a row with no human input, past the start
|
|
442
|
+
# grace) and the source hasn't woken the session in the last
|
|
443
|
+
# CONTEXT_WAKE_WINDOW. The others stay plain notes. Within the start
|
|
444
|
+
# grace a change is a note: it came while the session was away.
|
|
445
|
+
def context_wake_for(batch, now: Time.now)
|
|
446
|
+
candidates = batch.deliveries.select(&:wake_note)
|
|
447
|
+
return nil if candidates.empty? || !context_wakes_on?
|
|
448
|
+
return nil if @wakes_paused || @turn_flow.awaiting_continue? || wake_grace_left.positive?
|
|
449
|
+
|
|
450
|
+
if @wakes_in_a_row >= max_wakes
|
|
451
|
+
Log.info(:worker, "context_wake_held", reason: "max_wakes", names: candidates.map(&:name).join(","))
|
|
452
|
+
return nil
|
|
453
|
+
end
|
|
454
|
+
candidates.find { |delivery| !woke_lately?(delivery.subscription, now) }
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def context_wakes_on? = Config.get("context.wake") != false
|
|
458
|
+
|
|
459
|
+
def woke_lately?(subscription, now)
|
|
460
|
+
at = subscription&.wakes_at && Time.iso8601(subscription.wakes_at)
|
|
461
|
+
at ? now - at < CONTEXT_WAKE_WINDOW : false
|
|
462
|
+
rescue ArgumentError
|
|
463
|
+
false
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# +batch+ with +waking+'s subscription recording the wake (the 10-minute window).
|
|
467
|
+
def woken(batch, waking)
|
|
468
|
+
stamped = waking.with(subscription: waking.subscription.with(wakes_at: Time.now.iso8601))
|
|
469
|
+
batch.with(deliveries: batch.deliveries.map { |delivery| delivery == waking ? stamped : delivery })
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# A turn nobody typed for a changed source (a context wake): a continue
|
|
473
|
+
# turn from context:<name> whose first message is the wake note already
|
|
474
|
+
# in the conversation (named by +turn_id+), on the same rules as a
|
|
475
|
+
# delegate report's wake turn: it counts toward session.max_wakes, and
|
|
476
|
+
# a failure goes back to before the turn (the note stays: it was saved
|
|
477
|
+
# before the checkpoint) and pauses wakes until a human's input.
|
|
478
|
+
def run_context_wake_turn(name, turn_id)
|
|
479
|
+
@wakes_in_a_row += 1
|
|
480
|
+
Log.info(:worker, "context_wake_turn", name: name, in_a_row: @wakes_in_a_row)
|
|
481
|
+
@turn_flow.before_prompt_turn
|
|
482
|
+
run_engine_turn(nil, continue: true, origin: { client_id: "#{Steer::CONTEXT_CLIENT_PREFIX}#{name}" },
|
|
483
|
+
id: turn_id, max_iterations: IterationLimit.for) do |result, error|
|
|
484
|
+
if error
|
|
485
|
+
note = TurnNote.failed(error.respond_to?(:summary) ? error.summary : error.message)
|
|
486
|
+
@turn_flow.prompt_turn_failed(note: note)
|
|
487
|
+
@wakes_paused = true
|
|
488
|
+
else
|
|
489
|
+
@continue_offer.after_turn(result)
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# Whether the session has had a turn: a user or assistant message.
|
|
495
|
+
def conversation_started?
|
|
496
|
+
Array(@session.messages).any? { |m| %w[user assistant model].include?((m[:role] || m["role"]).to_s) }
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# The first prompt (spawn_session's): a delegate child's task, which is
|
|
500
|
+
# its parent's although no client sent it.
|
|
501
|
+
def run_initial_prompt(prompt)
|
|
502
|
+
@initial_turn = true
|
|
503
|
+
run_prompt(prompt, nil)
|
|
504
|
+
ensure
|
|
505
|
+
@initial_turn = false
|
|
506
|
+
end
|
|
507
|
+
|
|
336
508
|
def run_input_file(input_file)
|
|
337
509
|
claimed_file = SessionInbox.claim_input_file(input_file)
|
|
338
510
|
return unless claimed_file
|
|
@@ -352,10 +524,11 @@ module Samagotchi
|
|
|
352
524
|
# its continue turn
|
|
353
525
|
# @param images [Array<Hash>] the prompt's image refs ({file:, name:})
|
|
354
526
|
def run_prompt(prompt, origin, no_interrupt: false, images: [])
|
|
527
|
+
# A first turn sees the context attached before it.
|
|
528
|
+
absorb_context(before_turn: true)
|
|
355
529
|
user_input(origin&.dig(:client_id))
|
|
356
530
|
@continue_offer.drop(origin)
|
|
357
531
|
@turn_flow.before_prompt_turn
|
|
358
|
-
@merged_this_turn = []
|
|
359
532
|
run_engine_turn(prompt, origin: origin, max_iterations: max_iterations(no_interrupt),
|
|
360
533
|
images: images) do |result, error|
|
|
361
534
|
if error
|
|
@@ -387,6 +560,85 @@ module Samagotchi
|
|
|
387
560
|
true
|
|
388
561
|
end
|
|
389
562
|
|
|
563
|
+
# :due when an idle parent should run a turn for its delegate
|
|
564
|
+
# children's reports now (session.delegate_reports: wake): rings wait,
|
|
565
|
+
# no continue offer does (they merge into its turn), wakes aren't paused
|
|
566
|
+
# by a failed one, and the wake budget isn't spent; :budget when only
|
|
567
|
+
# the budget stops it; nil otherwise.
|
|
568
|
+
def wake_state
|
|
569
|
+
return nil unless @child_reports && ChildRing.mode == "wake"
|
|
570
|
+
return nil if @wakes_paused || @turn_flow&.awaiting_continue?
|
|
571
|
+
return nil unless @child_reports.waiting?
|
|
572
|
+
|
|
573
|
+
@wakes_in_a_row < max_wakes ? :due : :budget
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
# Run a turn nobody typed for the reports the rings bring (a wake turn):
|
|
577
|
+
# a continue turn (no prompt bubble) whose first boundary merges them,
|
|
578
|
+
# with origin child:<id8>. A failure goes back to the checkpoint, keeps
|
|
579
|
+
# the rings and pauses wakes until a human's input (a provider outage
|
|
580
|
+
# doesn't loop).
|
|
581
|
+
# @return [Boolean] whether a wake turn ran
|
|
582
|
+
def run_wake_turn
|
|
583
|
+
return false if wake_grace_left.positive?
|
|
584
|
+
|
|
585
|
+
state = wake_state
|
|
586
|
+
budget_notice if state == :budget
|
|
587
|
+
return false unless state == :due
|
|
588
|
+
|
|
589
|
+
reports, = take_child_reports
|
|
590
|
+
# The turn must not generate with nothing to say: the drain gets this
|
|
591
|
+
# exact list at its first boundary.
|
|
592
|
+
return false if reports.empty?
|
|
593
|
+
|
|
594
|
+
@wake_reports = reports
|
|
595
|
+
@wakes_in_a_row += 1
|
|
596
|
+
Log.info(:worker, "delegate_wake_turn", reports: reports.size, in_a_row: @wakes_in_a_row)
|
|
597
|
+
@turn_flow.before_prompt_turn
|
|
598
|
+
run_engine_turn(nil, continue: true, origin: reports.first.origin, max_iterations: IterationLimit.for) do |result, error|
|
|
599
|
+
if error
|
|
600
|
+
note = TurnNote.failed(error.respond_to?(:summary) ? error.summary : error.message)
|
|
601
|
+
@turn_flow.prompt_turn_failed(note: note)
|
|
602
|
+
@wakes_paused = true
|
|
603
|
+
else
|
|
604
|
+
@continue_offer.after_turn(result)
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
true
|
|
608
|
+
ensure
|
|
609
|
+
@wake_reports = nil
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def wake_grace_left
|
|
613
|
+
WAKE_START_GRACE - (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at)
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
# The idle loop's sleep: the fallback tick, or less while reports wait
|
|
617
|
+
# out the start grace.
|
|
618
|
+
def idle_wait
|
|
619
|
+
left = wake_grace_left
|
|
620
|
+
left.positive? && wake_state == :due ? [left, @poll_interval].min : @poll_interval
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
def max_wakes
|
|
624
|
+
value = Integer(Config.get(ChildRing::MAX_WAKES_KEY), exception: false)
|
|
625
|
+
value&.positive? ? value : ChildRing::MAX_WAKES_DEFAULT
|
|
626
|
+
rescue StandardError
|
|
627
|
+
ChildRing::MAX_WAKES_DEFAULT
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
# The wake budget is spent: the parent's UIs hear once that reports
|
|
631
|
+
# wait for the next message.
|
|
632
|
+
def budget_notice
|
|
633
|
+
return if @budget_noticed
|
|
634
|
+
|
|
635
|
+
@budget_noticed = true
|
|
636
|
+
count = SessionInbox.find_ring_files(@session_dir).map { |f| SessionInbox.read_ring(f)&.dig(:child_id) }.uniq.size
|
|
637
|
+
@engine.announce(type: :hook_notice, hook: "delegate", level: :info, between_turns: true,
|
|
638
|
+
text: "#{count} delegate report#{"s" if count != 1} waiting; #{count == 1 ? "it joins" : "they join"} " \
|
|
639
|
+
"your next message (#{max_wakes} turn#{"s" if max_wakes != 1} ran for reports in a row, #{ChildRing::MAX_WAKES_KEY})")
|
|
640
|
+
end
|
|
641
|
+
|
|
390
642
|
def max_iterations(no_interrupt) = IterationLimit.for(no_interrupt: no_interrupt)
|
|
391
643
|
|
|
392
644
|
# @return [Boolean] whether any command ran
|
|
@@ -496,6 +748,10 @@ module Samagotchi
|
|
|
496
748
|
# @yieldparam result [Object, nil] Engine#run_turn's, nil on a failure
|
|
497
749
|
# @yieldparam error [StandardError, nil]
|
|
498
750
|
def run_engine_turn(prompt, **turn_args)
|
|
751
|
+
# Every kind of turn merges steering (a reminder turn too, which may be
|
|
752
|
+
# a fresh worker's first), so the list is this turn's from the start.
|
|
753
|
+
@merged_this_turn = []
|
|
754
|
+
@reporting_turn = reports_to_parent?(turn_args[:origin])
|
|
499
755
|
begin
|
|
500
756
|
@session.status = Session::STATUS_RUNNING
|
|
501
757
|
@session.save(state_dir: @state_dir)
|
|
@@ -519,6 +775,51 @@ module Samagotchi
|
|
|
519
775
|
SessionInbox.write_output(@session_dir, response)
|
|
520
776
|
end
|
|
521
777
|
save_or_log(:turn) { save_session }
|
|
778
|
+
settle_child_reports(error)
|
|
779
|
+
ring_parent_after_turn
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
# The delegate reports this turn took: a kept turn moves the cursors on
|
|
783
|
+
# and deletes their rings; a failed one (rolled back) leaves the rings
|
|
784
|
+
# for the next turn.
|
|
785
|
+
def settle_child_reports(error)
|
|
786
|
+
error ? @child_reports.release : @child_reports.commit
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
# A delegate child's turn that was its parent's (#reports_to_parent?)
|
|
790
|
+
# rings the parent, after the save: the parent reads a settled child.
|
|
791
|
+
# A question it rang about that still waits makes the next turn ring
|
|
792
|
+
# too, whoever starts it (the user answering Continue on the web).
|
|
793
|
+
def ring_parent_after_turn
|
|
794
|
+
ChildRing.ring(@session, why: "turn_end", state_dir: @state_dir) if @reporting_turn
|
|
795
|
+
@owes_parent = @asked_parent && !@session.pending_question.nil?
|
|
796
|
+
@asked_parent = false
|
|
797
|
+
@reporting_turn = false
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
# Whether the turn starting now is a delegate child's parent's: its
|
|
801
|
+
# task (the initial prompt), a follow-up, a continue the parent
|
|
802
|
+
# answered, a reminder, anything but a human typing into the child
|
|
803
|
+
# (ArchiveStore.user_input?), and any turn after a question the parent
|
|
804
|
+
# was rung about.
|
|
805
|
+
def reports_to_parent?(origin)
|
|
806
|
+
return false unless @session.delegate?
|
|
807
|
+
|
|
808
|
+
@initial_turn || @owes_parent || !ArchiveStore.user_input?(origin&.dig(:client_id))
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
# The Engine published a question (after the question desk saved it).
|
|
812
|
+
# In a turn that reports to the parent, the model's own question and
|
|
813
|
+
# the step-limit continue ring it; approvals and hooks' questions are
|
|
814
|
+
# the user's, on this session's card.
|
|
815
|
+
def ring_question(event)
|
|
816
|
+
return unless @reporting_turn
|
|
817
|
+
|
|
818
|
+
kind = event.dig(:pending_question, :kind).to_s
|
|
819
|
+
return unless ["", ContinueOffer::KIND].include?(kind)
|
|
820
|
+
|
|
821
|
+
ChildRing.ring(@session, why: "question", state_dir: @state_dir)
|
|
822
|
+
@asked_parent = true
|
|
522
823
|
end
|
|
523
824
|
|
|
524
825
|
# A save between turns (the turn's own, a command's, the notes', the
|
|
@@ -588,14 +889,42 @@ module Samagotchi
|
|
|
588
889
|
end
|
|
589
890
|
end
|
|
590
891
|
merged.each { |_prompt, origin| user_input(origin&.dig(:client_id)) }
|
|
591
|
-
|
|
592
|
-
|
|
892
|
+
# Delegate children's reports: never handed back on a failure
|
|
893
|
+
# (their rings stay for the next turn), so not in @merged_this_turn.
|
|
894
|
+
reports, mark = take_child_reports
|
|
895
|
+
unless merged.empty? && reports.empty?
|
|
896
|
+
@engine.announce(type: :input_merged, count: merged.size + reports.size,
|
|
897
|
+
origins: merged.filter_map(&:last) + reports.map(&:origin))
|
|
593
898
|
@merged_this_turn.concat(merged)
|
|
594
899
|
end
|
|
595
900
|
# Each line keeps its sender (Steer.source_for_client): a chi send or
|
|
596
901
|
# a delegating parent's line is saved and shown to the model as theirs.
|
|
597
|
-
merged.map { |prompt, origin| Steer::Line.new(text: prompt, source: Steer.source_for_client(origin&.dig(:client_id))) }
|
|
902
|
+
lines = merged.map { |prompt, origin| Steer::Line.new(text: prompt, source: Steer.source_for_client(origin&.dig(:client_id))) } +
|
|
903
|
+
reports.map(&:line)
|
|
904
|
+
# A wake turn starts with its first line, whoever sent it.
|
|
905
|
+
lines[0] = lines[0].with(mark: mark) if mark && lines.any?
|
|
906
|
+
lines
|
|
907
|
+
end
|
|
908
|
+
end
|
|
909
|
+
|
|
910
|
+
# The reports for rings that came in since the turn last looked. A
|
|
911
|
+
# failure to read them must not take the turn's steering with it.
|
|
912
|
+
# A wake turn's first call gets the reports it was started for (their
|
|
913
|
+
# message is the turn's start: turn_start, turn_id, so a reload shows
|
|
914
|
+
# the turn as its own).
|
|
915
|
+
# @return [Array(Array<ChildReports::Report>, Hash|nil)] the reports
|
|
916
|
+
# and the mark of their message
|
|
917
|
+
def take_child_reports
|
|
918
|
+
if @wake_reports
|
|
919
|
+
reports = @wake_reports
|
|
920
|
+
@wake_reports = nil
|
|
921
|
+
return [reports, { turn_start: true, turn_id: @turn_id }.compact]
|
|
598
922
|
end
|
|
923
|
+
|
|
924
|
+
[@child_reports.take, nil]
|
|
925
|
+
rescue StandardError => e
|
|
926
|
+
Log.warn(:worker, "delegate_reports_failed", error: e.class.name, msg: e.message)
|
|
927
|
+
[[], nil]
|
|
599
928
|
end
|
|
600
929
|
|
|
601
930
|
# Check again with the event log held, which the Bridge holds while it
|
|
@@ -696,8 +1025,14 @@ module Samagotchi
|
|
|
696
1025
|
|
|
697
1026
|
# A human's input (ArchiveStore.user_input?) un-archives the session;
|
|
698
1027
|
# a delegate's, a plugin's or a reminder's doesn't.
|
|
1028
|
+
# It also resets the delegate-report wakes (budget and pause).
|
|
699
1029
|
def user_input(client_id)
|
|
700
|
-
|
|
1030
|
+
return unless ArchiveStore.user_input?(client_id)
|
|
1031
|
+
|
|
1032
|
+
ArchiveStore.user_input(@session_id, state_dir: @state_dir)
|
|
1033
|
+
@wakes_in_a_row = 0
|
|
1034
|
+
@wakes_paused = false
|
|
1035
|
+
@budget_noticed = false
|
|
701
1036
|
end
|
|
702
1037
|
|
|
703
1038
|
def stopped_on_disk?
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: samagotchi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.32.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -125,6 +125,7 @@ files:
|
|
|
125
125
|
- docs/architecture.md
|
|
126
126
|
- docs/cli.md
|
|
127
127
|
- docs/configuration.md
|
|
128
|
+
- docs/context.md
|
|
128
129
|
- docs/desktop.md
|
|
129
130
|
- docs/guardrails.md
|
|
130
131
|
- docs/hooks.md
|
|
@@ -170,6 +171,9 @@ files:
|
|
|
170
171
|
- lib/samagotchi/bundles/check-in/plugin.rb
|
|
171
172
|
- lib/samagotchi/bundles/core/manifest.yml
|
|
172
173
|
- lib/samagotchi/bundles/dev/manifest.yml
|
|
174
|
+
- lib/samagotchi/bundles/github-pr/manifest.yml
|
|
175
|
+
- lib/samagotchi/bundles/github-pr/plugin.rb
|
|
176
|
+
- lib/samagotchi/bundles/github-pr/scripts/pr_context.rb
|
|
173
177
|
- lib/samagotchi/bundles/guardrails/guardrails.md
|
|
174
178
|
- lib/samagotchi/bundles/guardrails/guardrails/rules.yml
|
|
175
179
|
- lib/samagotchi/bundles/guardrails/guardrails/small-models.yml
|
|
@@ -193,6 +197,7 @@ files:
|
|
|
193
197
|
- lib/samagotchi/bundles/system/memory_guide.md
|
|
194
198
|
- lib/samagotchi/bundles/system/self_map.md
|
|
195
199
|
- lib/samagotchi/cancellation_controller.rb
|
|
200
|
+
- lib/samagotchi/child_reports.rb
|
|
196
201
|
- lib/samagotchi/cli/command.rb
|
|
197
202
|
- lib/samagotchi/cli/exit.rb
|
|
198
203
|
- lib/samagotchi/cli/flags.rb
|
|
@@ -202,8 +207,14 @@ files:
|
|
|
202
207
|
- lib/samagotchi/commands/registry.rb
|
|
203
208
|
- lib/samagotchi/config.rb
|
|
204
209
|
- lib/samagotchi/config_text_edit.rb
|
|
210
|
+
- lib/samagotchi/context_absorber.rb
|
|
211
|
+
- lib/samagotchi/context_command.rb
|
|
212
|
+
- lib/samagotchi/context_fetch.rb
|
|
205
213
|
- lib/samagotchi/context_note.rb
|
|
214
|
+
- lib/samagotchi/context_poller.rb
|
|
215
|
+
- lib/samagotchi/context_providers.rb
|
|
206
216
|
- lib/samagotchi/context_quote.rb
|
|
217
|
+
- lib/samagotchi/context_sources.rb
|
|
207
218
|
- lib/samagotchi/context_status.rb
|
|
208
219
|
- lib/samagotchi/context_usage.rb
|
|
209
220
|
- lib/samagotchi/context_window.rb
|
|
@@ -314,6 +325,7 @@ files:
|
|
|
314
325
|
- lib/samagotchi/paths.rb
|
|
315
326
|
- lib/samagotchi/pending_input_queue.rb
|
|
316
327
|
- lib/samagotchi/plugin/api.rb
|
|
328
|
+
- lib/samagotchi/plugin/attached_context.rb
|
|
317
329
|
- lib/samagotchi/plugin/context.rb
|
|
318
330
|
- lib/samagotchi/plugin/loader.rb
|
|
319
331
|
- lib/samagotchi/plugin/service.rb
|
|
@@ -393,7 +405,9 @@ files:
|
|
|
393
405
|
- lib/samagotchi/tools/builtin_calls.rb
|
|
394
406
|
- lib/samagotchi/tools/builtins.rb
|
|
395
407
|
- lib/samagotchi/tools/cancel_reminder.rb
|
|
408
|
+
- lib/samagotchi/tools/context_read.rb
|
|
396
409
|
- lib/samagotchi/tools/delegate.rb
|
|
410
|
+
- lib/samagotchi/tools/delegate_cursor.rb
|
|
397
411
|
- lib/samagotchi/tools/delegate_relay.rb
|
|
398
412
|
- lib/samagotchi/tools/delegate_result.rb
|
|
399
413
|
- lib/samagotchi/tools/delegate_wait.rb
|
|
@@ -446,6 +460,7 @@ files:
|
|
|
446
460
|
- lib/samagotchi/web/public/command_list.js
|
|
447
461
|
- lib/samagotchi/web/public/command_view.js
|
|
448
462
|
- lib/samagotchi/web/public/composer_size.js
|
|
463
|
+
- lib/samagotchi/web/public/context_chips.js
|
|
449
464
|
- lib/samagotchi/web/public/copy.js
|
|
450
465
|
- lib/samagotchi/web/public/ctx.js
|
|
451
466
|
- lib/samagotchi/web/public/data.js
|