samagotchi 0.15.0 → 0.16.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/docs/architecture.md +10 -0
  4. data/docs/cli.md +3 -2
  5. data/docs/configuration.md +3 -1
  6. data/docs/guardrails.md +16 -2
  7. data/docs/sessions.md +7 -2
  8. data/docs/sub-agent.md +15 -3
  9. data/lib/samagotchi/answer_display.rb +6 -2
  10. data/lib/samagotchi/bridge/card_store.rb +16 -1
  11. data/lib/samagotchi/bridge/turn_accumulator.rb +6 -0
  12. data/lib/samagotchi/bridge.rb +84 -0
  13. data/lib/samagotchi/bridge_client.rb +33 -6
  14. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  16. data/lib/samagotchi/empty_answer_retry.rb +2 -1
  17. data/lib/samagotchi/engine.rb +51 -13
  18. data/lib/samagotchi/kernel_loop.rb +16 -5
  19. data/lib/samagotchi/llm/chat_loop.rb +14 -8
  20. data/lib/samagotchi/llm/model_result.rb +12 -7
  21. data/lib/samagotchi/log_subscriber.rb +1 -0
  22. data/lib/samagotchi/parent_report.rb +8 -0
  23. data/lib/samagotchi/question_desk.rb +97 -18
  24. data/lib/samagotchi/relay.rb +88 -0
  25. data/lib/samagotchi/relay_desk.rb +73 -0
  26. data/lib/samagotchi/relay_verifier.rb +113 -0
  27. data/lib/samagotchi/relay_watcher.rb +46 -0
  28. data/lib/samagotchi/reply_wait.rb +10 -1
  29. data/lib/samagotchi/session.rb +15 -2
  30. data/lib/samagotchi/session_manager.rb +3 -2
  31. data/lib/samagotchi/session_metrics.rb +17 -0
  32. data/lib/samagotchi/sessions_command.rb +10 -4
  33. data/lib/samagotchi/terminal_ui/attach_launcher.rb +11 -2
  34. data/lib/samagotchi/terminal_ui/attached_loop.rb +69 -18
  35. data/lib/samagotchi/terminal_ui/event_renderer.rb +6 -1
  36. data/lib/samagotchi/terminal_ui/formatting.rb +7 -0
  37. data/lib/samagotchi/terminal_ui/question_prompt.rb +48 -2
  38. data/lib/samagotchi/terminal_ui/question_slot.rb +5 -1
  39. data/lib/samagotchi/tool_declarations.rb +2 -2
  40. data/lib/samagotchi/tools/delegate_relay.rb +216 -0
  41. data/lib/samagotchi/tools/delegate_wait.rb +72 -6
  42. data/lib/samagotchi/tools/peers.rb +6 -4
  43. data/lib/samagotchi/turn_note.rb +30 -2
  44. data/lib/samagotchi/version.rb +1 -1
  45. data/lib/samagotchi/web/app.rb +20 -0
  46. data/lib/samagotchi/web/public/app.js +24 -6
  47. data/lib/samagotchi/web/public/index.html +7 -0
  48. data/lib/samagotchi/web/public/notify.js +18 -5
  49. data/lib/samagotchi/web/public/question_card.js +45 -3
  50. data/lib/samagotchi/web/public/question_cards.js +49 -1
  51. data/lib/samagotchi/web/public/sessions_list.js +8 -1
  52. data/lib/samagotchi/web/public/stage_model.js +8 -3
  53. data/lib/samagotchi/web/public/stage_view.js +7 -2
  54. data/lib/samagotchi/web/public/timing.js +11 -3
  55. data/lib/samagotchi/web/public/turn_events.js +9 -0
  56. data/lib/samagotchi/web/public/turn_view.js +34 -12
  57. metadata +6 -1
@@ -56,6 +56,7 @@ require_relative "thinking"
56
56
  require_relative "answer_display"
57
57
  require_relative "edit_preview"
58
58
  require_relative "question_desk"
59
+ require_relative "relay_desk"
59
60
  require_relative "guardrail_wiring"
60
61
  require_relative "plugin_tasks"
61
62
 
@@ -161,6 +162,8 @@ module Samagotchi
161
162
  interface: -> { interface },
162
163
  user_input: ->(sid) { ArchiveStore.user_input(sid, state_dir: session_state_dir) }
163
164
  )
165
+ # Built here, not on first use: the turn thread and the Bridge's read it.
166
+ @relay_desk = RelayDesk.new
164
167
  @hooks = load_hooks_from_config
165
168
  load_hooks_from_bundles
166
169
  # The tools this session offers (the prompts' declarations and the
@@ -1232,15 +1235,25 @@ module Samagotchi
1232
1235
  # Open a question for the UIs and wait for its answer
1233
1236
  # (QuestionDesk#open_question: BLOCKS until answered or cancelled).
1234
1237
  # @param fields [Hash] question:, options:, header:, multi_select:, allow_freeform:, …
1238
+ # (as a Hash or as keywords)
1239
+ # @param watch [#call, nil] closes the question with the reason it returns
1235
1240
  # @return [Hash, String] the answer, or {error:, …}
1236
- def open_question(fields)
1237
- @question_desk.open_question(fields)
1241
+ def open_question(fields = nil, watch: nil, **more)
1242
+ @question_desk.open_question({ **fields.to_h, **more }, watch: watch)
1238
1243
  end
1239
1244
 
1240
1245
  # Answer the pending question (called from UI thread; QuestionDesk#answer).
1241
1246
  # @return [Hash] normalized answer
1242
- def answer_question(id:, selected:, freeform: nil, client_id: nil)
1243
- @question_desk.answer(id: id, selected: selected, freeform: freeform, client_id: client_id)
1247
+ def answer_question(id:, selected:, freeform: nil, client_id: nil, parent_agent: nil)
1248
+ @question_desk.answer(id: id, selected: selected, freeform: freeform, client_id: client_id,
1249
+ parent_agent: parent_agent)
1250
+ end
1251
+
1252
+ # Mark the pending question as relayed, or clear the mark
1253
+ # (QuestionDesk#annotate).
1254
+ # @return [Boolean]
1255
+ def annotate_question(id, relayed_to:, reason: nil)
1256
+ @question_desk.annotate(id, relayed_to: relayed_to, reason: reason)
1244
1257
  end
1245
1258
 
1246
1259
  def set_question_sync_handler(&block)
@@ -1293,6 +1306,30 @@ module Samagotchi
1293
1306
  def cwd = engine.session&.working_directory
1294
1307
  def state_dir = engine.peer_state_dir
1295
1308
  def cancelled? = !!engine.active_cancel_controller&.cancelled?
1309
+ def relay = engine.relay_peer
1310
+ end
1311
+
1312
+ # What the approval relay needs from the parent's Engine (Peers#relay):
1313
+ # its own question flow, which every UI attached to it answers.
1314
+ RelayPeer = Struct.new(:engine) do
1315
+ def open_question(fields, watch: nil) = engine.open_question(fields, watch: watch)
1316
+ def interface = engine.interface
1317
+ def relay_desk = engine.relay_desk
1318
+ end
1319
+
1320
+ # This worker's relays of its delegates' approvals (memory only).
1321
+ # @return [RelayDesk]
1322
+ attr_reader :relay_desk
1323
+
1324
+ # The relay's view of this Engine, or nil when it can't host a relay: a
1325
+ # worker's Engine only (its Bridge is what a child verifies against;
1326
+ # --non-interactive has no one to ask), never with a REPL's sync
1327
+ # handler (no wait loop, no watch).
1328
+ # @return [RelayPeer, nil]
1329
+ def relay_peer
1330
+ return nil unless interface == :worker && !@question_desk.sync_handler?
1331
+
1332
+ RelayPeer.new(self)
1296
1333
  end
1297
1334
 
1298
1335
  # @return [String] the state dir holding this Engine's sessions
@@ -1692,17 +1729,15 @@ module Samagotchi
1692
1729
  # continue resumes from them rather than after a made-up reply.
1693
1730
  def kept_messages(turn, result, seconds)
1694
1731
  conversation = result.conversation if result.conversation.is_a?(Array)
1695
- # Nothing visible (the native loop: no text; the chat loop: its
1696
- # placeholder text) is a turn the model should know ended that way.
1732
+ # Nothing visible (no text) is a turn the model should know ended
1733
+ # that way: the note, and nothing made up as its answer. The note's
1734
+ # marker is for the UIs (the notice, and the empty steps after a
1735
+ # reload); it goes on the result's conversation too (the REPL keeps it).
1736
+ # A retry's nudge at the tail goes: this note says it all.
1697
1737
  if result.empty_answer?
1698
- # The placeholder is for the UIs (a new array: it must not leak
1699
- # into the result); the note is for the model, so it goes on the
1700
- # result's conversation too.
1701
- # A retry's nudge at the tail goes: this note says it all.
1702
- note = TurnNote.empty
1738
+ note = TurnNote.empty(retries: result.empty_retries, steps: result.empty_steps)
1703
1739
  conversation&.replace(TurnNote.without_trailing(conversation))
1704
1740
  saved = TurnNote.without_trailing(conversation || turn.session.messages)
1705
- saved << { role: "model", content: "[No response]" } if result.output.to_s.strip.empty?
1706
1741
  conversation << note if conversation
1707
1742
  saved + [note]
1708
1743
  elsif result.canceled? && conversation
@@ -1901,7 +1936,7 @@ module Samagotchi
1901
1936
  # @param result [LLM::ModelResult]
1902
1937
  # @return [Hash]
1903
1938
  def turn_summary(result)
1904
- {
1939
+ summary = {
1905
1940
  output: result.output.to_s,
1906
1941
  exhausted: result.exhausted?,
1907
1942
  resumable: result.resumable?,
@@ -1909,6 +1944,9 @@ module Samagotchi
1909
1944
  tool_activity: Array(result.tool_activity).map(&:dup),
1910
1945
  context_status: result.context_status&.dup
1911
1946
  }
1947
+ # No answer: every UI shows its notice (TurnNote.empty_answer_line).
1948
+ summary[:empty_answer] = { retries: result.empty_retries.to_i } if result.empty_answer?
1949
+ summary
1912
1950
  end
1913
1951
 
1914
1952
  # ── Session messages API ───────────────────────────────────────────────
@@ -219,7 +219,7 @@ module Samagotchi
219
219
  # the prefill, and what the caller gave.
220
220
  Turn = Struct.new(:conversation, :context, :empty_retry, :tool_activity, :buffer, :qwen_attempts, :qwen_partial,
221
221
  :prefill, :pending_tool_calls, :model_name, :pending_input, :on_stream_event,
222
- :cancel_controller, :cap, :emit, :iteration, keyword_init: true)
222
+ :cancel_controller, :cap, :emit, :iteration, :empty_steps, :ended_empty, keyword_init: true)
223
223
  # One request: the prompt and its images as sent, the images' token
224
224
  # estimate, and the window it was measured against.
225
225
  Request = Struct.new(:prompt, :images, :image_tokens, :window, keyword_init: true)
@@ -234,7 +234,7 @@ module Samagotchi
234
234
  conversation = prepare_conversation(messages)
235
235
  Turn.new(
236
236
  conversation: conversation, context: ContextStatus.new(conversation: conversation),
237
- empty_retry: EmptyAnswerRetry.new, tool_activity: [], buffer: +"", qwen_attempts: 0, qwen_partial: nil,
237
+ empty_retry: EmptyAnswerRetry.new, empty_steps: [], tool_activity: [], buffer: +"", qwen_attempts: 0, qwen_partial: nil,
238
238
  # Qwen with thinking off: an empty thought after the cue, so the model
239
239
  # answers at once. Kept in the turn's model messages, so each tool-loop
240
240
  # prompt starts with what the server already has cached.
@@ -418,11 +418,18 @@ module Samagotchi
418
418
  # The empty generation goes (its thinking would be sent again and
419
419
  # prime the same loop); an empty answer that will be retried is no
420
420
  # answer site, so a plugin's steer joins the retry.
421
- turn.conversation.pop if retry_empty
421
+ turn.empty_steps << turn.conversation.pop if retry_empty
422
422
  # Queued steering keeps the turn going: the model answers the
423
423
  # injected message instead of stopping here.
424
424
  return :next if inject_pending_input!(turn, answer: retry_empty ? nil : answer)
425
425
 
426
+ # The last one goes too: earlier thinking is stripped from the prompt,
427
+ # so it would be an empty assistant turn there. The UIs draw it from
428
+ # the result's empty_steps.
429
+ if empty && !retry_empty
430
+ turn.empty_steps << turn.conversation.pop
431
+ turn.ended_empty = true
432
+ end
426
433
  if retry_empty
427
434
  turn.empty_retry.nudge!(turn.conversation, TurnNote.empty_retry,
428
435
  emit: turn.emit, iteration: turn.iteration, finish_reason: generation.finish_reason,
@@ -452,7 +459,9 @@ module Samagotchi
452
459
  # then the answer shows only its text.
453
460
  def finish(turn)
454
461
  exhausted = turn.pending_tool_calls && tool_response_turn?(turn.conversation.last)
455
- output = strip_thought_blocks(last_model_content(turn.conversation))
462
+ # An empty answer left the conversation: an earlier model message
463
+ # isn't this turn's answer.
464
+ output = turn.ended_empty ? "" : strip_thought_blocks(last_model_content(turn.conversation))
456
465
  output = parser.strip_tool_calls(output) if exhausted
457
466
  LLM::ModelResult.new(
458
467
  text: PromptLiteralGuard.restore(output, profile: @profile).to_s,
@@ -460,7 +469,9 @@ module Samagotchi
460
469
  exhausted: exhausted,
461
470
  pending_tool_calls: turn.pending_tool_calls,
462
471
  tool_activity: turn.tool_activity,
463
- context_status: turn.context.display
472
+ context_status: turn.context.display,
473
+ empty_steps: turn.empty_steps,
474
+ empty_retries: turn.empty_retry.attempts
464
475
  )
465
476
  end
466
477
 
@@ -195,8 +195,9 @@ module Samagotchi
195
195
  # reasoning, never sent back), a result's tool_call_id, the image
196
196
  # refs of a user message or a tool result, a prompt's turn_id (Engine,
197
197
  # never sent), and a plugin tool result's tool_params and tool_labels
198
- # (the live row's params line and label, never sent back), and an
199
- # edit/write result's tool_diffs (never sent).
198
+ # (the live row's params line and label, never sent back), an
199
+ # edit/write result's tool_diffs (never sent), and an empty-answer
200
+ # note's marker (TurnNote.empty, never sent).
200
201
  def plain(conversation)
201
202
  conversation.map do |entry|
202
203
  content = entry[:content].is_a?(Array) ? entry[:content] : entry[:content].to_s
@@ -208,6 +209,8 @@ module Samagotchi
208
209
  message[:thinking] = entry[:thinking] if entry[:thinking].is_a?(String) && !entry[:thinking].empty?
209
210
  ToolResponse::SAVED_KEYS.each { |key| message[key] = entry[key] if entry[key] }
210
211
  message[TurnNote::RETRY_NUDGE] = true if TurnNote.retry_nudge?(entry)
212
+ marker = TurnNote.empty_answer(entry)
213
+ message[TurnNote::EMPTY_ANSWER] = marker if marker
211
214
  message[AnswerDisplay::KEY] = entry[AnswerDisplay::KEY] if entry[AnswerDisplay::KEY]
212
215
  ContextNote::KEYS.each { |key| message[key] = entry[key] if entry.key?(key) }
213
216
  message
@@ -302,11 +305,12 @@ module Samagotchi
302
305
  @pending_input = pending_input
303
306
  @tool_activity = []
304
307
  @empty_retry = EmptyAnswerRetry.new
308
+ # The empty generations, never saved: the UIs draw the turn's
309
+ # steps from them (ModelResult#empty_steps).
310
+ @empty_steps = []
305
311
  @context = ContextStatus.new(conversation: conversation)
306
312
  end
307
313
 
308
- EMPTY_ANSWER = "(the model returned an empty answer)"
309
-
310
314
  def call(max_iterations:, cap:)
311
315
  last_text = ""
312
316
  exhausted = true
@@ -340,11 +344,13 @@ module Samagotchi
340
344
  # plugin's steer joins the retry instead of being dropped, and
341
345
  # queued input (a user's line, a steer) goes in place of the nudge.
342
346
  next if inject_pending_input(iteration, answer: retry_empty ? nil : last_text)
347
+
348
+ # An empty answer (content "" + stop, seen from a remote host)
349
+ # ends the turn with no text: the UIs show their notice.
350
+ @empty_steps << with_thinking({ role: "model", content: "" }, response) if empty
343
351
  next if retry_empty && nudge_empty_answer(iteration, response)
344
352
 
345
- # Shown, not saved: an empty answer (content "" + stop, seen from
346
- # a remote host) would otherwise end the turn with nothing.
347
- last_text = EMPTY_ANSWER if empty
353
+ last_text = "" if empty
348
354
  exhausted = false
349
355
  break
350
356
  end
@@ -573,7 +579,7 @@ module Samagotchi
573
579
 
574
580
  def result(text, exhausted:)
575
581
  ModelResult.new(text: text, conversation: @loop.plain(@conversation), exhausted: exhausted,
576
- tool_activity: @tool_activity, empty_answer: text == EMPTY_ANSWER,
582
+ tool_activity: @tool_activity, empty_steps: @empty_steps, empty_retries: @empty_retry.attempts,
577
583
  context_status: @context.display)
578
584
  end
579
585
 
@@ -9,13 +9,19 @@ module Samagotchi
9
9
  # .canceled?, .cancellation_reason, .exhausted?, .resumable?,
10
10
  # .empty_answer?, plus the native loop's .tool_activity, .context_status
11
11
  # and .pending_tool_calls, which the interactive renderer needs.
12
+ #
13
+ # A turn with no answer also carries .empty_steps (its empty
14
+ # generations as model messages, which the loops keep out of the
15
+ # conversation) and .empty_retries (the retries spent): the Engine saves
16
+ # them on the turn's note for the UIs (TurnNote.empty).
12
17
  class ModelResult
13
18
  attr_reader :text, :conversation, :canceled, :cancellation_reason, :exhausted,
14
- :tool_activity, :context_status, :pending_tool_calls
19
+ :tool_activity, :context_status, :pending_tool_calls, :empty_steps, :empty_retries
15
20
 
16
21
  def initialize(text:, conversation: nil, canceled: false, cancellation_reason: nil, exhausted: false,
17
- tool_activity: [], context_status: nil, pending_tool_calls: false, empty_answer: false)
18
- @empty_answer = empty_answer
22
+ tool_activity: [], context_status: nil, pending_tool_calls: false, empty_steps: [], empty_retries: 0)
23
+ @empty_steps = Array(empty_steps)
24
+ @empty_retries = empty_retries.to_i
19
25
  @text = text
20
26
  @conversation = conversation
21
27
  @canceled = canceled
@@ -35,13 +41,12 @@ module Samagotchi
35
41
  !!canceled
36
42
  end
37
43
 
38
- # The turn ended with nothing visible: no text, or the chat loop's
39
- # placeholder (empty_answer:). A cancelled turn or one that can be
40
- # continued is not an empty answer.
44
+ # The turn ended with nothing visible (no text). A cancelled turn or
45
+ # one that can be continued is not an empty answer.
41
46
  def empty_answer?
42
47
  return false if canceled? || resumable?
43
48
 
44
- !!@empty_answer || text.to_s.strip.empty?
49
+ text.to_s.strip.empty?
45
50
  end
46
51
 
47
52
  def exhausted?
@@ -27,6 +27,7 @@ module Samagotchi
27
27
  question_requested: [],
28
28
  question_answered: %i[id],
29
29
  question_cancelled: %i[id reason],
30
+ question_relay: %i[id reason],
30
31
  generation_cancelled: %i[iteration],
31
32
  empty_answer_retry: %i[iteration attempt of finish_reason thinking_chars stopped_by]
32
33
  }.freeze
@@ -145,6 +145,13 @@ module Samagotchi
145
145
  # What a parent does with an approval.
146
146
  DENY_AND_TELL = "deny it, and tell your user"
147
147
 
148
+ # The other way: the user answers it in chi (the web's bell and badge
149
+ # say it waits), and a wait with no message waits past it.
150
+ def leave_open(session_id)
151
+ "or leave it open: tell your user it waits in chi web (session #{session_id[0, 8]}); " \
152
+ "chi send --wait --format json #{session_id} waits until they answer"
153
+ end
154
+
148
155
  def approval?(question)
149
156
  question.is_a?(Hash) && (question[:kind] || question["kind"]).to_s == "approval"
150
157
  end
@@ -180,6 +187,7 @@ module Samagotchi
180
187
  if approval?(q)
181
188
  lines << " allowing it is up to your user: #{DENY_AND_TELL}"
182
189
  lines << " deny: #{answer_with(session_id, q)}"
190
+ lines << " #{leave_open(session_id)}"
183
191
  else
184
192
  lines << " more than one allowed: repeat --option" if q[:multi_select]
185
193
  lines << " free text allowed: --text" if q[:allow_freeform]
@@ -36,6 +36,9 @@ module Samagotchi
36
36
  end
37
37
  end
38
38
 
39
+ # Seconds between two looks of an open question's watch (#open_question).
40
+ WATCH_INTERVAL = 0.5
41
+
39
42
  DISMISSED_NOTE = "The user dismissed the question without answering. Don't do what you asked about, " \
40
43
  "or anything else that changes files or state. Finish your reply with what you found " \
41
44
  "and what you would do, and wait."
@@ -73,11 +76,13 @@ module Samagotchi
73
76
  # @param payload [Hash] {question:, options:, header:, multi_select:, allow_freeform:}
74
77
  # @return [String] normalized answer JSON
75
78
  def request(payload)
76
- result = open_question(**payload.slice(:question, :options, :header),
77
- multi_select: !!payload[:multi_select], allow_freeform: !!payload[:allow_freeform])
79
+ result = open_question({ **payload.slice(:question, :options, :header),
80
+ multi_select: !!payload[:multi_select], allow_freeform: !!payload[:allow_freeform] })
78
81
  # Dismissed (the card's dismiss, Esc): an answer of its own, not a
79
82
  # tool failure the model learns to avoid the tool from.
80
83
  result = { dismissed: true, id: result[:id], note: DISMISSED_NOTE } if result.is_a?(Hash) && result[:error] == "no answer"
84
+ # Who answered is for the relay, not the model's tool result.
85
+ result = result.except(:by) if result.is_a?(Hash)
81
86
  result.is_a?(String) ? result : JSON.generate(result)
82
87
  end
83
88
 
@@ -87,9 +92,13 @@ module Samagotchi
87
92
  # The fields go to pending as given (no cleaning), extra keys
88
93
  # included, so a caller can add its own (kind:, approval:).
89
94
  # @param fields [Hash] question:, options:, header:, multi_select:, allow_freeform:, …
95
+ # @param watch [#call, nil] asked every WATCH_INTERVAL seconds while the
96
+ # question waits (outside the lock): a String closes the question with
97
+ # that reason, announced and returned as a cancel; nil keeps waiting.
98
+ # An answer recorded first wins. Not on the sync-handler path.
90
99
  # @return [Hash, String] the answer {id:, selected:, freeform:, selected_indices:},
91
100
  # or {error:, …}; a String when a sync handler returned text itself
92
- def open_question(fields)
101
+ def open_question(fields, watch: nil)
93
102
  id = SecureRandom.uuid
94
103
  pending = { id: id, **fields, status: "pending", created_at: Time.now.iso8601(3) }.compact
95
104
 
@@ -150,20 +159,15 @@ module Samagotchi
150
159
  end
151
160
 
152
161
  # Block until answered/cancelled (cross-thread path: WEB/Bridge/background worker)
162
+ closed_reason = wait_for_answer(watch)
153
163
  answer = nil
154
164
  cancelled_reason = nil
155
165
  @lock.synchronize do
156
- loop do
157
- break if @answer
158
- break if cancel_controller&.cancelled?
159
- break if @pending.nil? || @pending[:status] != "pending"
160
-
161
- # Wait with timeout to check cancel; 0.2s matches reminder poll
162
- @cv.wait(0.2)
163
- end
164
166
  answer = @answer
165
167
  controller = cancel_controller
166
- cancelled_reason = controller.reason.to_s if answer.nil? && controller&.cancelled?
168
+ if answer.nil?
169
+ cancelled_reason = closed_reason || (controller.reason.to_s if controller&.cancelled?)
170
+ end
167
171
  @pending = nil
168
172
  end
169
173
  # Saved and announced outside the question lock (see the sync path).
@@ -185,16 +189,19 @@ module Samagotchi
185
189
  # @param id [String] pending id
186
190
  # @param selected [Array<String>] values/labels
187
191
  # @param freeform [String, nil]
188
- # @param client_id [String, nil] who answers; chi answer's (a parent
189
- # agent) is held to guardrails.parent_approvals on an approval
190
- # @return [Hash] normalized answer
192
+ # @param client_id [String, nil] who answers
193
+ # @param parent_agent [Boolean, nil] the answer is a parent agent's, so
194
+ # it is held to guardrails.parent_approvals on an approval and doesn't
195
+ # bring the session back to the lists; nil: chi answer's client id says so
196
+ # @return [Hash] normalized answer; a parent agent's carries by: "parent_agent"
191
197
  # @raise [NotPending, ArgumentError, Refused]
192
- def answer(id:, selected:, freeform: nil, client_id: nil)
198
+ def answer(id:, selected:, freeform: nil, client_id: nil, parent_agent: nil)
193
199
  sel = Array(selected).map { |v| v.to_s.strip }.reject(&:empty?)
194
200
  fm = freeform.to_s.strip
195
201
  fm = nil if fm.empty?
202
+ parent_agent = client_id.to_s == Guardrails::ParentApprovals::CLIENT_ID if parent_agent.nil?
196
203
  # Read before the lock (config may touch the disk); this worker's own.
197
- parent_setting = Guardrails::ParentApprovals.setting if client_id.to_s == Guardrails::ParentApprovals::CLIENT_ID
204
+ parent_setting = Guardrails::ParentApprovals.setting if parent_agent
198
205
  @lock.synchronize do
199
206
  pending = @pending
200
207
  raise NotPending, "no pending question" unless pending
@@ -228,19 +235,50 @@ module Samagotchi
228
235
  answer = { id: id.to_s, selected: sel, freeform: fm }
229
236
  # Derive indices for convenience
230
237
  answer[:selected_indices] = indices.compact
238
+ answer[:by] = "parent_agent" if parent_agent
231
239
  @answer = answer
232
240
  @cv.broadcast
233
241
  answer
234
242
  end.tap do
235
243
  # A human answered: the session is back in the lists (ArchiveStore).
236
- @user_input.call(session&.id)
244
+ @user_input.call(session&.id) unless parent_agent
245
+ end
246
+ end
247
+
248
+ # Mark the pending question as relayed to a parent session's user, or
249
+ # clear the mark (relayed_to: nil): saved with it and announced as
250
+ # :question_relay, so every UI shows where else it can be answered. Under
251
+ # the question lock, so the save never lands after the turn thread
252
+ # cleared the question.
253
+ # @param relayed_to [Hash, nil] {parent_id:, parent_short:, relay_id:}
254
+ # @param reason [String, nil] why a mark was cleared (parent_gone, …)
255
+ # @return [Boolean] false when +id+ isn't the question pending now
256
+ def annotate(id, relayed_to:, reason: nil)
257
+ marked = @lock.synchronize do
258
+ pending = @pending
259
+ next nil unless pending && pending[:id].to_s == id.to_s
260
+ next nil if @answer || pending[:status].to_s != "pending"
261
+
262
+ relayed_to ? pending[:relayed_to] = relayed_to : pending.delete(:relayed_to)
263
+ if session
264
+ session.pending_question = pending.dup
265
+ begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
266
+ end
267
+ pending[:id]
237
268
  end
269
+ return false unless marked
270
+
271
+ emit({ type: :question_relay, id: marked, relayed_to: relayed_to, reason: reason }.compact.merge(relayed_to: relayed_to))
272
+ true
238
273
  end
239
274
 
240
275
  def sync_handler=(block)
241
276
  @sync_handler = block
242
277
  end
243
278
 
279
+ # @return [Boolean] a REPL answers inline (no wait loop)
280
+ def sync_handler? = !@sync_handler.nil?
281
+
244
282
  # Cancel the pending question (e.g. /cancel, a dismiss). Announces which
245
283
  # one, so every UI closes it; with none pending there is nothing to
246
284
  # announce. A question already answered (the turn thread hasn't taken the
@@ -292,6 +330,47 @@ module Samagotchi
292
330
 
293
331
  private
294
332
 
333
+ # Wait until the question is answered, cancelled or closed by +watch+.
334
+ # @return [String, nil] the watch's close reason
335
+ def wait_for_answer(watch)
336
+ next_watch = monotonic + WATCH_INTERVAL
337
+ loop do
338
+ done = @lock.synchronize do
339
+ next true if @answer || cancel_controller&.cancelled?
340
+ next true if @pending.nil? || @pending[:status] != "pending"
341
+
342
+ # Wait with timeout to check cancel; 0.2s matches reminder poll
343
+ @cv.wait([0.2, watch ? next_watch - monotonic : 0.2].min.clamp(0.0, 0.2))
344
+ false
345
+ end
346
+ return nil if done
347
+ next unless watch && monotonic >= next_watch
348
+
349
+ next_watch = monotonic + WATCH_INTERVAL
350
+ reason = run_watch(watch)
351
+ next unless reason
352
+
353
+ closed = @lock.synchronize do
354
+ next false if @answer || @pending.nil? || @pending[:status] != "pending"
355
+
356
+ @pending[:status] = "cancelled"
357
+ true
358
+ end
359
+ return reason if closed
360
+ end
361
+ end
362
+
363
+ # The watch's close reason; one that raises closes nothing.
364
+ def run_watch(watch)
365
+ reason = watch.call
366
+ reason&.to_s
367
+ rescue StandardError => e
368
+ Log.warn(:turn, "question_watch_failed", error: e.class.name, message: e.message)
369
+ nil
370
+ end
371
+
372
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
373
+
295
374
  def session = @session_lookup.call
296
375
  def state_dir = @state_dir_lookup.call
297
376
  def cancel_controller = @cancel_controller_lookup.call
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "guardrails/approval"
4
+
5
+ module Samagotchi
6
+ # A delegate's approval reopened as the parent session's own (the
7
+ # approval relay): the same kind "approval" and the same approval facts,
8
+ # so every UI shows it as it shows the parent's own approvals, the diff
9
+ # included, and Guardrails::ParentApprovals judges it the same way. The
10
+ # question text names the delegate and its task over the child's own
11
+ # text; `relay:` says which child and which of its questions it answers.
12
+ # A relay of a relay (a grandchild's) grows relay.chain.
13
+ module Relay
14
+ # How much of the child's task the card keeps, and the question line.
15
+ TASK_CHARS = 200
16
+ TASK_LINE_CHARS = 80
17
+
18
+ module_function
19
+
20
+ # @param child [Session] the child as loaded (its id, its first prompt)
21
+ # @param pending [Hash] the child's pending question (string keys inside
22
+ # when it came from a saved session)
23
+ # @param relay_id [String] RelayDesk's id
24
+ # @param more [Integer] other delegates' approvals waiting behind this one
25
+ # @return [Hash] Engine#open_question fields
26
+ def card(child, pending, relay_id:, more: 0)
27
+ pending = deep_symbolize(pending)
28
+ facts = pending[:approval].is_a?(Hash) ? pending[:approval] : {}
29
+ short = child.id.to_s[0, 8]
30
+ inner = pending[:relay].is_a?(Hash) ? pending[:relay] : nil
31
+ chain = inner ? [*Array(inner[:chain]), short] : [short]
32
+ asked = inner ? inner[:asked].to_s : pending[:question].to_s
33
+ task = inner ? inner[:task].to_s : task_of(child)
34
+ who = chain.reverse.join(" → ")
35
+ {
36
+ question: question_text(who, task, asked),
37
+ options: relabel(Array(pending[:options]), facts[:scopes], who),
38
+ header: header(who, more),
39
+ multi_select: false,
40
+ allow_freeform: pending.key?(:allow_freeform) ? !!pending[:allow_freeform] : true,
41
+ kind: Guardrails::Approval::KIND,
42
+ approval: facts,
43
+ relay: { id: relay_id, child_id: child.id, child_short: short, child_question_id: pending[:id].to_s,
44
+ task: task, chain: chain, asked: asked, more: more.to_i }
45
+ }
46
+ end
47
+
48
+ # delegate ab12 ("fix the flaky spec in …") asks:
49
+ # execute: git push origin main
50
+ # in /path/repo (repo samagotchi, branch main)
51
+ def question_text(who, task, asked)
52
+ line = task.empty? ? "delegate #{who} asks:" : "delegate #{who} (#{cut(task, TASK_LINE_CHARS).inspect}) asks:"
53
+ [line, *asked.lines.map { |l| " #{l.chomp}" }].join("\n")
54
+ end
55
+
56
+ def header(who, more)
57
+ text = "Approve delegate #{who}'s tool call?"
58
+ more.to_i.positive? ? "#{text} (+#{more} more delegate#{"s" if more.to_i > 1} waiting)" : text
59
+ end
60
+
61
+ # The child's options, by index: "session" is the child's session, so
62
+ # it says whose; "once", "repo", "rule" and Deny keep their labels (the
63
+ # approvals store is shared, a session scope keys on the child's id).
64
+ def relabel(options, scopes, who)
65
+ scopes = Array(scopes)
66
+ options.each_with_index.map do |label, index|
67
+ scopes[index].to_s == "session" ? "Allow this call for delegate #{who}'s session" : label.to_s
68
+ end
69
+ end
70
+
71
+ # The child's task: its first prompt, on one line, cut.
72
+ def task_of(child)
73
+ raw = [child.first_preview, child.last_prompt].map(&:to_s).find { |text| !text.strip.empty? }
74
+ cut(raw.to_s.gsub(/\s+/, " ").strip, TASK_CHARS)
75
+ end
76
+
77
+ def cut(text, limit) = text.length > limit ? "#{text[0, limit - 1]}…" : text
78
+
79
+ # Session.load symbolizes only top-level keys.
80
+ def deep_symbolize(value)
81
+ case value
82
+ when Hash then value.to_h { |key, inner| [key.to_sym, deep_symbolize(inner)] }
83
+ when Array then value.map { |inner| deep_symbolize(inner) }
84
+ else value
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Samagotchi
6
+ # A parent worker's relays of its delegates' approvals: relay id → the
7
+ # child and its question, and the answer this worker's own question flow
8
+ # took from a UI. A child believes a relayed answer only when it asked
9
+ # this table for it, through the parent's Bridge (POST relay/status).
10
+ #
11
+ # Memory only: never in the session file, the event log, a snapshot, a
12
+ # log line or the environment, so nothing but this worker's question flow
13
+ # writes it. Entries are dropped KEEP_SECONDS after they settle.
14
+ class RelayDesk
15
+ KEEP_SECONDS = 600
16
+
17
+ # @param clock [#call] monotonic seconds (specs)
18
+ def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
19
+ @clock = clock
20
+ @mutex = Mutex.new
21
+ @relays = {}
22
+ end
23
+
24
+ # @return [String] the new relay's id (a UUID: an id, not a secret)
25
+ def open(child_id:, child_question_id:)
26
+ id = SecureRandom.uuid
27
+ @mutex.synchronize do
28
+ prune
29
+ @relays[id] = { child_id: child_id.to_s, child_question_id: child_question_id.to_s, state: "open",
30
+ answer: nil, by: nil, settled_at: nil }
31
+ end
32
+ id
33
+ end
34
+
35
+ # The parent's user (or a parent agent) answered the relay card.
36
+ # @param by [String] "user" or "parent_agent"
37
+ # @return [Boolean] false when the relay isn't open (first answer wins)
38
+ def record(id, selected_indices: [], freeform: nil, dismissed: false, by:)
39
+ @mutex.synchronize do
40
+ relay = @relays[id.to_s]
41
+ next false unless relay && relay[:state] == "open"
42
+
43
+ relay.merge!(state: "answered", by: by.to_s, settled_at: @clock.call,
44
+ answer: { selected_indices: Array(selected_indices), freeform: freeform, dismissed: !!dismissed })
45
+ true
46
+ end
47
+ end
48
+
49
+ # The relay ended with no answer (a Stop, the child gone or answered).
50
+ def close(id)
51
+ @mutex.synchronize do
52
+ relay = @relays[id.to_s]
53
+ relay&.merge!(state: "closed", settled_at: @clock.call) if relay && relay[:state] == "open"
54
+ end
55
+ nil
56
+ end
57
+
58
+ # @return [Hash, nil] {child_id:, child_question_id:, state:, answer:, by:}
59
+ def status(id)
60
+ @mutex.synchronize do
61
+ prune
62
+ @relays[id.to_s]&.except(:settled_at)&.then { |relay| Marshal.load(Marshal.dump(relay)) }
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def prune
69
+ now = @clock.call
70
+ @relays.delete_if { |_, relay| relay[:settled_at] && now - relay[:settled_at] > KEEP_SECONDS }
71
+ end
72
+ end
73
+ end