samagotchi 0.29.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -1
  3. data/docs/cli.md +4 -4
  4. data/docs/configuration.md +4 -0
  5. data/docs/hooks.md +1 -1
  6. data/docs/internals/engine.md +1 -1
  7. data/docs/plugins.md +1 -1
  8. data/docs/sessions.md +11 -4
  9. data/lib/samagotchi/bridge/card_store.rb +1 -1
  10. data/lib/samagotchi/bridge/turn_notice.rb +1 -1
  11. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/mcp/plugin.rb +4 -3
  13. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +17 -4
  14. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/system/delegated.md +1 -0
  16. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  17. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  18. data/lib/samagotchi/child_reports.rb +214 -0
  19. data/lib/samagotchi/client.rb +35 -4
  20. data/lib/samagotchi/command_steps.rb +35 -15
  21. data/lib/samagotchi/config.rb +5 -0
  22. data/lib/samagotchi/engine.rb +2 -0
  23. data/lib/samagotchi/host_registry.rb +4 -0
  24. data/lib/samagotchi/idle_recap.rb +4 -0
  25. data/lib/samagotchi/kernel_loop.rb +63 -11
  26. data/lib/samagotchi/llm/chat_loop.rb +14 -7
  27. data/lib/samagotchi/llm/errors.rb +9 -0
  28. data/lib/samagotchi/log_subscriber.rb +1 -1
  29. data/lib/samagotchi/model_list_store.rb +117 -0
  30. data/lib/samagotchi/model_profile.rb +43 -0
  31. data/lib/samagotchi/prompt_warmup.rb +30 -5
  32. data/lib/samagotchi/reply_wait.rb +39 -8
  33. data/lib/samagotchi/send_command.rb +5 -3
  34. data/lib/samagotchi/session.rb +31 -5
  35. data/lib/samagotchi/session_inbox.rb +34 -1
  36. data/lib/samagotchi/session_manager.rb +41 -5
  37. data/lib/samagotchi/session_metrics.rb +6 -3
  38. data/lib/samagotchi/steer.rb +39 -7
  39. data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
  40. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  41. data/lib/samagotchi/terminal_ui/formatting.rb +38 -8
  42. data/lib/samagotchi/terminal_ui.rb +44 -5
  43. data/lib/samagotchi/tool_activity.rb +6 -2
  44. data/lib/samagotchi/tool_call_parser.rb +40 -0
  45. data/lib/samagotchi/tool_declarations.rb +7 -7
  46. data/lib/samagotchi/tools/delegate.rb +26 -13
  47. data/lib/samagotchi/tools/delegate_cursor.rb +91 -0
  48. data/lib/samagotchi/tools/delegate_wait.rb +50 -31
  49. data/lib/samagotchi/tools/execute.rb +3 -1
  50. data/lib/samagotchi/tools/task_runtime.rb +21 -2
  51. data/lib/samagotchi/tools/task_wait.rb +2 -2
  52. data/lib/samagotchi/version.rb +1 -1
  53. data/lib/samagotchi/web/app.rb +8 -2
  54. data/lib/samagotchi/web/public/activity.js +2 -1
  55. data/lib/samagotchi/web/public/app.js +21 -11
  56. data/lib/samagotchi/web/public/command_view.js +19 -5
  57. data/lib/samagotchi/web/public/format.js +8 -1
  58. data/lib/samagotchi/web/public/index.html +14 -6
  59. data/lib/samagotchi/web/public/notify.js +4 -2
  60. data/lib/samagotchi/web/public/stage_model.js +18 -0
  61. data/lib/samagotchi/web/public/stage_view.js +16 -11
  62. data/lib/samagotchi/web/public/task_stop.js +11 -2
  63. data/lib/samagotchi/web/public/timing.js +12 -7
  64. data/lib/samagotchi/web/public/turn_events.js +29 -9
  65. data/lib/samagotchi/web/public/turn_model.js +16 -11
  66. data/lib/samagotchi/web/public/turn_view.js +49 -7
  67. data/lib/samagotchi/worker.rb +240 -23
  68. metadata +4 -1
@@ -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
@@ -241,9 +241,10 @@ module Samagotchi
241
241
 
242
242
  # A worker session like the web start page's: saved as running with the
243
243
  # message before its worker spawns, so lists and the web show it at
244
- # once. The full id, so a script can pass it on. Only the model's host
245
- # is checked here (in spawn_session, as in the web): a wrong model id
246
- # fails in the worker.
244
+ # once. The full id, so a script can pass it on. The model's host is
245
+ # checked here (in spawn_session, as in the web): an unknown one is
246
+ # refused before anything is spawned; an id the host's saved model list
247
+ # doesn't have starts with a warning on stderr.
247
248
  # @return [Integer] the exit status
248
249
  def run_new(prompt, options)
249
250
  images = !@images.empty?
@@ -264,6 +265,7 @@ module Samagotchi
264
265
  error_line("chi send: could not start a session: #{e.message}")
265
266
  return 1
266
267
  end
268
+ error_line("chi send: warning: #{session.model_warning}") if session.model_warning
267
269
  if through_worker
268
270
  return 1 unless deliver_new(session, prompt)
269
271
  else
@@ -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
@@ -79,12 +86,17 @@ module Samagotchi
79
86
  # sets it); not saved.
80
87
  attr_accessor :seed_images_dropped
81
88
 
89
+ # The warning for a model id its host's saved list doesn't have
90
+ # (ModelProfile.model_warning; SessionManager.spawn_session sets it);
91
+ # not saved.
92
+ attr_accessor :model_warning
93
+
82
94
  def initialize(id:, mode:, model_name:, working_directory:, messages:, created_at:, updated_at:,
83
95
  metadata_version: METADATA_VERSION, status: STATUS_IDLE, last_prompt: "",
84
96
  first_preview: "", test_run: false, pending_question: nil,
85
97
  used_memory_names: [], project_root: nil,
86
98
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
87
- last_turn: nil, model_typed: nil)
99
+ last_turn: nil, model_typed: nil, delegate: false)
88
100
  @id = id
89
101
  @metadata_version = metadata_version
90
102
  @mode = mode
@@ -106,9 +118,20 @@ module Samagotchi
106
118
  @parent_id = parent_id&.to_s
107
119
  @scratch = !!scratch
108
120
  @last_turn = last_turn
121
+ @delegate = !!delegate
109
122
  @archived = false
110
123
  end
111
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
+
112
135
  # The question this session waits on, as the lists show it: {id:, kind:}
113
136
  # with kind "question" (the model's), "approval", "hook" or "continue"
114
137
  # (the step-limit question, asked between turns); nil with
@@ -146,7 +169,7 @@ module Samagotchi
146
169
  # seed); [] by default
147
170
  def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
148
171
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [],
149
- scratch: false, model_typed: nil)
172
+ scratch: false, model_typed: nil, delegate: false)
150
173
  now = Time.now.iso8601(3)
151
174
  resolved_test = if test_run.nil?
152
175
  test_session_env?
@@ -169,7 +192,8 @@ module Samagotchi
169
192
  preloaded_memory_names: preloaded_memory_names,
170
193
  muted_memory_names: muted_memory_names,
171
194
  parent_id: parent_id,
172
- scratch: scratch
195
+ scratch: scratch,
196
+ delegate: delegate
173
197
  )
174
198
  end
175
199
 
@@ -223,7 +247,8 @@ module Samagotchi
223
247
  "muted_memory_names" => [],
224
248
  "parent_id" => nil,
225
249
  "scratch" => false,
226
- "last_turn" => nil
250
+ "last_turn" => nil,
251
+ "delegate" => false
227
252
  }.freeze
228
253
 
229
254
  # A session from a parsed session file. +messages+ false leaves the
@@ -341,6 +366,7 @@ module Samagotchi
341
366
  when "messages" then @messages.map { |msg| scrub_utf8(stringify_message_keys(msg)) }
342
367
  when "pending_question" then @pending_question && scrub_utf8(stringify_message_keys(@pending_question))
343
368
  when "test_run" then !!@test_run
369
+ when "delegate" then @delegate
344
370
  when "used_memory_names", "preloaded_memory_names", "muted_memory_names"
345
371
  Array(instance_variable_get(:"@#{key}"))
346
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)
@@ -130,14 +130,20 @@ module Samagotchi
130
130
  # @param images_from [String, nil] the session dir the seed's images are in
131
131
  # @param title [String, nil] what the lists show before the first turn
132
132
  # (the prompt's preview by default, else the seed's first user message)
133
- # @return [Session] with #seed_images_dropped: refs whose file was gone
133
+ # @return [Session] with #seed_images_dropped: refs whose file was gone,
134
+ # and #model_warning: an id its host's saved list doesn't have
134
135
  def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
135
136
  memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
136
- title: nil)
137
+ title: nil, delegate: false)
137
138
  sd = state_dir || Session.default_state_dir
138
139
  # The resolved ref is stored (a resumed session keeps its model when an
139
- # alias is retargeted), with the name as typed beside it.
140
+ # alias is retargeted), with the name as typed beside it. A wrong host
141
+ # refuses here, before anything is saved or spawned; a model id the
142
+ # host's saved list doesn't have (ModelListStore) only warns
143
+ # (#model_warning on the session), as some hosts serve ids they don't
144
+ # list.
140
145
  typed = Samagotchi::ModelProfile.check_host!(Samagotchi::ModelProfile.required_model_name(model_name))
146
+ model_warning = Samagotchi::ModelProfile.model_warning(typed)
141
147
  ref = ConfigFile.model_ref(typed).ref
142
148
  session = Session.new_session(
143
149
  mode: mode,
@@ -147,8 +153,10 @@ module Samagotchi
147
153
  preloaded_memory_names: memories,
148
154
  muted_memory_names: muted_memories,
149
155
  parent_id: parent_id,
150
- messages: messages
156
+ messages: messages,
157
+ delegate: delegate
151
158
  )
159
+ session.model_warning = model_warning
152
160
  # With no prompt there is no first turn to run (an attaching UI sends
153
161
  # the prompts), so the session starts idle.
154
162
  session.status = prompt.to_s.strip.empty? ? Session::STATUS_IDLE : Session::STATUS_RUNNING
@@ -455,6 +463,32 @@ module Samagotchi
455
463
  session
456
464
  end
457
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
+
458
492
  # How long after a spawn a resume trusts that worker to take the
459
493
  # session, while it runs (and the session wasn't stopped since).
460
494
  WORKER_START_GRACE = 30.0
@@ -825,8 +859,10 @@ module Samagotchi
825
859
  # owner after its write and wakes one, or this finds its input.
826
860
  # A restart starts its successor here, on the newest chi installed
827
861
  # (worker_command), once this one's lock is free.
862
+ # Delegate reports that rang while it left wake one too.
828
863
  if result == :restart ||
829
- (%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?))
830
866
  resume_session(session_id, state_dir: sd)
831
867
  elsif worker.discard?
832
868
  discard_left_session(session_id, state_dir: sd, default_model: worker.default_model)
@@ -481,9 +481,12 @@ module Samagotchi
481
481
  # A turn that failed mid-stream never saw its generation end:
482
482
  # what it got to still counts.
483
483
  close_generation if @turn.gen_open
484
- # A call still running when the turn ends (a cancel, a failure)
485
- # never completes: its record says so, as the count already has it.
486
- @turn.tool_calls_by_id.each_value { |active| finish_tool_record(active, "canceled") }
484
+ # A call still running when the turn ends never completes: its
485
+ # record says so, as the count already has it. A cancel stopped it
486
+ # ("stopped", as ToolActivity names a call the Stop cut); a
487
+ # failure left it "canceled".
488
+ cut = status == "canceled" ? "stopped" : "canceled"
489
+ @turn.tool_calls_by_id.each_value { |active| finish_tool_record(active, cut) }
487
490
  @turn.tool_calls_by_id.clear
488
491
  finished_at = now
489
492
  record = {
@@ -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