samagotchi 0.85.0 → 0.86.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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -1
  3. data/docs/architecture.md +34 -5
  4. data/docs/bundle-authoring.md +125 -0
  5. data/docs/cli.md +38 -3
  6. data/docs/configuration.md +6 -5
  7. data/docs/guardrails.md +63 -35
  8. data/docs/hooks.md +19 -3
  9. data/docs/internals/anthropic.md +2 -2
  10. data/docs/internals/bundles.md +11 -2
  11. data/docs/internals/context-telemetry.md +19 -1
  12. data/docs/internals/engine.md +1 -1
  13. data/docs/memory.md +1 -1
  14. data/docs/plugins.md +59 -2
  15. data/docs/releasing.md +1 -1
  16. data/docs/sessions.md +6 -6
  17. data/docs/sub-agent.md +35 -24
  18. data/docs/testing.md +7 -2
  19. data/lib/samagotchi/answer_command.rb +21 -25
  20. data/lib/samagotchi/bridge/client_event.rb +50 -0
  21. data/lib/samagotchi/bridge/sse_writer.rb +6 -2
  22. data/lib/samagotchi/bridge.rb +22 -11
  23. data/lib/samagotchi/bridge_client.rb +2 -2
  24. data/lib/samagotchi/bundle_command/check.rb +348 -0
  25. data/lib/samagotchi/bundle_command/scaffold.rb +135 -0
  26. data/lib/samagotchi/bundle_command/test_run.rb +89 -0
  27. data/lib/samagotchi/bundle_command.rb +125 -2
  28. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  29. data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
  31. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  32. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -2
  33. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  34. data/lib/samagotchi/bundles/ruby/manifest.yml +11 -0
  35. data/lib/samagotchi/bundles/ruby/plugin.rb +74 -0
  36. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  37. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  38. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  39. data/lib/samagotchi/client_id.rb +1 -1
  40. data/lib/samagotchi/config.rb +195 -115
  41. data/lib/samagotchi/context_status.rb +1 -1
  42. data/lib/samagotchi/continue_offer.rb +2 -1
  43. data/lib/samagotchi/engine.rb +4 -4
  44. data/lib/samagotchi/guardrail_wiring.rb +4 -0
  45. data/lib/samagotchi/guardrails/approval.rb +12 -2
  46. data/lib/samagotchi/guardrails/load_failures.rb +17 -3
  47. data/lib/samagotchi/guardrails/parent_approvals.rb +14 -70
  48. data/lib/samagotchi/guardrails/parent_policy.rb +258 -0
  49. data/lib/samagotchi/guardrails/verdict.rb +6 -3
  50. data/lib/samagotchi/hook_routes.rb +5 -3
  51. data/lib/samagotchi/hooks/bundle_loader.rb +3 -1
  52. data/lib/samagotchi/hooks/registry.rb +13 -6
  53. data/lib/samagotchi/hooks.rb +5 -0
  54. data/lib/samagotchi/kernel_loop.rb +5 -2
  55. data/lib/samagotchi/llm/anthropic_assembly.rb +1 -1
  56. data/lib/samagotchi/memory_bundle/shas.rb +127 -0
  57. data/lib/samagotchi/memory_bundle.rb +1 -0
  58. data/lib/samagotchi/parent_report.rb +87 -14
  59. data/lib/samagotchi/pending_question.rb +8 -4
  60. data/lib/samagotchi/plugin/api.rb +16 -0
  61. data/lib/samagotchi/plugin/context.rb +11 -5
  62. data/lib/samagotchi/plugin/loader.rb +5 -3
  63. data/lib/samagotchi/question_desk.rb +37 -24
  64. data/lib/samagotchi/queued_command.rb +5 -4
  65. data/lib/samagotchi/relay.rb +5 -1
  66. data/lib/samagotchi/relay_verifier.rb +4 -5
  67. data/lib/samagotchi/reply_wait.rb +14 -5
  68. data/lib/samagotchi/send_command.rb +34 -8
  69. data/lib/samagotchi/session_commands.rb +14 -3
  70. data/lib/samagotchi/terminal_ui/attach_launcher.rb +1 -1
  71. data/lib/samagotchi/terminal_ui/attached_loop.rb +13 -11
  72. data/lib/samagotchi/terminal_ui/thinking_line.rb +5 -9
  73. data/lib/samagotchi/terminal_ui.rb +12 -9
  74. data/lib/samagotchi/testing/boundary.rb +0 -3
  75. data/lib/samagotchi/testing/bundle.rb +7 -0
  76. data/lib/samagotchi/testing/host.rb +6 -4
  77. data/lib/samagotchi/testing/install.rb +119 -0
  78. data/lib/samagotchi/testing/recorder.rb +13 -4
  79. data/lib/samagotchi/testing.rb +1 -0
  80. data/lib/samagotchi/tools/builtins.rb +2 -1
  81. data/lib/samagotchi/tools/delegate_wait.rb +6 -1
  82. data/lib/samagotchi/tools/task_runtime.rb +5 -3
  83. data/lib/samagotchi/turn_checkpoint.rb +64 -2
  84. data/lib/samagotchi/turn_flow.rb +10 -0
  85. data/lib/samagotchi/version.rb +1 -1
  86. data/lib/samagotchi/web/app.rb +48 -10
  87. data/lib/samagotchi/web/public/chunk_router.js +8 -19
  88. data/lib/samagotchi/web/public/ctx.js +12 -26
  89. data/lib/samagotchi/web/public/stream_handlers.js +1 -1
  90. data/lib/samagotchi/web/server.rb +4 -0
  91. data/lib/samagotchi/worker.rb +19 -5
  92. data/lib/samagotchi/worker_inbound.rb +4 -4
  93. metadata +11 -2
  94. data/lib/samagotchi/guardrails/parent_continue.rb +0 -43
@@ -3,6 +3,7 @@
3
3
  require_relative "session"
4
4
  require_relative "turn_note"
5
5
  require_relative "session_inbox"
6
+ require_relative "turn_checkpoint"
6
7
  require_relative "worker_sidecar"
7
8
 
8
9
  module Samagotchi
@@ -32,9 +33,10 @@ module Samagotchi
32
33
  # @!attribute question [PendingQuestion, nil] the pending question
33
34
  # (:waiting_for_answer)
34
35
  # @!attribute outcome [String, nil] how the turn ended when it left no
35
- # reply (:no_reply): "failed", "canceled", "completed" (empty),
36
+ # reply (:no_reply): "failed", "canceled", "completed" (empty turn or
37
+ # file-delivered command ran without a turn),
36
38
  # "exhausted" (it ran out of iterations, with nobody asked whether to
37
- # continue) or "not_continued" (a Stop answered the step-limit
39
+ # continue), "not_continued" (a Stop answered the step-limit
38
40
  # question; no turn ran), from the session's last_turn, or "removed"
39
41
  # (turn:'s queued input was taken back before it ran, its tombstone:
40
42
  # SessionInbox.removed?; no turn ran); nil when unknown. With
@@ -47,8 +49,9 @@ module Samagotchi
47
49
  # retry
48
50
  # @!attribute cancel_reason [String, nil] a "canceled" turn's reason
49
51
  # (user, hook, ctrl_c, manual, session_stopped: the session was
50
- # stopped mid-turn), passed through as the worker saved it; any
51
- # reason is a "canceled" outcome
52
+ # stopped mid-turn; worker_stopped: turn:'s worker died in it and the
53
+ # next one settled it, TurnCheckpoint.settled_turn), passed through
54
+ # as the worker saved it; any reason is a "canceled" outcome
52
55
  # @!attribute stopped_by [String, nil] the hook that canceled it
53
56
  # (loop-guard)
54
57
  # @!attribute session [Session, nil] the session as the look that
@@ -156,6 +159,12 @@ module Samagotchi
156
159
  reply.session = settled(session, id, state_dir: state_dir)
157
160
  return reply
158
161
  end
162
+ # Its worker died in it and the next one settled it, or an input
163
+ # command finished without a turn. After the reply check: a worker
164
+ # killed between writing the reply and its idle save left the reply.
165
+ if turn && (settled = TurnCheckpoint.settled_turn(session_dir, turn))
166
+ return Result.new(status: :no_reply, outcome: settled.outcome, session: session, **settled.stop_facts)
167
+ end
159
168
 
160
169
  case session.status
161
170
  when Session::STATUS_ERROR
@@ -314,7 +323,7 @@ module Samagotchi
314
323
 
315
324
  running = session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == id
316
325
  queued = SessionInbox.waiting_input(session_dir, id) == :turn_end
317
- ended = session.last_turn&.enqueued_id == id
326
+ ended = session.last_turn&.enqueued_id == id || !TurnCheckpoint.settled_turn(session_dir, id).nil?
318
327
  running || queued || ended
319
328
  end
320
329
 
@@ -12,6 +12,9 @@ require_relative "session_chain"
12
12
  require_relative "context_quote"
13
13
  require_relative "reply_wait"
14
14
  require_relative "parent_report"
15
+ require_relative "turn_flow"
16
+ require_relative "guardrails/parent_approvals"
17
+ require_relative "guardrails/parent_policy"
15
18
  require_relative "image_store"
16
19
  require_relative "host_registry"
17
20
  require_relative "model_profile"
@@ -241,6 +244,14 @@ module Samagotchi
241
244
  if prompt && (local = local_command(prompt))
242
245
  return refuse_local_command(local, options)
243
246
  end
247
+
248
+ # A parent agent's /continue yes is chi answer --option Continue by
249
+ # another route: held to guardrails.parent_answers here, and marked
250
+ # as chi answer's (#client_id_for) so the worker checks it again.
251
+ if prompt && parent_continue?(prompt) && (refusal = Guardrails::ParentPolicy.continue_refusal)
252
+ @stderr.puts("chi send: #{refusal}")
253
+ return CLI::Exit::FAILED
254
+ end
244
255
  # A file that is not there or not an image: a run-time failure (1),
245
256
  # reported in the --format json line too; --image itself was fine.
246
257
  return CLI::Exit::FAILED unless ingest_images(options[:images])
@@ -414,7 +425,8 @@ module Samagotchi
414
425
  return report_command(session.id) if @sent_command
415
426
 
416
427
  wait_for_reply(session.id, cursor: nil, baseline: baseline_of(session, question_id: nil),
417
- timeout: options[:timeout], report_turn_id: @sent_enqueued_id)
428
+ timeout: options[:timeout], turn: @sent_input_file ? @sent_enqueued_id : nil,
429
+ report_turn_id: @sent_enqueued_id)
418
430
  end
419
431
 
420
432
  # The model the new session will run, for the images check: --model
@@ -461,11 +473,13 @@ module Samagotchi
461
473
  # The turn waits on a question: the message would only queue
462
474
  # behind it, and the answer goes in with chi answer.
463
475
  if (waiting = session.waiting_question(live: owner.worker?))
476
+ # The whole question (its options, its tags) for what a parent may give.
477
+ question = ParentReport.question(session.pending_question)
464
478
  if ParentReport.approval?(waiting)
465
- error_line("#{id[0, 8]} waiting for an approval: #{ParentReport::DENY_AND_TELL} " \
466
- "(#{ParentReport.answer_with(id, waiting)})")
479
+ error_line("#{id[0, 8]} waiting for an approval: #{ParentReport.approval_advice(question)} " \
480
+ "(#{ParentReport.answer_with(id, question)})")
467
481
  else
468
- error_line("#{id[0, 8]} waiting for an answer: #{ParentReport.answer_with(id, waiting)} (or --text); " \
482
+ error_line("#{id[0, 8]} waiting for an answer: #{ParentReport.answer_with(id, question)} (or --text); " \
469
483
  "chi --attach #{id} shows it")
470
484
  end
471
485
  else
@@ -485,7 +499,9 @@ module Samagotchi
485
499
  # The ack's id names the queued message's turn, also when the turn
486
500
  # running at the check ended before it went in (an idle worker's ack
487
501
  # says next_step, and the file still runs as that turn).
488
- turn = @queue ? @sent_enqueued_id : nil
502
+ # A file fallback has no delivery field; use its id to see terminal
503
+ # outcomes for commands that finish without starting a turn.
504
+ turn = @sent_enqueued_id if @queue || @sent_input_file
489
505
  report_turn_id = if @queue
490
506
  turn
491
507
  elsif session.status == Session::STATUS_RUNNING
@@ -493,7 +509,8 @@ module Samagotchi
493
509
  else
494
510
  @sent_enqueued_id
495
511
  end
496
- wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], turn: turn, queued: !turn.nil?,
512
+ wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], turn: turn,
513
+ queued: @queue && !turn.nil?,
497
514
  report_turn_id: report_turn_id)
498
515
  end
499
516
 
@@ -550,7 +567,7 @@ module Samagotchi
550
567
  begin
551
568
  refs = copy_images(dir)
552
569
  if BridgeClient.wait_for(session.id, session_dir: dir, timeout: SessionManager::TURN_BRIDGE_WAIT)
553
- result = SessionManager.deliver_turn(session.id, prompt: prompt, client_id: CLIENT_ID, images: refs,
570
+ result = SessionManager.deliver_turn(session.id, prompt: prompt, client_id: client_id_for(prompt), images: refs,
554
571
  state_dir: @state_dir)
555
572
  end
556
573
  rescue StandardError => e
@@ -569,6 +586,7 @@ module Samagotchi
569
586
  end
570
587
 
571
588
  ack = result[:ack].is_a?(Hash) ? result[:ack] : {}
589
+ @sent_input_file = !ack.key?("delivery")
572
590
  @sent_enqueued_id = (ack["enqueued_id"] || ack[:enqueued_id])&.to_s
573
591
  @sent_command = command_ack?(result[:ack])
574
592
  @info.puts("#{session.id} started#{continued_words(session)}#{with_images}#{"; #{SENT_COMMAND}" if @sent_command}")
@@ -581,6 +599,13 @@ module Samagotchi
581
599
  # /turn whose line is one answers with its command_id).
582
600
  def command_ack?(ack) = ack.is_a?(Hash) && !ack["command_id"].to_s.empty?
583
601
 
602
+ # chi send's client id, or chi answer's for a parent agent's /continue
603
+ # yes (the worker holds that to its guardrails.parent_answers).
604
+ def client_id_for(prompt) = parent_continue?(prompt) ? ClientId::CLI_ANSWER : CLIENT_ID
605
+
606
+ # A /continue yes (or bare /continue) from a parent agent's process.
607
+ def parent_continue?(prompt) = TurnFlow.continue_yes?(prompt) && Guardrails::ParentApprovals.parent_process?
608
+
584
609
  # Whether +text+ may be a session command, which only its worker knows
585
610
  # (a bundle's too).
586
611
  def command_like?(text) = text.to_s.lstrip.start_with?("/", "!")
@@ -648,7 +673,7 @@ module Samagotchi
648
673
  delivery = if @queue then Delivery::QUEUE
649
674
  elsif @cut && running then Delivery::CUT
650
675
  end
651
- result = SessionManager.deliver_turn(id, prompt: prompt, client_id: CLIENT_ID, images: refs, state_dir: @state_dir,
676
+ result = SessionManager.deliver_turn(id, prompt: prompt, client_id: client_id_for(prompt), images: refs, state_dir: @state_dir,
652
677
  delivery: delivery)
653
678
  unless result[:status] == :accepted
654
679
  # A worker older than queueing would steer it: refused, not sent.
@@ -664,6 +689,7 @@ module Samagotchi
664
689
  end
665
690
 
666
691
  ack = result[:ack].is_a?(Hash) ? result[:ack] : {}
692
+ @sent_input_file = !ack.key?("delivery")
667
693
  # What the worker applied, for --format json: its ack's delivery
668
694
  # (next_step for an idle session) and a cut's outcome.
669
695
  @sent_delivery = ack["delivery"] || delivery
@@ -14,6 +14,7 @@ require_relative "llm_context_command"
14
14
  require_relative "thinking_command"
15
15
  require_relative "toolkit_command"
16
16
  require_relative "reasoning_history_command"
17
+ require_relative "guardrails/parent_policy"
17
18
 
18
19
  module Samagotchi
19
20
  # The session commands a REPL and a session worker both run: /model,
@@ -80,7 +81,7 @@ module Samagotchi
80
81
  next reply("nothing to continue") unless @turn_flow.awaiting_continue?
81
82
 
82
83
  answer = text.delete_prefix(CONTINUE_COMMAND).strip
83
- continue_answer(answer.empty? ? CONTINUE_COMMAND : answer)
84
+ continue_answer(answer.empty? ? CONTINUE_COMMAND : answer, as_parent: @as_parent)
84
85
  end
85
86
  # It reads the hosts only (the registry is mutex-guarded): mid-turn too.
86
87
  registry.register(MODELS_COMMAND, "list the hosts' models (/models <text> filters)", anytime: true) do |text|
@@ -254,8 +255,12 @@ module Samagotchi
254
255
  # change asks first (CacheConfirm): true for a person at a web tab or
255
256
  # a TUI; false applies it at once (chi send, a delegate, a plugin,
256
257
  # chi -p)
258
+ # @param as_parent [Boolean] a parent agent sent it (client cli:answer):
259
+ # a /continue yes is held to guardrails.parent_answers, as chi
260
+ # answer --option Continue is (Guardrails::ParentPolicy)
257
261
  # @return [Result, nil] nil when +line+ is not one of these commands
258
- def run(line, confirm: true)
262
+ def run(line, confirm: true, as_parent: false)
263
+ @as_parent = as_parent
259
264
  text = line.to_s.strip
260
265
  entry = @registry.lookup(text)
261
266
  return nil unless entry&.handler
@@ -268,9 +273,15 @@ module Samagotchi
268
273
  end
269
274
 
270
275
  # Answer the pending continue offer (yes / no / no, <reason>).
276
+ # @param as_parent [Boolean] a parent agent's answer: a yes its strategy
277
+ # doesn't allow is refused (the offer stays; decision :invalid)
271
278
  # @return [Result] resume: true on yes
272
- def continue_answer(text)
279
+ def continue_answer(text, as_parent: false)
273
280
  decision, reason = TurnFlow.continue_decision(text)
281
+ if decision == :resume && as_parent && (refusal = Guardrails::ParentPolicy.continue_refusal)
282
+ return reply(refusal, status: :error).tap { |result| result.decision = :invalid }
283
+ end
284
+
274
285
  result = case decision
275
286
  when :resume then Result.new(status: :ok, changed: [], model_name: model_name, resume: true, shell: false)
276
287
  when :abort
@@ -77,7 +77,7 @@ module Samagotchi
77
77
  first_command: first_command, no_interrupt: no_interrupt,
78
78
  default_input: default_input && !prompt && !attach && !resume,
79
79
  wait_at_eof: !$stdin.tty?,
80
- parent_answers: Guardrails::ParentApprovals.parent_process?, state_dir: state_dir)
80
+ as_parent: Guardrails::ParentApprovals.parent_process?, state_dir: state_dir)
81
81
  ended = begin
82
82
  attached.run
83
83
  ensure
@@ -105,7 +105,7 @@ module Samagotchi
105
105
  # @param state_dir [String] the state dir the worker was found in: its
106
106
  # session folder (@path images, saved records) is under it
107
107
  def initialize(client:, screen:, client_id:, first_prompt: nil, first_command: nil, no_interrupt: false,
108
- default_input: false, wait_at_eof: false, parent_answers: false,
108
+ default_input: false, wait_at_eof: false, as_parent: false,
109
109
  clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
110
110
  delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) },
111
111
  archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) },
@@ -121,8 +121,8 @@ module Samagotchi
121
121
  @client_id = client_id
122
122
  # A parent agent drives this attach (AttachLauncher:
123
123
  # ParentApprovals.parent_process?): its answers go as chi answer's,
124
- # so the worker holds them to guardrails.parent_approvals.
125
- @parent_answers = parent_answers
124
+ # so the worker holds them to Guardrails::ParentPolicy.
125
+ @as_parent = as_parent
126
126
  @view = AttachedView.new(screen)
127
127
  @renderer = EventRenderer.new(@view)
128
128
  @shown_enqueued = Set.new
@@ -592,7 +592,10 @@ module Samagotchi
592
592
  # The worker runs it and every UI renders its :command_ran, this one
593
593
  # too, so nothing waits here.
594
594
  def send_command(line)
595
- reply = @client.post_command(line: line, client_id: @client_id)
595
+ # A parent agent's /continue yes goes as chi answer's: the worker
596
+ # holds it to guardrails.parent_answers.
597
+ client_id = @as_parent && TurnFlow.continue_yes?(line) ? ClientId::CLI_ANSWER : @client_id
598
+ reply = @client.post_command(line: line, client_id: client_id)
596
599
  if reply.status == 202
597
600
  # Input from a pipe ends once its output is in (#command_ran).
598
601
  command_id = reply.json&.fetch("command_id", nil)
@@ -990,7 +993,7 @@ module Samagotchi
990
993
  return @screen.commit(answer.error)
991
994
  end
992
995
 
993
- parent = @parent_answers ? { client_id: ClientId::CLI_ANSWER } : {}
996
+ parent = @as_parent ? { client_id: ClientId::CLI_ANSWER } : {}
994
997
  reply = @client.answer(id: @question.id, selected: answer.selected, freeform: answer.freeform, **parent)
995
998
  case reply.status
996
999
  when 200
@@ -1554,14 +1557,13 @@ module Samagotchi
1554
1557
  elsif !(own?(origin[:client_id]) || @shown_enqueued.include?(origin[:enqueued_id]))
1555
1558
  @screen.commit(prompt_line(origin[:client_id], event[:prompt]))
1556
1559
  end
1557
- # Another client's prompt line is up (here or at its enqueue); an
1558
- # own send has none, so its image lines are its echo.
1559
- @renderer.call(own?(origin[:client_id]) ? event : without_named_images(event, event[:prompt]))
1560
+ # The own-send echo already names an image-only prompt; another
1561
+ # client's prompt line does too, here or at its enqueue.
1562
+ @renderer.call(without_named_images(event, event[:prompt]))
1560
1563
  end
1561
1564
 
1562
- # The turn's start without its image lines when the prompt line shown
1563
- # for it is the web's "[image: name]" text of a turn of images alone:
1564
- # that line names them already.
1565
+ # The turn's start without its image lines when the shown prompt is
1566
+ # the image-only "[image: name]" placeholder: that line names them.
1565
1567
  def without_named_images(event, shown_prompt)
1566
1568
  ImageRef.placeholder_only?(shown_prompt) ? event.except(:images) : event
1567
1569
  end
@@ -11,9 +11,9 @@ module Samagotchi
11
11
  # ThinkingTicker (so it changes at most once per dwell). Both TUIs use it
12
12
  # in place of the raw tail of the stream.
13
13
  #
14
- # A chunk carries :text (and :thinking): both loops split their stream,
15
- # for every profile. A chunk without them (an older worker) has its raw
16
- # :content, markup stripped, stand for thinking. A switch of lane starts the next sentence from the new
14
+ # A chunk carries :text and :thinking: both loops split their stream,
15
+ # for every profile (the raw :content stays in the worker,
16
+ # Bridge::ClientEvent). A switch of lane starts the next sentence from the new
17
17
  # lane's text; the line shown keeps the label of the lane it came from.
18
18
  class ThinkingLine
19
19
  include Formatting
@@ -43,12 +43,8 @@ module Samagotchi
43
43
  # @return [Boolean] whether the line changed
44
44
  def chunk(event)
45
45
  # The Bridge's TurnAccumulator reads the lanes by the same rule.
46
- if event.key?(:text)
47
- changed = add(:thinking, event[:thinking])
48
- add(:writing, event[:text]) || changed
49
- else
50
- add(:thinking, event[:content], raw: true)
51
- end
46
+ changed = add(:thinking, event[:thinking])
47
+ add(:writing, event[:text]) || changed
52
48
  end
53
49
 
54
50
  # A turn joined mid-way: the newest thinking or text part so far.
@@ -295,7 +295,7 @@ module Samagotchi
295
295
  claim_session!(session.id) unless @owner_lock
296
296
  # A turn a worker died in (TurnCheckpoint): ended as canceled, with its
297
297
  # note, before this REPL's first turn appends after it.
298
- session.save if @resume_session && TurnCheckpoint.settle(session)
298
+ session.save if @resume_session && TurnCheckpoint.settle(session, session_dir: Session.session_dir(session.id))
299
299
  keep_cards(session)
300
300
  # Saved at once, so a process killed before its first turn leaves a
301
301
  # file the sweep knows to delete.
@@ -830,7 +830,7 @@ module Samagotchi
830
830
  end
831
831
 
832
832
  def answer_continue_offer(session, input)
833
- answer = @commands.continue_answer(input)
833
+ answer = @commands.continue_answer(input, as_parent: as_parent?)
834
834
  if answer.decision == :invalid
835
835
  # The read left no echo: the line shows above its error.
836
836
  @surface.commit("#{paint(QUESTION_PROMPT, 33)}#{input}")
@@ -876,7 +876,9 @@ module Samagotchi
876
876
  command = if command_registry.anytime?(input)
877
877
  @engine.running_anytime { @commands.run(input, confirm: confirm) }
878
878
  else
879
- @commands.run(input, confirm: confirm)
879
+ # A parent's /continue yes is held to guardrails.parent_answers.
880
+ parent = TurnFlow.continue_yes?(input) && as_parent? ? { as_parent: true } : {}
881
+ @commands.run(input, confirm: confirm, **parent)
880
882
  end
881
883
  if command
882
884
  show_command_result(command)
@@ -1579,15 +1581,16 @@ module Samagotchi
1579
1581
  end
1580
1582
 
1581
1583
  begin
1582
- parent = parent_answers? ? { client_id: ClientId::CLI_ANSWER } : {}
1584
+ parent = as_parent? ? { client_id: ClientId::CLI_ANSWER } : {}
1583
1585
  @engine.answer_question(id: prompt.id, selected: answer.selected, freeform: answer.freeform, **parent)
1584
1586
  close_question_widget(prompt, prompt.answer_text(answer))
1585
1587
  return true
1586
1588
  rescue QuestionDesk::Refused => e
1587
- # A parent's allow guardrails.parent_approvals doesn't let through:
1588
- # the question stays open for a deny (or the end of the input).
1589
+ # A parent's pick its strategy doesn't let through
1590
+ # (Guardrails::ParentPolicy): the question stays open for a deny
1591
+ # (or the end of the input).
1589
1592
  echo_choice(raw)
1590
- @surface.commit(Guardrails::ParentApprovals.message(e.reason, typed: true))
1593
+ @surface.commit(e.detail(typed: true))
1591
1594
  next
1592
1595
  rescue ArgumentError => e
1593
1596
  @surface.commit("Invalid: #{e.message}. Try again.")
@@ -1600,8 +1603,8 @@ module Samagotchi
1600
1603
  end
1601
1604
 
1602
1605
  # Whether the answers typed here are a parent agent's (piped stdin, an
1603
- # agent marker): QuestionDesk holds them to guardrails.parent_approvals.
1604
- def parent_answers?
1606
+ # agent marker): QuestionDesk holds them to Guardrails::ParentPolicy.
1607
+ def as_parent?
1605
1608
  Guardrails::ParentApprovals.parent_process?
1606
1609
  end
1607
1610
 
@@ -79,9 +79,6 @@ module Samagotchi
79
79
  "Samagotchi::Session.new_session" => "spec: #{SESSION} (ctx.delegate?, ctx.fork?, ctx.context read it)",
80
80
  "Samagotchi::ContextSources.session_location" => "spec: reads and removes the session's sources (added_by), " \
81
81
  "which ctx.context.list doesn't show",
82
- 'require "samagotchi/memory_bundle/installer"' => "spec: for Installer",
83
- "Samagotchi::MemoryBundle::Installer.new" => "spec: installs the bundle so its context provider resolves: " \
84
- "needs a kit install helper",
85
82
  'require "samagotchi/context_providers"' => "spec: for ContextProviders",
86
83
  "Samagotchi::ContextProviders.resolve" => "spec: the manifest's URL provider through chi's own resolver",
87
84
  'require "samagotchi/answer_display"' => "spec: for AnswerDisplay",
@@ -240,6 +240,13 @@ module Samagotchi
240
240
  failing { entry.handler.call({ name: name.to_s, args: stringify(args) }, nil) }
241
241
  end
242
242
 
243
+ # Install this bundle's dir into its sandbox as chi does and load the
244
+ # installed copy with chi's loaders (Install.run, with its settings):
245
+ # what a test of the installed bundle needs (its scripts, context
246
+ # providers), and the install warnings and load failures.
247
+ # @return [Install::Result]
248
+ def install(force: false) = Install.run(@dir, settings: @settings, sandbox: @sandbox, force: force)
249
+
243
250
  # Run the chi.init blocks in order, as chi's init tasks do.
244
251
  # @return [Array] their results
245
252
  def run_inits = failing { @inits.map { |init| init.block.call } }
@@ -27,8 +27,9 @@ module Samagotchi
27
27
  messages: -> { recorder[:messages] },
28
28
  messages_partial: -> { recorder[:messages_partial] },
29
29
  notify: ->(text, level, label, fallback_for: nil) { recorder.notify(text, level, label, fallback_for) },
30
- ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:|
31
- recorder.ask_user(question: question, options: options, header: header, allow_freeform: allow_freeform, hook: hook)
30
+ ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:, effects: nil|
31
+ recorder.ask_user(question: question, options: options, header: header, allow_freeform: allow_freeform, hook: hook,
32
+ effects: effects)
32
33
  },
33
34
  cancelled: -> { recorder[:cancelled] },
34
35
  card: ->(**card) { recorder.card(**card) },
@@ -88,8 +89,9 @@ module Samagotchi
88
89
  def build(recorder)
89
90
  Hooks::Runtime.new(
90
91
  notify: ->(text:, level:, hook:, fallback_for: nil) { recorder.notify(text, level, hook, fallback_for) },
91
- ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:|
92
- recorder.ask_user(question: question, options: options, header: header, allow_freeform: allow_freeform, hook: hook)
92
+ ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:, effects: nil|
93
+ recorder.ask_user(question: question, options: options, header: header, allow_freeform: allow_freeform, hook: hook,
94
+ effects: effects)
93
95
  },
94
96
  stop_turn: ->(reason:, hook:) { recorder.stop(:turn, reason, hook) },
95
97
  steer: ->(text:, hook:) { recorder.steer(text, hook) },
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../extension_load"
4
+ require_relative "../guardrails/load_failures"
5
+ require_relative "../memory_bundle/installer"
6
+ require_relative "../memory_bundle/provenance"
7
+ require_relative "../hooks"
8
+ require_relative "bundle"
9
+ require_relative "sandbox"
10
+ require_relative "recorder"
11
+ require_relative "host"
12
+
13
+ module Samagotchi
14
+ module Testing
15
+ # A bundle as chi installs and loads it, unlike Bundle (its source as it
16
+ # is): MemoryBundle::Installer copies it into the sandbox's state (sha256
17
+ # and requires_chi checked), then chi's own loaders load what is
18
+ # installed (ExtensionLoad: the bundle hooks, then the plugins). What
19
+ # failed names the bundle's source files, not the installed copies.
20
+ #
21
+ # result = Samagotchi::Testing::Install.run("path/to/bundle", settings: { "every" => 3 })
22
+ # result.warnings, result.plugin_failures, result.hook_failures, result.registered[:commands]
23
+ module Install
24
+ # warnings: the Installer's; plugin_failures / hook_failures:
25
+ # [Guardrails::LoadFailures::Failure] of this bundle, each location
26
+ # in the source dir; registered: what it registered (#registered).
27
+ Result = Data.define(:name, :warnings, :plugin_failures, :hook_failures, :registered) do
28
+ def failures = plugin_failures + hook_failures
29
+ def ok? = failures.empty?
30
+ end
31
+
32
+ module_function
33
+
34
+ # Install the bundle at +dir+ into +sandbox+ (default Sandbox.current,
35
+ # else one of its own, left before returning) and load it.
36
+ # @param settings [Hash] config.yml `bundles: <name>:` (written to the
37
+ # sandbox's config.yml)
38
+ # @param force [Boolean] the Installer's --force (a name chi ships)
39
+ # @return [Result]
40
+ # @raise [MemoryBundle::Installer::InstallError] what the Installer refused
41
+ def run(dir, settings: {}, sandbox: nil, force: false)
42
+ return Sandbox.with { |own| run(dir, settings: settings, sandbox: own, force: force) } if sandbox.nil? && Sandbox.current.nil?
43
+
44
+ sandbox ||= Sandbox.current
45
+ dir = File.expand_path(dir)
46
+ name = MemoryBundle::Manifest.read(dir: dir).name
47
+ sandbox.config("bundles" => { name => settings })
48
+ installer = MemoryBundle::Installer.new(source: dir, name: name, scope: "system", force: force)
49
+ installer.run
50
+ load_installed(name, dir, installer.warnings, sandbox)
51
+ end
52
+
53
+ def load_installed(name, dir, warnings, sandbox)
54
+ inits = []
55
+ # A session's values, as an Engine's host gives them (only what the loaders register matters).
56
+ host = Host.build(Recorder.new(cwd: sandbox.work_dir))
57
+ hook_failures = Guardrails::LoadFailures.new
58
+ plugin_failures = Guardrails::LoadFailures.new
59
+ extension = ExtensionLoad.new(hook_failures: hook_failures)
60
+ registries = Plugin::Registries.new(
61
+ commands: SessionCommands.register_builtins(Commands::Registry.new),
62
+ tools: Tools::Builtins.registry, hooks: extension.hooks,
63
+ context_for: lambda { |bundle, bundle_settings, label|
64
+ Plugin::Context.new(bundle: bundle, label: label, settings: bundle_settings, host: host)
65
+ },
66
+ services: Plugin::Services.new, stage_tools: ->(*) {},
67
+ init: ->(_bundle, label, _plugin_label, **, &_block) { inits << { label: label } },
68
+ toolkits: [], tool_index: Tools::ToolIndex.new, peer_sources: PeerSources.new
69
+ )
70
+ extension.load_plugins(registries, failures: plugin_failures)
71
+ installed = File.join(MemoryBundle::Provenance.bundles_dir, name)
72
+ Result.new(name: name, warnings: warnings.dup,
73
+ plugin_failures: own(plugin_failures, name, installed, dir),
74
+ hook_failures: own(hook_failures, name, installed, dir),
75
+ registered: registered(registries, name, inits))
76
+ ensure
77
+ registries&.services&.stop_all
78
+ end
79
+ private_class_method :load_installed
80
+
81
+ # What bundle +name+ registered, from the registries it loaded into:
82
+ # commands ([name, mid_turn, annotation labels]), tools, hooks per
83
+ # event ([bundle, hook, priority]; Hooks::EVENTS, then any other event
84
+ # the bundle registered for), inits, toolkits, tool sources and
85
+ # peer sources.
86
+ # @param inits [Array<Hash>] {label:} of each chi.init
87
+ # @return [Hash]
88
+ def registered(registries, name, inits)
89
+ {
90
+ commands: registries.commands.entries.select { |e| e.source == name }.map { |e| [e.name, e.mid_turn, e.annotations.map(&:label)] },
91
+ tools: registries.tools.by_source(name).map(&:name),
92
+ hooks: hooks_of(registries.hooks, name),
93
+ inits: inits.map { |init| init[:label] },
94
+ toolkits: Array(registries.toolkits).map(&:name),
95
+ tool_sources: registries.tool_index.sources.map(&:label),
96
+ peer_sources: registries.peer_sources.names
97
+ }
98
+ end
99
+
100
+ def hooks_of(hooks, name)
101
+ own = ->(event) { hooks.bundle_hook_names(event).select { |bundle, _, _| bundle == name } }
102
+ others = (hooks.bundle_events - Hooks::EVENTS).to_h { |event| [event, own.call(event)] }.reject { |_, list| list.empty? }
103
+ Hooks::EVENTS.to_h { |event| [event, own.call(event)] }.merge(others)
104
+ end
105
+ private_class_method :hooks_of
106
+
107
+ # +failures+' entries for bundle +name+, their location moved from the
108
+ # installed copy to the source dir.
109
+ def own(failures, name, installed, dir)
110
+ failures.list.select { |f| f.what.end_with?("(bundle #{name})") }.map do |failure|
111
+ # The plugin is installed into plugin/, the hooks into hooks/.
112
+ location = failure.location&.sub(%r{\A#{Regexp.escape(installed)}/(?:plugin/)?}, "#{dir}/")
113
+ failure.dup.tap { |copy| copy.location = location }
114
+ end
115
+ end
116
+ private_class_method :own
117
+ end
118
+ end
119
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../plugin/card"
4
4
  require_relative "../plugin/context"
5
+ require_relative "../guardrails/parent_policy"
5
6
 
6
7
  module Samagotchi
7
8
  module Testing
@@ -12,8 +13,11 @@ module Samagotchi
12
13
  Steer = Data.define(:text, :hook)
13
14
  # A stop_turn or stop_generation (+kind+ :turn or :generation).
14
15
  Stop = Data.define(:kind, :reason, :hook)
15
- # An ask_user question.
16
- Question = Data.define(:question, :options, :header, :allow_freeform, :hook)
16
+ # An ask_user question; effects: its options' tags as the bundle gave
17
+ # them (label => effect), nil for none.
18
+ Question = Data.define(:question, :options, :header, :allow_freeform, :hook, :effects) do
19
+ def initialize(effects: nil, **) = super
20
+ end
17
21
  # An ask_model request: the request Context built (SideQuestion), its
18
22
  # limits, cancel controller and the model: it named (nil: the session's;
19
23
  # the kit doesn't check it against any hosts), and +source+, the asking
@@ -100,9 +104,14 @@ module Samagotchi
100
104
  end
101
105
 
102
106
  # The next queued answer (#answers), nil with none (no one to ask).
103
- def ask_user(question:, options:, header:, allow_freeform:, hook:)
107
+ # Effects chi would refuse (a label the options don't have, an
108
+ # unknown effect: Guardrails::ParentPolicy.option_tags) ask no one, as
109
+ # in chi: nil, nothing recorded, no answer taken.
110
+ def ask_user(question:, options:, header:, allow_freeform:, hook:, effects: nil)
111
+ return nil if Guardrails::ParentPolicy.option_tags(options, effects) == false
112
+
104
113
  record(@questions, Question.new(question: question, options: options, header: header,
105
- allow_freeform: allow_freeform, hook: hook))
114
+ allow_freeform: allow_freeform, hook: hook, effects: effects))
106
115
  answer = @mutex.synchronize { @answers.shift }
107
116
  answer.respond_to?(:call) ? answer.call(@questions.last) : answer
108
117
  end
@@ -22,5 +22,6 @@ require_relative "testing/recording_api"
22
22
  require_relative "testing/waiting"
23
23
  require_relative "testing/fake_executable"
24
24
  require_relative "testing/bundle"
25
+ require_relative "testing/install"
25
26
  require_relative "testing/helpers"
26
27
  require_relative "testing/boundary"
@@ -118,7 +118,8 @@ module Samagotchi
118
118
 
119
119
  # What execute and task_create export into the commands they run: a
120
120
  # chi started there answers as a parent agent
121
- # (Guardrails::ParentApprovals.parent_process?), even under a PTY
121
+ # (Guardrails::ParentApprovals.parent_process?, held to
122
+ # Guardrails::ParentPolicy), even under a PTY
122
123
  # wrapper. The session's id, "chi" without one. And the model the
123
124
  # session runs on (SAMAGOTCHI_SESSION_MODEL, read live, so right after
124
125
  # /model), which chi self reports; unset when unknown, so a value
@@ -305,10 +305,15 @@ module Samagotchi
305
305
  # The tool's last line follows #reports_mode.
306
306
  def waiting_text(child_id, pending, report: false, reports: "off")
307
307
  text = "Child #{child_id} is #{ParentReport.question_text(pending, session_id: child_id)}"
308
- if ParentReport.continue?(pending)
308
+ if ParentReport.continue?(pending) && ParentReport.may_continue?(pending)
309
309
  text += "The child ran out of steps before it answered. Decide: continue it (run the chi answer command " \
310
310
  "above with execute) if it is getting somewhere; send it a narrower follow-up with delegate " \
311
311
  "session: #{child_id} (that drops the question); or stop it and report back to your user.\n"
312
+ elsif ParentReport.continue?(pending)
313
+ # guardrails.parent_answers: hard: continuing is the user's.
314
+ text += "The child ran out of steps before it answered. Decide: send it a narrower follow-up with delegate " \
315
+ "session: #{child_id} (that drops the question); or stop it (the chi answer Stop command above, " \
316
+ "with execute) and report back to your user, who may continue it.\n"
312
317
  end
313
318
  return "#{text}#{REPORT_NEXT_REPLY}" if report
314
319
  return "#{text}delegate_result #{child_id} waits again once it is answered." if reports == "off"