samagotchi 0.90.0 → 0.91.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.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "securerandom"
4
5
 
5
6
  require_relative "client_id"
6
7
  require_relative "config"
@@ -31,6 +32,9 @@ require_relative "child_reports"
31
32
  require_relative "child_move"
32
33
  require_relative "context_absorber"
33
34
  require_relative "context_poller"
35
+ require_relative "kernel_loop"
36
+ require_relative "task_finish_note"
37
+ require_relative "task_watch"
34
38
  require_relative "tools/task_runtime"
35
39
 
36
40
  module Samagotchi
@@ -74,6 +78,8 @@ module Samagotchi
74
78
  # How much of a command's output goes into its :command_ran.
75
79
  COMMAND_OUTPUT_LIMIT = 4096
76
80
  BUSY_OUTPUT = "busy: wait for the turn to end"
81
+ # /exit held by background tasks a finish wake waits for: how to end them.
82
+ TASK_STOP_HINT = "stop one with the web's stop-task button or by asking for task_stop"
77
83
  # !rollback mid-turn would mean "the turn I'm in".
78
84
  ROLLBACK_BUSY_OUTPUT = "busy: Ctrl-C the turn first, then !rollback"
79
85
  # How long a signalled worker's turn has to end (it is canceled at once,
@@ -242,7 +248,7 @@ module Samagotchi
242
248
  # a command queued mid-turn (/thinking, /model, /llm-context) changes
243
249
  # what it starts with: no turn-end warm-up then.
244
250
  @engine.next_turn_waiting = lambda {
245
- !@command_queue.empty? || SessionInbox.find_new_input_files(@session_dir).any? || wake_due?
251
+ !@command_queue.empty? || SessionInbox.find_new_input_files(@session_dir).any? || wake_due? || task_wake_due?
246
252
  }
247
253
  @turn_flow = TurnFlow.new(engine: @engine)
248
254
  # Its question's answer is queued as the matching /continue (the
@@ -258,6 +264,9 @@ module Samagotchi
258
264
  # The seq of the last turn's end event: a command queued before it was
259
265
  # queued while that turn ran.
260
266
  @turn_end_seq = 0
267
+ # The background tasks this session started and hasn't seen finish:
268
+ # one that finishes unseen wakes the idle session (#run_task_wake).
269
+ @task_watch = TaskWatch.new
261
270
  @engine.subscribe(observer: lambda { |event|
262
271
  if Events::TURN_END.include?(event[:type])
263
272
  @turn_end_seq = event[:event_seq]
@@ -269,6 +278,7 @@ module Samagotchi
269
278
  @turn_ending = false
270
279
  end
271
280
  ring_question(event) if event[:type] == :question_requested
281
+ watch_tasks(event)
272
282
  })
273
283
  # This session's own delegate children's news (rings in children/).
274
284
  @child_reports = ChildReports.new(session_id: @session_id, state_dir: @state_dir,
@@ -315,7 +325,10 @@ module Samagotchi
315
325
  timeout_minutes: @idle_exit_minutes || SessionManager.config_idle_exit_minutes,
316
326
  input_pending: -> { !@command_queue.empty? || !SessionInbox.find_new_input_files(@session_dir).empty? },
317
327
  awaiting_continue: -> { @turn_flow.awaiting_continue? },
318
- running_tasks: -> { Tools::TaskRuntime.running_created_in(@engine.messages_checkpoint) }
328
+ running_tasks: -> { Tools::TaskRuntime.running_created_in(@engine.messages_checkpoint) },
329
+ # Only when a finish would wake the session (D3): with queue its end
330
+ # is just a note, not worth keeping the worker up for.
331
+ watched_tasks: -> { TaskWatch.mode == "wake" ? @task_watch.running : [] }
319
332
  )
320
333
  end
321
334
 
@@ -371,12 +384,13 @@ module Samagotchi
371
384
  run_input_files(input_files)
372
385
  end
373
386
 
374
- # Nothing queued: a delegate wake turn or a due reminder runs, else
375
- # the worker leaves when the idle exit or a client's exit is due, else
376
- # it sleeps until woken or the fallback tick.
387
+ # Nothing queued: a delegate wake turn, a task finish wake turn or a due
388
+ # reminder runs, else the worker leaves when the idle exit or a client's
389
+ # exit is due, else it sleeps until woken or the fallback tick.
377
390
  # @return [Symbol, nil] as #pass
378
391
  def idle_pass
379
392
  return if run_wake_turn
393
+ return if run_task_wake
380
394
  return if run_due_reminders
381
395
  return left(:idle_exit) if @idle_exit.due? && leave_idle
382
396
  return left(@exit_restart ? :restart : :exit_requested) if @exit_requested && leave_on_request
@@ -441,6 +455,9 @@ module Samagotchi
441
455
  memories: @session.preloaded_memory_names,
442
456
  muted_memories: @session.muted_memory_names,
443
457
  appended_prompt_files: @session.appended_prompt_files,
458
+ # The finish wake reports a task the model leaves running: the tool
459
+ # descriptions say so, fixed for this worker (a stable prompt prefix).
460
+ task_finish_notes: TaskWatch.mode != "off",
444
461
  reminders: {
445
462
  callback: lambda { |due_names|
446
463
  # A reminder is due: the loop runs a reminder turn for it once
@@ -552,8 +569,10 @@ module Samagotchi
552
569
  # @param prompt [WorkerInbound::Prompt]
553
570
  def run_prompt(prompt)
554
571
  origin = prompt.origin
555
- # A first turn sees the context attached before it.
572
+ # A first turn sees the context attached before it, and any turn the
573
+ # background tasks that finished since the last look.
556
574
  absorb_context(before_turn: true)
575
+ absorb_task_notes
557
576
  user_input(origin&.dig(:client_id))
558
577
  @continue_offer.drop(origin)
559
578
  @turn_flow.before_prompt_turn
@@ -647,6 +666,153 @@ module Samagotchi
647
666
  @wakes.drop_handed
648
667
  end
649
668
 
669
+ # The Engine's raw events feed the task watch (session.task_wakes not
670
+ # off): a result that started a task or showed its end, a turn's start,
671
+ # a user's Stop of it (M5).
672
+ def watch_tasks(event)
673
+ case event[:type]
674
+ when :tool_call_completed then @task_watch.observe(event) unless TaskWatch.mode == "off"
675
+ when :turn_started then @task_watch.turn_started
676
+ when :turn_canceled then @task_watch.turn_canceled(event[:cancellation_reason])
677
+ end
678
+ end
679
+
680
+ # Whether a finished task would start a wake turn now: the Engine skips
681
+ # the turn-end warm-up then (next_turn_waiting).
682
+ def task_wake_due?
683
+ TaskWatch.mode == "wake" && @task_watch.any? && @wakes.task_open?(awaiting_continue: @turn_flow&.awaiting_continue?) &&
684
+ @task_watch.finished?
685
+ end
686
+
687
+ # The background tasks this session started that finished unseen
688
+ # (TaskWatch#take_finished), each as a TASK FINISHED note
689
+ # (TaskFinishNote): the first one not note-only starts a wake turn from
690
+ # task:<id> when session.task_wakes is wake, nothing is queued, no exit
691
+ # was asked for and the wake budget is open (WorkerWakes#task_open?);
692
+ # the others, and every one otherwise, are plain notes for the next
693
+ # turn (M2). A spent budget is announced once.
694
+ # @return [Boolean] whether a wake turn ran
695
+ def run_task_wake
696
+ return false if @wakes.in_grace? || !@task_watch.any?
697
+
698
+ finished = take_finished_tasks
699
+ return false if finished.empty?
700
+
701
+ waking = finished.find { |entry, _| !entry.note_only } if task_wake_open?
702
+ task_wake = note_finished_tasks(finished, waking: waking)
703
+ return false if task_wake == :failed
704
+
705
+ save_or_log(:task_notes) { save_session }
706
+ unless task_wake
707
+ task_budget_notice(finished.count { |entry, _| !entry.note_only }) if TaskWatch.mode == "wake" && @wakes.spent?
708
+ return false
709
+ end
710
+
711
+ run_task_wake_turn(task_wake)
712
+ true
713
+ end
714
+
715
+ # Before a prompt's turn: the finished tasks join it as plain notes.
716
+ def absorb_task_notes
717
+ return unless @task_watch.any?
718
+
719
+ finished = take_finished_tasks
720
+ return if finished.empty?
721
+ return if note_finished_tasks(finished) == :failed
722
+
723
+ save_or_log(:task_notes) { save_session }
724
+ end
725
+
726
+ # Each finished task's note into the conversation: +waking+'s as the
727
+ # wake turn's start, the others plain. A note that can't be written (a
728
+ # log that can't be read, say) puts it and the ones after it back in
729
+ # the watch for the next look.
730
+ # @return [WorkerInbound::TaskWake, nil, :failed]
731
+ def note_finished_tasks(finished, waking: nil)
732
+ written = []
733
+ (finished - [waking]).each do |entry, record|
734
+ add_task_note(entry, record)
735
+ written << entry
736
+ end
737
+ waking && add_task_wake_note(*waking)
738
+ rescue StandardError => e
739
+ Log.warn(:worker, "task_note_failed", error: e.class.name, msg: e.message)
740
+ @task_watch.put_back(finished.map(&:first) - written)
741
+ :failed
742
+ end
743
+
744
+ # Only the tasks the conversation still holds (a !rollback, a restored
745
+ # turn may have dropped the result that started one). A failure to
746
+ # read them is logged: they wait for the next look.
747
+ def take_finished_tasks
748
+ @task_watch.take_finished(keep: Tools::TaskRuntime.created_ids_in(@engine.messages_checkpoint))
749
+ rescue StandardError => e
750
+ Log.warn(:worker, "task_watch_failed", error: e.class.name, msg: e.message)
751
+ []
752
+ end
753
+
754
+ def task_wake_open?
755
+ TaskWatch.mode == "wake" && nothing_queued? && !@exit_requested &&
756
+ @wakes.task_open?(awaiting_continue: @turn_flow.awaiting_continue?)
757
+ end
758
+
759
+ def task_note(entry, record, wake:, turn_id: nil)
760
+ TaskFinishNote.note(record, limit_sec: entry.limit_sec, wake: wake, turn_id: turn_id,
761
+ cap: KernelLoop.resolve_output_char_cap(nil))
762
+ end
763
+
764
+ def add_task_note(entry, record)
765
+ @engine.add_context_note(@session, task_note(entry, record, wake: false))
766
+ end
767
+
768
+ # The wake note, in the conversation as the wake turn's first message
769
+ # (before TurnFlow#before_prompt_turn, so a restore keeps it).
770
+ # @return [WorkerInbound::TaskWake]
771
+ def add_task_wake_note(entry, record)
772
+ turn_id = SecureRandom.uuid
773
+ @engine.add_context_note(@session, task_note(entry, record, wake: true, turn_id: turn_id))
774
+ WorkerInbound::TaskWake.new(ids: [entry.id], turn_id: turn_id, plain_note: task_note(entry, record, wake: false))
775
+ end
776
+
777
+ # A turn nobody typed for a finished task: a continue turn from
778
+ # task:<id> whose first message is the wake note, on the delegate wake's
779
+ # rules (counted toward session.max_wakes). A failure that kept nothing,
780
+ # or a Stop that restored the conversation, leaves the note as a plain
781
+ # one (WorkerInbound#unmark_wake) and pauses wakes until a human's input (M4).
782
+ def run_task_wake_turn(task_wake)
783
+ Log.info(:worker, "task_wake_turn", task_id: task_wake.ids.first, in_a_row: @wakes.count!)
784
+ @turn_flow.before_prompt_turn
785
+ restores = @turn_flow.restores
786
+ run_engine_turn(nil, continue: true, origin: task_wake.origin, id: task_wake.turn_id,
787
+ max_iterations: IterationLimit.for) do |result, error|
788
+ if error
789
+ kept = @turn_flow.prompt_turn_failed(
790
+ error: error, wake: "a finished background task (#{task_wake.name.delete_prefix("task ")})",
791
+ wake_kept: "Its note stays for the user's next message."
792
+ ) == :kept
793
+ @inbound.unmark_wake(task_wake) unless kept
794
+ @wakes.pause!
795
+ else
796
+ @continue_offer.after_turn(result)
797
+ if result.canceled? && @turn_flow.restores != restores
798
+ @inbound.unmark_wake(task_wake)
799
+ @wakes.pause!
800
+ end
801
+ end
802
+ end
803
+ end
804
+
805
+ # The wake budget is spent: the UIs hear once that finished tasks wait
806
+ # as notes (one notice per stretch, shared with the other kinds).
807
+ def task_budget_notice(count)
808
+ return unless count.positive? && @wakes.notice_budget!
809
+
810
+ @engine.announce(type: :hook_notice, hook: "task", level: :info, between_turns: true,
811
+ text: "#{count} finished background task#{"s" if count != 1} noted; the model reads " \
812
+ "#{count == 1 ? "it" : "them"} with your next message " \
813
+ "(#{@wakes.max} wake turn#{"s" if @wakes.max != 1} in a row, #{ChildRing::MAX_WAKES_KEY})")
814
+ end
815
+
650
816
  # The idle loop's sleep: the fallback tick, or less while reports wait
651
817
  # out the start grace.
652
818
  def idle_wait = @wakes.idle_wait(@poll_interval) { wake_state == :due }
@@ -1200,12 +1366,16 @@ module Samagotchi
1200
1366
  # +delete+: the session is deleted after (/exit --delete), so no recap.
1201
1367
  # +restart+: a new worker takes over (on the newest chi installed), so
1202
1368
  # WorkerIdleExit#hold_for_restart's rules, and no recap or discard.
1203
- # @return [Symbol, nil] what keeps the worker up, nil when it will leave
1369
+ # @return [Symbol, Hash, nil] what keeps the worker up ({reason:
1370
+ # :background_tasks, tasks:} names the watched tasks still running),
1371
+ # nil when it will leave
1204
1372
  def exit_request(client_id, delete: false, restart: false)
1205
1373
  # The Bridge serves before the idle-exit policy exists.
1206
1374
  return :starting unless @idle_exit
1207
1375
 
1208
1376
  hold = restart ? @idle_exit.hold_for_restart : @idle_exit.hold_for_request(requester: client_id)
1377
+ # /exit names the tasks it waits for (D3).
1378
+ return { reason: hold, tasks: @task_watch.running, hint: TASK_STOP_HINT } if hold == :background_tasks && !restart
1209
1379
  return hold if hold
1210
1380
 
1211
1381
  @exit_requested = true
@@ -13,6 +13,8 @@ module Samagotchi
13
13
  # * a reminder is registered. Reminders live only in the Engine and repeat
14
14
  # until the agent cancels them, so an exit would drop them for good;
15
15
  # * a continue offer waits for an answer; it too lives only in memory;
16
+ # * a background task the worker watches for a finish wake (TaskWatch)
17
+ # still runs: its end would go unnoticed after an exit (D3);
16
18
  # * less than the timeout has passed since the worker started, the Engine's
17
19
  # last activity, or the last client request or disconnect.
18
20
  #
@@ -29,15 +31,18 @@ module Samagotchi
29
31
  # @param awaiting_continue [#call] true while a continue offer waits
30
32
  # @param running_tasks [#call] the background tasks this conversation
31
33
  # started that still run (Tools::TaskRuntime.running_created_in)
34
+ # @param watched_tasks [#call] the ones a finish wake watches that still
35
+ # run (TaskWatch#running): they hold the idle exit and /exit too
32
36
  # @param clock [#call] monotonic seconds, the Engine's and Bridge's clock
33
37
  def initialize(engine:, bridge:, timeout_minutes:, input_pending:, awaiting_continue: -> { false },
34
- running_tasks: -> { [] }, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
38
+ running_tasks: -> { [] }, watched_tasks: -> { [] }, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
35
39
  @engine = engine
36
40
  @bridge = bridge
37
41
  @timeout = timeout_minutes.to_f * 60
38
42
  @input_pending = input_pending
39
43
  @awaiting_continue = awaiting_continue
40
44
  @running_tasks = running_tasks
45
+ @watched_tasks = watched_tasks
41
46
  @clock = clock
42
47
  @started_at = clock.call
43
48
  end
@@ -49,7 +54,8 @@ module Samagotchi
49
54
 
50
55
  # What keeps the worker up, or nil when nothing does.
51
56
  # @return [Symbol, nil] :disabled, :turn_running, :input_queued,
52
- # :continue_offered, :client_connected, :reminders or :recent_activity
57
+ # :continue_offered, :client_connected, :reminders, :background_tasks
58
+ # or :recent_activity
53
59
  def hold
54
60
  return :disabled unless @timeout.positive?
55
61
  return :turn_running if @engine.turn_running?
@@ -57,6 +63,7 @@ module Samagotchi
57
63
  return :continue_offered if @awaiting_continue.call
58
64
  return :client_connected if @bridge && @bridge.open_streams.positive?
59
65
  return :reminders if @engine.reminder_store&.any?
66
+ return :background_tasks if @watched_tasks.call.any?
60
67
  return :recent_activity if idle_seconds < @timeout
61
68
 
62
69
  nil
@@ -71,13 +78,14 @@ module Samagotchi
71
78
  # either (the last check before leaving, when the asker's stream may
72
79
  # still be open, untagged)
73
80
  # @return [Symbol, nil] :turn_running, :input_queued, :continue_offered,
74
- # :client_connected or :reminders
81
+ # :client_connected, :reminders or :background_tasks
75
82
  def hold_for_request(requester:, streams: true)
76
83
  return :turn_running if @engine.turn_running?
77
84
  return :input_queued if @input_pending.call
78
85
  return :continue_offered if @awaiting_continue.call
79
86
  return :client_connected if streams && @bridge && @bridge.open_streams_except(requester).positive?
80
87
  return :reminders if @engine.reminder_store&.any?
88
+ return :background_tasks if @watched_tasks.call.any?
81
89
 
82
90
  nil
83
91
  end
@@ -11,6 +11,7 @@ require_relative "context_note"
11
11
  require_relative "log"
12
12
  require_relative "session"
13
13
  require_relative "session_inbox"
14
+ require_relative "tools/task_record"
14
15
  require_relative "worker_wakes"
15
16
 
16
17
  module Samagotchi
@@ -51,6 +52,19 @@ module Samagotchi
51
52
  # that agrees closes the round and counts toward session.max_wakes only.
52
53
  def peer_turn? = peer? && !delivery.agreed
53
54
  def origin = { client_id: "#{ClientId::CONTEXT_PREFIX}#{name}" }
55
+ # The plain note #unmark_wake puts back.
56
+ def note = delivery.note
57
+ end
58
+
59
+ # A task finish wake turn to run (TaskWatch, TaskFinishNote): its wake
60
+ # note is in the conversation as the start of the turn +turn_id+;
61
+ # +plain_note+ is the same note without the turn start or the ask, which
62
+ # #unmark_wake puts back after a failed or restored wake turn. +ids+: the
63
+ # finished tasks it runs for (the first one names it).
64
+ TaskWake = Data.define(:ids, :turn_id, :plain_note) do
65
+ def name = "task #{Tools::TaskRecord.short_id(ids.first)}"
66
+ def origin = { client_id: "#{ClientId::TASK_PREFIX}#{ids.first}" }
67
+ def note = plain_note
54
68
  end
55
69
 
56
70
  # A save between turns (the turn's own, a command's, the notes', the
@@ -144,13 +158,13 @@ module Samagotchi
144
158
 
145
159
  # A failed wake turn kept nothing but its note: the note goes back to
146
160
  # a plain note (no turn start, the background wording), so a reload
147
- # draws no empty turn for it.
148
- def unmark_wake(context_wake)
161
+ # draws no empty turn for it. +wake+: a ContextWake or a TaskWake.
162
+ def unmark_wake(wake)
149
163
  messages = @engine.messages_checkpoint
150
- index = messages.index { |m| m[:turn_start] && m[:turn_id] == context_wake.turn_id }
164
+ index = messages.index { |m| m[:turn_start] && m[:turn_id] == wake.turn_id }
151
165
  return unless index
152
166
 
153
- messages[index] = ContextNote.message(**context_wake.delivery.note)
167
+ messages[index] = ContextNote.message(**wake.note)
154
168
  @engine.rollback_to(messages)
155
169
  end
156
170
 
@@ -6,10 +6,10 @@ require_relative "child_reports"
6
6
 
7
7
  module Samagotchi
8
8
  # A worker's wake budget: turns nobody typed (a delegate report's wake
9
- # turn, an attached context source's) run only past the start grace, at
10
- # most session.max_wakes in a row with no human input, and not after a
11
- # failed one until a human's input (a provider outage doesn't loop).
12
- # Shared by both kinds of wake; the Worker asks it before each and tells
9
+ # turn, a finished background task's, an attached context source's) run
10
+ # only past the start grace, at most session.max_wakes in a row with no
11
+ # human input, and not after a failed one until a human's input (a
12
+ # provider outage doesn't loop). Shared by every kind of wake; the Worker asks it before each and tells
13
13
  # it what happened. It also holds the reports a delegate wake turn was
14
14
  # started for until the turn's first boundary takes them (#hand_over,
15
15
  # #take_handed).
@@ -110,6 +110,16 @@ module Samagotchi
110
110
  true
111
111
  end
112
112
 
113
+ # For a background task that finished unseen (TaskWatch): no continue
114
+ # offer, not paused, past the start grace and under the budget. #count!
115
+ # when its wake turn starts.
116
+ def task_open?(awaiting_continue:)
117
+ !@paused && !awaiting_continue && !in_grace? && @in_a_row < max
118
+ end
119
+
120
+ # Whether wakes are paused (a failed wake turn) until a human's input.
121
+ def paused? = @paused
122
+
113
123
  # A wake turn starts; +peer+: for a peer's answer, which counts toward
114
124
  # both budgets. @return [Integer] the wake turns in a row now
115
125
  def count!(peer: false)
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.90.0
4
+ version: 0.91.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -520,6 +520,8 @@ files:
520
520
  - lib/samagotchi/steer_cut.rb
521
521
  - lib/samagotchi/steer_cut_after.rb
522
522
  - lib/samagotchi/system_prompt.rb
523
+ - lib/samagotchi/task_finish_note.rb
524
+ - lib/samagotchi/task_watch.rb
523
525
  - lib/samagotchi/terminal_ui.rb
524
526
  - lib/samagotchi/terminal_ui/attach_launcher.rb
525
527
  - lib/samagotchi/terminal_ui/attached_loop.rb