samagotchi 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -1
  3. data/docs/configuration.md +4 -0
  4. data/docs/internals/engine.md +1 -1
  5. data/docs/sessions.md +9 -2
  6. data/lib/samagotchi/bridge/card_store.rb +1 -1
  7. data/lib/samagotchi/bridge/turn_notice.rb +1 -1
  8. data/lib/samagotchi/bundles/system/delegated.md +1 -0
  9. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  10. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  11. data/lib/samagotchi/child_reports.rb +214 -0
  12. data/lib/samagotchi/client.rb +35 -4
  13. data/lib/samagotchi/config.rb +5 -0
  14. data/lib/samagotchi/engine.rb +2 -0
  15. data/lib/samagotchi/idle_recap.rb +4 -0
  16. data/lib/samagotchi/kernel_loop.rb +44 -5
  17. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  18. data/lib/samagotchi/llm/errors.rb +9 -0
  19. data/lib/samagotchi/log_subscriber.rb +1 -1
  20. data/lib/samagotchi/prompt_warmup.rb +30 -5
  21. data/lib/samagotchi/reply_wait.rb +39 -8
  22. data/lib/samagotchi/session.rb +26 -5
  23. data/lib/samagotchi/session_inbox.rb +34 -1
  24. data/lib/samagotchi/session_manager.rb +32 -3
  25. data/lib/samagotchi/steer.rb +39 -7
  26. data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
  27. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  28. data/lib/samagotchi/terminal_ui/formatting.rb +31 -4
  29. data/lib/samagotchi/terminal_ui.rb +9 -0
  30. data/lib/samagotchi/tool_call_parser.rb +40 -0
  31. data/lib/samagotchi/tool_declarations.rb +7 -7
  32. data/lib/samagotchi/tools/delegate.rb +13 -4
  33. data/lib/samagotchi/tools/delegate_cursor.rb +91 -0
  34. data/lib/samagotchi/tools/delegate_wait.rb +50 -31
  35. data/lib/samagotchi/tools/task_runtime.rb +21 -2
  36. data/lib/samagotchi/tools/task_wait.rb +2 -2
  37. data/lib/samagotchi/version.rb +1 -1
  38. data/lib/samagotchi/web/app.rb +8 -2
  39. data/lib/samagotchi/web/public/app.js +12 -4
  40. data/lib/samagotchi/web/public/format.js +8 -1
  41. data/lib/samagotchi/web/public/notify.js +4 -2
  42. data/lib/samagotchi/web/public/turn_events.js +26 -8
  43. data/lib/samagotchi/web/public/turn_view.js +23 -4
  44. data/lib/samagotchi/worker.rb +217 -8
  45. metadata +3 -1
@@ -213,7 +213,7 @@ module Samagotchi
213
213
  # Array), plus a model turn's tool_calls and thinking (the host's
214
214
  # reasoning, never sent back), a result's tool_call_id, the image
215
215
  # refs of a user message or a tool result, a prompt's turn_id (Engine,
216
- # never sent), and a plugin tool result's tool_params and tool_labels
216
+ # never sent) and a wake turn's report's turn_start (Steer, never sent), and a plugin tool result's tool_params and tool_labels
217
217
  # (the live row's params line and label, never sent back), an
218
218
  # edit/write result's tool_diffs (never sent), and an empty-answer
219
219
  # note's marker (TurnNote.empty, never sent).
@@ -225,6 +225,7 @@ module Samagotchi
225
225
  message[:tool_call_id] = entry[:tool_call_id] if entry[:tool_call_id]
226
226
  message[:images] = entry[:images] if entry[:images].is_a?(Array) && !entry[:images].empty?
227
227
  message[:turn_id] = entry[:turn_id] if entry[:turn_id]
228
+ message[:turn_start] = true if entry[:turn_start] == true
228
229
  message[:thinking] = entry[:thinking] if entry[:thinking].is_a?(String) && !entry[:thinking].empty?
229
230
  ToolResponse::SAVED_KEYS.each { |key| message[key] = entry[key] if entry[key] }
230
231
  message[TurnNote::RETRY_NUDGE] = true if TurnNote.retry_nudge?(entry)
@@ -207,6 +207,15 @@ module Samagotchi
207
207
  def summary = "unexpected response from host #{host}: #{detail}"
208
208
  end
209
209
 
210
+ # A native generation that came out corrupt twice (KernelLoop: a tool
211
+ # call never closed, or a fresh thought header after the answer), the
212
+ # second time without the server's prompt cache. A poisoned llama.cpp
213
+ # prompt cache does this (ggml-org/llama.cpp#27148); the text is not
214
+ # an answer.
215
+ class MalformedGeneration < ProtocolError
216
+ def summary = "malformed generation from host #{host}: #{detail}"
217
+ end
218
+
210
219
  # A request that kept failing on network errors until the retry budget
211
220
  # ran out (or failed mid-stream, where a retry would repeat output).
212
221
  class RetryExhausted < ConnectionError
@@ -29,7 +29,7 @@ module Samagotchi
29
29
  question_cancelled: %i[id reason],
30
30
  question_relay: %i[id reason],
31
31
  generation_cancelled: %i[iteration],
32
- empty_answer_retry: %i[iteration attempt of finish_reason thinking_chars stopped_by]
32
+ empty_answer_retry: %i[iteration attempt of finish_reason thinking_chars stopped_by malformed]
33
33
  }.freeze
34
34
 
35
35
  # @param session_id [#call] the session the events are about (the
@@ -20,9 +20,18 @@ module Samagotchi
20
20
  # another slot (12 s);
21
21
  # - once the warm-up is done, nothing is pinned: the server finds the
22
22
  # state itself, and pinning to a slot it has since cleared skips its
23
- # prompt cache (12 s again). #take_pin forgets the slot either way.
23
+ # prompt cache (12 s again). #take_pin forgets the slot either way;
24
+ # - no warm-up when /slots says that slot is busy (Client#slot_status):
25
+ # another session took it (a delegate child, often), and llama.cpp
26
+ # defers a pinned request until the slot is free, so the warm-up and
27
+ # then this session's next turn would wait out that generation. The
28
+ # next request then goes unpinned, and the server's prompt cache
29
+ # restores the state. When /slots can't say, the warm-up runs.
24
30
  class PromptWarmup
25
31
  MODES = %w[auto off].freeze
32
+ # Seconds #take_pin waits for the busy-slot check (Client#slot_status
33
+ # gives up within 3 s).
34
+ DECIDE_TIMEOUT = 3
26
35
 
27
36
  # Whether cache.warmup allows a warm-up (auto, the default; off, false,
28
37
  # no or 0 turn it off; anything else warns once and is auto).
@@ -52,8 +61,15 @@ module Samagotchi
52
61
  # nothing to pin the next request to)
53
62
  # @return [Thread]
54
63
  def start(client:, prompt:, model:, slot:, images: [])
64
+ # Whether the warm-up went to its slot (true) or was skipped: the
65
+ # first value pushed is the answer.
66
+ sent = Queue.new
55
67
  thread = Thread.new do
56
68
  Thread.current.report_on_exception = false
69
+ busy = slot.is_a?(Integer) && client.slot_status(slot, model: model) == :busy
70
+ sent << !busy
71
+ next skip(slot) if busy
72
+
57
73
  result = client.warm_up(prompt, model: model, slot: slot, images: images)
58
74
  Log.info(:model, "warmup", slot: result&.slot, cached: result&.cache_n, prefilled: result&.prompt_n,
59
75
  prefill_ms: result&.prompt_ms, chars: prompt.length)
@@ -61,25 +77,34 @@ module Samagotchi
61
77
  rescue StandardError => e
62
78
  Log.warn(:model, "warmup_failed", error: e.class.name, msg: e.message.to_s[0, 200])
63
79
  nil
80
+ ensure
81
+ sent << false
64
82
  end
65
83
  @mutex.synchronize do
66
84
  @thread = thread
67
- @state = { thread: thread, client: client, slot: slot }
85
+ @state = { thread: thread, client: client, slot: slot, sent: sent }
68
86
  end
69
87
  thread
70
88
  end
71
89
 
72
90
  # The slot the next request on +client+ must run on: the warm-up's,
73
- # while it still runs there; nil otherwise (done, failed, unpinned, or
74
- # another host). Called once per request; the first call forgets the
75
- # warm-up, so no later request is ever pinned.
91
+ # while it still runs there; nil otherwise (done, failed, skipped,
92
+ # unpinned, or another host). Called once per request; the first call
93
+ # forgets the warm-up, so no later request is ever pinned. Waits for
94
+ # the busy-slot check (DECIDE_TIMEOUT at most).
76
95
  def take_pin(client)
77
96
  state = @mutex.synchronize { @state.tap { @state = nil } }
78
97
  return nil unless state && state[:slot].is_a?(Integer) && state[:client].equal?(client)
98
+ return nil unless state[:sent].pop(timeout: DECIDE_TIMEOUT)
79
99
 
80
100
  state[:thread].alive? ? state[:slot] : nil
81
101
  end
82
102
 
103
+ private def skip(slot)
104
+ Log.info(:model, "warmup_skipped", slot: slot, why: "slot busy")
105
+ nil
106
+ end
107
+
83
108
  # Whether a warm-up is running (specs, measurements).
84
109
  def running?
85
110
  thread = @mutex.synchronize { @thread }
@@ -41,8 +41,11 @@ module Samagotchi
41
41
  # (user, hook, ctrl_c, manual)
42
42
  # @!attribute stopped_by [String, nil] the hook that canceled it
43
43
  # (loop-guard)
44
+ # @!attribute session [Session, nil] the session as the look that
45
+ # decided the outcome loaded it (a caller's next baseline); nil for
46
+ # :canceled and :timeout
44
47
  Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, :error_kind, :retryable, :cancel_reason,
45
- :stopped_by, keyword_init: true)
48
+ :stopped_by, :session, keyword_init: true)
46
49
 
47
50
  module_function
48
51
 
@@ -78,14 +81,15 @@ module Samagotchi
78
81
  session = Session.load(id, state_dir: state_dir)
79
82
 
80
83
  if (reply = reply_past(id, state_dir: state_dir, cursor: cursor))
84
+ reply.session = settled(session, id, state_dir: state_dir)
81
85
  return reply
82
86
  end
83
87
 
84
88
  case session.status
85
89
  when Session::STATUS_ERROR
86
- return Result.new(status: :error, text: session.last_prompt.to_s.strip)
90
+ return Result.new(status: :error, text: session.last_prompt.to_s.strip, session: session)
87
91
  when Session::STATUS_STOPPED
88
- return Result.new(status: :stopped)
92
+ return Result.new(status: :stopped, session: session)
89
93
  end
90
94
 
91
95
  # Only a question a live worker holds waits for an answer (the
@@ -94,7 +98,7 @@ module Samagotchi
94
98
  pending = session.pending_question
95
99
  if pending && !(baseline && pending[:id] == baseline[:question_id]) &&
96
100
  session.waiting_question(live: SessionManager.worker_live?(id, state_dir: state_dir))
97
- return Result.new(status: :waiting_for_answer, question: pending)
101
+ return Result.new(status: :waiting_for_answer, question: pending, session: session)
98
102
  end
99
103
 
100
104
  return Result.new(status: :canceled) if cancelled.call
@@ -107,14 +111,15 @@ module Samagotchi
107
111
  # file-delivered message its worker has not picked up yet.
108
112
  # The worker writes the reply before its idle save, so the read
109
113
  # above has it; one more look costs nothing should that change.
110
- return reply_past(id, state_dir: state_dir, cursor: cursor) || no_reply(session, baseline)
114
+ return reply_past(id, state_dir: state_dir, cursor: cursor)&.tap { |r| r.session = session } ||
115
+ no_reply(session, baseline)
111
116
  end
112
117
 
113
118
  if owner_grace
114
119
  if SessionManager.session_owner(id, state_dir: state_dir)
115
120
  gone_since = nil
116
121
  elsif monotonic - (gone_since ||= monotonic) > owner_grace
117
- return Result.new(status: :worker_gone)
122
+ return Result.new(status: :worker_gone, session: session)
118
123
  end
119
124
  end
120
125
 
@@ -157,11 +162,37 @@ module Samagotchi
157
162
  last = session.last_turn.is_a?(Hash) ? session.last_turn : {}
158
163
  fresh = baseline.nil? || !baseline.key?(:last_turn) || (last["ended_at"] && last["ended_at"] != baseline[:last_turn])
159
164
  outcome = fresh ? last["outcome"] : nil
160
- return Result.new(status: :no_reply, outcome: "exhausted", limit: last["limit"]) if fresh && last["exhausted"]
165
+ if fresh && last["exhausted"]
166
+ return Result.new(status: :no_reply, outcome: "exhausted", limit: last["limit"], session: session)
167
+ end
161
168
 
162
169
  text = outcome == "failed" ? failure_summary(session.messages) : nil
163
170
  why = fresh ? last.slice("error_kind", "retryable", "cancel_reason", "stopped_by").transform_keys(&:to_sym) : {}
164
- Result.new(status: :no_reply, outcome: outcome, text: text, **why)
171
+ Result.new(status: :no_reply, outcome: outcome, text: text, session: session, **why)
172
+ end
173
+
174
+ SETTLE_SECONDS = 1.0
175
+
176
+ # The session after the turn that wrote a reply was saved. The worker
177
+ # writes the reply just before that save, so a look between the two
178
+ # loaded the session still running, with the turn before's last_turn: a
179
+ # baseline from it would read the reply's own turn as a later one that
180
+ # left no reply. Loads again (briefly) until it shows idle or its
181
+ # last_turn moved on; a session running a next turn by then has the
182
+ # reply's turn as its last_turn, which is right too.
183
+ def settled(session, id, state_dir:)
184
+ return session unless session.status == Session::STATUS_RUNNING
185
+
186
+ before = session.last_turn.is_a?(Hash) ? session.last_turn["ended_at"] : nil
187
+ deadline = monotonic + SETTLE_SECONDS
188
+ loop do
189
+ sleep(0.02)
190
+ fresh = Session.load(id, state_dir: state_dir)
191
+ ended = fresh.last_turn.is_a?(Hash) ? fresh.last_turn["ended_at"] : nil
192
+ return fresh if fresh.status != Session::STATUS_RUNNING || ended != before || monotonic > deadline
193
+ end
194
+ rescue ArgumentError
195
+ session
165
196
  end
166
197
 
167
198
  # The summary in the failed-turn note at the conversation's tail, nil
@@ -30,6 +30,8 @@ module Samagotchi
30
30
  # resume removes it. A file from before the marker says "stopped" in
31
31
  # its status field, which reads the same.
32
32
  STOPPED_FILE = "stopped"
33
+ # The memory a delegate child starts with (Tools::Delegate::CHILD_MEMORIES).
34
+ DELEGATE_MEMORY = "system/delegated"
33
35
 
34
36
  SORT_KEYS = %w[created_at updated_at].freeze
35
37
  SORT_ORDERS = %w[asc desc].freeze
@@ -54,13 +56,18 @@ module Samagotchi
54
56
  # plugin forked it from (ctx.sessions.fork), else nil.
55
57
  # Set before the spawn and kept on respawns, like preloaded_memory_names.
56
58
  attr_accessor :parent_id
59
+ # Started by the `delegate` tool (a fork has a parent_id too, but isn't
60
+ # one): see #delegate?.
61
+ attr_writer :delegate
57
62
  # A `chi scratch` session: deleted when its REPL ends, and by the next
58
63
  # sweep (or `chi sessions clean`) when the process died first.
59
64
  attr_accessor :scratch
60
65
  # How the last turn ended, for the web's notifications (the hub sends
61
66
  # it in the summary): {"outcome" => "completed"|"failed"|"canceled",
62
67
  # "ended_at" => iso8601, "seconds" => Float, "origin" =>
63
- # "client"|"reminder"|"delegate"}; nil before the first turn.
68
+ # "client"|"reminder"|"delegate"|"delegate_report"}; nil before the
69
+ # first turn. delegate_report: a turn a parent ran for its delegate
70
+ # children's reports (ChildReports).
64
71
  attr_accessor :last_turn
65
72
 
66
73
  # The hook that stopped the last turn (stop_turn, or a cut with no retry
@@ -89,7 +96,7 @@ module Samagotchi
89
96
  first_preview: "", test_run: false, pending_question: nil,
90
97
  used_memory_names: [], project_root: nil,
91
98
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
92
- last_turn: nil, model_typed: nil)
99
+ last_turn: nil, model_typed: nil, delegate: false)
93
100
  @id = id
94
101
  @metadata_version = metadata_version
95
102
  @mode = mode
@@ -111,9 +118,20 @@ module Samagotchi
111
118
  @parent_id = parent_id&.to_s
112
119
  @scratch = !!scratch
113
120
  @last_turn = last_turn
121
+ @delegate = !!delegate
114
122
  @archived = false
115
123
  end
116
124
 
125
+ # A `delegate` child: its turns report to its parent (ChildReports). A
126
+ # child saved before the field is known by the memory every delegate
127
+ # starts with; a fork is neither.
128
+ def delegate?
129
+ return true if @delegate
130
+ return false unless @parent_id
131
+
132
+ @preloaded_memory_names.include?(DELEGATE_MEMORY)
133
+ end
134
+
117
135
  # The question this session waits on, as the lists show it: {id:, kind:}
118
136
  # with kind "question" (the model's), "approval", "hook" or "continue"
119
137
  # (the step-limit question, asked between turns); nil with
@@ -151,7 +169,7 @@ module Samagotchi
151
169
  # seed); [] by default
152
170
  def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
153
171
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [],
154
- scratch: false, model_typed: nil)
172
+ scratch: false, model_typed: nil, delegate: false)
155
173
  now = Time.now.iso8601(3)
156
174
  resolved_test = if test_run.nil?
157
175
  test_session_env?
@@ -174,7 +192,8 @@ module Samagotchi
174
192
  preloaded_memory_names: preloaded_memory_names,
175
193
  muted_memory_names: muted_memory_names,
176
194
  parent_id: parent_id,
177
- scratch: scratch
195
+ scratch: scratch,
196
+ delegate: delegate
178
197
  )
179
198
  end
180
199
 
@@ -228,7 +247,8 @@ module Samagotchi
228
247
  "muted_memory_names" => [],
229
248
  "parent_id" => nil,
230
249
  "scratch" => false,
231
- "last_turn" => nil
250
+ "last_turn" => nil,
251
+ "delegate" => false
232
252
  }.freeze
233
253
 
234
254
  # A session from a parsed session file. +messages+ false leaves the
@@ -346,6 +366,7 @@ module Samagotchi
346
366
  when "messages" then @messages.map { |msg| scrub_utf8(stringify_message_keys(msg)) }
347
367
  when "pending_question" then @pending_question && scrub_utf8(stringify_message_keys(@pending_question))
348
368
  when "test_run" then !!@test_run
369
+ when "delegate" then @delegate
349
370
  when "used_memory_names", "preloaded_memory_names", "muted_memory_names"
350
371
  Array(instance_variable_get(:"@#{key}"))
351
372
  else instance_variable_get(:"@#{key}")
@@ -10,7 +10,8 @@ require_relative "session"
10
10
  module Samagotchi
11
11
  # A session directory's file inbox and outbox (see SessionManager for the
12
12
  # layout): input/ holds one JSON file per queued turn, notes/ the context
13
- # notes a worker adds between turns, output/ one text file per reply.
13
+ # notes a worker adds between turns, output/ one text file per reply,
14
+ # children/ the rings of delegate children that have news (ChildReports).
14
15
  # Writers are any process (the web, chi send, chi note, send_note); the
15
16
  # worker claims input and notes by renaming them to *.processing.
16
17
  module SessionInbox
@@ -20,6 +21,8 @@ module Samagotchi
20
21
  NOTES_DIR = "notes"
21
22
  NOTE_MAX_BYTES = 16 * 1024
22
23
  OUTPUT_DIR = "output"
24
+ # A delegate child's doorbell: "look at me", not the report itself.
25
+ CHILDREN_DIR = "children"
23
26
  # Input-file format this worker reads (JSON with the sender's ids and
24
27
  # images: refs), advertised in the Bridge sidecar so a chi from before
25
28
  # it (one that wrote plain .txt to a worker not advertising 2, or held
@@ -117,6 +120,36 @@ module Samagotchi
117
120
  nil
118
121
  end
119
122
 
123
+ # Ring a parent: a file naming the child and why. The caller makes sure
124
+ # the parent's session exists (no orphan dir for a deleted one).
125
+ # @param why [String] "turn_end", "question" or "crash"
126
+ # @return [String] the ring file's path
127
+ def self.write_ring(session_dir, child_id:, why:)
128
+ dir = File.join(session_dir, CHILDREN_DIR)
129
+ FileUtils.mkdir_p(dir)
130
+ path = File.join(dir, "#{Time.now.strftime("%Y%m%d%H%M%S%9N")}-#{child_id.to_s[0, 8]}.json")
131
+ AtomicFile.write(path, JSON.generate({ "child_id" => child_id, "why" => why, "at" => Time.now.iso8601(3) }))
132
+ path
133
+ end
134
+
135
+ # The rings waiting, oldest first.
136
+ def self.find_ring_files(session_dir)
137
+ dir = File.join(session_dir, CHILDREN_DIR)
138
+ return [] unless Dir.exist?(dir)
139
+
140
+ Dir.glob(File.join(dir, "*.json")).sort_by { |p| File.basename(p) }
141
+ end
142
+
143
+ # @return [Hash, nil] {child_id:, why:, at:}, nil for a file that holds none
144
+ def self.read_ring(ring_file)
145
+ data = JSON.parse(File.read(ring_file))
146
+ return nil unless data.is_a?(Hash) && !data["child_id"].to_s.empty?
147
+
148
+ { child_id: data["child_id"].to_s, why: data["why"].to_s, at: data["at"] }
149
+ rescue JSON::ParserError, SystemCallError
150
+ nil
151
+ end
152
+
120
153
  def self.write_output(session_dir, response)
121
154
  output_dir = File.join(session_dir, OUTPUT_DIR)
122
155
  FileUtils.mkdir_p(output_dir)
@@ -134,7 +134,7 @@ module Samagotchi
134
134
  # and #model_warning: an id its host's saved list doesn't have
135
135
  def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
136
136
  memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
137
- title: nil)
137
+ title: nil, delegate: false)
138
138
  sd = state_dir || Session.default_state_dir
139
139
  # The resolved ref is stored (a resumed session keeps its model when an
140
140
  # alias is retargeted), with the name as typed beside it. A wrong host
@@ -153,7 +153,8 @@ module Samagotchi
153
153
  preloaded_memory_names: memories,
154
154
  muted_memory_names: muted_memories,
155
155
  parent_id: parent_id,
156
- messages: messages
156
+ messages: messages,
157
+ delegate: delegate
157
158
  )
158
159
  session.model_warning = model_warning
159
160
  # With no prompt there is no first turn to run (an attaching UI sends
@@ -462,6 +463,32 @@ module Samagotchi
462
463
  session
463
464
  end
464
465
 
466
+ # A delegate child rang a parent that has no worker (ChildRing): start
467
+ # one, which runs a turn for the report. Not for a parent that is gone,
468
+ # stopped, archived, a scratch session, or open in a chi REPL: the ring
469
+ # waits on disk for whoever runs it next (#resume_session itself would
470
+ # clear the stop). A stop landing between the check and the resume is
471
+ # a small window, left as is.
472
+ # @return [Symbol] :woken, or why not (:gone, :stopped, :archived,
473
+ # :scratch, :owned)
474
+ def self.wake_for_report(session_id, state_dir: nil)
475
+ sd = state_dir || Session.default_state_dir
476
+ return :gone unless Session.exist?(session_id, state_dir: sd)
477
+ return :owned if session_owner(session_id, state_dir: sd)
478
+ return :stopped if Session.stopped_marker?(session_id, state_dir: sd)
479
+ return :archived if ArchiveStore.archived?(Session.session_dir(session_id, state_dir: sd))
480
+
481
+ session = Session.load(session_id, state_dir: sd)
482
+ return :stopped if session.status == Session::STATUS_STOPPED
483
+ return :scratch if session.scratch
484
+
485
+ resume_session(session_id, state_dir: sd)
486
+ Log.info(:worker, "delegate_woke_parent", parent: session_id[0, 8])
487
+ :woken
488
+ rescue OwnedByTUI
489
+ :owned
490
+ end
491
+
465
492
  # How long after a spawn a resume trusts that worker to take the
466
493
  # session, while it runs (and the session wasn't stopped since).
467
494
  WORKER_START_GRACE = 30.0
@@ -832,8 +859,10 @@ module Samagotchi
832
859
  # owner after its write and wakes one, or this finds its input.
833
860
  # A restart starts its successor here, on the newest chi installed
834
861
  # (worker_command), once this one's lock is free.
862
+ # Delegate reports that rang while it left wake one too.
835
863
  if result == :restart ||
836
- (%i[idle_exit exit_requested].include?(result) && !SessionInbox.find_new_input_files(session_dir).empty?)
864
+ (%i[idle_exit exit_requested].include?(result) &&
865
+ (!SessionInbox.find_new_input_files(session_dir).empty? || worker.wake_due?))
837
866
  resume_session(session_id, state_dir: sd)
838
867
  elsif worker.discard?
839
868
  discard_left_session(session_id, state_dir: sd, default_model: worker.default_model)
@@ -19,14 +19,22 @@ module Samagotchi
19
19
  INPUT_KIND = "input"
20
20
 
21
21
  # A drain item for an input line with its sender (source nil = the user).
22
- # A plain String is still a valid item: the user's line.
23
- Line = Data.define(:text, :source)
22
+ # A plain String is still a valid item: the user's line. +mark+: fields
23
+ # its input message carries ({turn_start: true, turn_id:} for the
24
+ # first message of a turn with no prompt, a delegate report's wake
25
+ # turn), nil for none.
26
+ Line = Data.define(:text, :source, :mark) do
27
+ def initialize(text:, source:, mark: nil) = super
28
+ end
24
29
 
25
30
  # The client ids whose input is not the user's own words. Literals, not
26
31
  # SendCommand::CLIENT_ID / Tools::Delegate::CLIENT_PREFIX, so this file
27
32
  # needs no requires (a spec pins them equal).
28
33
  CHI_SEND_CLIENT = "cli:send"
29
34
  DELEGATE_CLIENT_PREFIX = "delegate:"
35
+ # A delegate child's report (ChildReports::CLIENT_PREFIX).
36
+ CHILD_CLIENT_PREFIX = "child:"
37
+ DELEGATE_REPORT = "delegate_report"
30
38
  PLUGIN_CLIENT = "plugin"
31
39
 
32
40
  # What one drain brought: the merged user text (nil when none) and how
@@ -43,16 +51,22 @@ module Samagotchi
43
51
  list = inputs.map do |input|
44
52
  message = { role: "user", kind: INPUT_KIND }
45
53
  message[:source] = input[:source] if input[:source]
54
+ message.merge!(input[:mark]) if input[:mark]
46
55
  message.merge(content: input[:content])
47
56
  end
48
57
  list + steers.map { |steer| Steer.message(**steer) }
49
58
  end
50
59
 
51
60
  # The :pending_input_merged fields beside iteration and answer;
52
- # steers: only when there are some, so plain merges stay as they were.
61
+ # steers: only when there are some, so plain merges stay as they were;
62
+ # reports: the delegate reports' text, one per report (a UI shows
63
+ # each as the child's, not as a merged message).
53
64
  def event_fields
54
65
  fields = { count: count, content: content }
55
66
  fields[:steers] = steers unless steers.empty?
67
+ reports = inputs.select { |input| input[:source] == DELEGATE_REPORT }
68
+ .flat_map { |input| input[:content].split(/\n\n(?=session: )/) }
69
+ fields[:reports] = reports unless reports.empty?
56
70
  fields
57
71
  end
58
72
  end
@@ -68,6 +82,19 @@ module Samagotchi
68
82
  "plugin_send" => "sent by a plugin"
69
83
  }.freeze
70
84
  USER_HEADER = "[Steer #{SENDERS["user"]}, #{HEADER_TAIL}".freeze
85
+ # A source whose message is not a steer at all gets its own whole
86
+ # header line here instead of "[Steer <sender>, …]". A delegate
87
+ # child's report (ChildReports) is chi's news, not the user's: its
88
+ # wording was picked on small models (it keeps them on their own task
89
+ # mid-turn and off delegate_result; "Follow it" made one apply the
90
+ # child's fix unasked).
91
+ HEADERS = {
92
+ "delegate_report" => "[Delegate report, delivered by chi when your delegate session ended its turn; " \
93
+ "not your user's message. Tell your user what it found or what went wrong, in your own " \
94
+ "words; if you are in the middle of a task, include it in your reply and finish the task. " \
95
+ "Don't call delegate_result for it; to retry or redirect that child, use delegate with " \
96
+ "its session:.]"
97
+ }.freeze
71
98
 
72
99
  module_function
73
100
 
@@ -77,6 +104,7 @@ module Samagotchi
77
104
  id = client_id.to_s
78
105
  if id == CHI_SEND_CLIENT then "chi_send"
79
106
  elsif id.start_with?(DELEGATE_CLIENT_PREFIX) then "parent_agent"
107
+ elsif id.start_with?(CHILD_CLIENT_PREFIX) then DELEGATE_REPORT
80
108
  elsif id == PLUGIN_CLIENT then "plugin_send"
81
109
  end
82
110
  end
@@ -89,6 +117,8 @@ module Samagotchi
89
117
  return unless [KIND, INPUT_KIND].include?(kind)
90
118
 
91
119
  source = (message[:source] || message["source"]).to_s
120
+ return HEADERS[source] if HEADERS.key?(source)
121
+
92
122
  source = "user" if source.empty?
93
123
  sender = SENDERS[source] || (kind == KIND ? "from the #{source} plugin" : SENDERS["user"])
94
124
  "[Steer #{sender}, #{HEADER_TAIL}"
@@ -139,9 +169,11 @@ module Samagotchi
139
169
  end
140
170
 
141
171
  # A prompt that started a turn: not a steer, not input merged into a
142
- # running turn. What the turns a page shows count.
172
+ # running turn, except the input a turn with no prompt starts with (a
173
+ # wake turn's delegate report, marked turn_start). What the turns a
174
+ # page shows count.
143
175
  def turn_prompt?(message)
144
- prompt?(message) && !input?(message)
176
+ prompt?(message) && (!input?(message) || (message[:turn_start] || message["turn_start"]) == true)
145
177
  end
146
178
 
147
179
  # Call a loop's drain: +at_answer+ goes only to a drain that takes it
@@ -197,10 +229,10 @@ module Samagotchi
197
229
  def input_groups(lines)
198
230
  pairs = lines.filter_map do |line|
199
231
  text = (line.is_a?(Line) ? line.text : line).to_s.strip
200
- [text, line.is_a?(Line) ? line.source : nil] unless text.empty?
232
+ [text, line.is_a?(Line) ? line.source : nil, line.is_a?(Line) ? line.mark : nil] unless text.empty?
201
233
  end
202
234
  pairs.chunk_while { |a, b| a[1] == b[1] }.map do |run|
203
- { content: run.map(&:first).join("\n\n"), source: run.first[1] }
235
+ { content: run.map(&:first).join("\n\n"), source: run.first[1], mark: run.first[2] }.compact
204
236
  end
205
237
  end
206
238
  end
@@ -1041,7 +1041,7 @@ module Samagotchi
1041
1041
  render_join_notes(messages)
1042
1042
  return unless last_user
1043
1043
 
1044
- @screen.commit(prompt_line(nil, exchange[last_user][:content]))
1044
+ @screen.commit(join_prompt_line(exchange[last_user]))
1045
1045
  Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
1046
1046
  from = messages.index { |m| m.equal?(exchange[last_user]) }
1047
1047
  render_join_steps(messages, from, durations: join_tool_records(exchange[last_user]))
@@ -1224,7 +1224,8 @@ module Samagotchi
1224
1224
  if event[:prompt].nil?
1225
1225
  # A continue turn (after the offer's yes) has no prompt to show; a
1226
1226
  # reminder turn shows its reminders (:reminder_injected).
1227
- @screen.commit(prompt_line(origin[:client_id], "(continuing)")) unless reminder_origin?(origin)
1227
+ # A turn run for delegate reports shows them as they merge.
1228
+ @screen.commit(prompt_line(origin[:client_id], "(continuing)")) unless reminder_origin?(origin) || report_origin?(origin)
1228
1229
  elsif !(own?(origin[:client_id]) || @shown_enqueued.include?(origin[:enqueued_id]))
1229
1230
  @screen.commit(prompt_line(origin[:client_id], event[:prompt]))
1230
1231
  end
@@ -1267,6 +1268,7 @@ module Samagotchi
1267
1268
  end
1268
1269
 
1269
1270
  def reminder_origin?(origin) = origin[:client_id].to_s.start_with?("system:")
1271
+ def report_origin?(origin) = origin[:client_id].to_s.start_with?(Steer::CHILD_CLIENT_PREFIX)
1270
1272
  end
1271
1273
  end
1272
1274
  end
@@ -186,7 +186,9 @@ module Samagotchi
186
186
  def render_merge(event)
187
187
  @view.finish_thinking_spinner
188
188
  @view.print_line(event[:answer]) unless event[:answer].to_s.strip.empty?
189
- count = event[:count].to_i
189
+ reports = Array(event[:reports])
190
+ reports.each { |report| @view.print_line(@view.report_line(report)) }
191
+ count = event[:count].to_i - reports.size
190
192
  @view.print_line("(#{count} message#{"s" unless count == 1} merged into the running turn)") if count.positive?
191
193
  Array(event[:steers]).each { |steer| @view.print_line(@view.format_steer_line(source: steer[:source], text: steer[:text])) }
192
194
  end
@@ -3,6 +3,7 @@
3
3
  require_relative "../served_model"
4
4
  require_relative "../image_store"
5
5
  require_relative "../turn_note"
6
+ require_relative "../steer"
6
7
 
7
8
  module Samagotchi
8
9
  class TerminalUI
@@ -43,7 +44,8 @@ module Samagotchi
43
44
  # Who a steer names as its sender: a plugin's own label, chi's sender
44
45
  # ids in words (the web's format.js steerSender agrees;
45
46
  # spec/shared/labels_matrix.json). "": no one named.
46
- STEER_SENDERS = { "parent_agent" => "parent agent", "chi_send" => "chi send", "plugin_send" => "plugin" }.freeze
47
+ STEER_SENDERS = { "parent_agent" => "parent agent", "chi_send" => "chi send", "plugin_send" => "plugin",
48
+ "delegate_report" => "delegate report" }.freeze
47
49
 
48
50
  def steer_sender(source)
49
51
  STEER_SENDERS.fetch(source.to_s, source.to_s)
@@ -59,9 +61,13 @@ module Samagotchi
59
61
  end
60
62
 
61
63
  # "↻ empty answer, asking again (1/1)": the loop retries an empty answer;
62
- # "↻ cut by loop-guard, asking again (1/1)" after a plugin cut it.
64
+ # "↻ cut by loop-guard, asking again (1/1)" after a plugin cut it;
65
+ # "↻ malformed answer, …" after a corrupt native generation.
63
66
  def format_empty_retry_line(event)
64
- what = event[:stopped_by] ? "cut by #{event[:stopped_by]}" : "empty answer"
67
+ what = if event[:stopped_by] then "cut by #{event[:stopped_by]}"
68
+ elsif event[:malformed] then "malformed answer"
69
+ else "empty answer"
70
+ end
65
71
  paint("↻ #{what}, asking again (#{event[:attempt]}/#{event[:of]})", 90)
66
72
  end
67
73
 
@@ -109,7 +115,8 @@ module Samagotchi
109
115
 
110
116
  # Prompt labels by the sender's client_id prefix (turn_events.js
111
117
  # CLIENT_LABELS; spec/shared/labels_matrix.json).
112
- CLIENT_LABELS = { "web" => "web", "tui" => "tui", "system" => "reminder", "delegate" => "delegate" }.freeze
118
+ CLIENT_LABELS = { "web" => "web", "tui" => "tui", "system" => "reminder", "delegate" => "delegate",
119
+ "child" => "delegate report" }.freeze
113
120
 
114
121
  # "web> <prompt>": a prompt, labelled by who sent it.
115
122
  def prompt_line(client_id, prompt)
@@ -117,6 +124,26 @@ module Samagotchi
117
124
  "#{paint("#{label}>", 35)} #{prompt}"
118
125
  end
119
126
 
127
+ # "delegate report> 3f2a1c9e answered: <the reply's first line>": a
128
+ # delegate child's report (ChildReports) merged into the turn.
129
+ def report_line(report)
130
+ id = report[/\Asession: (\S+)/, 1].to_s[0, 8]
131
+ status = report[/^status: (.*)$/, 1]
132
+ rest = report.split("\n").drop(2).reject { |line| line.strip.empty? || line == "---" }.first.to_s
133
+ rest = "#{rest[0, STEER_PREVIEW - 1]}…" if rest.length > STEER_PREVIEW
134
+ "#{paint("#{CLIENT_LABELS["child"]}>", 35)} #{[id, status].compact.join(" ")}#{": #{rest}" unless rest.empty?}"
135
+ end
136
+
137
+ # The last prompt as a join shows it: a delegate report (a wake
138
+ # turn's) as the child's lines, anything else as the user's.
139
+ def join_prompt_line(message)
140
+ source = (message[:source] || message["source"]).to_s
141
+ content = (message[:content] || message["content"]).to_s
142
+ return prompt_line(nil, content) unless source == Steer::DELEGATE_REPORT
143
+
144
+ content.split(/\n\n(?=session: )/).map { |report| report_line(report) }.join("\n")
145
+ end
146
+
120
147
  # "reminder: a, b": the reminders a turn runs for (names or hashes).
121
148
  def reminder_line(reminders)
122
149
  names = Array(reminders).filter_map { |r| r.is_a?(Hash) ? r[:name] : r }