samagotchi 0.14.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -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/plugins.md +3 -3
  8. data/docs/sessions.md +7 -2
  9. data/docs/sub-agent.md +15 -3
  10. data/lib/samagotchi/answer_display.rb +6 -2
  11. data/lib/samagotchi/bridge/card_store.rb +16 -1
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +6 -0
  13. data/lib/samagotchi/bridge.rb +84 -0
  14. data/lib/samagotchi/bridge_client.rb +33 -6
  15. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  17. data/lib/samagotchi/empty_answer_retry.rb +2 -1
  18. data/lib/samagotchi/engine.rb +58 -17
  19. data/lib/samagotchi/kernel_loop.rb +16 -5
  20. data/lib/samagotchi/llm/chat_loop.rb +17 -9
  21. data/lib/samagotchi/llm/model_result.rb +12 -7
  22. data/lib/samagotchi/log_subscriber.rb +1 -0
  23. data/lib/samagotchi/parent_report.rb +8 -0
  24. data/lib/samagotchi/plugin_session_state.rb +30 -0
  25. data/lib/samagotchi/question_desk.rb +97 -18
  26. data/lib/samagotchi/relay.rb +88 -0
  27. data/lib/samagotchi/relay_desk.rb +73 -0
  28. data/lib/samagotchi/relay_verifier.rb +113 -0
  29. data/lib/samagotchi/relay_watcher.rb +46 -0
  30. data/lib/samagotchi/reply_wait.rb +10 -1
  31. data/lib/samagotchi/self_report.rb +24 -4
  32. data/lib/samagotchi/session.rb +15 -2
  33. data/lib/samagotchi/session_manager.rb +5 -2
  34. data/lib/samagotchi/session_metrics.rb +18 -1
  35. data/lib/samagotchi/session_retention.rb +6 -1
  36. data/lib/samagotchi/sessions_command.rb +10 -4
  37. data/lib/samagotchi/terminal_ui/attach_launcher.rb +11 -2
  38. data/lib/samagotchi/terminal_ui/attached_loop.rb +88 -12
  39. data/lib/samagotchi/terminal_ui/event_renderer.rb +6 -1
  40. data/lib/samagotchi/terminal_ui/formatting.rb +7 -0
  41. data/lib/samagotchi/terminal_ui/question_prompt.rb +48 -2
  42. data/lib/samagotchi/terminal_ui/question_slot.rb +5 -1
  43. data/lib/samagotchi/terminal_ui.rb +11 -0
  44. data/lib/samagotchi/tool_declarations.rb +2 -2
  45. data/lib/samagotchi/tools/delegate_relay.rb +216 -0
  46. data/lib/samagotchi/tools/delegate_wait.rb +72 -6
  47. data/lib/samagotchi/tools/peers.rb +6 -4
  48. data/lib/samagotchi/turn_note.rb +30 -2
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +24 -0
  51. data/lib/samagotchi/web/public/app.js +32 -11
  52. data/lib/samagotchi/web/public/index.html +9 -0
  53. data/lib/samagotchi/web/public/notify.js +18 -5
  54. data/lib/samagotchi/web/public/question_card.js +45 -3
  55. data/lib/samagotchi/web/public/question_cards.js +49 -1
  56. data/lib/samagotchi/web/public/sessions_list.js +16 -1
  57. data/lib/samagotchi/web/public/stage_model.js +8 -3
  58. data/lib/samagotchi/web/public/stage_view.js +7 -2
  59. data/lib/samagotchi/web/public/strip.js +8 -1
  60. data/lib/samagotchi/web/public/timing.js +22 -5
  61. data/lib/samagotchi/web/public/turn_events.js +9 -0
  62. data/lib/samagotchi/web/public/turn_view.js +38 -13
  63. metadata +7 -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
@@ -1362,8 +1399,10 @@ module Samagotchi
1362
1399
  # +ended+: the end event is out (what follows is post-turn work).
1363
1400
  # +settings+: the kernel's LLM::TurnSettings, made in #prepare_turn and
1364
1401
  # set on the kernel in #generate.
1402
+ # +id+: names the turn on :turn_started (its metrics record's id) and on
1403
+ # its saved prompt, so a UI pairs the two whatever left the conversation.
1365
1404
  Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
1366
- :settings) do
1405
+ :settings, :id) do
1367
1406
  # The turn's boundary events carry the origin only when there is one,
1368
1407
  # so payloads stay unchanged for callers that don't pass it.
1369
1408
  def tag(event) = origin ? event.merge(origin: origin) : event
@@ -1478,7 +1517,8 @@ module Samagotchi
1478
1517
  @guardrail_wiring.begin_turn(origin)
1479
1518
 
1480
1519
  Turn.new(session: session, prompt: continue ? nil : prompt, continue: continue, on_event: on_event,
1481
- controller: effective_controller, origin: origin, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC))
1520
+ controller: effective_controller, origin: origin, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
1521
+ id: SecureRandom.uuid)
1482
1522
  end
1483
1523
  private :begin_turn
1484
1524
 
@@ -1492,7 +1532,7 @@ module Samagotchi
1492
1532
  image_refs, image_error = turn_image_refs(session, images)
1493
1533
  # Emit turn_started event
1494
1534
  bind_metrics(session)
1495
- turn_started = { type: :turn_started, session_id: session.id, prompt: turn.prompt }
1535
+ turn_started = { type: :turn_started, session_id: session.id, prompt: turn.prompt, turn_id: turn.id }
1496
1536
  turn_started[:continue] = true if turn.continue
1497
1537
  turn_started[:images] = image_refs unless image_refs.empty?
1498
1538
  emit_event(turn.on_event, turn.tag(turn_started))
@@ -1595,7 +1635,7 @@ module Samagotchi
1595
1635
 
1596
1636
  return if turn.continue
1597
1637
 
1598
- user_message = { role: "user", content: turn.prompt }
1638
+ user_message = { role: "user", content: turn.prompt, turn_id: turn.id }
1599
1639
  user_message[:images] = image_refs unless image_refs.empty?
1600
1640
  turn.messages << user_message
1601
1641
  session.last_prompt = turn.prompt
@@ -1689,17 +1729,15 @@ module Samagotchi
1689
1729
  # continue resumes from them rather than after a made-up reply.
1690
1730
  def kept_messages(turn, result, seconds)
1691
1731
  conversation = result.conversation if result.conversation.is_a?(Array)
1692
- # Nothing visible (the native loop: no text; the chat loop: its
1693
- # 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.
1694
1737
  if result.empty_answer?
1695
- # The placeholder is for the UIs (a new array: it must not leak
1696
- # into the result); the note is for the model, so it goes on the
1697
- # result's conversation too.
1698
- # A retry's nudge at the tail goes: this note says it all.
1699
- note = TurnNote.empty
1738
+ note = TurnNote.empty(retries: result.empty_retries, steps: result.empty_steps)
1700
1739
  conversation&.replace(TurnNote.without_trailing(conversation))
1701
1740
  saved = TurnNote.without_trailing(conversation || turn.session.messages)
1702
- saved << { role: "model", content: "[No response]" } if result.output.to_s.strip.empty?
1703
1741
  conversation << note if conversation
1704
1742
  saved + [note]
1705
1743
  elsif result.canceled? && conversation
@@ -1898,7 +1936,7 @@ module Samagotchi
1898
1936
  # @param result [LLM::ModelResult]
1899
1937
  # @return [Hash]
1900
1938
  def turn_summary(result)
1901
- {
1939
+ summary = {
1902
1940
  output: result.output.to_s,
1903
1941
  exhausted: result.exhausted?,
1904
1942
  resumable: result.resumable?,
@@ -1906,6 +1944,9 @@ module Samagotchi
1906
1944
  tool_activity: Array(result.tool_activity).map(&:dup),
1907
1945
  context_status: result.context_status&.dup
1908
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
1909
1950
  end
1910
1951
 
1911
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
 
@@ -193,9 +193,11 @@ module Samagotchi
193
193
  # The shape the engine persists: role and content (parts stay an
194
194
  # Array), plus a model turn's tool_calls and thinking (the host's
195
195
  # reasoning, never sent back), a result's tool_call_id, the image
196
- # refs of a user message or a tool result, and a plugin tool result's
197
- # tool_params and tool_labels (the live row's params line and label,
198
- # never sent back), and an edit/write result's tool_diffs (never sent).
196
+ # refs of a user message or a tool result, a prompt's turn_id (Engine,
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), an
199
+ # edit/write result's tool_diffs (never sent), and an empty-answer
200
+ # note's marker (TurnNote.empty, never sent).
199
201
  def plain(conversation)
200
202
  conversation.map do |entry|
201
203
  content = entry[:content].is_a?(Array) ? entry[:content] : entry[:content].to_s
@@ -203,9 +205,12 @@ module Samagotchi
203
205
  message[:tool_calls] = entry[:tool_calls] if entry[:tool_calls].is_a?(Array) && !entry[:tool_calls].empty?
204
206
  message[:tool_call_id] = entry[:tool_call_id] if entry[:tool_call_id]
205
207
  message[:images] = entry[:images] if entry[:images].is_a?(Array) && !entry[:images].empty?
208
+ message[:turn_id] = entry[:turn_id] if entry[:turn_id]
206
209
  message[:thinking] = entry[:thinking] if entry[:thinking].is_a?(String) && !entry[:thinking].empty?
207
210
  ToolResponse::SAVED_KEYS.each { |key| message[key] = entry[key] if entry[key] }
208
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
209
214
  message[AnswerDisplay::KEY] = entry[AnswerDisplay::KEY] if entry[AnswerDisplay::KEY]
210
215
  ContextNote::KEYS.each { |key| message[key] = entry[key] if entry.key?(key) }
211
216
  message
@@ -300,11 +305,12 @@ module Samagotchi
300
305
  @pending_input = pending_input
301
306
  @tool_activity = []
302
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 = []
303
311
  @context = ContextStatus.new(conversation: conversation)
304
312
  end
305
313
 
306
- EMPTY_ANSWER = "(the model returned an empty answer)"
307
-
308
314
  def call(max_iterations:, cap:)
309
315
  last_text = ""
310
316
  exhausted = true
@@ -338,11 +344,13 @@ module Samagotchi
338
344
  # plugin's steer joins the retry instead of being dropped, and
339
345
  # queued input (a user's line, a steer) goes in place of the nudge.
340
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
341
351
  next if retry_empty && nudge_empty_answer(iteration, response)
342
352
 
343
- # Shown, not saved: an empty answer (content "" + stop, seen from
344
- # a remote host) would otherwise end the turn with nothing.
345
- last_text = EMPTY_ANSWER if empty
353
+ last_text = "" if empty
346
354
  exhausted = false
347
355
  break
348
356
  end
@@ -571,7 +579,7 @@ module Samagotchi
571
579
 
572
580
  def result(text, exhausted:)
573
581
  ModelResult.new(text: text, conversation: @loop.plain(@conversation), exhausted: exhausted,
574
- tool_activity: @tool_activity, empty_answer: text == EMPTY_ANSWER,
582
+ tool_activity: @tool_activity, empty_steps: @empty_steps, empty_retries: @empty_retry.attempts,
575
583
  context_status: @context.display)
576
584
  end
577
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]
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "paths"
5
+ require_relative "session"
6
+
7
+ module Samagotchi
8
+ # A plugin's state for one session, by convention under its data dir
9
+ # (ctx.data_dir): sessions/<id>.json or sessions/<id>/. It goes when the
10
+ # session does (SessionManager.delete_session: the CLI, the web, a
11
+ # discarded empty session; SessionRetention's prune), so no bundle keeps
12
+ # state for sessions that are gone (check-in's /checkin settings).
13
+ module PluginSessionState
14
+ module_function
15
+
16
+ # @return [Array<String>] the paths removed
17
+ def remove(session_id, env: ENV)
18
+ id = session_id.to_s
19
+ # Only a plain id: anything else could name a path outside the dir.
20
+ return [] unless Session.valid_id?(id)
21
+
22
+ Dir.glob(File.join(Paths.state_dir(env: env), "plugins", "*", "sessions")).flat_map do |dir|
23
+ [File.join(dir, "#{id}.json"), File.join(dir, id)].select { |path| File.exist?(path) }
24
+ .each { |path| FileUtils.rm_rf(path) }
25
+ end
26
+ rescue SystemCallError
27
+ []
28
+ end
29
+ end
30
+ end
@@ -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