samagotchi 0.85.0 → 0.87.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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -1
  3. data/bin/chi +39 -2
  4. data/docs/architecture.md +36 -5
  5. data/docs/bundle-authoring.md +125 -0
  6. data/docs/cli.md +51 -3
  7. data/docs/configuration.md +6 -5
  8. data/docs/guardrails.md +112 -39
  9. data/docs/hooks.md +19 -3
  10. data/docs/internals/anthropic.md +2 -2
  11. data/docs/internals/bundles.md +11 -2
  12. data/docs/internals/context-telemetry.md +19 -1
  13. data/docs/internals/engine.md +1 -1
  14. data/docs/memory.md +1 -1
  15. data/docs/plugins.md +59 -2
  16. data/docs/releasing.md +1 -1
  17. data/docs/sessions.md +19 -7
  18. data/docs/sub-agent.md +39 -25
  19. data/docs/testing.md +7 -2
  20. data/lib/samagotchi/answer_command.rb +21 -25
  21. data/lib/samagotchi/bridge/client_event.rb +50 -0
  22. data/lib/samagotchi/bridge/sse_writer.rb +6 -2
  23. data/lib/samagotchi/bridge.rb +22 -11
  24. data/lib/samagotchi/bridge_client.rb +2 -2
  25. data/lib/samagotchi/bundle_command/check.rb +348 -0
  26. data/lib/samagotchi/bundle_command/scaffold.rb +135 -0
  27. data/lib/samagotchi/bundle_command/test_run.rb +89 -0
  28. data/lib/samagotchi/bundle_command.rb +125 -2
  29. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
  31. data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
  32. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -2
  34. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  35. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -3
  36. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  37. data/lib/samagotchi/bundles/ruby/manifest.yml +11 -0
  38. data/lib/samagotchi/bundles/ruby/plugin.rb +74 -0
  39. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  40. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  41. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  42. data/lib/samagotchi/cli/parent_wait.rb +3 -1
  43. data/lib/samagotchi/client_id.rb +1 -1
  44. data/lib/samagotchi/config.rb +195 -115
  45. data/lib/samagotchi/context_status.rb +1 -1
  46. data/lib/samagotchi/continue_offer.rb +2 -1
  47. data/lib/samagotchi/engine.rb +4 -4
  48. data/lib/samagotchi/guardrail_wiring.rb +4 -0
  49. data/lib/samagotchi/guardrails/approval.rb +12 -2
  50. data/lib/samagotchi/guardrails/load_failures.rb +17 -3
  51. data/lib/samagotchi/guardrails/parent_approvals.rb +14 -70
  52. data/lib/samagotchi/guardrails/parent_policy.rb +327 -0
  53. data/lib/samagotchi/guardrails/verdict.rb +6 -3
  54. data/lib/samagotchi/hook_routes.rb +5 -3
  55. data/lib/samagotchi/hooks/bundle_loader.rb +3 -1
  56. data/lib/samagotchi/hooks/registry.rb +13 -6
  57. data/lib/samagotchi/hooks.rb +5 -0
  58. data/lib/samagotchi/kernel_loop.rb +5 -2
  59. data/lib/samagotchi/llm/anthropic_assembly.rb +1 -1
  60. data/lib/samagotchi/memory_bundle/shas.rb +127 -0
  61. data/lib/samagotchi/memory_bundle.rb +1 -0
  62. data/lib/samagotchi/parent_answers_command.rb +80 -0
  63. data/lib/samagotchi/parent_report.rb +122 -30
  64. data/lib/samagotchi/pending_question.rb +8 -4
  65. data/lib/samagotchi/plugin/api.rb +16 -0
  66. data/lib/samagotchi/plugin/context.rb +11 -5
  67. data/lib/samagotchi/plugin/loader.rb +5 -3
  68. data/lib/samagotchi/question_desk.rb +39 -25
  69. data/lib/samagotchi/queued_command.rb +5 -4
  70. data/lib/samagotchi/relay.rb +5 -1
  71. data/lib/samagotchi/relay_verifier.rb +4 -5
  72. data/lib/samagotchi/reply_wait.rb +14 -5
  73. data/lib/samagotchi/send_command.rb +103 -12
  74. data/lib/samagotchi/session.rb +26 -2
  75. data/lib/samagotchi/session_commands.rb +45 -8
  76. data/lib/samagotchi/session_setup.rb +10 -8
  77. data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -8
  78. data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -12
  79. data/lib/samagotchi/terminal_ui/thinking_line.rb +5 -9
  80. data/lib/samagotchi/terminal_ui.rb +20 -11
  81. data/lib/samagotchi/testing/boundary.rb +0 -3
  82. data/lib/samagotchi/testing/bundle.rb +7 -0
  83. data/lib/samagotchi/testing/host.rb +6 -4
  84. data/lib/samagotchi/testing/install.rb +119 -0
  85. data/lib/samagotchi/testing/recorder.rb +13 -4
  86. data/lib/samagotchi/testing.rb +1 -0
  87. data/lib/samagotchi/tools/builtins.rb +2 -1
  88. data/lib/samagotchi/tools/delegate.rb +4 -1
  89. data/lib/samagotchi/tools/delegate_wait.rb +16 -7
  90. data/lib/samagotchi/tools/task_runtime.rb +5 -3
  91. data/lib/samagotchi/turn_checkpoint.rb +64 -2
  92. data/lib/samagotchi/turn_flow.rb +10 -0
  93. data/lib/samagotchi/version.rb +1 -1
  94. data/lib/samagotchi/web/app.rb +48 -10
  95. data/lib/samagotchi/web/public/chunk_router.js +8 -19
  96. data/lib/samagotchi/web/public/ctx.js +12 -26
  97. data/lib/samagotchi/web/public/stream_handlers.js +1 -1
  98. data/lib/samagotchi/web/server.rb +4 -0
  99. data/lib/samagotchi/worker.rb +27 -5
  100. data/lib/samagotchi/worker_inbound.rb +4 -4
  101. metadata +12 -2
  102. data/lib/samagotchi/guardrails/parent_continue.rb +0 -43
@@ -13,7 +13,9 @@ require_relative "commands/registry"
13
13
  require_relative "llm_context_command"
14
14
  require_relative "thinking_command"
15
15
  require_relative "toolkit_command"
16
+ require_relative "parent_answers_command"
16
17
  require_relative "reasoning_history_command"
18
+ require_relative "guardrails/parent_policy"
17
19
 
18
20
  module Samagotchi
19
21
  # The session commands a REPL and a session worker both run: /model,
@@ -34,6 +36,7 @@ module Samagotchi
34
36
  LLM_CONTEXT_COMMAND = LLMContextCommand::NAME
35
37
  THINKING_COMMAND = ThinkingCommand::NAME
36
38
  TOOLKIT_COMMAND = ToolkitCommand::NAME
39
+ PARENT_ANSWERS_COMMAND = ParentAnswersCommand::NAME
37
40
  REASONING_HISTORY_COMMAND = ReasoningHistoryCommand::NAME
38
41
  # A remote catalog has hundreds of ids (OpenRouter ~380): plain /models
39
42
  # shows this many per host; /models <text> lists every match.
@@ -76,12 +79,8 @@ module Samagotchi
76
79
  id: :rollback) { |_text| rollback }
77
80
  registry.register("!", "run a shell command; its output goes into the conversation",
78
81
  id: :shell, mid_turn: :queue, match: ->(text) { text.match?(/\A!\s*\S/) }) { |text| shell(text) }
79
- registry.register(CONTINUE_COMMAND, "answer the continue offer (yes, no or no, <reason>)") do |text|
80
- next reply("nothing to continue") unless @turn_flow.awaiting_continue?
81
-
82
- answer = text.delete_prefix(CONTINUE_COMMAND).strip
83
- continue_answer(answer.empty? ? CONTINUE_COMMAND : answer)
84
- end
82
+ # #run passes who sent it (as_parent:) per call.
83
+ registry.register(CONTINUE_COMMAND, "answer the continue offer (yes, no or no, <reason>)") { |text| continue_command(text) }
85
84
  # It reads the hosts only (the registry is mutex-guarded): mid-turn too.
86
85
  registry.register(MODELS_COMMAND, "list the hosts' models (/models <text> filters)", anytime: true) do |text|
87
86
  reply(models_listing(text.delete_prefix(MODELS_COMMAND).strip))
@@ -108,6 +107,11 @@ module Samagotchi
108
107
  registry.register(TOOLKIT_COMMAND, "show or change the tools this session declares (NAME, add TOOL, remove TOOL)",
109
108
  id: :toolkit, mid_turn: show_mid_turn(TOOLKIT_COMMAND),
110
109
  match: ->(text) { text.match?(%r{\A/toolkit(?:\s+.*)?\z}) }) { |text| toolkit(text) }
110
+ # Tighten-only from anyone; loosening is a user's at a UI (#run's user_ui:).
111
+ registry.register(PARENT_ANSWERS_COMMAND, "show or set what a parent agent may answer here (hard|standard|soft|default; " \
112
+ "never looser than config.yml's)",
113
+ id: :parent_answers, mid_turn: show_mid_turn(PARENT_ANSWERS_COMMAND),
114
+ match: ->(text) { text.match?(%r{\A/parent-answers(?:\s+.*)?\z}) }) { |text| parent_answers(text) }
111
115
  registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
112
116
  registry.register("/stats", "show the session's stats", local: true)
113
117
  registry.register("/recap", "show the session's recap", local: true)
@@ -254,8 +258,14 @@ module Samagotchi
254
258
  # change asks first (CacheConfirm): true for a person at a web tab or
255
259
  # a TUI; false applies it at once (chi send, a delegate, a plugin,
256
260
  # chi -p)
261
+ # @param as_parent [Boolean] a parent agent sent it (client cli:answer):
262
+ # a /continue yes is held to guardrails.parent_answers, as chi
263
+ # answer --option Continue is (Guardrails::ParentPolicy)
264
+ # @param user_ui [Boolean] a user at a UI sent it (the REPL, an attached
265
+ # TUI, the web; not a piped or marked one): only such a line loosens
266
+ # /parent-answers
257
267
  # @return [Result, nil] nil when +line+ is not one of these commands
258
- def run(line, confirm: true)
268
+ def run(line, confirm: true, as_parent: false, user_ui: false)
259
269
  text = line.to_s.strip
260
270
  entry = @registry.lookup(text)
261
271
  return nil unless entry&.handler
@@ -263,14 +273,33 @@ module Samagotchi
263
273
 
264
274
  # The two that may ask take the flag; the rest never ask.
265
275
  return send(entry.id, text, confirm: confirm) if CONFIRMING.include?(entry.id)
276
+ # Who sent it goes to its handler per call, never through an ivar:
277
+ # an anytime command runs on another thread against this instance.
278
+ return continue_command(text, as_parent: as_parent) if entry.id == :continue
279
+ return parent_answers(text, user_ui: user_ui) if entry.id == :parent_answers
266
280
 
267
281
  instance_exec(text, &entry.handler)
268
282
  end
269
283
 
284
+ # /continue [yes|no|no, <reason>]: the continue offer's answer.
285
+ def continue_command(text, as_parent: false)
286
+ return reply("nothing to continue") unless @turn_flow.awaiting_continue?
287
+
288
+ answer = text.delete_prefix(CONTINUE_COMMAND).strip
289
+ continue_answer(answer.empty? ? CONTINUE_COMMAND : answer, as_parent: as_parent)
290
+ end
291
+
270
292
  # Answer the pending continue offer (yes / no / no, <reason>).
293
+ # @param as_parent [Boolean] a parent agent's answer: a yes its strategy
294
+ # doesn't allow is refused (the offer stays; decision :invalid)
271
295
  # @return [Result] resume: true on yes
272
- def continue_answer(text)
296
+ def continue_answer(text, as_parent: false)
273
297
  decision, reason = TurnFlow.continue_decision(text)
298
+ if decision == :resume && as_parent &&
299
+ (refusal = Guardrails::ParentPolicy.continue_refusal(strategy: Guardrails::ParentPolicy.for_session(@engine.session)))
300
+ return reply(refusal, status: :error).tap { |result| result.decision = :invalid }
301
+ end
302
+
274
303
  result = case decision
275
304
  when :resume then Result.new(status: :ok, changed: [], model_name: model_name, resume: true, shell: false)
276
305
  when :abort
@@ -485,6 +514,14 @@ module Samagotchi
485
514
  reply(e.message, status: :error)
486
515
  end
487
516
 
517
+ # /parent-answers (ParentAnswersCommand); :parent_answers in changed
518
+ # when it set the session's own level.
519
+ def parent_answers(text, user_ui: false)
520
+ output, changed, status = ParentAnswersCommand.new(engine: @engine, save: ->(session) { save_setup(session) })
521
+ .run(text.delete_prefix(PARENT_ANSWERS_COMMAND), user_ui: user_ui)
522
+ reply(output, status: status, changed: changed ? [:parent_answers] : [])
523
+ end
524
+
488
525
  # A session setting changed (/llm-context, /thinking, /reasoning-history):
489
526
  # saved with its model.
490
527
  def save_setup(session)
@@ -18,24 +18,26 @@ module Samagotchi
18
18
  # +toolkit+ a Toolkit or nil (SessionManager.spawn_session gives the
19
19
  # session config's toolkit.default then), +bundle_settings+ the bundle
20
20
  # settings chi --set gave ({bundle => {key => value}}, frozen; {} with
21
- # none), which Plugin::Context#settings lays over config.yml's.
21
+ # none), which Plugin::Context#settings lays over config.yml's,
22
+ # +parent_answers+ the session's own guardrails.parent_answers (a level
23
+ # String, tighten-only: Guardrails::ParentPolicy.effective) or nil.
22
24
  #
23
25
  # It doesn't save itself: the session keeps each setting in its own field
24
26
  # and file key, and resolves it on its own. A delegate child starts
25
- # without one (but the toolkit its delegate call named); a continue and a
26
- # fork copy their session's (.of).
27
+ # without one (but the toolkit its delegate call named, and its parent's
28
+ # parent_answers); a continue and a fork copy their session's (.of).
27
29
  SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history, :appended_prompt_files, :toolkit,
28
- :bundle_settings) do
30
+ :bundle_settings, :parent_answers) do
29
31
  def initialize(llm_context: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [], toolkit: nil,
30
- bundle_settings: {})
32
+ bundle_settings: {}, parent_answers: nil)
31
33
  super(llm_context: llm_context, thinking: thinking, reasoning_history: reasoning_history,
32
34
  appended_prompt_files: Array(appended_prompt_files).map(&:to_s).freeze, toolkit: toolkit,
33
- bundle_settings: BundleSessionSettings.normalize(bundle_settings))
35
+ bundle_settings: BundleSessionSettings.normalize(bundle_settings), parent_answers: parent_answers&.to_s)
34
36
  end
35
37
 
36
38
  def empty?
37
39
  llm_context.nil? && thinking.nil? && reasoning_history.nil? && appended_prompt_files.empty? && toolkit.nil? &&
38
- bundle_settings.empty?
40
+ bundle_settings.empty? && parent_answers.nil?
39
41
  end
40
42
 
41
43
  # A session's own setup, for a session that starts from it. Its
@@ -48,7 +50,7 @@ module Samagotchi
48
50
 
49
51
  new(llm_context: session.llm_context, thinking: session.thinking, reasoning_history: session.reasoning_history,
50
52
  appended_prompt_files: session.appended_prompt_files, toolkit: Toolkit.of(session.toolkit),
51
- bundle_settings: session.bundle_settings)
53
+ bundle_settings: session.bundle_settings, parent_answers: session.parent_answers)
52
54
  end
53
55
  end
54
56
  end
@@ -53,6 +53,10 @@ module Samagotchi
53
53
  # the line an existing session's run prints (--set too)
54
54
  # @param bundle_settings [Hash] --set's values (BundleSessionSettings):
55
55
  # a new session's setup carries them; an existing session keeps its own
56
+ # @param parent_answers [String, nil] --parent-answers: a new session's
57
+ # own guardrails.parent_answers; an existing session keeps its own
58
+ # (never sent as a /parent-answers command: that would be a
59
+ # loosening route from a command line)
56
60
  # @param state_dir [String, nil] where the sessions are (the default
57
61
  # state dir by default): the worker's, for the attached loop too
58
62
  # @return [Symbol] :detached, :closed when the worker went away,
@@ -60,12 +64,12 @@ module Samagotchi
60
64
  # pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
61
65
  def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
62
66
  memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], toolkit: nil,
63
- toolkit_flags: [], bundle_settings: {}, state_dir: nil)
67
+ toolkit_flags: [], bundle_settings: {}, parent_answers: nil, state_dir: nil)
64
68
  state_dir ||= Session.default_state_dir
65
69
  client = connect(attach: attach, shared: shared, resume: resume, model: model, state_dir: state_dir,
66
70
  memories: memories, muted_memories: muted_memories, llm_context: llm_context, thinking: thinking,
67
71
  appended_prompt_files: appended_prompt_files, toolkit: toolkit, toolkit_flags: toolkit_flags,
68
- bundle_settings: bundle_settings)
72
+ bundle_settings: bundle_settings, parent_answers: parent_answers)
69
73
  commands = []
70
74
  commands << "/model #{model}" if model && (attach || resume)
71
75
  commands << LLMContextOverride.command_line(llm_context) if (attach || resume) && !llm_context.empty?
@@ -77,13 +81,15 @@ module Samagotchi
77
81
  first_command: first_command, no_interrupt: no_interrupt,
78
82
  default_input: default_input && !prompt && !attach && !resume,
79
83
  wait_at_eof: !$stdin.tty?,
80
- parent_answers: Guardrails::ParentApprovals.parent_process?, state_dir: state_dir)
84
+ as_parent: Guardrails::ParentApprovals.parent_process?, state_dir: state_dir)
81
85
  ended = begin
82
86
  attached.run
83
87
  ensure
84
88
  close_surface(surface)
85
89
  end
86
- report_unanswered(attached.unanswered, session_id: client.session_id) if ended == :unanswered && attached.unanswered
90
+ if ended == :unanswered && attached.unanswered
91
+ report_unanswered(attached.unanswered, session_id: client.session_id, state_dir: state_dir)
92
+ end
87
93
  report_ended(ended)
88
94
  ended
89
95
  end
@@ -92,9 +98,12 @@ module Samagotchi
92
98
  # --wait` prints it (ParentReport), so a script or a parent agent
93
99
  # can answer it with chi answer.
94
100
  # @param pending [Hash] the pending question as the Bridge sent it
95
- def report_unanswered(pending, session_id:, err: $stderr)
101
+ # @param state_dir [String, nil] where the session file is: what a
102
+ # parent may pick follows its own parent_answers too
103
+ def report_unanswered(pending, session_id:, err: $stderr, state_dir: nil)
96
104
  question = PendingQuestion.from_file(pending)
97
- err.print("chi: #{ParentReport.question_text(question, session_id: session_id)}")
105
+ strategy = ParentReport.strategy_for(session_id, state_dir: state_dir || Session.default_state_dir)
106
+ err.print("chi: #{ParentReport.question_text(question, session_id: session_id, strategy: strategy)}")
98
107
  err.flush
99
108
  end
100
109
 
@@ -134,7 +143,7 @@ module Samagotchi
134
143
  # @raise [Error]
135
144
  def connect(attach: nil, shared: false, resume: nil, model: nil, state_dir: nil, wait: BRIDGE_WAIT,
136
145
  memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], toolkit: nil,
137
- toolkit_flags: [], bundle_settings: {}, err: $stderr)
146
+ toolkit_flags: [], bundle_settings: {}, parent_answers: nil, err: $stderr)
138
147
  sd = state_dir || Session.default_state_dir
139
148
  if attach || resume
140
149
  warn_memory_flags_ignored(attach || resume, memories, muted_memories, err, appended_prompt_files: appended_prompt_files,
@@ -156,7 +165,8 @@ module Samagotchi
156
165
  setup: SessionSetup.new(llm_context: llm_context.empty? ? nil : LLMContextOverride.update(nil, llm_context),
157
166
  thinking: Thinking.session_level(thinking),
158
167
  appended_prompt_files: appended_prompt_files,
159
- toolkit: toolkit, bundle_settings: bundle_settings))
168
+ toolkit: toolkit, bundle_settings: bundle_settings,
169
+ parent_answers: parent_answers))
160
170
  end
161
171
  client = BridgeClient.wait_for(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
162
172
  client || raise(Error, "the worker for session #{session.id} did not start its Bridge in time")
@@ -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,7 @@ 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
+ reply = @client.post_command(line: line, client_id: client_id_for(line))
596
596
  if reply.status == 202
597
597
  # Input from a pipe ends once its output is in (#command_ran).
598
598
  command_id = reply.json&.fetch("command_id", nil)
@@ -805,13 +805,27 @@ module Samagotchi
805
805
  steer_cut_note(ack["cut"].to_s.to_sym, ack["cut_after"])
806
806
  end
807
807
 
808
+ # The client id +line+ goes with, as a command (#send_command) or as a
809
+ # message the Bridge may run as one (#send_prompt: /cut and /queue
810
+ # text). A parent agent's /continue yes and /parent-answers go as chi
811
+ # answer's: the worker holds the first to guardrails.parent_answers and
812
+ # never takes the second as a user's loosening. The command is the
813
+ # registry's own match (the worker's), not a pattern of ours.
814
+ def client_id_for(line)
815
+ return @client_id unless @as_parent
816
+ return ClientId::CLI_ANSWER if TurnFlow.continue_yes?(line)
817
+ return ClientId::CLI_ANSWER if command_registry.lookup(line)&.id == :parent_answers
818
+
819
+ @client_id
820
+ end
821
+
808
822
  def send_prompt(text, delivery: nil)
809
823
  persist_recent_history(text)
810
824
  images = attach_images(text)
811
825
  return own_prompt_failed if images.nil?
812
826
 
813
827
  running = @running
814
- options = { prompt: text, client_id: @client_id }
828
+ options = { prompt: text, client_id: client_id_for(text) }
815
829
  options[:no_interrupt] = true if @no_interrupt
816
830
  options[:images] = images unless images.empty?
817
831
  options[:delivery] = delivery if delivery
@@ -990,7 +1004,7 @@ module Samagotchi
990
1004
  return @screen.commit(answer.error)
991
1005
  end
992
1006
 
993
- parent = @parent_answers ? { client_id: ClientId::CLI_ANSWER } : {}
1007
+ parent = @as_parent ? { client_id: ClientId::CLI_ANSWER } : {}
994
1008
  reply = @client.answer(id: @question.id, selected: answer.selected, freeform: answer.freeform, **parent)
995
1009
  case reply.status
996
1010
  when 200
@@ -1554,14 +1568,13 @@ module Samagotchi
1554
1568
  elsif !(own?(origin[:client_id]) || @shown_enqueued.include?(origin[:enqueued_id]))
1555
1569
  @screen.commit(prompt_line(origin[:client_id], event[:prompt]))
1556
1570
  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]))
1571
+ # The own-send echo already names an image-only prompt; another
1572
+ # client's prompt line does too, here or at its enqueue.
1573
+ @renderer.call(without_named_images(event, event[:prompt]))
1560
1574
  end
1561
1575
 
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.
1576
+ # The turn's start without its image lines when the shown prompt is
1577
+ # the image-only "[image: name]" placeholder: that line names them.
1565
1578
  def without_named_images(event, shown_prompt)
1566
1579
  ImageRef.placeholder_only?(shown_prompt) ? event.except(:images) : event
1567
1580
  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.
@@ -105,10 +105,14 @@ module Samagotchi
105
105
  # @param bundle_settings [Hash] --set's values (BundleSessionSettings):
106
106
  # a new session's own; a resumed one keeps its own (--set is in
107
107
  # +toolkit_flags+ then)
108
+ # @param parent_answers [String, nil] --parent-answers: a new session's
109
+ # own guardrails.parent_answers; a resumed one keeps its own (named in
110
+ # +toolkit_flags+ then)
108
111
  def initialize(prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
109
112
  spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {}, thinking: nil,
110
- appended_prompt_files: [], toolkit: nil, toolkit_flags: [], bundle_settings: {})
113
+ appended_prompt_files: [], toolkit: nil, toolkit_flags: [], bundle_settings: {}, parent_answers: nil)
111
114
  @scratch = scratch
115
+ @parent_answers = parent_answers
112
116
  @toolkit = toolkit
113
117
  @toolkit_flags = toolkit_flags
114
118
  @bundle_settings = bundle_settings
@@ -281,7 +285,7 @@ module Samagotchi
281
285
  muted_memory_names: @muted_memory_names,
282
286
  scratch: @scratch,
283
287
  setup: SessionSetup.new(appended_prompt_files: @appended_prompt_files, toolkit: @toolkit || Toolkit.configured,
284
- bundle_settings: @bundle_settings)
288
+ bundle_settings: @bundle_settings, parent_answers: @parent_answers)
285
289
  ))
286
290
  # --llm-context*: the session's own values, over a resumed one's.
287
291
  # Saved at once on a resumed one: leaving before a turn keeps them.
@@ -295,7 +299,7 @@ module Samagotchi
295
299
  claim_session!(session.id) unless @owner_lock
296
300
  # A turn a worker died in (TurnCheckpoint): ended as canceled, with its
297
301
  # note, before this REPL's first turn appends after it.
298
- session.save if @resume_session && TurnCheckpoint.settle(session)
302
+ session.save if @resume_session && TurnCheckpoint.settle(session, session_dir: Session.session_dir(session.id))
299
303
  keep_cards(session)
300
304
  # Saved at once, so a process killed before its first turn leaves a
301
305
  # file the sweep knows to delete.
@@ -830,7 +834,7 @@ module Samagotchi
830
834
  end
831
835
 
832
836
  def answer_continue_offer(session, input)
833
- answer = @commands.continue_answer(input)
837
+ answer = @commands.continue_answer(input, as_parent: as_parent?)
834
838
  if answer.decision == :invalid
835
839
  # The read left no echo: the line shows above its error.
836
840
  @surface.commit("#{paint(QUESTION_PROMPT, 33)}#{input}")
@@ -876,7 +880,11 @@ module Samagotchi
876
880
  command = if command_registry.anytime?(input)
877
881
  @engine.running_anytime { @commands.run(input, confirm: confirm) }
878
882
  else
879
- @commands.run(input, confirm: confirm)
883
+ # A parent's /continue yes is held to guardrails.parent_answers;
884
+ # only a person here (not piped, no agent marker) loosens
885
+ # /parent-answers.
886
+ parent = as_parent?
887
+ @commands.run(input, confirm: confirm, as_parent: parent && TurnFlow.continue_yes?(input), user_ui: !parent)
880
888
  end
881
889
  if command
882
890
  show_command_result(command)
@@ -1579,15 +1587,16 @@ module Samagotchi
1579
1587
  end
1580
1588
 
1581
1589
  begin
1582
- parent = parent_answers? ? { client_id: ClientId::CLI_ANSWER } : {}
1590
+ parent = as_parent? ? { client_id: ClientId::CLI_ANSWER } : {}
1583
1591
  @engine.answer_question(id: prompt.id, selected: answer.selected, freeform: answer.freeform, **parent)
1584
1592
  close_question_widget(prompt, prompt.answer_text(answer))
1585
1593
  return true
1586
1594
  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).
1595
+ # A parent's pick its strategy doesn't let through
1596
+ # (Guardrails::ParentPolicy): the question stays open for a deny
1597
+ # (or the end of the input).
1589
1598
  echo_choice(raw)
1590
- @surface.commit(Guardrails::ParentApprovals.message(e.reason, typed: true))
1599
+ @surface.commit(e.detail(typed: true))
1591
1600
  next
1592
1601
  rescue ArgumentError => e
1593
1602
  @surface.commit("Invalid: #{e.message}. Try again.")
@@ -1600,8 +1609,8 @@ module Samagotchi
1600
1609
  end
1601
1610
 
1602
1611
  # 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?
1612
+ # agent marker): QuestionDesk holds them to Guardrails::ParentPolicy.
1613
+ def as_parent?
1605
1614
  Guardrails::ParentApprovals.parent_process?
1606
1615
  end
1607
1616
 
@@ -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