samagotchi 0.86.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.
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "guardrails/parent_policy"
4
+
5
+ module Samagotchi
6
+ # /parent-answers, the session's own guardrails.parent_answers
7
+ # (Session#parent_answers): with no argument, the level that applies, the
8
+ # session's own and config.yml's; `/parent-answers LEVEL` (hard, standard,
9
+ # soft) or `default` (none of its own) sets it, saved at once, for the next
10
+ # answer a parent gives. It may only tighten config.yml's
11
+ # (Guardrails::ParentPolicy.effective). Tightening goes from anyone;
12
+ # loosening (toward soft, or default) only from a user at a UI: the REPL,
13
+ # an attached TUI, the web (+user_ui+). A parent agent's line (chi send,
14
+ # a piped or marked attach, a delegate's follow-up) is refused.
15
+ class ParentAnswersCommand
16
+ NAME = "/parent-answers"
17
+ USAGE = "usage: #{NAME} [hard|standard|soft|default]".freeze
18
+ REFUSED = "loosening parent_answers is the user's: #{NAME} in chi --attach, the REPL or the web " \
19
+ "(not chi send or a parent agent)".freeze
20
+
21
+ # What +args+ (the text after the name) asks for.
22
+ # @return [Array] [:show], [:set, level or nil (default)] or [:usage]
23
+ def self.parse(args)
24
+ words = args.to_s.split
25
+ return [:show] if words.empty?
26
+ return [:usage] if words.size > 1
27
+
28
+ word = words.first.downcase
29
+ return [:set, nil] if word == Guardrails::ParentPolicy::UNSET
30
+
31
+ Guardrails::ParentPolicy::ORDER.include?(word) ? [:set, word] : [:usage]
32
+ end
33
+
34
+ # Whether the command line +line+ loosens a session whose own value is
35
+ # +current+ (chi send's early refusal, before it sends the line).
36
+ def self.loosens?(line, current)
37
+ kind, value = parse(line.to_s.strip.delete_prefix(NAME))
38
+ kind == :set && Guardrails::ParentPolicy.looser?(current, value)
39
+ end
40
+
41
+ # @param engine [Engine] #session
42
+ # @param save [#call] saves the session after a change
43
+ def initialize(engine:, save:)
44
+ @engine = engine
45
+ @save = save
46
+ end
47
+
48
+ # @param args [String] what follows /parent-answers
49
+ # @param user_ui [Boolean] a user at a UI typed it: it may loosen
50
+ # @return [Array(String, Boolean, Symbol)] the reply, whether the
51
+ # session's value changed, and :ok or :error
52
+ def run(args, user_ui:)
53
+ session = @engine.session or return ["no session yet", false, :error]
54
+ kind, value = self.class.parse(args)
55
+ return [USAGE, false, :error] if kind == :usage
56
+ return [listing(session), false, :ok] if kind == :show
57
+ return ["#{listing(session)}\n(no change)", false, :ok] if value == session.parent_answers
58
+ return [REFUSED, false, :error] if Guardrails::ParentPolicy.looser?(session.parent_answers, value) && !user_ui
59
+
60
+ session.parent_answers = value
61
+ @save.call(session)
62
+ capped = Guardrails::ParentPolicy.capped_note(value)
63
+ [[listing(session), ("(#{capped})" if capped)].compact.join("\n"), true, :ok]
64
+ end
65
+
66
+ private
67
+
68
+ # "parent answers: standard (this session: standard; config.yml
69
+ # guardrails.parent_answers: soft)"
70
+ def listing(session)
71
+ config = Guardrails::ParentPolicy.current
72
+ effective = Guardrails::ParentPolicy.for_session(session)
73
+ own = session.parent_answers || "none"
74
+ # A hand-edited value that isn't a level: fail-closed, said here.
75
+ own = "#{own} (unreadable value, read as hard)" if session.parent_answers &&
76
+ !Guardrails::ParentPolicy::ORDER.include?(session.parent_answers)
77
+ "parent answers: #{effective.name} (this session: #{own}; config.yml #{config.label})"
78
+ end
79
+ end
80
+ end
@@ -5,6 +5,7 @@ require "json"
5
5
 
6
6
  require_relative "cli/exit"
7
7
  require_relative "pending_question"
8
+ require_relative "session"
8
9
  require_relative "guardrails/parent_policy"
9
10
 
10
11
  module Samagotchi
@@ -77,18 +78,20 @@ module Samagotchi
77
78
  # @param extra [Hash] more keys for the object (chi answer's
78
79
  # answered_here: false)
79
80
  # @return [Hash] the --format json object
80
- def json(result, session_id:, timeout: nil, extra: {})
81
+ # @param strategy [Guardrails::ParentPolicy::Strategy, nil] what a parent
82
+ # may pick on the waited-for session (#strategy_for); nil: config.yml's
83
+ def json(result, session_id:, timeout: nil, extra: {}, strategy: nil)
81
84
  report = { status: status(result), session_id: session_id }
82
85
  report[:turn_id] = result.turn_id unless result.turn_id.to_s.empty?
83
86
  case report[:status]
84
87
  when "answered" then report[:text] = result.text.to_s
85
88
  when "question"
86
- question = question(result.question)
89
+ question = question(result.question, strategy: strategy)
87
90
  report[:question] = question
88
91
  report[:answer_with] = answer_with(session_id, question)
89
92
  else
90
93
  report.merge!(stop_facts(result))
91
- report[:detail] = detail(result, session_id: session_id, timeout: timeout)
94
+ report[:detail] = detail(result, session_id: session_id, timeout: timeout, strategy: strategy)
92
95
  end
93
96
  report.merge(extra)
94
97
  end
@@ -106,8 +109,8 @@ module Samagotchi
106
109
  end
107
110
 
108
111
  # @return [String] the JSON object as one line
109
- def json_line(result, session_id:, timeout: nil, extra: {})
110
- JSON.generate(json(result, session_id: session_id, timeout: timeout, extra: extra))
112
+ def json_line(result, session_id:, timeout: nil, extra: {}, strategy: nil)
113
+ JSON.generate(json(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy))
111
114
  end
112
115
 
113
116
  # Print a wait's end the way the commands do: the JSON object on
@@ -116,17 +119,17 @@ module Samagotchi
116
119
  # @param command [String] "chi send", "chi answer"
117
120
  # @param extra [Hash] more keys for the JSON object (#json)
118
121
  # @return [Integer] the exit status
119
- def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil, extra: {})
122
+ def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil, extra: {}, strategy: nil)
120
123
  if json
121
- stdout.puts(json_line(result, session_id: session_id, timeout: timeout, extra: extra))
124
+ stdout.puts(json_line(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy))
122
125
  elsif result.status == :done
123
126
  stdout.puts(result.text)
124
127
  else
125
128
  stdout.flush
126
129
  text = if result.status == :waiting_for_answer
127
- question_text(result.question, session_id: session_id)
130
+ question_text(result.question, session_id: session_id, strategy: strategy)
128
131
  else
129
- "#{detail(result, session_id: session_id, timeout: timeout, turn_id: result.turn_id)}\n"
132
+ "#{detail(result, session_id: session_id, timeout: timeout, turn_id: result.turn_id, strategy: strategy)}\n"
130
133
  end
131
134
  stderr.print("#{command}: #{text}")
132
135
  end
@@ -146,14 +149,14 @@ module Samagotchi
146
149
  # kind is "question" for the model's own (the desk stores none),
147
150
  # "hook", "approval" or "continue" (the step-limit question, with the
148
151
  # turn's limit). allowed_options: the options a parent may pick, by
149
- # this process's guardrails.parent_answers (Guardrails::ParentPolicy),
150
- # when that is not all of them.
152
+ # +strategy+ (nil: this process's guardrails.parent_answers;
153
+ # Guardrails::ParentPolicy), when that is not all of them.
151
154
  # @return [Hash]
152
- def question(pending)
155
+ def question(pending, strategy: nil)
153
156
  pending = PendingQuestion.from_file(pending) || PendingQuestion.new
154
157
  approval = pending.approval.is_a?(Hash) ? symbolize(pending.approval) : nil
155
158
  options = Array(pending.options).map(&:to_s)
156
- allowed = allowed_options(pending)
159
+ allowed = allowed_options(pending, strategy: strategy)
157
160
  {
158
161
  id: pending.id.to_s,
159
162
  kind: (pending.kind || "question").to_s,
@@ -189,33 +192,49 @@ module Samagotchi
189
192
  DENY_AND_TELL = "deny it, and tell your user"
190
193
 
191
194
  # The labels a parent may pick on +pending+ (a PendingQuestion, or a
192
- # Hash with its fields) by this process's strategy.
195
+ # Hash with its fields) by +strategy+ (nil: this process's).
193
196
  # @return [Array<String>]
194
- def allowed_options(pending)
197
+ def allowed_options(pending, strategy: nil)
195
198
  pending = PendingQuestion.from_file(pending) || PendingQuestion.new
196
- Guardrails::ParentPolicy.allowed_options(pending, strategy: Guardrails::ParentPolicy.current)
199
+ Guardrails::ParentPolicy.allowed_options(pending, strategy: policy(strategy))
197
200
  end
198
201
 
202
+ # What a parent may pick on the session +session_id+: config.yml's
203
+ # level tightened by the session file's own parent_answers
204
+ # (Guardrails::ParentPolicy.for_session); config.yml's when there is no
205
+ # such session, hard (the strictest) when its file can't be read.
206
+ # @return [Guardrails::ParentPolicy::Strategy]
207
+ def strategy_for(session_id, state_dir:)
208
+ return Guardrails::ParentPolicy.current unless Session.exist?(session_id, state_dir: state_dir)
209
+
210
+ Guardrails::ParentPolicy.for_session(Session.load(session_id, state_dir: state_dir))
211
+ rescue ArgumentError, KeyError, TypeError, NoMethodError, SystemCallError, JSON::ParserError
212
+ Guardrails::ParentPolicy.strategy(Guardrails::ParentPolicy::HARD)
213
+ end
214
+
215
+ # +strategy+, or this process's config.yml's.
216
+ def policy(strategy) = strategy || Guardrails::ParentPolicy.current
217
+
199
218
  # An approval's options a parent may allow (Allow once under soft).
200
- def allowed_allows(question)
201
- question = question(question) unless question.is_a?(Hash) && question.key?(:text)
219
+ def allowed_allows(question, strategy: nil)
220
+ question = question(question, strategy: strategy) unless question.is_a?(Hash) && question.key?(:text)
202
221
  deny = Guardrails::Approval::DENY
203
222
  question.fetch(:allowed_options, question[:options]).reject { |label| label == deny }
204
223
  end
205
224
 
206
225
  # What a parent does with an approval: deny it and tell its user, or
207
226
  # give the Allow once its strategy lets it.
208
- def approval_advice(question)
209
- allows = allowed_allows(question)
227
+ def approval_advice(question, strategy: nil)
228
+ allows = allowed_allows(question, strategy: strategy)
210
229
  return DENY_AND_TELL if allows.empty?
211
230
 
212
- "#{allows.join(" or ")} is yours to give (#{Guardrails::ParentPolicy.current.label}); " \
231
+ "#{allows.join(" or ")} is yours to give (#{policy(strategy).label}); " \
213
232
  "anything wider is up to your user: #{DENY_AND_TELL}"
214
233
  end
215
234
 
216
235
  # Whether a parent may answer Continue to a step-limit question.
217
- def may_continue?(question)
218
- question = question(question) unless question.is_a?(Hash) && question.key?(:text)
236
+ def may_continue?(question, strategy: nil)
237
+ question = question(question, strategy: strategy) unless question.is_a?(Hash) && question.key?(:text)
219
238
  question.fetch(:allowed_options, question[:options]).include?(Guardrails::ParentPolicy::CONTINUE)
220
239
  end
221
240
 
@@ -252,23 +271,23 @@ module Samagotchi
252
271
  end
253
272
 
254
273
  # What a wait that ended without an answer says, one line.
255
- def detail(result, session_id:, timeout: nil, turn_id: nil)
274
+ def detail(result, session_id:, timeout: nil, turn_id: nil, strategy: nil)
256
275
  attach = "chi --attach #{session_id}"
257
276
  case result.status
258
277
  when :waiting_for_answer
259
- q = question(result.question)
278
+ q = question(result.question, strategy: strategy)
260
279
  if approval?(q)
261
- "waiting for an approval: #{first_line(q[:text])}; #{approval_advice(q)}"
280
+ "waiting for an approval: #{first_line(q[:text])}; #{approval_advice(q, strategy: strategy)}"
262
281
  elsif continue?(q) && !may_continue?(q)
263
282
  "waiting at the step limit: #{first_line(q[:text])}; a parent may only stop it here " \
264
- "(#{Guardrails::ParentPolicy.current.label}): #{stop_command(session_id, q)}, or tell your user it waits"
283
+ "(#{policy(strategy).label}): #{stop_command(session_id, q)}, or tell your user it waits"
265
284
  elsif continue?(q)
266
285
  "waiting at the step limit: #{first_line(q[:text])}; " \
267
286
  "answer Continue or Stop: chi answer #{session_id} --question #{q[:id]} --option Continue"
268
287
  else
269
288
  "waiting for an answer: #{first_line(q[:text])}; open it: #{attach} or the web"
270
289
  end
271
- when :no_reply then "#{no_reply_line(result, session_id)}; #{attach} shows it"
290
+ when :no_reply then "#{no_reply_line(result, session_id, strategy: strategy)}; #{attach} shows it"
272
291
  when :error then "the worker failed: #{result.text}; #{attach} shows what happened"
273
292
  when :worker_gone then "the worker is gone; #{attach} shows what happened"
274
293
  when :stopped then "the session was stopped (chi sessions stop)"
@@ -282,8 +301,8 @@ module Samagotchi
282
301
  # The whole question for stderr: header, text, numbered options, what
283
302
  # else it takes, and the commands that answer it.
284
303
  # @return [String] lines, newline-terminated
285
- def question_text(pending, session_id:)
286
- q = question(pending)
304
+ def question_text(pending, session_id:, strategy: nil)
305
+ q = question(pending, strategy: strategy)
287
306
  lines = ["waiting for an answer (#{q[:kind]}): #{q[:header] || first_line(q[:text])}"]
288
307
  text_lines = q[:text].strip.lines.map(&:rstrip)
289
308
  text_lines = text_lines.drop(1) unless q[:header]
@@ -294,7 +313,7 @@ module Samagotchi
294
313
  if allows.empty?
295
314
  lines << " allowing it is up to your user: #{DENY_AND_TELL}"
296
315
  else
297
- lines << " #{approval_advice(q)}"
316
+ lines << " #{approval_advice(q, strategy: strategy)}"
298
317
  allows.each { |label| lines << " allow: chi answer #{session_id} --question #{q[:id]} --option #{option_arg(label)}" }
299
318
  end
300
319
  lines << " deny: #{answer_with(session_id, q)}"
@@ -303,7 +322,7 @@ module Samagotchi
303
322
  lines << if may_continue?(q)
304
323
  " continue: #{answer_with(session_id, q)}"
305
324
  else
306
- " continuing it is up to your user (#{Guardrails::ParentPolicy.current.label}): stop it, or tell your user it waits"
325
+ " continuing it is up to your user (#{policy(strategy).label}): stop it, or tell your user it waits"
307
326
  end
308
327
  lines << " stop: #{stop_command(session_id, q)} (the text is optional; the model reads it)"
309
328
  lines << " #{message_drops(session_id)}"
@@ -319,7 +338,7 @@ module Samagotchi
319
338
  lines.map { |line| "#{line}\n" }.join
320
339
  end
321
340
 
322
- def no_reply_line(result, session_id = nil)
341
+ def no_reply_line(result, session_id = nil, strategy: nil)
323
342
  case result.outcome
324
343
  when "failed" then failed_line(result)
325
344
  when "canceled"
@@ -330,7 +349,7 @@ module Samagotchi
330
349
  when "removed" then "the queued message was removed before it ran; no turn ran for it"
331
350
  when "exhausted"
332
351
  ran_out = "the turn ran out of iterations#{" (#{result.limit} steps)" if result.limit} before it answered"
333
- strategy = Guardrails::ParentPolicy.current
352
+ strategy = policy(strategy)
334
353
  if strategy.allow?(Guardrails::ParentPolicy::SPEND)
335
354
  "#{ran_out}; chi send #{session_id} -m '/continue yes' continues it, a message drops it"
336
355
  else
@@ -305,8 +305,9 @@ module Samagotchi
305
305
  fm = freeform.to_s.strip
306
306
  fm = nil if fm.empty?
307
307
  parent_agent = client_id.to_s == ClientId::CLI_ANSWER if parent_agent.nil?
308
- # Read before the lock (config may touch the disk); this worker's own.
309
- strategy = Guardrails::ParentPolicy.current if parent_agent
308
+ # Read before the lock (config may touch the disk); this worker's own
309
+ # config.yml, tightened by its session's own parent_answers.
310
+ strategy = Guardrails::ParentPolicy.for_session(@session_lookup.call) if parent_agent
310
311
  standing = nil
311
312
  answer = @lock.synchronize do
312
313
  pending = @pending
@@ -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)