samagotchi 0.86.0 → 0.88.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -1
  3. data/bin/chi +39 -2
  4. data/docs/architecture.md +5 -3
  5. data/docs/cli.md +18 -5
  6. data/docs/configuration.md +1 -1
  7. data/docs/guardrails.md +53 -3
  8. data/docs/hooks.md +1 -1
  9. data/docs/plugins.md +1 -1
  10. data/docs/sessions.md +19 -7
  11. data/docs/sub-agent.md +15 -3
  12. data/lib/samagotchi/answer_command.rb +6 -6
  13. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -3
  14. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  16. data/lib/samagotchi/child_reports.rb +100 -7
  17. data/lib/samagotchi/cli/parent_wait.rb +3 -1
  18. data/lib/samagotchi/guardrails/parent_policy.rb +94 -5
  19. data/lib/samagotchi/parent_answers_command.rb +80 -0
  20. data/lib/samagotchi/parent_report.rb +53 -34
  21. data/lib/samagotchi/question_desk.rb +3 -2
  22. data/lib/samagotchi/relay.rb +34 -2
  23. data/lib/samagotchi/relay_desk.rb +13 -0
  24. data/lib/samagotchi/send_command.rb +75 -10
  25. data/lib/samagotchi/session.rb +26 -2
  26. data/lib/samagotchi/session_commands.rb +35 -9
  27. data/lib/samagotchi/session_setup.rb +10 -8
  28. data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -8
  29. data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -5
  30. data/lib/samagotchi/terminal_ui/question_prompt.rb +2 -1
  31. data/lib/samagotchi/terminal_ui.rb +12 -6
  32. data/lib/samagotchi/tools/delegate.rb +4 -1
  33. data/lib/samagotchi/tools/delegate_relay.rb +69 -15
  34. data/lib/samagotchi/tools/delegate_wait.rb +14 -8
  35. data/lib/samagotchi/version.rb +1 -1
  36. data/lib/samagotchi/web/public/notify.js +6 -2
  37. data/lib/samagotchi/web/public/question_card.js +2 -0
  38. data/lib/samagotchi/web/public/question_cards.js +25 -18
  39. data/lib/samagotchi/worker.rb +60 -10
  40. metadata +2 -1
@@ -16,6 +16,8 @@ module Samagotchi
16
16
  # How much of the child's task the card keeps, and the question line.
17
17
  TASK_CHARS = 200
18
18
  TASK_LINE_CHARS = 80
19
+ # A hook's question (QuestionDesk#ask_for_hook), relayed as such (P4).
20
+ HOOK_KIND = "hook"
19
21
 
20
22
  module_function
21
23
 
@@ -33,6 +35,14 @@ module Samagotchi
33
35
  asked = inner ? inner[:asked].to_s : pending.question.to_s
34
36
  task = inner ? inner[:task].to_s : task_of(child)
35
37
  who = chain.reverse.join(" → ")
38
+ relay = { id: relay_id, child_id: child.id, child_short: short, child_question_id: pending.id.to_s,
39
+ task: task, chain: chain, asked: asked, more: more.to_i }
40
+ if pending.kind.to_s == HOOK_KIND
41
+ # The asking hook's own header, kept down a chain (a relay of a relay).
42
+ relay[:header] = inner ? inner[:header].to_s : pending.header.to_s
43
+ return hook_card(pending, who, more, relay)
44
+ end
45
+
36
46
  {
37
47
  question: question_text(who, task, asked),
38
48
  options: relabel(Array(pending.options), facts[:scopes], who),
@@ -43,11 +53,33 @@ module Samagotchi
43
53
  **{ effects: pending.effects, user_only: pending.user_only }.compact,
44
54
  kind: Guardrails::Approval::KIND,
45
55
  approval: facts,
46
- relay: { id: relay_id, child_id: child.id, child_short: short, child_question_id: pending.id.to_s,
47
- task: task, chain: chain, asked: asked, more: more.to_i }
56
+ relay: relay
48
57
  }
49
58
  end
50
59
 
60
+ # A hook's question (P4: one its parent can't usefully answer): its
61
+ # kind, hook, options and effects as the child asked them, and no
62
+ # approval facts (ParentApprovals.approval? stays false on it, so the
63
+ # policy judges it by its own tags, as on the child).
64
+ def hook_card(pending, who, more, relay)
65
+ {
66
+ question: question_text(who, relay[:task], relay[:asked]),
67
+ options: Array(pending.options).map(&:to_s),
68
+ header: hook_header(who, relay[:header], more),
69
+ multi_select: !!pending.multi_select,
70
+ allow_freeform: !!pending.allow_freeform,
71
+ **{ effects: pending.effects, user_only: pending.user_only, hook: pending.hook }.compact,
72
+ kind: HOOK_KIND,
73
+ relay: relay
74
+ }
75
+ end
76
+
77
+ # delegate ab12 asks: Cost cap (+1 more delegate waiting)
78
+ def hook_header(who, header, more)
79
+ text = "delegate #{who} asks: #{header.to_s.strip.empty? ? "a question" : header}"
80
+ more.to_i.positive? ? "#{text} (+#{more} more delegate#{"s" if more.to_i > 1} waiting)" : text
81
+ end
82
+
51
83
  # delegate ab12 ("fix the flaky spec in …") asks:
52
84
  # execute: git push origin main
53
85
  # in /path/repo (repo samagotchi, branch main)
@@ -63,6 +63,19 @@ module Samagotchi
63
63
  end
64
64
  end
65
65
 
66
+ # Whether the parent's user (or a parent agent) answered or dismissed a
67
+ # relay of this child's question: it isn't relayed again (a refused
68
+ # answer leaves it on the child's card). A closed one (a Stop, the
69
+ # child gone) is not settled. Forgotten after KEEP_SECONDS or a restart.
70
+ def settled?(child_id, child_question_id)
71
+ @mutex.synchronize do
72
+ prune
73
+ @relays.each_value.any? do |relay|
74
+ relay[:state] == "answered" && relay[:child_id] == child_id.to_s && relay[:child_question_id] == child_question_id.to_s
75
+ end
76
+ end
77
+ end
78
+
66
79
  # Whether a relay is open, or settled recently enough that its child
67
80
  # may still ask about it: a restart would drop them.
68
81
  def active?
@@ -5,6 +5,7 @@ require_relative "delivery"
5
5
  require_relative "terminal_ui/formatting"
6
6
  require_relative "session"
7
7
  require_relative "session_commands"
8
+ require_relative "parent_answers_command"
8
9
  require_relative "session_inbox"
9
10
  require_relative "session_manager"
10
11
  require_relative "bundle_session_settings"
@@ -43,7 +44,7 @@ module Samagotchi
43
44
 
44
45
  USAGE = <<~TEXT
45
46
  Usage: chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...
46
- chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [--toolkit NAME] [--set BUNDLE.KEY=VALUE]... [-m TEXT] [--image PATH]...
47
+ chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [--toolkit NAME] [--set BUNDLE.KEY=VALUE]... [--parent-answers LEVEL] [-m TEXT] [--image PATH]...
47
48
  chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT] [--image PATH]...
48
49
  chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
49
50
  chi send --queue --wait [--timeout S] [--format json] [-m TEXT] ID
@@ -95,6 +96,11 @@ module Samagotchi
95
96
  manifest lists in session_settings:; 3, 2.5, true,
96
97
  false and off are typed, the rest is text:
97
98
  --set cost-cap.cap=3)
99
+ --parent-answers LEVEL
100
+ (--new) what a parent agent may answer on its
101
+ questions: hard, standard or soft, only stricter than
102
+ config.yml's guardrails.parent_answers (a looser one
103
+ warns; config.yml's applies)
98
104
  --wait wait for the answer and print it (one session); the
99
105
  other lines go to stderr. Exit 3: it waits for an
100
106
  answer from you: the question, its options and the
@@ -153,6 +159,7 @@ module Samagotchi
153
159
  f.value "--tool", key: :tools, repeat: true
154
160
  f.value "--no-tool", key: :no_tools, repeat: true
155
161
  f.value "--set", key: :sets, repeat: true
162
+ f.value "--parent-answers", key: :parent_answers
156
163
  f.value "--image", key: :images, repeat: true
157
164
  f.switch "--wait"
158
165
  f.switch "--cut"
@@ -246,12 +253,21 @@ module Samagotchi
246
253
  end
247
254
 
248
255
  # 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)
256
+ # another route: held to guardrails.parent_answers (tightened by each
257
+ # target's own) here, and marked as chi answer's (#client_id_for) so
258
+ # the worker checks it again.
259
+ if prompt && parent_continue?(prompt) && (refusal = continue_refusal_for(options))
252
260
  @stderr.puts("chi send: #{refusal}")
253
261
  return CLI::Exit::FAILED
254
262
  end
263
+ # chi send never loosens a session's own parent_answers (only a user
264
+ # at a UI does: the worker refuses it too); refused before anything
265
+ # is sent. Tightening goes.
266
+ if prompt && SessionCommands.builtin_registry.lookup(prompt)&.id == :parent_answers &&
267
+ target_parent_answers(options).any? { |value| ParentAnswersCommand.loosens?(prompt, value) }
268
+ error_line("chi send: refused: #{ParentAnswersCommand::REFUSED}")
269
+ return CLI::Exit::FAILED
270
+ end
255
271
  # A file that is not there or not an image: a run-time failure (1),
256
272
  # reported in the --format json line too; --image itself was fine.
257
273
  return CLI::Exit::FAILED unless ingest_images(options[:images])
@@ -305,6 +321,7 @@ module Samagotchi
305
321
  TOOLKIT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") unless Array(options[key]).empty? }
306
322
  LLM_CONTEXT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") if options[key] }
307
323
  return usage_error("--set needs --new") unless Array(options[:sets]).empty?
324
+ return usage_error("--parent-answers needs --new") if options[:parent_answers]
308
325
  return usage_error("give session ids") if options[:ids].empty?
309
326
 
310
327
  options
@@ -324,10 +341,10 @@ module Samagotchi
324
341
  taken = [("--dir" if options[:dir])] +
325
342
  LLM_CONTEXT_FLAGS.filter_map { |key, flag| flag if options[key] } + [("--thinking" if options[:thinking])] +
326
343
  [("--toolkit" if options[:toolkit])] + TOOLKIT_FLAGS.filter_map { |key, flag| flag unless Array(options[key]).empty? } +
327
- [("--set" unless Array(options[:sets]).empty?)]
344
+ [("--set" unless Array(options[:sets]).empty?), ("--parent-answers" if options[:parent_answers])]
328
345
  unless taken.compact.empty?
329
346
  return usage_error("--continues takes the previous session's folder, LLM context, thinking level, " \
330
- "toolkit and bundle settings; " \
347
+ "toolkit, bundle settings and parent_answers; " \
331
348
  "leave out #{taken.compact.join(", ")}")
332
349
  end
333
350
  end
@@ -347,6 +364,13 @@ module Samagotchi
347
364
 
348
365
  options[:thinking] = Thinking.session_level(word)
349
366
  end
367
+ if options[:parent_answers]
368
+ word = options[:parent_answers].strip.downcase
369
+ levels = Guardrails::ParentPolicy::ORDER
370
+ return usage_error("--parent-answers takes #{levels.join(", ")}, not #{options[:parent_answers]}") unless levels.include?(word)
371
+
372
+ options[:parent_answers] = word
373
+ end
350
374
  toolkit = Toolkit.from_flags(name: options[:toolkit], add: options[:tools], remove: options[:no_tools])
351
375
  options[:toolkit_value] = toolkit if toolkit
352
376
  begin
@@ -404,7 +428,8 @@ module Samagotchi
404
428
  setup: SessionSetup.new(llm_context: options[:llm_context],
405
429
  thinking: options[:thinking],
406
430
  toolkit: options[:toolkit_value],
407
- bundle_settings: options[:bundle_settings] || {}),
431
+ bundle_settings: options[:bundle_settings] || {},
432
+ parent_answers: options[:parent_answers]),
408
433
  state_dir: @state_dir)
409
434
  end
410
435
  rescue SessionContinue::Refused, SessionManager::ArchiveRefused, SessionManager::OwnedByTUI => e
@@ -415,6 +440,9 @@ module Samagotchi
415
440
  return 1
416
441
  end
417
442
  error_line("chi send: warning: #{session.model_warning}") if session.model_warning
443
+ if (capped = Guardrails::ParentPolicy.capped_note(options[:parent_answers]))
444
+ error_line("chi send: warning: --parent-answers #{capped}")
445
+ end
418
446
  if through_worker
419
447
  return 1 unless deliver_new(session, prompt)
420
448
  else
@@ -473,10 +501,12 @@ module Samagotchi
473
501
  # The turn waits on a question: the message would only queue
474
502
  # behind it, and the answer goes in with chi answer.
475
503
  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)
504
+ # The whole question (its options, its tags) for what a parent may
505
+ # give: config.yml tightened by the session's own parent_answers.
506
+ strategy = Guardrails::ParentPolicy.for_session(session)
507
+ question = ParentReport.question(session.pending_question, strategy: strategy)
478
508
  if ParentReport.approval?(waiting)
479
- error_line("#{id[0, 8]} waiting for an approval: #{ParentReport.approval_advice(question)} " \
509
+ error_line("#{id[0, 8]} waiting for an approval: #{ParentReport.approval_advice(question, strategy: strategy)} " \
480
510
  "(#{ParentReport.answer_with(id, question)})")
481
511
  else
482
512
  error_line("#{id[0, 8]} waiting for an answer: #{ParentReport.answer_with(id, question)} (or --text); " \
@@ -603,6 +633,41 @@ module Samagotchi
603
633
  # yes (the worker holds that to its guardrails.parent_answers).
604
634
  def client_id_for(prompt) = parent_continue?(prompt) ? ClientId::CLI_ANSWER : CLIENT_ID
605
635
 
636
+ # The first refusal of a parent's /continue yes among the targets, each
637
+ # by config.yml tightened by its own parent_answers; nil when none
638
+ # refuses.
639
+ def continue_refusal_for(options)
640
+ target_parent_answers(options).each do |value|
641
+ strategy = Guardrails::ParentPolicy.effective(value)
642
+ refusal = Guardrails::ParentPolicy.continue_refusal(strategy: strategy)
643
+ return refusal if refusal
644
+ end
645
+ nil
646
+ end
647
+
648
+ # The targets' own parent_answers values: a --new session's
649
+ # --parent-answers, a --continues link's previous session's (the link
650
+ # keeps it), each id's. An id that doesn't resolve is skipped (#resolve
651
+ # reports it later); none left is config.yml's alone ([nil]).
652
+ def target_parent_answers(options)
653
+ return [options[:parent_answers]] if options[:new] && !options[:continues]
654
+
655
+ sessions = if options[:new]
656
+ [target_session { SessionChain.resolve(options[:continues], state_dir: @state_dir) }]
657
+ else
658
+ options[:ids].map { |given| target_session { Session.resolve_id(given, state_dir: @state_dir) } }
659
+ end
660
+ values = sessions.compact.map(&:parent_answers)
661
+ values.empty? ? [nil] : values
662
+ end
663
+
664
+ # The session the block's id names, or nil when there is none.
665
+ def target_session
666
+ Session.load(yield, state_dir: @state_dir)
667
+ rescue ArgumentError
668
+ nil
669
+ end
670
+
606
671
  # A /continue yes (or bare /continue) from a parent agent's process.
607
672
  def parent_continue?(prompt) = TurnFlow.continue_yes?(prompt) && Guardrails::ParentApprovals.parent_process?
608
673
 
@@ -220,6 +220,27 @@ module Samagotchi
220
220
  @bundle_settings = BundleSessionSettings.normalize(value)
221
221
  end
222
222
 
223
+ # The session's own guardrails.parent_answers (hard|standard|soft), nil
224
+ # for none (config.yml's). It may only tighten config.yml's
225
+ # (Guardrails::ParentPolicy.effective); only the user loosens it
226
+ # (/parent-answers in a UI). Given at creation (chi --parent-answers,
227
+ # chi send --new --parent-answers); a respawn, a continue and a fork keep
228
+ # it (SessionSetup.of), a delegate child takes its parent's.
229
+ attr_reader :parent_answers
230
+
231
+ # @param value [String, Symbol, nil] stripped and downcased; "" and
232
+ # "default" are none; a value that isn't a level is kept as written
233
+ # (read as hard: ParentPolicy.session_level)
234
+ def parent_answers=(value)
235
+ word = value.to_s.strip
236
+ word = word.downcase if PARENT_ANSWERS_WORDS.include?(word.downcase)
237
+ @parent_answers = word.empty? || word == "default" ? nil : word
238
+ end
239
+
240
+ # The levels and "default" (Guardrails::ParentPolicy::ORDER and UNSET;
241
+ # not required here: that file loads the guardrails).
242
+ PARENT_ANSWERS_WORDS = %w[hard standard soft default].freeze
243
+
223
244
  # The hook that stopped the last turn (stop_turn, or a cut with no retry
224
245
  # left), e.g. "loop-guard"; nil when it ended otherwise.
225
246
  def stopped_by
@@ -254,7 +275,7 @@ module Samagotchi
254
275
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
255
276
  last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
256
277
  continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
257
- toolkit: nil, bundle_settings: {}, interrupted_turn: nil)
278
+ toolkit: nil, bundle_settings: {}, interrupted_turn: nil, parent_answers: nil)
258
279
  @id = id
259
280
  @metadata_version = metadata_version
260
281
  @mode = mode
@@ -285,6 +306,7 @@ module Samagotchi
285
306
  self.appended_prompt_files = appended_prompt_files
286
307
  self.toolkit = toolkit
287
308
  self.bundle_settings = bundle_settings
309
+ self.parent_answers = parent_answers
288
310
  self.interrupted_turn = interrupted_turn
289
311
  @turn_checkpoint = nil
290
312
  @archived = false
@@ -383,6 +405,7 @@ module Samagotchi
383
405
  appended_prompt_files: setup.appended_prompt_files,
384
406
  toolkit: setup.toolkit,
385
407
  bundle_settings: setup.bundle_settings,
408
+ parent_answers: setup.parent_answers,
386
409
  continues: continues
387
410
  )
388
411
  end
@@ -449,7 +472,8 @@ module Samagotchi
449
472
  "appended_prompt_files" => [],
450
473
  "toolkit" => nil,
451
474
  "bundle_settings" => {},
452
- "interrupted_turn" => nil
475
+ "interrupted_turn" => nil,
476
+ "parent_answers" => nil
453
477
  }.freeze
454
478
 
455
479
  # A session from a parsed session file. +messages+ false leaves the
@@ -13,6 +13,7 @@ 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"
17
18
  require_relative "guardrails/parent_policy"
18
19
 
@@ -35,6 +36,7 @@ module Samagotchi
35
36
  LLM_CONTEXT_COMMAND = LLMContextCommand::NAME
36
37
  THINKING_COMMAND = ThinkingCommand::NAME
37
38
  TOOLKIT_COMMAND = ToolkitCommand::NAME
39
+ PARENT_ANSWERS_COMMAND = ParentAnswersCommand::NAME
38
40
  REASONING_HISTORY_COMMAND = ReasoningHistoryCommand::NAME
39
41
  # A remote catalog has hundreds of ids (OpenRouter ~380): plain /models
40
42
  # shows this many per host; /models <text> lists every match.
@@ -77,12 +79,8 @@ module Samagotchi
77
79
  id: :rollback) { |_text| rollback }
78
80
  registry.register("!", "run a shell command; its output goes into the conversation",
79
81
  id: :shell, mid_turn: :queue, match: ->(text) { text.match?(/\A!\s*\S/) }) { |text| shell(text) }
80
- registry.register(CONTINUE_COMMAND, "answer the continue offer (yes, no or no, <reason>)") do |text|
81
- next reply("nothing to continue") unless @turn_flow.awaiting_continue?
82
-
83
- answer = text.delete_prefix(CONTINUE_COMMAND).strip
84
- continue_answer(answer.empty? ? CONTINUE_COMMAND : answer, as_parent: @as_parent)
85
- 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) }
86
84
  # It reads the hosts only (the registry is mutex-guarded): mid-turn too.
87
85
  registry.register(MODELS_COMMAND, "list the hosts' models (/models <text> filters)", anytime: true) do |text|
88
86
  reply(models_listing(text.delete_prefix(MODELS_COMMAND).strip))
@@ -109,6 +107,11 @@ module Samagotchi
109
107
  registry.register(TOOLKIT_COMMAND, "show or change the tools this session declares (NAME, add TOOL, remove TOOL)",
110
108
  id: :toolkit, mid_turn: show_mid_turn(TOOLKIT_COMMAND),
111
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) }
112
115
  registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
113
116
  registry.register("/stats", "show the session's stats", local: true)
114
117
  registry.register("/recap", "show the session's recap", local: true)
@@ -258,9 +261,11 @@ module Samagotchi
258
261
  # @param as_parent [Boolean] a parent agent sent it (client cli:answer):
259
262
  # a /continue yes is held to guardrails.parent_answers, as chi
260
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
261
267
  # @return [Result, nil] nil when +line+ is not one of these commands
262
- def run(line, confirm: true, as_parent: false)
263
- @as_parent = as_parent
268
+ def run(line, confirm: true, as_parent: false, user_ui: false)
264
269
  text = line.to_s.strip
265
270
  entry = @registry.lookup(text)
266
271
  return nil unless entry&.handler
@@ -268,17 +273,30 @@ module Samagotchi
268
273
 
269
274
  # The two that may ask take the flag; the rest never ask.
270
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
271
280
 
272
281
  instance_exec(text, &entry.handler)
273
282
  end
274
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
+
275
292
  # Answer the pending continue offer (yes / no / no, <reason>).
276
293
  # @param as_parent [Boolean] a parent agent's answer: a yes its strategy
277
294
  # doesn't allow is refused (the offer stays; decision :invalid)
278
295
  # @return [Result] resume: true on yes
279
296
  def continue_answer(text, as_parent: false)
280
297
  decision, reason = TurnFlow.continue_decision(text)
281
- if decision == :resume && as_parent && (refusal = Guardrails::ParentPolicy.continue_refusal)
298
+ if decision == :resume && as_parent &&
299
+ (refusal = Guardrails::ParentPolicy.continue_refusal(strategy: Guardrails::ParentPolicy.for_session(@engine.session)))
282
300
  return reply(refusal, status: :error).tap { |result| result.decision = :invalid }
283
301
  end
284
302
 
@@ -496,6 +514,14 @@ module Samagotchi
496
514
  reply(e.message, status: :error)
497
515
  end
498
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
+
499
525
  # A session setting changed (/llm-context, /thinking, /reasoning-history):
500
526
  # saved with its model.
501
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?
@@ -83,7 +87,9 @@ module Samagotchi
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")
@@ -185,7 +195,7 @@ module Samagotchi
185
195
 
186
196
  verb = flags.size == 1 ? "applies" : "apply"
187
197
  names = flags.size > 2 ? "#{flags[0..-2].join(", ")} and #{flags.last}" : flags.join(" and ")
188
- err.puts "(#{names} #{verb} to a new session; #{session_id}'s prompt is already built)"
198
+ err.puts "(#{names} #{verb} to a new session; #{session_id} keeps its own)"
189
199
  end
190
200
 
191
201
  # @return [BridgeClient, nil] the running worker's, or nil when none runs
@@ -592,10 +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
- # 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)
595
+ reply = @client.post_command(line: line, client_id: client_id_for(line))
599
596
  if reply.status == 202
600
597
  # Input from a pipe ends once its output is in (#command_ran).
601
598
  command_id = reply.json&.fetch("command_id", nil)
@@ -808,13 +805,27 @@ module Samagotchi
808
805
  steer_cut_note(ack["cut"].to_s.to_sym, ack["cut_after"])
809
806
  end
810
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
+
811
822
  def send_prompt(text, delivery: nil)
812
823
  persist_recent_history(text)
813
824
  images = attach_images(text)
814
825
  return own_prompt_failed if images.nil?
815
826
 
816
827
  running = @running
817
- options = { prompt: text, client_id: @client_id }
828
+ options = { prompt: text, client_id: client_id_for(text) }
818
829
  options[:no_interrupt] = true if @no_interrupt
819
830
  options[:images] = images unless images.empty?
820
831
  options[:delivery] = delivery if delivery
@@ -96,7 +96,8 @@ module Samagotchi
96
96
  case reason.to_s
97
97
  when "answered_on_child" then "(answered in #{@delegate})"
98
98
  when "child_gone" then "(#{@delegate}'s worker is gone)"
99
- when "dismissed" then "(denied)"
99
+ # A relayed hook question's dismissal: the hook decides what it means.
100
+ when "dismissed" then approval? ? "(denied)" : "(dismissed)"
100
101
  else "(left open in #{@delegate})"
101
102
  end
102
103
  end
@@ -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.
@@ -733,7 +737,7 @@ module Samagotchi
733
737
  return if @toolkit_flags.empty?
734
738
 
735
739
  names = @toolkit_flags.size > 1 ? "#{@toolkit_flags[0..-2].join(", ")} and #{@toolkit_flags.last}" : @toolkit_flags.first
736
- warn "(#{names} #{@toolkit_flags.size == 1 ? "applies" : "apply"} to a new session; #{session.id}'s prompt is already built)"
740
+ warn "(#{names} #{@toolkit_flags.size == 1 ? "applies" : "apply"} to a new session; #{session.id} keeps its own)"
737
741
  end
738
742
  private :warn_toolkit_flags_ignored
739
743
 
@@ -876,9 +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
- # 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)
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)
882
888
  end
883
889
  if command
884
890
  show_command_result(command)