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
@@ -10,8 +10,7 @@ require_relative "log"
10
10
  require_relative "pending_question"
11
11
  require_relative "question_answer"
12
12
  require_relative "tools/ask_user_question"
13
- require_relative "guardrails/parent_approvals"
14
- require_relative "guardrails/parent_continue"
13
+ require_relative "guardrails/parent_policy"
15
14
 
16
15
  module Samagotchi
17
16
  # An Engine's question flow: the one open question (ask_user_question, a
@@ -36,18 +35,25 @@ module Samagotchi
36
35
  class NotPending < ArgumentError; end
37
36
 
38
37
  # Raised by #answer when a parent agent's answer (chi answer's client
39
- # id) allows an approval further than this worker's
40
- # guardrails.parent_approvals lets it (Guardrails::ParentApprovals), or
41
- # continues a turn turn.parent_continue keeps it from
42
- # (Guardrails::ParentContinue). The question stays open; transports map
38
+ # id) picks an option this worker's strategy keeps parents from
39
+ # (Guardrails::ParentPolicy). The question stays open; transports map
43
40
  # it to 403.
44
41
  class Refused < StandardError
45
- # @return [Symbol] :off, :once_only, :protected or :stop_only
42
+ # @return [Symbol] ParentPolicy.refusal's: :user_only, :permit_wide,
43
+ # :permit or :spend
46
44
  attr_reader :reason
47
45
 
48
- def initialize(reason)
46
+ def initialize(reason, pending:, strategy:)
49
47
  @reason = reason
50
- super(reason == :stop_only ? "a parent may not continue this turn" : "a parent may not allow this approval (#{reason})")
48
+ @pending = pending
49
+ @strategy = strategy
50
+ super("a parent may not pick that option here (#{reason})")
51
+ end
52
+
53
+ # The line for the parent (ParentPolicy.message).
54
+ # @param typed [Boolean] typed at a ? prompt (the REPL)
55
+ def detail(typed: false)
56
+ Guardrails::ParentPolicy.message(reason, pending: @pending, strategy: @strategy, typed: typed)
51
57
  end
52
58
  end
53
59
 
@@ -290,7 +296,7 @@ module Samagotchi
290
296
  # @param freeform [String, nil]
291
297
  # @param client_id [String, nil] who answers
292
298
  # @param parent_agent [Boolean, nil] the answer is a parent agent's, so
293
- # it is held to guardrails.parent_approvals on an approval and doesn't
299
+ # it is held to Guardrails::ParentPolicy and doesn't
294
300
  # bring the session back to the lists; nil: chi answer's client id says so
295
301
  # @return [QuestionAnswer] the answer; a parent agent's carries by: "parent_agent"
296
302
  # @raise [NotPending, ArgumentError, Refused]
@@ -299,9 +305,9 @@ module Samagotchi
299
305
  fm = freeform.to_s.strip
300
306
  fm = nil if fm.empty?
301
307
  parent_agent = client_id.to_s == ClientId::CLI_ANSWER if parent_agent.nil?
302
- # Read before the lock (config may touch the disk); this worker's own.
303
- parent_setting = Guardrails::ParentApprovals.setting if parent_agent
304
- parent_continue = Guardrails::ParentContinue.allowed? 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
305
311
  standing = nil
306
312
  answer = @lock.synchronize do
307
313
  pending = @pending
@@ -313,10 +319,7 @@ module Samagotchi
313
319
  raise NotPending, "question already answered" if @answer
314
320
  raise NotPending, "question #{pending.status}" unless pending.status.to_s == "pending"
315
321
 
316
- answer = validated_answer(pending, sel, fm, parent_agent: parent_agent, parent_setting: parent_setting)
317
- if parent_agent && (reason = Guardrails::ParentContinue.refusal(pending, sel, allowed: parent_continue))
318
- raise Refused, reason
319
- end
322
+ answer = validated_answer(pending, sel, fm, parent_agent: parent_agent, strategy: strategy)
320
323
 
321
324
  if @standing
322
325
  # No waiter: cleared here, so a second answer finds none.
@@ -401,9 +404,13 @@ module Samagotchi
401
404
  # web), single-select, kind "hook". A --non-interactive run has no one
402
405
  # to ask: nil at once. Anything but an answer (a sync handler's text, no
403
406
  # answer, cancelled) is nil too.
407
+ # @param effects [Hash, nil] label => effect for a parent agent's answer
408
+ # (Guardrails::ParentPolicy); a label left out is "answer". A label
409
+ # the options don't have, or an effect it doesn't know, makes the ask
410
+ # invalid (nil, warned), like bad options.
404
411
  # @return [Hash, nil] {selected:, freeform:, selected_indices:} (a plain
405
412
  # Hash: the bundle boundary)
406
- def ask_for_hook(question, options, header, allow_freeform, hook)
413
+ def ask_for_hook(question, options, header, allow_freeform, hook, effects: nil)
407
414
  return nil if @interface_lookup.call == :non_interactive
408
415
 
409
416
  opts = Tools::AskUserQuestion.normalize_options(options)
@@ -411,9 +418,16 @@ module Samagotchi
411
418
  Log.warn(:hooks, "ask_user_invalid", echo: "[samagotchi:hooks] #{hook} asked with invalid options (2-8 strings)", hook: hook.to_s)
412
419
  return nil
413
420
  end
421
+ tags = Guardrails::ParentPolicy.option_tags(opts, effects)
422
+ if tags == false
423
+ Log.warn(:hooks, "ask_user_invalid",
424
+ echo: "[samagotchi:hooks] #{hook} asked with invalid effects (option label => " \
425
+ "#{Guardrails::ParentPolicy::EFFECTS.join("|")})", hook: hook.to_s)
426
+ return nil
427
+ end
414
428
 
415
429
  fields = { question: question.to_s, options: opts, header: header, multi_select: false,
416
- allow_freeform: !!allow_freeform, kind: "hook", hook: hook.to_s }.compact
430
+ allow_freeform: !!allow_freeform, effects: tags, kind: "hook", hook: hook.to_s }.compact
417
431
  answer = open_question(fields)
418
432
  return nil unless answer.is_a?(QuestionAnswer)
419
433
 
@@ -493,11 +507,11 @@ module Samagotchi
493
507
 
494
508
  # An answer to +pending+, checked: the selections are its options (value
495
509
  # == label in v1), one for a single-select, one or a text when required,
496
- # and a parent agent's within guardrails.parent_approvals. Under the
510
+ # and a parent agent's within its strategy (ParentPolicy). Under the
497
511
  # question lock: checked against the question pending now.
498
512
  # @return [QuestionAnswer]
499
513
  # @raise [ArgumentError, Refused]
500
- def validated_answer(pending, sel, fm, parent_agent:, parent_setting:)
514
+ def validated_answer(pending, sel, fm, parent_agent:, strategy:)
501
515
  opts = Array(pending.options)
502
516
  invalid = sel.reject { |v| opts.include?(v) }
503
517
  raise ArgumentError, "invalid selection: #{invalid.join(", ")} (valid: #{opts.join(", ")})" unless invalid.empty?
@@ -505,10 +519,10 @@ module Samagotchi
505
519
  raise ArgumentError, "selection required" if pending.multi_select == false && sel.empty? && fm.nil?
506
520
 
507
521
  indices = sel.map { |v| opts.index(v) }
508
- # The one whose answer settles the approval (Approval.settle, by index).
509
- if parent_setting
510
- reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: parent_setting)
511
- raise Refused, reason if reason
522
+ # By index, as the effects (and Approval.settle) read it.
523
+ if strategy
524
+ reason = Guardrails::ParentPolicy.refusal(pending, indices, strategy: strategy)
525
+ raise Refused.new(reason, pending: pending, strategy: strategy) if reason
512
526
  end
513
527
  QuestionAnswer.new(id: pending.id.to_s, selected: sel, freeform: fm, selected_indices: indices.compact,
514
528
  by: parent_agent ? "parent_agent" : nil)
@@ -9,10 +9,11 @@ module Samagotchi
9
9
  # question's answer), whose line the UIs leave out. The worker adds
10
10
  # where it waits (Worker#on_command): +mid_turn+ (the policy it got:
11
11
  # :queue, :refuse, :loop), +after_seq+ (the event count then, so a
12
- # turn's end knows it came before) and +after_file+ (the input file it
13
- # runs after, a basename).
14
- QueuedCommand = Data.define(:command_id, :client_id, :line, :card, :mid_turn, :after_seq, :after_file) do
15
- def initialize(command_id:, line:, client_id: nil, card: false, mid_turn: nil, after_seq: nil, after_file: nil) = super
12
+ # turn's end knows it came before), +after_file+ (the input file it runs
13
+ # after, a basename) and +enqueued_id+ (a file-delivered command's sender).
14
+ QueuedCommand = Data.define(:command_id, :client_id, :line, :card, :mid_turn, :after_seq, :after_file, :enqueued_id) do
15
+ def initialize(command_id:, line:, client_id: nil, card: false, mid_turn: nil, after_seq: nil, after_file: nil,
16
+ enqueued_id: nil) = super
16
17
 
17
18
  # A new command with a fresh id.
18
19
  def self.mint(line, client_id: nil, card: false)
@@ -6,7 +6,9 @@ module Samagotchi
6
6
  # A delegate's approval reopened as the parent session's own (the
7
7
  # approval relay): the same kind "approval" and the same approval facts,
8
8
  # so every UI shows it as it shows the parent's own approvals, the diff
9
- # included, and Guardrails::ParentApprovals judges it the same way. The
9
+ # included, and Guardrails::ParentPolicy judges it the same way: the
10
+ # child's effects and user_only come along (an older child's question
11
+ # has none; the policy derives them from the approval facts). The
10
12
  # question text names the delegate and its task over the child's own
11
13
  # text; `relay:` says which child and which of its questions it answers.
12
14
  # A relay of a relay (a grandchild's) grows relay.chain.
@@ -37,6 +39,8 @@ module Samagotchi
37
39
  header: header(who, more),
38
40
  multi_select: false,
39
41
  allow_freeform: pending.allow_freeform.nil? || !!pending.allow_freeform,
42
+ # Same order: only the session option's label changes.
43
+ **{ effects: pending.effects, user_only: pending.user_only }.compact,
40
44
  kind: Guardrails::Approval::KIND,
41
45
  approval: facts,
42
46
  relay: { id: relay_id, child_id: child.id, child_short: short, child_question_id: pending.id.to_s,
@@ -5,7 +5,6 @@ require_relative "session"
5
5
  require_relative "bridge_client"
6
6
  require_relative "worker_sidecar"
7
7
  require_relative "question_desk"
8
- require_relative "guardrails/parent_approvals"
9
8
  require_relative "log"
10
9
 
11
10
  module Samagotchi
@@ -24,8 +23,8 @@ module Samagotchi
24
23
  end
25
24
 
26
25
  # @return [Array(Hash, Integer, Hash)] headers, status, body: 200
27
- # answered, 409 no longer pending, 403 refused (a parent agent's allow
28
- # beyond this worker's guardrails.parent_approvals), 422
26
+ # answered, 409 no longer pending, 403 refused (a parent agent's pick
27
+ # beyond this worker's Guardrails::ParentPolicy), 422
29
28
  # relay_unverified (logged; the question stays open)
30
29
  def answer(relay_id:, question_id:)
31
30
  parent_id = own_parent_id or return unverified("this session has no parent", relay_id)
@@ -59,8 +58,8 @@ module Samagotchi
59
58
  not_pending(question_id)
60
59
  rescue QuestionDesk::Refused => e
61
60
  log("relay_refused", relay_id, id: question_id, reason: e.reason)
62
- [{}, 403, { error: "parent_approval_refused", reason: e.reason.to_s,
63
- detail: Guardrails::ParentApprovals.message(e.reason) }]
61
+ [{}, 403, { error: "parent_answer_refused", reason: e.reason.to_s,
62
+ detail: e.detail }]
64
63
  rescue ArgumentError => e
65
64
  unverified("the answer doesn't fit the question: #{e.message}", relay_id)
66
65
  end
@@ -3,6 +3,7 @@
3
3
  require_relative "session"
4
4
  require_relative "turn_note"
5
5
  require_relative "session_inbox"
6
+ require_relative "turn_checkpoint"
6
7
  require_relative "worker_sidecar"
7
8
 
8
9
  module Samagotchi
@@ -32,9 +33,10 @@ module Samagotchi
32
33
  # @!attribute question [PendingQuestion, nil] the pending question
33
34
  # (:waiting_for_answer)
34
35
  # @!attribute outcome [String, nil] how the turn ended when it left no
35
- # reply (:no_reply): "failed", "canceled", "completed" (empty),
36
+ # reply (:no_reply): "failed", "canceled", "completed" (empty turn or
37
+ # file-delivered command ran without a turn),
36
38
  # "exhausted" (it ran out of iterations, with nobody asked whether to
37
- # continue) or "not_continued" (a Stop answered the step-limit
39
+ # continue), "not_continued" (a Stop answered the step-limit
38
40
  # question; no turn ran), from the session's last_turn, or "removed"
39
41
  # (turn:'s queued input was taken back before it ran, its tombstone:
40
42
  # SessionInbox.removed?; no turn ran); nil when unknown. With
@@ -47,8 +49,9 @@ module Samagotchi
47
49
  # retry
48
50
  # @!attribute cancel_reason [String, nil] a "canceled" turn's reason
49
51
  # (user, hook, ctrl_c, manual, session_stopped: the session was
50
- # stopped mid-turn), passed through as the worker saved it; any
51
- # reason is a "canceled" outcome
52
+ # stopped mid-turn; worker_stopped: turn:'s worker died in it and the
53
+ # next one settled it, TurnCheckpoint.settled_turn), passed through
54
+ # as the worker saved it; any reason is a "canceled" outcome
52
55
  # @!attribute stopped_by [String, nil] the hook that canceled it
53
56
  # (loop-guard)
54
57
  # @!attribute session [Session, nil] the session as the look that
@@ -156,6 +159,12 @@ module Samagotchi
156
159
  reply.session = settled(session, id, state_dir: state_dir)
157
160
  return reply
158
161
  end
162
+ # Its worker died in it and the next one settled it, or an input
163
+ # command finished without a turn. After the reply check: a worker
164
+ # killed between writing the reply and its idle save left the reply.
165
+ if turn && (settled = TurnCheckpoint.settled_turn(session_dir, turn))
166
+ return Result.new(status: :no_reply, outcome: settled.outcome, session: session, **settled.stop_facts)
167
+ end
159
168
 
160
169
  case session.status
161
170
  when Session::STATUS_ERROR
@@ -314,7 +323,7 @@ module Samagotchi
314
323
 
315
324
  running = session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == id
316
325
  queued = SessionInbox.waiting_input(session_dir, id) == :turn_end
317
- ended = session.last_turn&.enqueued_id == id
326
+ ended = session.last_turn&.enqueued_id == id || !TurnCheckpoint.settled_turn(session_dir, id).nil?
318
327
  running || queued || ended
319
328
  end
320
329
 
@@ -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"
@@ -12,6 +13,9 @@ require_relative "session_chain"
12
13
  require_relative "context_quote"
13
14
  require_relative "reply_wait"
14
15
  require_relative "parent_report"
16
+ require_relative "turn_flow"
17
+ require_relative "guardrails/parent_approvals"
18
+ require_relative "guardrails/parent_policy"
15
19
  require_relative "image_store"
16
20
  require_relative "host_registry"
17
21
  require_relative "model_profile"
@@ -40,7 +44,7 @@ module Samagotchi
40
44
 
41
45
  USAGE = <<~TEXT
42
46
  Usage: chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...
43
- 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]...
44
48
  chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT] [--image PATH]...
45
49
  chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
46
50
  chi send --queue --wait [--timeout S] [--format json] [-m TEXT] ID
@@ -92,6 +96,11 @@ module Samagotchi
92
96
  manifest lists in session_settings:; 3, 2.5, true,
93
97
  false and off are typed, the rest is text:
94
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)
95
104
  --wait wait for the answer and print it (one session); the
96
105
  other lines go to stderr. Exit 3: it waits for an
97
106
  answer from you: the question, its options and the
@@ -150,6 +159,7 @@ module Samagotchi
150
159
  f.value "--tool", key: :tools, repeat: true
151
160
  f.value "--no-tool", key: :no_tools, repeat: true
152
161
  f.value "--set", key: :sets, repeat: true
162
+ f.value "--parent-answers", key: :parent_answers
153
163
  f.value "--image", key: :images, repeat: true
154
164
  f.switch "--wait"
155
165
  f.switch "--cut"
@@ -241,6 +251,23 @@ module Samagotchi
241
251
  if prompt && (local = local_command(prompt))
242
252
  return refuse_local_command(local, options)
243
253
  end
254
+
255
+ # A parent agent's /continue yes is chi answer --option Continue by
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))
260
+ @stderr.puts("chi send: #{refusal}")
261
+ return CLI::Exit::FAILED
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
244
271
  # A file that is not there or not an image: a run-time failure (1),
245
272
  # reported in the --format json line too; --image itself was fine.
246
273
  return CLI::Exit::FAILED unless ingest_images(options[:images])
@@ -294,6 +321,7 @@ module Samagotchi
294
321
  TOOLKIT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") unless Array(options[key]).empty? }
295
322
  LLM_CONTEXT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") if options[key] }
296
323
  return usage_error("--set needs --new") unless Array(options[:sets]).empty?
324
+ return usage_error("--parent-answers needs --new") if options[:parent_answers]
297
325
  return usage_error("give session ids") if options[:ids].empty?
298
326
 
299
327
  options
@@ -313,10 +341,10 @@ module Samagotchi
313
341
  taken = [("--dir" if options[:dir])] +
314
342
  LLM_CONTEXT_FLAGS.filter_map { |key, flag| flag if options[key] } + [("--thinking" if options[:thinking])] +
315
343
  [("--toolkit" if options[:toolkit])] + TOOLKIT_FLAGS.filter_map { |key, flag| flag unless Array(options[key]).empty? } +
316
- [("--set" unless Array(options[:sets]).empty?)]
344
+ [("--set" unless Array(options[:sets]).empty?), ("--parent-answers" if options[:parent_answers])]
317
345
  unless taken.compact.empty?
318
346
  return usage_error("--continues takes the previous session's folder, LLM context, thinking level, " \
319
- "toolkit and bundle settings; " \
347
+ "toolkit, bundle settings and parent_answers; " \
320
348
  "leave out #{taken.compact.join(", ")}")
321
349
  end
322
350
  end
@@ -336,6 +364,13 @@ module Samagotchi
336
364
 
337
365
  options[:thinking] = Thinking.session_level(word)
338
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
339
374
  toolkit = Toolkit.from_flags(name: options[:toolkit], add: options[:tools], remove: options[:no_tools])
340
375
  options[:toolkit_value] = toolkit if toolkit
341
376
  begin
@@ -393,7 +428,8 @@ module Samagotchi
393
428
  setup: SessionSetup.new(llm_context: options[:llm_context],
394
429
  thinking: options[:thinking],
395
430
  toolkit: options[:toolkit_value],
396
- bundle_settings: options[:bundle_settings] || {}),
431
+ bundle_settings: options[:bundle_settings] || {},
432
+ parent_answers: options[:parent_answers]),
397
433
  state_dir: @state_dir)
398
434
  end
399
435
  rescue SessionContinue::Refused, SessionManager::ArchiveRefused, SessionManager::OwnedByTUI => e
@@ -404,6 +440,9 @@ module Samagotchi
404
440
  return 1
405
441
  end
406
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
407
446
  if through_worker
408
447
  return 1 unless deliver_new(session, prompt)
409
448
  else
@@ -414,7 +453,8 @@ module Samagotchi
414
453
  return report_command(session.id) if @sent_command
415
454
 
416
455
  wait_for_reply(session.id, cursor: nil, baseline: baseline_of(session, question_id: nil),
417
- timeout: options[:timeout], report_turn_id: @sent_enqueued_id)
456
+ timeout: options[:timeout], turn: @sent_input_file ? @sent_enqueued_id : nil,
457
+ report_turn_id: @sent_enqueued_id)
418
458
  end
419
459
 
420
460
  # The model the new session will run, for the images check: --model
@@ -461,11 +501,15 @@ module Samagotchi
461
501
  # The turn waits on a question: the message would only queue
462
502
  # behind it, and the answer goes in with chi answer.
463
503
  if (waiting = session.waiting_question(live: owner.worker?))
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)
464
508
  if ParentReport.approval?(waiting)
465
- error_line("#{id[0, 8]} waiting for an approval: #{ParentReport::DENY_AND_TELL} " \
466
- "(#{ParentReport.answer_with(id, waiting)})")
509
+ error_line("#{id[0, 8]} waiting for an approval: #{ParentReport.approval_advice(question, strategy: strategy)} " \
510
+ "(#{ParentReport.answer_with(id, question)})")
467
511
  else
468
- error_line("#{id[0, 8]} waiting for an answer: #{ParentReport.answer_with(id, waiting)} (or --text); " \
512
+ error_line("#{id[0, 8]} waiting for an answer: #{ParentReport.answer_with(id, question)} (or --text); " \
469
513
  "chi --attach #{id} shows it")
470
514
  end
471
515
  else
@@ -485,7 +529,9 @@ module Samagotchi
485
529
  # The ack's id names the queued message's turn, also when the turn
486
530
  # running at the check ended before it went in (an idle worker's ack
487
531
  # says next_step, and the file still runs as that turn).
488
- turn = @queue ? @sent_enqueued_id : nil
532
+ # A file fallback has no delivery field; use its id to see terminal
533
+ # outcomes for commands that finish without starting a turn.
534
+ turn = @sent_enqueued_id if @queue || @sent_input_file
489
535
  report_turn_id = if @queue
490
536
  turn
491
537
  elsif session.status == Session::STATUS_RUNNING
@@ -493,7 +539,8 @@ module Samagotchi
493
539
  else
494
540
  @sent_enqueued_id
495
541
  end
496
- wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], turn: turn, queued: !turn.nil?,
542
+ wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], turn: turn,
543
+ queued: @queue && !turn.nil?,
497
544
  report_turn_id: report_turn_id)
498
545
  end
499
546
 
@@ -550,7 +597,7 @@ module Samagotchi
550
597
  begin
551
598
  refs = copy_images(dir)
552
599
  if BridgeClient.wait_for(session.id, session_dir: dir, timeout: SessionManager::TURN_BRIDGE_WAIT)
553
- result = SessionManager.deliver_turn(session.id, prompt: prompt, client_id: CLIENT_ID, images: refs,
600
+ result = SessionManager.deliver_turn(session.id, prompt: prompt, client_id: client_id_for(prompt), images: refs,
554
601
  state_dir: @state_dir)
555
602
  end
556
603
  rescue StandardError => e
@@ -569,6 +616,7 @@ module Samagotchi
569
616
  end
570
617
 
571
618
  ack = result[:ack].is_a?(Hash) ? result[:ack] : {}
619
+ @sent_input_file = !ack.key?("delivery")
572
620
  @sent_enqueued_id = (ack["enqueued_id"] || ack[:enqueued_id])&.to_s
573
621
  @sent_command = command_ack?(result[:ack])
574
622
  @info.puts("#{session.id} started#{continued_words(session)}#{with_images}#{"; #{SENT_COMMAND}" if @sent_command}")
@@ -581,6 +629,48 @@ module Samagotchi
581
629
  # /turn whose line is one answers with its command_id).
582
630
  def command_ack?(ack) = ack.is_a?(Hash) && !ack["command_id"].to_s.empty?
583
631
 
632
+ # chi send's client id, or chi answer's for a parent agent's /continue
633
+ # yes (the worker holds that to its guardrails.parent_answers).
634
+ def client_id_for(prompt) = parent_continue?(prompt) ? ClientId::CLI_ANSWER : CLIENT_ID
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
+
671
+ # A /continue yes (or bare /continue) from a parent agent's process.
672
+ def parent_continue?(prompt) = TurnFlow.continue_yes?(prompt) && Guardrails::ParentApprovals.parent_process?
673
+
584
674
  # Whether +text+ may be a session command, which only its worker knows
585
675
  # (a bundle's too).
586
676
  def command_like?(text) = text.to_s.lstrip.start_with?("/", "!")
@@ -648,7 +738,7 @@ module Samagotchi
648
738
  delivery = if @queue then Delivery::QUEUE
649
739
  elsif @cut && running then Delivery::CUT
650
740
  end
651
- result = SessionManager.deliver_turn(id, prompt: prompt, client_id: CLIENT_ID, images: refs, state_dir: @state_dir,
741
+ result = SessionManager.deliver_turn(id, prompt: prompt, client_id: client_id_for(prompt), images: refs, state_dir: @state_dir,
652
742
  delivery: delivery)
653
743
  unless result[:status] == :accepted
654
744
  # A worker older than queueing would steer it: refused, not sent.
@@ -664,6 +754,7 @@ module Samagotchi
664
754
  end
665
755
 
666
756
  ack = result[:ack].is_a?(Hash) ? result[:ack] : {}
757
+ @sent_input_file = !ack.key?("delivery")
667
758
  # What the worker applied, for --format json: its ack's delivery
668
759
  # (next_step for an idle session) and a cut's outcome.
669
760
  @sent_delivery = ack["delivery"] || delivery
@@ -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