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
@@ -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 }
@@ -226,19 +253,22 @@ module Samagotchi
226
253
  # A cancel's reason as the UIs name it (anything else as it is);
227
254
  # spec/shared/labels_matrix.json pins it for both.
228
255
  CANCEL_REASONS = { "ctrl_c" => "Ctrl-C", "user" => "stopped", "hook" => "by a hook" }.freeze
256
+ # A canceled turn's mark: a stop someone chose (Ctrl-C, Stop, a hook),
257
+ # not an error's ✕ (timing.js STOP_MARK; the labels matrix pins it).
258
+ STOP_MARK = "■"
229
259
  # Under a canceled prompt turn (a canceled continue is back where it
230
260
  # started): its partial progress stays in the conversation.
231
261
  ROLLBACK_HINT = "partial progress kept; !rollback restores the pre-turn state"
232
262
 
233
- # "✕ turn canceled (Ctrl-C) · 3.1s"; a hook's stop that names who
234
- # (+by+, turn_canceled's cancelled_by): "✕ turn stopped by loop-guard"
263
+ # "■ turn canceled (Ctrl-C) · 3.1s"; a hook's stop that names who
264
+ # (+by+, turn_canceled's cancelled_by): "■ turn stopped by loop-guard"
235
265
  def turn_canceled_line(reason, duration_ms, by: nil)
236
266
  if reason.to_s == "hook" && !by.to_s.empty?
237
- return "#{paint("✕", 33)} turn stopped by #{by}#{turn_duration_suffix(duration_ms)}"
267
+ return "#{paint(STOP_MARK, 33)} turn stopped by #{by}#{turn_duration_suffix(duration_ms)}"
238
268
  end
239
269
 
240
270
  label = reason.to_s.empty? ? nil : CANCEL_REASONS.fetch(reason.to_s, reason.to_s)
241
- "#{paint("✕", 33)} turn canceled#{" (#{label})" if label}#{turn_duration_suffix(duration_ms)}"
271
+ "#{paint(STOP_MARK, 33)} turn canceled#{" (#{label})" if label}#{turn_duration_suffix(duration_ms)}"
242
272
  end
243
273
 
244
274
  # "✕ turn failed: <summary> · 2.0s"
@@ -16,6 +16,7 @@ require_relative "host_registry"
16
16
  require_relative "session"
17
17
  require_relative "owner_lock"
18
18
  require_relative "bridge/card_store"
19
+ require_relative "bridge/pending_card"
19
20
  require_relative "engine"
20
21
  require_relative "guardrails/parent_approvals"
21
22
  require_relative "tools/memory"
@@ -253,6 +254,7 @@ module Samagotchi
253
254
  ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir)
254
255
  # Retry lines and hook notices on stderr; stdout gets the answer below.
255
256
  sink = OneShotSink.new
257
+ save_before_first_turn(session)
256
258
  begin
257
259
  result = @engine.run_turn(
258
260
  session,
@@ -298,7 +300,10 @@ module Samagotchi
298
300
  ensure
299
301
  # However it ends (the early returns too; the Engine was built in
300
302
  # #initialize): the anytime commands finish, the plugins' services
301
- # stop (a server process).
303
+ # stop (a server process). Nothing resolves an open card any more, so
304
+ # its pending_card.json goes (a scratch session's folder is deleted
305
+ # below anyway; the others stay).
306
+ clear_pending_card
302
307
  @engine&.shutdown
303
308
  # A scratch session goes on every way out: /exit, Ctrl-D, an error,
304
309
  # SIGTERM or SIGHUP (Ruby raises those here). Only kill -9 leaves it
@@ -308,6 +313,17 @@ module Samagotchi
308
313
 
309
314
  EMPTY_ANSWER_ERROR = "chi: the model gave an empty answer"
310
315
 
316
+ # The session is over for this process (its exits and the ensure below):
317
+ # nothing will resolve the open card any more, so its pending_card.json
318
+ # goes (the Engine's TURN_END would clear it, but the REPL may end
319
+ # mid-turn).
320
+ def clear_pending_card
321
+ return unless @pending_card_handle
322
+
323
+ @pending_card_handle.observer.clear
324
+ @pending_card_handle = nil
325
+ end
326
+
311
327
  # A -p --non-interactive turn that failed (bin/chi exits 1): the error,
312
328
  # then how to go on. The Engine saved the failed turn with its prompt,
313
329
  # so the session is kept and `chi --resume` takes it up again.
@@ -415,13 +431,28 @@ module Samagotchi
415
431
  @surface.commit("Session #{session.id} was not archived (#{e.message}): chi sessions archive #{session.id}")
416
432
  end
417
433
 
418
- # The session's cards.json, kept as a worker's Bridge keeps it (it owns
419
- # the session as a worker would): this REPL's kept turns count on from
420
- # the saved count, so a card an earlier worker showed stays that many
421
- # turns back, and its own cards and notices are saved for the next.
434
+ # The session's cards.json and pending_card.json, kept as a worker's
435
+ # Bridge keeps them (it owns the session as a worker would): this REPL's
436
+ # kept turns count on from the saved count, so a card an earlier worker
437
+ # showed stays that many turns back, and its own cards and notices are
438
+ # saved for the next; an open card with actions is on disk, where
439
+ # `chi web`'s session hub reads it to mark the session as waiting.
422
440
  def keep_cards(session)
423
441
  path = File.join(Session.session_dir(session.id), Bridge::CardStore::FILE)
424
442
  @cards_handle ||= @engine.subscribe(observer: Bridge::CardStore.new(path: path))
443
+ keep_pending_card(session)
444
+ end
445
+
446
+ # A card a worker that died left isn't open: the file goes before the
447
+ # observer takes over, as Bridge#start does. Subscribed once: the REPL
448
+ # keeps one session, and a second observer would fold every card twice
449
+ # and clear the file at the first turn's end.
450
+ def keep_pending_card(session)
451
+ return if @pending_card_handle
452
+
453
+ pending = Bridge::PendingCard.new(Session.session_dir(session.id))
454
+ pending.clear
455
+ @pending_card_handle = @engine.subscribe(observer: pending)
425
456
  end
426
457
 
427
458
  # Take the session's OwnerLock for this process's lifetime: the TUI runs
@@ -552,6 +583,7 @@ module Samagotchi
552
583
  refresh_status_row
553
584
  # A prompt the user typed brings an archived session back to the lists.
554
585
  ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir) if prompt && !continue
586
+ save_before_first_turn(session)
555
587
  with_steering do
556
588
  @engine.run_turn(
557
589
  session,
@@ -779,6 +811,13 @@ module Samagotchi
779
811
  session.save
780
812
  end
781
813
 
814
+ # A fresh session reaches the disk before its first turn: a tool in that
815
+ # turn reads it from there (delegate loads its parent to start a child,
816
+ # "Session not found" otherwise). Later turns find it saved already.
817
+ def save_before_first_turn(session)
818
+ session.save unless Session.exist?(session.id)
819
+ end
820
+
782
821
  # A !cmd shows its own output; everything else is the model> line.
783
822
  def show_command_result(result)
784
823
  if result.shell
@@ -176,10 +176,14 @@ module Samagotchi
176
176
  # "error" for an "Error:" result, and for an execute whose command
177
177
  # exited non-zero (its last "exit: N" line; no number: killed by a
178
178
  # signal), so the turn tally's "(N failed)" counts it. "stopped" for a
179
- # task_wait the user's Stop ended (the task itself runs on), and for one
180
- # whose task was stopped (the stop-task button, or the model's task_stop).
179
+ # task_wait the user's Stop ended (the task itself runs on), for one
180
+ # whose task was stopped (the stop-task button, or the model's task_stop),
181
+ # and for a command the user's Stop killed or kept from starting: a
182
+ # cancel, not a failure (its text still starts "Error:" for the model).
181
183
  def tool_activity_status(result, tool_name = nil)
182
184
  text = result.to_s
185
+ return "stopped" if text == Tools::Execute::NOT_RUN_ON_STOP
186
+ return "stopped" if tool_name == Tools::Execute::NAME && text.start_with?(Tools::Execute::STOPPED_BY_USER)
183
187
  return "error" if text.start_with?("Error:")
184
188
  return "error" if tool_name == Tools::Execute::NAME && execute_failed?(text)
185
189
  return "stopped" if tool_name == Tools::TaskWait::NAME && text.match?(/^(wait_result: canceled|status: stopped)$/)
@@ -40,6 +40,8 @@ module Samagotchi
40
40
  TOOL_CALL_BODY_RE = /\Acall:([a-z_][a-z0-9_]{0,49})\{/
41
41
  THOUGHT_CHANNEL_OPEN = ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN
42
42
  THOUGHT_CHANNEL_CLOSE = ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE
43
+ # Any channel's header (a thought's is THOUGHT_CHANNEL_OPEN).
44
+ THOUGHT_CHANNEL_HEADER = "<|channel>"
43
45
  CONTROL_TOKEN_START = "<|"
44
46
 
45
47
  def initialize(profile)
@@ -120,8 +122,43 @@ module Samagotchi
120
122
  [parse(response.to_s), nil]
121
123
  end
122
124
 
125
+ # Why +text+ (one generation) is corrupt, or nil. A Gemma 4
126
+ # generation ends a tool call with its close (the stop then comes at
127
+ # <|tool_response>), and thinks once, before the answer. A poisoned
128
+ # llama.cpp prompt cache (ggml-org/llama.cpp#27148) broke both: a
129
+ # call left open, then a fresh thought header and another
130
+ # conversation's answer.
131
+ def malformed(text)
132
+ text = text.to_s
133
+ return "unclosed tool call" if unclosed_tool_call?(text)
134
+
135
+ "thought header after the answer" if thought_after_answer?(strip_tool_calls(text))
136
+ end
137
+
123
138
  private
124
139
 
140
+ def unclosed_tool_call?(text)
141
+ pos = 0
142
+ while (open_pos = text.index(@tool_call_open, pos))
143
+ close_pos = text.index(@tool_call_close, open_pos + @tool_call_open.length)
144
+ return true unless close_pos
145
+
146
+ pos = close_pos + @tool_call_close.length
147
+ end
148
+ false
149
+ end
150
+
151
+ # A <|channel> header with visible text before it.
152
+ def thought_after_answer?(text)
153
+ pos = 0
154
+ while (header = text.index(THOUGHT_CHANNEL_HEADER, pos))
155
+ return true unless strip_thought(text[0...header]).strip.empty?
156
+
157
+ pos = header + THOUGHT_CHANNEL_HEADER.length
158
+ end
159
+ false
160
+ end
161
+
125
162
  # Scan forward from +start+ for the next <| sequence that is NOT the Gemma
126
163
  # string delimiter <|"|>. Returns the position of that <| or nil if none.
127
164
  def next_real_control_token(text, start)
@@ -307,6 +344,9 @@ module Samagotchi
307
344
  read(text).map { |call| Tools::BuiltinCalls.build(call[:name], call[:args]) }
308
345
  end
309
346
 
347
+ # An open <tool_call> has its own recovery (#parse_with_recovery).
348
+ def malformed(_text) = nil
349
+
310
350
  # Each <tool_call> block as {name:, args:}: its <parameter=…>
311
351
  # (or <arg_key>/<arg_value>) text by lowercased key, unbuilt.
312
352
  def read(text)
@@ -179,13 +179,13 @@ module Samagotchi
179
179
  },
180
180
  {
181
181
  name: "task_get",
182
- description: "Get full metadata for a task by id. Use read on output_path to inspect command output.",
182
+ description: "Get a background task's metadata by its task id (from task_create or task_list; not a session id). Use read on output_path to inspect command output.",
183
183
  parameters: {
184
184
  type: "object",
185
185
  properties: {
186
186
  id: {
187
187
  type: "string",
188
- description: "Task id returned by task_create"
188
+ description: "Task id from task_create/task_list, e.g. 20261005093000-1a2b3c4d"
189
189
  }
190
190
  },
191
191
  required: ["id"]
@@ -201,13 +201,13 @@ module Samagotchi
201
201
  },
202
202
  {
203
203
  name: "task_stop",
204
- description: "Stop a running background task by id.",
204
+ description: "Stop a running background task by its task id (from task_create or task_list). Session ids don't work here.",
205
205
  parameters: {
206
206
  type: "object",
207
207
  properties: {
208
208
  id: {
209
209
  type: "string",
210
- description: "Task id to stop"
210
+ description: "Task id from task_create/task_list, e.g. 20261005093000-1a2b3c4d"
211
211
  }
212
212
  },
213
213
  required: ["id"]
@@ -221,7 +221,7 @@ module Samagotchi
221
221
  properties: {
222
222
  task_id: {
223
223
  type: "string",
224
- description: "Task id returned by task_create"
224
+ description: "Task id from task_create/task_list, e.g. 20261005093000-1a2b3c4d"
225
225
  },
226
226
  timeout: {
227
227
  type: "integer",
@@ -330,7 +330,7 @@ module Samagotchi
330
330
  },
331
331
  {
332
332
  name: "delegate",
333
- description: "Hand a task to a child chi session that runs in parallel in this folder and returns only its final reply (its trace stays out of this context). The child is a normal session: it shows in the lists as a child of this one and the user can attach to it. With session, send a follow-up to one of this session's children instead. A tool call the child needs approved goes to your user as one of your own approvals; you only see how it was answered, as a line in the result. Children keep running if this turn is canceled; delegate_result waits for them later. A child stays until stopped: once its work is done, stop it with execute `chi sessions stop ID` (a follow-up with session wakes it again).",
333
+ description: "Hand a task to a child chi session that runs in parallel in this folder and returns only its final reply (its trace stays out of this context). The child is a normal session: it shows in the lists as a child of this one and the user can attach to it. With session, send a follow-up to one of this session's children instead. A tool call the child needs approved goes to your user as one of your own approvals; you only see how it was answered, as a line in the result. Children keep running if this turn is canceled. With wait false, chi brings each child's reply to you by itself when the child ends its turn (a delegate report, also when you are idle): don't poll it with delegate_result. To retry or redirect a child, use delegate with its session (task_* tools are for background commands, not sessions). A child stays until stopped: once its work is done, stop it with execute `chi sessions stop ID` (a follow-up with session wakes it again).",
334
334
  parameters: {
335
335
  type: "object",
336
336
  properties: {
@@ -360,7 +360,7 @@ module Samagotchi
360
360
  },
361
361
  {
362
362
  name: "delegate_result",
363
- description: "Wait for a delegated child session's next reply: the child named, or this session's newest running child. Returns early if the child waits for a question the user must answer. A tool call the child needs approved goes to your user while this waits; the result says how it was answered.",
363
+ description: "Wait for a delegated child session's next reply: the child named, or this session's newest running child (name the child when a delegate report is about it). A delegate report already carries the reply: don't call this for it. Returns early if the child waits for a question the user must answer. A tool call the child needs approved goes to your user while this waits; the result says how it was answered.",
364
364
  parameters: {
365
365
  type: "object",
366
366
  properties: {
@@ -5,6 +5,7 @@ require_relative "../config"
5
5
  require_relative "../model_profile"
6
6
  require_relative "peers"
7
7
  require_relative "delegate_wait"
8
+ require_relative "../child_reports"
8
9
 
9
10
  module Samagotchi
10
11
  # Loaded on first use: session_manager requires terminal_ui, which
@@ -30,7 +31,7 @@ module Samagotchi
30
31
  STARTING_GRACE_SECONDS = 15
31
32
  # The memory every child starts with, scoped so a project memory of
32
33
  # the same name cannot shadow it.
33
- CHILD_MEMORIES = ["system/delegated"].freeze
34
+ CHILD_MEMORIES = [Session::DELEGATE_MEMORY].freeze
34
35
 
35
36
  def self.name = NAME
36
37
 
@@ -51,21 +52,31 @@ module Samagotchi
51
52
  wait = parse_wait(wait)
52
53
  timeout = parse_timeout(timeout)
53
54
 
54
- child_id = if session.to_s.strip.empty?
55
- start_child(task, parent: parent, model: model, state_dir: sd)
56
- else
57
- follow_up(task, session: session.to_s.strip, parent: parent, state_dir: sd)
58
- end
55
+ child_id, warning = if session.to_s.strip.empty?
56
+ start_child(task, parent: parent, model: model, state_dir: sd)
57
+ else
58
+ follow_up(task, session: session.to_s.strip, parent: parent, state_dir: sd)
59
+ end
59
60
  return child_id if child_id.start_with?("Error:")
60
- return DelegateWait.call(child_id, peers: peers, timeout: timeout) if wait
61
+
62
+ note = warning ? "Warning: #{warning}\n" : ""
63
+ return note + DelegateWait.call(child_id, peers: peers, timeout: timeout) if wait
61
64
 
62
65
  started = session.to_s.strip.empty? ? "Started a delegate session" : "Sent the follow-up to delegate #{child_id[0, 8]}"
63
- "session: #{child_id}\nstatus: running\n#{started}; delegate_result waits for its reply. " \
66
+ "#{note}session: #{child_id}\nstatus: running\n#{started}; #{running_hint} " \
64
67
  "It shows in chi sessions list and the web as a child of this session; the user can attach to it."
65
68
  rescue ArgumentError => e
66
69
  "Error: #{e.message}"
67
70
  end
68
71
 
72
+ # How the reply of a child left running comes back.
73
+ def self.running_hint
74
+ return "delegate_result waits for its reply." if ChildRing.mode == "off"
75
+
76
+ "chi brings its reply to you by itself when it ends its turn (a delegate report); don't poll with delegate_result."
77
+ end
78
+ private_class_method :running_hint
79
+
69
80
  # @return [String] the new child's id, or an Error: line
70
81
  def self.start_child(task, parent:, model:, state_dir:)
71
82
  if parent.parent_id
@@ -82,9 +93,9 @@ module Samagotchi
82
93
 
83
94
  child = SessionManager.spawn_session(prompt: task, working_directory: parent.working_directory,
84
95
  model_name: child_model(model, parent), memories: CHILD_MEMORIES,
85
- parent_id: parent.id, state_dir: state_dir)
86
- DelegateWait.mark_started(parent.id, child)
87
- child.id
96
+ parent_id: parent.id, delegate: true, state_dir: state_dir)
97
+ DelegateWait.mark_started(parent.id, child, state_dir: state_dir)
98
+ [child.id, child.model_warning]
88
99
  end
89
100
  private_class_method :start_child
90
101
 
@@ -118,8 +129,10 @@ module Samagotchi
118
129
  private_class_method :follow_up
119
130
 
120
131
  # The model as typed (spawn_session stores its resolved ref), else the
121
- # parent's. No existence check: an unknown model fails the child's
122
- # first turn.
132
+ # parent's. No existence check here: spawn_session checks the host
133
+ # (check_host!, its UnknownHost comes back as this tool's Error: line)
134
+ # and warns about an id the host's saved model list doesn't have
135
+ # (ModelProfile.model_warning, added to this tool's result).
123
136
  def self.child_model(model, parent)
124
137
  return parent.model_name if model.to_s.strip.empty?
125
138
 
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require_relative "../atomic_file"
6
+ require_relative "../session"
7
+
8
+ module Samagotchi
9
+ module Tools
10
+ # What a parent was already told about one delegate child: the newest
11
+ # reply it was given (+reply_file+, ReplyWait's cursor) and the child as
12
+ # it was then (+messages+, +question_id+, +last_turn+: ReplyWait's
13
+ # baseline). nil +messages+ means no baseline.
14
+ DelegateCursor = Data.define(:reply_file, :last_turn, :question_id, :messages) do
15
+ def self.empty = new(reply_file: nil, last_turn: nil, question_id: nil, messages: nil)
16
+
17
+ # @param baseline [Hash, nil] ReplyWait.baseline_of's
18
+ def with_baseline(baseline)
19
+ b = baseline || {}
20
+ with(messages: b[:messages], question_id: b[:question_id], last_turn: b[:last_turn])
21
+ end
22
+
23
+ # @return [Hash, nil] ReplyWait's baseline:, nil when none was taken
24
+ def baseline
25
+ return nil if messages.nil?
26
+
27
+ { messages: messages, question_id: question_id, last_turn: last_turn }
28
+ end
29
+
30
+ def to_json_hash = to_h.transform_keys(&:to_s).compact
31
+ end
32
+
33
+ # The parent's cursors, one per child, in <parent dir>/delegates.json, so
34
+ # a respawned worker (or a REPL resuming the session) doesn't hand the
35
+ # model a reply it already had. Written by whoever owns the parent (its
36
+ # worker, or the REPL its kernel runs in), so a Mutex is enough for its
37
+ # turn thread vs its loop thread; the file is replaced whole.
38
+ module DelegateCursors
39
+ FILE = "delegates.json"
40
+ LOCK = Mutex.new
41
+
42
+ module_function
43
+
44
+ # @return [DelegateCursor] the child's cursor, empty when none
45
+ def get(parent_id, child_id, state_dir:)
46
+ get_from(read(parent_id, state_dir: state_dir), child_id)
47
+ end
48
+
49
+ # Read, change and write one child's cursor under the lock.
50
+ # @yieldparam cursor [DelegateCursor]
51
+ # @yieldreturn [DelegateCursor]
52
+ # @return [DelegateCursor] the new cursor
53
+ def update(parent_id, child_id, state_dir:)
54
+ LOCK.synchronize do
55
+ all = read(parent_id, state_dir: state_dir)
56
+ cursor = yield get_from(all, child_id)
57
+ all[child_id] = cursor.to_json_hash
58
+ write(parent_id, all, state_dir: state_dir)
59
+ cursor
60
+ end
61
+ end
62
+
63
+ def get_from(all, child_id)
64
+ data = all[child_id]
65
+ return DelegateCursor.empty unless data.is_a?(Hash)
66
+
67
+ DelegateCursor.new(reply_file: data["reply_file"], last_turn: data["last_turn"],
68
+ question_id: data["question_id"], messages: data["messages"])
69
+ end
70
+
71
+ def path(parent_id, state_dir:)
72
+ File.join(Session.session_dir(parent_id, state_dir: state_dir), FILE)
73
+ end
74
+
75
+ # @return [Hash{String => Hash}] child id → cursor fields; {} when none
76
+ # or unreadable
77
+ def read(parent_id, state_dir:)
78
+ data = JSON.parse(File.read(path(parent_id, state_dir: state_dir)))
79
+ data.is_a?(Hash) ? data : {}
80
+ rescue Errno::ENOENT, JSON::ParserError
81
+ {}
82
+ end
83
+
84
+ def write(parent_id, all, state_dir:)
85
+ file = path(parent_id, state_dir: state_dir)
86
+ FileUtils.mkdir_p(File.dirname(file))
87
+ AtomicFile.write(file, JSON.generate(all))
88
+ end
89
+ end
90
+ end
91
+ end