samagotchi 0.92.0 → 0.94.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -1
  3. data/README.md +1 -1
  4. data/bin/chi +4 -4
  5. data/docs/architecture.md +4 -4
  6. data/docs/cli.md +1 -1
  7. data/docs/guardrails.md +4 -5
  8. data/docs/hooks.md +1 -1
  9. data/docs/internals/bundles.md +1 -1
  10. data/docs/internals/engine.md +6 -0
  11. data/docs/memory.md +26 -1
  12. data/docs/plugins.md +37 -0
  13. data/docs/releasing.md +2 -2
  14. data/docs/sessions.md +1 -1
  15. data/docs/testing.md +11 -2
  16. data/lib/samagotchi/bootstrap/probe.rb +2 -2
  17. data/lib/samagotchi/bootstrap_command.rb +1 -0
  18. data/lib/samagotchi/bundle_command.rb +1 -0
  19. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  20. data/lib/samagotchi/bundles/model-notes/manifest.yml +11 -0
  21. data/lib/samagotchi/bundles/model-notes/model_notes_deepseek.md +7 -0
  22. data/lib/samagotchi/bundles/model-notes/notes/README.md +37 -0
  23. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  24. data/lib/samagotchi/child_reports.rb +12 -14
  25. data/lib/samagotchi/client_id.rb +18 -0
  26. data/lib/samagotchi/config/restart_watch.rb +112 -0
  27. data/lib/samagotchi/config.rb +18 -59
  28. data/lib/samagotchi/engine.rb +4 -14
  29. data/lib/samagotchi/guardrails/data_readers.rb +34 -30
  30. data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
  31. data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
  32. data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
  33. data/lib/samagotchi/hook_question.rb +21 -0
  34. data/lib/samagotchi/hook_routes.rb +6 -6
  35. data/lib/samagotchi/hooks/registry.rb +5 -5
  36. data/lib/samagotchi/host_registry.rb +1 -1
  37. data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
  38. data/lib/samagotchi/memory_bundle/profile.rb +22 -4
  39. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -1
  40. data/lib/samagotchi/parent_answers_command.rb +6 -6
  41. data/lib/samagotchi/plugin/context.rb +6 -5
  42. data/lib/samagotchi/plugin/http_transport.rb +14 -3
  43. data/lib/samagotchi/plugin/sessions.rb +2 -3
  44. data/lib/samagotchi/question_desk.rb +12 -9
  45. data/lib/samagotchi/reply_wait/poll.rb +267 -0
  46. data/lib/samagotchi/reply_wait.rb +47 -190
  47. data/lib/samagotchi/send_command/parent_gate.rb +83 -0
  48. data/lib/samagotchi/send_command/wait_target.rb +55 -0
  49. data/lib/samagotchi/send_command.rb +31 -100
  50. data/lib/samagotchi/session.rb +10 -9
  51. data/lib/samagotchi/session_inbox.rb +31 -8
  52. data/lib/samagotchi/session_manager.rb +48 -15
  53. data/lib/samagotchi/session_metrics.rb +22 -3
  54. data/lib/samagotchi/sessions_command.rb +4 -4
  55. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
  56. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  57. data/lib/samagotchi/testing/bundle.rb +1 -1
  58. data/lib/samagotchi/testing/host.rb +2 -8
  59. data/lib/samagotchi/testing/recorder.rb +6 -9
  60. data/lib/samagotchi/testing/sandbox.rb +26 -0
  61. data/lib/samagotchi/tools/delegate.rb +10 -10
  62. data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
  63. data/lib/samagotchi/tools/delegate_relay.rb +31 -16
  64. data/lib/samagotchi/tools/delegate_wait.rb +4 -4
  65. data/lib/samagotchi/update_command.rb +1 -1
  66. data/lib/samagotchi/version.rb +1 -1
  67. data/lib/samagotchi/web/app.rb +46 -63
  68. data/lib/samagotchi/web/public/app.js +137 -638
  69. data/lib/samagotchi/web/public/dock_reserve.js +103 -0
  70. data/lib/samagotchi/web/public/index.html +4 -4
  71. data/lib/samagotchi/web/public/send_turn.js +173 -0
  72. data/lib/samagotchi/web/public/session_actions.js +203 -0
  73. data/lib/samagotchi/web/public/start_page.js +236 -0
  74. data/lib/samagotchi/web/public/toast_place.js +1 -1
  75. metadata +14 -1
@@ -5,6 +5,7 @@ require_relative "turn_note"
5
5
  require_relative "session_inbox"
6
6
  require_relative "turn_checkpoint"
7
7
  require_relative "worker_sidecar"
8
+ require_relative "reply_wait/poll"
8
9
 
9
10
  module Samagotchi
10
11
  # Loaded on first use: this autoload keeps SessionManager and its ~80
@@ -65,6 +66,31 @@ module Samagotchi
65
66
  Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, :error_kind, :retryable, :cancel_reason,
66
67
  :stopped_by, :kept_steps, :session, :turn_id, :origin, keyword_init: true)
67
68
 
69
+ # The session as it was before a message went in (call's baseline:,
70
+ # #baseline_of): its message count, the question pending then, and when
71
+ # its last turn ended (+last_turn+, last_turn.ended_at, maybe nil).
72
+ # Every turn's end records last_turn as it goes idle (Engine#end_turn),
73
+ # so +last_turn+ alone says whether a turn ended since: messages also
74
+ # grow between turns (a new worker's context note, a chi note) with no
75
+ # turn run. +messages+ is kept for a delegate's cursor (DelegateCursor:
76
+ # nil there means no baseline taken).
77
+ Baseline = Data.define(:messages, :question_id, :last_turn) do
78
+ def initialize(messages: nil, question_id: nil, last_turn: nil) = super
79
+
80
+ # Whether +session+'s last turn ended since: its last_turn moved on.
81
+ def turn_ended_since?(session)
82
+ ended = session.last_turn&.ended_at
83
+ !ended.nil? && ended != last_turn
84
+ end
85
+
86
+ # Whether +ended_at+ is the end of the turn that was last at the
87
+ # baseline (a turn that ended before it).
88
+ def ended_at?(ended_at) = ended_at == last_turn
89
+
90
+ # Whether +question_id+ was pending at the baseline (not the answer's).
91
+ def question?(question_id) = question_id == self.question_id
92
+ end
93
+
68
94
  module_function
69
95
 
70
96
  # @param id [String] the session's full id
@@ -73,16 +99,11 @@ module Samagotchi
73
99
  # @param timeout [Numeric, nil] seconds; nil waits with no limit
74
100
  # @param poll_interval [Float]
75
101
  # @param cancelled [#call] true stops the wait (:canceled)
76
- # @param baseline [Hash, nil] {messages:, question_id:} as the session
77
- # was before the message went in. With it, a turn that grew the
78
- # messages (a failed, canceled or empty one leaves its note) and went
79
- # idle again ends the wait even if it was never seen running, and a
80
- # question already pending then is not the answer's. A nil
81
- # messages: skips the count. last_turn: (the session's
82
- # last_turn.ended_at then, maybe nil) ends the wait on any turn
83
- # that ended since, idle again, however fast it ran (a failure after
84
- # a failure replaces the note and leaves the count as it was), and
85
- # with it the count isn't used: a note between turns grows it too.
102
+ # @param baseline [Baseline, nil] the session as it was before the
103
+ # message went in (#baseline_of). With it, any turn that ended since
104
+ # (its last_turn moved on) and went idle again ends the wait, even if
105
+ # it was never seen running, however fast it ran, and a question
106
+ # already pending then is not the answer's.
86
107
  # @param owner_grace [Numeric, nil] seconds with no live worker before
87
108
  # :worker_gone (one that died before its rescue leaves it running)
88
109
  # @param interject [#call, nil] called once a poll, before the sleep
@@ -116,187 +137,22 @@ module Samagotchi
116
137
  # @raise [ArgumentError] no such session
117
138
  def call(id, state_dir:, cursor:, timeout: nil, poll_interval: POLL_INTERVAL, cancelled: -> { false },
118
139
  baseline: nil, owner_grace: nil, interject: nil, turn: nil, queued: false, ended_before: false)
119
- deadline = timeout && (monotonic + timeout.to_f)
120
- seen_running = false
121
- gone_since = nil
122
- turn = turn.to_s.empty? ? nil : turn.to_s
123
- session_dir = Session.session_dir(id, state_dir: state_dir) if turn
124
- # Whether the turn's input file has been taken (latched): looked at
125
- # before each load, so a running session seen after it is that turn
126
- # or a later one, never the turn that ran when the message was sent.
127
- claimed = false
128
- # Its input runs a turn of its own (a queue, images: +queued+, or seen
129
- # waiting as one): never merged into another turn.
130
- own_turn_input = queued
131
- # The turn was seen running (its mark's id), and last_turn's ended_at
132
- # then: a later end that isn't its is a turn after it.
133
- seen_own = false
134
- own_base = nil
135
-
136
- loop do
137
- if turn && !claimed
138
- waiting = SessionInbox.waiting_input(session_dir, turn)
139
- own_turn_input ||= waiting == :turn_end
140
- claimed = waiting.nil?
141
- end
142
- session = Session.load(id, state_dir: state_dir)
143
- # Taken back before it ran (POST turn/dequeue): no turn will come.
144
- if turn && claimed && !seen_own && SessionInbox.removed?(session_dir, turn)
145
- return Result.new(status: :no_reply, outcome: "removed", session: session)
146
- end
147
-
148
- if turn && !seen_own && session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == turn
149
- seen_own = true
150
- own_base = session.last_turn&.ended_at
151
- end
152
- # Taken but never run as a turn of its own: a running turn's drain
153
- # merged it as a steer, so that turn's reply (or question) is its
154
- # answer. Waited for as the next reply past the cursor, as without
155
- # turn:, never as a turn that will not come.
156
- if turn && claimed && !seen_own && !own_turn_input && merged_elsewhere?(session, turn, baseline, session_dir)
157
- turn = nil
158
- seen_running = session.status == Session::STATUS_RUNNING
159
- end
160
-
161
- if (reply = turn ? reply_for_turn(id, state_dir: state_dir, turn: turn) : reply_past(id, state_dir: state_dir, cursor: cursor))
162
- reply.session = settled(session, id, state_dir: state_dir)
163
- return reply
164
- end
165
- # Its worker died in it and the next one settled it, or an input
166
- # command finished without a turn. After the reply check: a worker
167
- # killed between writing the reply and its idle save left the reply.
168
- if turn && (settled = TurnCheckpoint.settled_turn(session_dir, turn))
169
- return Result.new(status: :no_reply, outcome: settled.outcome, session: session, origin: settled.origin,
170
- **settled.stop_facts)
171
- end
172
-
173
- case session.status
174
- when Session::STATUS_ERROR
175
- return Result.new(status: :error, text: session.last_prompt.to_s.strip, session: session)
176
- when Session::STATUS_STOPPED
177
- return Result.new(status: :stopped, session: session)
178
- end
179
-
180
- # Only a question a live worker holds waits for an answer (the
181
- # lists' rule): one a dead worker left waits for no one, and a chi
182
- # REPL's is answered there.
183
- pending = session.pending_question
184
- if pending && !(baseline && pending.id == baseline[:question_id]) &&
185
- (turn.nil? || own_question?(session, turn, claimed)) &&
186
- session.waiting_question(live: SessionManager.worker_live?(id, state_dir: state_dir))
187
- return Result.new(status: :waiting_for_answer, question: pending, session: session)
188
- end
189
-
190
- return Result.new(status: :canceled) if cancelled.call
191
-
192
- # The turn that ended before this wait (ended_before: chi send
193
- # --wait with no message, on a session that went idle in the gap
194
- # between waits): its last_turn still stands, the session is idle,
195
- # so its end is the answer, not a wait that times out "running"
196
- # again. Its reply file sorts at the cursor (the newest), not past
197
- # it: it is the turn's when it is at least as new as the turn's
198
- # end (the worker writes it after recording the ended_at), else the
199
- # turn left no reply and an earlier turn's file is not its.
200
- if ended_before && turn.nil? && session.status == Session::STATUS_IDLE &&
201
- (ended = session.last_turn&.ended_at) && !ended.to_s.empty? &&
202
- baseline && ended == baseline[:last_turn]
203
- file = newest_reply(id, state_dir: state_dir)
204
- path = File.join(reply_dir(id, state_dir: state_dir), file) if file
205
- if file && reply_is_turns?(path, ended)
206
- reply = Result.new(status: :done, text: File.read(path), file: file)
207
- reply.session = settled(session, id, state_dir: state_dir)
208
- return reply
209
- end
210
-
211
- return no_reply(session, baseline, fresh: true)
212
- end
213
-
214
- if ended_before && turn && session.last_turn&.enqueued_id == turn &&
215
- !(session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == turn) &&
216
- (ended = session.last_turn&.ended_at) && !ended.to_s.empty? &&
217
- baseline && ended == baseline[:last_turn]
218
- # The turn-specific reply lookup above already found its file, if
219
- # one exists. Without it, don't use the newest file as a fallback:
220
- # it could belong to another turn.
221
- return no_reply(session, baseline, fresh: true)
222
- end
223
-
224
- if turn
225
- # Only the turn's own end: the session runs another turn when the
226
- # wait starts, and that one's end is not the answer. A last_turn
227
- # with its id is its end, even with a next queued turn running
228
- # by now (the look between the two missed the idle).
229
- # Only an end after the baseline: a continue offer answered from
230
- # idle leaves the exhausted turn's last_turn, with the same id,
231
- # until the continue turn (or a Stop) records its own.
232
- if session.last_turn&.enqueued_id == turn && ended_since_baseline?(session, baseline)
233
- return reply_for_turn(id, state_dir: state_dir, turn: turn)&.tap { |r| r.session = session } ||
234
- no_reply(session, baseline, fresh: true)
235
- end
236
- # Seen running, and now another turn runs or a later turn ended
237
- # while last_turn (looked at first) isn't its: lost (its worker
238
- # died), or it ended with no reply and a next turn ran and ended
239
- # between two looks. Waiting on would sit through those turns.
240
- return Result.new(status: :no_reply, session: session) if seen_own && past_own?(session, turn, own_base)
241
- elsif session.status == Session::STATUS_RUNNING
242
- seen_running = true
243
- elsif (seen_running && !baseline&.key?(:last_turn)) || turn_ended_since?(session, baseline)
244
- # It ran and is idle again with no new reply: canceled, failed or
245
- # empty. Before it was ever seen running, idle means a
246
- # file-delivered message its worker has not picked up yet. With
247
- # the baseline's last_turn only a turn that ended since counts:
248
- # a session left running by a worker that died goes idle when the
249
- # next worker settles that turn (TurnCheckpoint.settle), before
250
- # the sent message's turn runs.
251
- # The worker writes the reply before its idle save, so the read
252
- # above has it; one more look costs nothing should that change.
253
- return reply_past(id, state_dir: state_dir, cursor: cursor)&.tap { |r| r.session = session } ||
254
- no_reply(session, baseline)
255
- end
256
-
257
- if owner_grace
258
- if SessionManager.session_owner(id, state_dir: state_dir)
259
- gone_since = nil
260
- elsif monotonic - (gone_since ||= monotonic) > owner_grace
261
- return Result.new(status: :worker_gone, session: session)
262
- end
263
- end
264
-
265
- return Result.new(status: :timeout) if deadline && monotonic > deadline
266
-
267
- if interject
268
- started = monotonic
269
- interject.call
270
- deadline += monotonic - started if deadline
271
- next if cancelled.call
272
- end
273
- sleep(poll_interval)
274
- end
140
+ Poll.new(id, state_dir: state_dir, cursor: cursor, timeout: timeout, poll_interval: poll_interval,
141
+ cancelled: cancelled, baseline: baseline, owner_grace: owner_grace, interject: interject, turn: turn,
142
+ queued: queued, ended_before: ended_before).run
275
143
  end
276
144
 
277
145
  # The session as it was before a message went in (call's baseline:):
278
146
  # its messages, the question pending then, and when its last turn
279
147
  # ended, so a turn that ends before the first look still ends the wait.
280
- # @return [Hash]
148
+ # @return [Baseline]
281
149
  def baseline_of(session, question_id: session.pending_question&.id)
282
- { messages: session.messages.size, question_id: question_id, last_turn: session.last_turn&.ended_at }
150
+ Baseline.new(messages: session.messages.size, question_id: question_id, last_turn: session.last_turn&.ended_at)
283
151
  end
284
152
 
285
- # An idle session whose turn ended after the baseline was taken: its
286
- # last_turn moved on. Every turn's end records last_turn as it goes
287
- # idle (Engine#end_turn), so with one in the baseline that alone
288
- # decides: messages also grow between turns (a new worker's context
289
- # note, a chi note) with no turn run. A baseline without it falls back
290
- # to the count (a turn's note).
291
- def turn_ended_since?(session, baseline)
292
- return false unless baseline
293
-
294
- if baseline.key?(:last_turn)
295
- ended = session.last_turn&.ended_at
296
- return !ended.nil? && ended != baseline[:last_turn]
297
- end
298
- !!(baseline[:messages] && session.messages.size > baseline[:messages])
299
- end
153
+ # A session whose turn ended after the baseline was taken (Baseline#
154
+ # turn_ended_since?); false with no baseline.
155
+ def turn_ended_since?(session, baseline) = baseline ? baseline.turn_ended_since?(session) : false
300
156
 
301
157
  # The turn a caller that sent nothing means (chi answer, chi send --wait
302
158
  # with no message): the one running now (its checkpoint mark's
@@ -354,20 +210,21 @@ module Samagotchi
354
210
  !ended && claimed
355
211
  end
356
212
 
357
- # The turn's end, after the baseline was taken (with no baseline, or
358
- # one without last_turn, any).
213
+ # The turn's end, after the baseline was taken (with no baseline, any).
359
214
  def ended_since_baseline?(session, baseline)
360
- return true unless baseline&.key?(:last_turn)
215
+ return true unless baseline
361
216
 
362
- session.last_turn&.ended_at != baseline[:last_turn]
217
+ !baseline.ended_at?(session.last_turn&.ended_at)
363
218
  end
364
219
 
365
220
  # A turn: wait's taken input ran in another turn (a steer its drain
366
221
  # merged): that turn runs (its mark has another id, or none from a
367
222
  # worker that writes ids), or, idle, a turn not its ended since the
368
- # baseline. last_turn or a reply file being its own is never that.
223
+ # baseline. last_turn or a reply file being its own is never that, nor
224
+ # an input with a tombstone (a removal's, written before its claim).
369
225
  def merged_elsewhere?(session, turn, baseline, session_dir)
370
226
  return false if session.last_turn&.enqueued_id == turn || SessionInbox.reply_for(session_dir, turn)
227
+ return false if SessionInbox.removed?(session_dir, turn)
371
228
 
372
229
  if session.status == Session::STATUS_RUNNING
373
230
  running_id = session.interrupted_turn&.enqueued_id
@@ -375,7 +232,7 @@ module Samagotchi
375
232
 
376
233
  return turn_ids?(session_dir)
377
234
  end
378
- baseline&.key?(:last_turn) ? turn_ended_since?(session, baseline) : false
235
+ turn_ended_since?(session, baseline)
379
236
  end
380
237
 
381
238
  # A turn seen running (+own_base+: last_turn's ended_at then) is past:
@@ -406,7 +263,7 @@ module Samagotchi
406
263
  # records, when it is the turn waited for
407
264
  def no_reply(session, baseline, fresh: nil)
408
265
  last = session.last_turn || LastTurn.new
409
- fresh = baseline.nil? || !baseline.key?(:last_turn) || (last.ended_at && last.ended_at != baseline[:last_turn]) if fresh.nil?
266
+ fresh = baseline.nil? || (last.ended_at && !baseline.ended_at?(last.ended_at)) if fresh.nil?
410
267
  outcome = fresh ? last.outcome : nil
411
268
  return Result.new(status: :no_reply, outcome: "exhausted", limit: last.limit, session: session) if fresh && last.exhausted
412
269
 
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../session"
4
+ require_relative "../session_chain"
5
+ require_relative "../session_commands"
6
+ require_relative "../parent_answers_command"
7
+ require_relative "../turn_flow"
8
+ require_relative "../guardrails/parent_approvals"
9
+ require_relative "../guardrails/parent_policy"
10
+
11
+ module Samagotchi
12
+ class SendCommand
13
+ # What a parent agent's line may do, checked before chi send sends it
14
+ # (before a --new session is created too), against each target's own
15
+ # parent_answers: a --new session's --parent-answers, a --continues
16
+ # link's previous session's (the link keeps it), each id's.
17
+ #
18
+ # A parent agent's /continue yes is chi answer --option Continue by
19
+ # another route: held to guardrails.parent_answers tightened by each
20
+ # target's own (#continue_refusal), and sent marked as chi answer's
21
+ # (SendCommand#client_id_for) so the worker checks it again. A
22
+ # /parent-answers line never loosens a target's own value (#loosens?):
23
+ # only a user at a UI does, and the worker refuses it too. Tightening
24
+ # goes.
25
+ class ParentGate
26
+ # @param options [Hash] chi send's parsed options (:new, :continues,
27
+ # :parent_answers, :ids)
28
+ def initialize(options, state_dir:)
29
+ @options = options
30
+ @state_dir = state_dir
31
+ end
32
+
33
+ # A /continue yes (or bare /continue) from a parent agent's process.
34
+ def self.parent_continue?(prompt) = TurnFlow.continue_yes?(prompt) && Guardrails::ParentApprovals.parent_process?
35
+
36
+ # @return [String, nil] the first target's refusal of a parent's
37
+ # /continue yes; nil for any other line, or when none refuses
38
+ def continue_refusal(prompt)
39
+ return nil unless self.class.parent_continue?(prompt)
40
+
41
+ target_values.each do |value|
42
+ strategy = Guardrails::ParentPolicy.effective(value)
43
+ refusal = Guardrails::ParentPolicy.continue_refusal(strategy: strategy)
44
+ return refusal if refusal
45
+ end
46
+ nil
47
+ end
48
+
49
+ # Whether +prompt+ is a /parent-answers line that loosens a target's
50
+ # own value.
51
+ def loosens?(prompt)
52
+ SessionCommands.builtin_registry.lookup(prompt)&.id == :parent_answers &&
53
+ target_values.any? { |value| ParentAnswersCommand.loosens?(prompt, value) }
54
+ end
55
+
56
+ private
57
+
58
+ # The targets' own parent_answers values. An id that doesn't resolve
59
+ # is skipped (SendCommand#resolve reports it later); none left is
60
+ # config.yml's alone ([nil]).
61
+ def target_values
62
+ @target_values ||= if @options[:new] && !@options[:continues]
63
+ [@options[:parent_answers]]
64
+ else
65
+ sessions = if @options[:new]
66
+ [target_session { SessionChain.resolve(@options[:continues], state_dir: @state_dir) }]
67
+ else
68
+ @options[:ids].map { |given| target_session { Session.resolve_id(given, state_dir: @state_dir) } }
69
+ end
70
+ values = sessions.compact.map(&:parent_answers)
71
+ values.empty? ? [nil] : values
72
+ end
73
+ end
74
+
75
+ # The session the block's id names, or nil when there is none.
76
+ def target_session
77
+ Session.load(yield, state_dir: @state_dir)
78
+ rescue ArgumentError
79
+ nil
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../reply_wait"
4
+ require_relative "../session"
5
+
6
+ module Samagotchi
7
+ class SendCommand
8
+ # Which turn chi send --wait waits for and names in its report:
9
+ # ReplyWait's +turn+ (an enqueued_id; nil: the next reply), +queued+
10
+ # and +ended_before+, and ParentWait's +report_turn_id+ (the turn_id
11
+ # of the --format json line). Each run path builds it its way.
12
+ WaitTarget = Data.define(:turn, :report_turn_id, :queued, :ended_before) do
13
+ def initialize(turn: nil, report_turn_id: nil, queued: false, ended_before: false) = super
14
+
15
+ # A --new session's first message. A file-delivered one's own turn
16
+ # (its id also names the outcome of a session command that runs no
17
+ # turn); through the Bridge, the next reply.
18
+ # @param delivered [SessionManager::DeliverResult, nil] nil when the
19
+ # message started the session itself (no delivery)
20
+ def self.first_message(delivered)
21
+ return new unless delivered
22
+
23
+ new(turn: delivered.file? ? delivered.enqueued_id : nil, report_turn_id: delivered.enqueued_id)
24
+ end
25
+
26
+ # A message to an existing session. --queue: its own turn, the ack's
27
+ # id, also when the turn running at the check ended before it went in
28
+ # (an idle worker's ack says next_step, and the file still runs as
29
+ # that turn). A file fallback's id names its turn too. Otherwise the
30
+ # next reply, reported as the turn that was running when it went in
31
+ # (+running_turn+, ReplyWait.own_turn), or as its own on an idle one.
32
+ # @param running [Boolean] the session was running when it went in
33
+ def self.message(delivered, queue:, running:, running_turn:)
34
+ turn = delivered.enqueued_id if queue || delivered.file?
35
+ report = if queue then turn
36
+ elsif running then running_turn
37
+ else delivered.enqueued_id
38
+ end
39
+ new(turn: turn, report_turn_id: report, queued: queue && !turn.nil?)
40
+ end
41
+
42
+ # --wait with no message: --turn's (+requested+), or the turn running
43
+ # now or whose continue offer stands (ReplyWait.own_turn). Settled
44
+ # (idle, a turn ended, no question standing: a continue offer waits
45
+ # for its answer), its last turn's end is the answer (ended_before);
46
+ # with --turn, when that turn is the last one.
47
+ def self.wait_only(session, session_dir:, requested:)
48
+ turn = requested || ReplyWait.own_turn(session, session_dir: session_dir)
49
+ settled = session.status == Session::STATUS_IDLE && !session.last_turn.nil? && session.pending_question.nil?
50
+ ended_before = requested ? session.last_turn&.enqueued_id == requested : settled
51
+ new(turn: turn, report_turn_id: turn || (session.last_turn&.enqueued_id if settled), ended_before: ended_before)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -23,6 +23,8 @@ require_relative "vision_support"
23
23
  require_relative "cli/command"
24
24
  require_relative "cli/flags"
25
25
  require_relative "cli/parent_wait"
26
+ require_relative "send_command/parent_gate"
27
+ require_relative "send_command/wait_target"
26
28
 
27
29
  module Samagotchi
28
30
  # `chi send`: put text into sessions as the user's message, the same as
@@ -252,19 +254,14 @@ module Samagotchi
252
254
  return refuse_local_command(local, options)
253
255
  end
254
256
 
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))
257
+ # A parent agent's /continue yes held to guardrails.parent_answers,
258
+ # and a /parent-answers that loosens: refused before anything is sent.
259
+ gate = ParentGate.new(options, state_dir: @state_dir)
260
+ if prompt && (refusal = gate.continue_refusal(prompt))
260
261
  @stderr.puts("chi send: #{refusal}")
261
262
  return CLI::Exit::FAILED
262
263
  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) }
264
+ if prompt && gate.loosens?(prompt)
268
265
  error_line("chi send: refused: #{ParentAnswersCommand::REFUSED}")
269
266
  return CLI::Exit::FAILED
270
267
  end
@@ -365,11 +362,12 @@ module Samagotchi
365
362
  options[:thinking] = Thinking.session_level(word)
366
363
  end
367
364
  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)
365
+ level = Guardrails::ParentPolicy.parse_level(options[:parent_answers])
366
+ unless Guardrails::ParentPolicy::ORDER.include?(level)
367
+ return usage_error("--parent-answers takes #{Guardrails::ParentPolicy::ORDER.join(", ")}, not #{options[:parent_answers]}")
368
+ end
371
369
 
372
- options[:parent_answers] = word
370
+ options[:parent_answers] = level
373
371
  end
374
372
  toolkit = Toolkit.from_flags(name: options[:toolkit], add: options[:tools], remove: options[:no_tools])
375
373
  options[:toolkit_value] = toolkit if toolkit
@@ -453,8 +451,7 @@ module Samagotchi
453
451
  return report_command(session.id) if @sent_command
454
452
 
455
453
  wait_for_reply(session.id, cursor: nil, baseline: baseline_of(session, question_id: nil),
456
- timeout: options[:timeout], turn: @sent_input_file ? @sent_enqueued_id : nil,
457
- report_turn_id: @sent_enqueued_id)
454
+ timeout: options[:timeout], **WaitTarget.first_message(@delivered).to_h)
458
455
  end
459
456
 
460
457
  # The model the new session will run, for the images check: --model
@@ -526,29 +523,15 @@ module Samagotchi
526
523
  return 1 unless deliver(id, prompt)
527
524
  return report_command(id) if @sent_command
528
525
 
529
- # The ack's id names the queued message's turn, also when the turn
530
- # running at the check ended before it went in (an idle worker's ack
531
- # says next_step, and the file still runs as that turn).
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
535
- report_turn_id = if @queue
536
- turn
537
- elsif session.status == Session::STATUS_RUNNING
538
- running_turn
539
- else
540
- @sent_enqueued_id
541
- end
542
- wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], turn: turn,
543
- queued: @queue && !turn.nil?,
544
- report_turn_id: report_turn_id)
526
+ target = WaitTarget.message(@delivered, queue: @queue, running: session.status == Session::STATUS_RUNNING,
527
+ running_turn: running_turn)
528
+ wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], **target.to_h)
545
529
  end
546
530
 
547
531
  # --wait with no message: the session's next reply, sending nothing,
548
532
  # so an agent can go back to waiting after exit 3 or 130. A running
549
533
  # turn is fine here (nothing joins its queue); a question pending now
550
- # was already reported. No message count: a note landing between turns
551
- # grows the messages without a turn. An idle session with no worker
534
+ # was already reported. An idle session with no worker
552
535
  # waits for whatever wakes one (the web, chi send) rather than calling
553
536
  # it gone. The turn running now, or the one whose continue offer
554
537
  # stands, is the one waited for (ReplyWait.own_turn): its reply, not a
@@ -568,17 +551,10 @@ module Samagotchi
568
551
  return 1
569
552
  end
570
553
  live = session.status == Session::STATUS_RUNNING || SessionManager.session_owner(id, state_dir: @state_dir)
571
- turn = requested_turn || ReplyWait.own_turn(session, session_dir: session_dir)
572
- # The gate for the settled case: idle, a turn ended (last_turn), no
573
- # question standing (a continue offer waits for its answer).
574
- settled = session.status == Session::STATUS_IDLE && session.last_turn && session.pending_question.nil?
575
- ended_before = requested_turn ? session.last_turn&.enqueued_id == requested_turn : settled
576
- report_turn_id = turn
577
- report_turn_id ||= session.last_turn&.enqueued_id if settled
554
+ target = WaitTarget.wait_only(session, session_dir: session_dir, requested: requested_turn)
578
555
  wait_for_reply(id, cursor: ReplyWait.newest_reply(id, state_dir: @state_dir),
579
- baseline: baseline_of(session).merge(messages: nil),
580
- timeout: options[:timeout], owner_grace: live ? self.class::WORKER_GONE_AFTER : nil,
581
- turn: turn, ended_before: ended_before, report_turn_id: report_turn_id)
556
+ baseline: baseline_of(session),
557
+ timeout: options[:timeout], owner_grace: live ? self.class::WORKER_GONE_AFTER : nil, **target.to_h)
582
558
  end
583
559
 
584
560
  # The session as it was before the message went in (ReplyWait's
@@ -610,66 +586,22 @@ module Samagotchi
610
586
  @info.puts("#{session.id} failed: its worker did not start; the session is kept (chi --attach #{session.id})")
611
587
  return false
612
588
  end
613
- unless result[:status] == :accepted
614
- @info.puts("#{session.id} failed: #{result.dig(:ack, "detail") || "could not queue it"}")
589
+ unless result.accepted?
590
+ @info.puts("#{session.id} failed: #{result.detail || "could not queue it"}")
615
591
  return false
616
592
  end
617
593
 
618
- ack = result[:ack].is_a?(Hash) ? result[:ack] : {}
619
- @sent_input_file = !ack.key?("delivery")
620
- @sent_enqueued_id = (ack["enqueued_id"] || ack[:enqueued_id])&.to_s
621
- @sent_command = command_ack?(result[:ack])
594
+ @delivered = result
595
+ @sent_command = result.command?
622
596
  @info.puts("#{session.id} started#{continued_words(session)}#{with_images}#{"; #{SENT_COMMAND}" if @sent_command}")
623
597
  true
624
598
  end
625
599
 
626
600
  SENT_COMMAND = "sent as a session command"
627
601
 
628
- # Whether the worker took the message as a session command (a POST
629
- # /turn whose line is one answers with its command_id).
630
- def command_ack?(ack) = ack.is_a?(Hash) && !ack["command_id"].to_s.empty?
631
-
632
602
  # chi send's client id, or chi answer's for a parent agent's /continue
633
603
  # 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?
604
+ def client_id_for(prompt) = ParentGate.parent_continue?(prompt) ? ClientId::CLI_ANSWER : CLIENT_ID
673
605
 
674
606
  # Whether +text+ may be a session command, which only its worker knows
675
607
  # (a bundle's too).
@@ -740,26 +672,25 @@ module Samagotchi
740
672
  end
741
673
  result = SessionManager.deliver_turn(id, prompt: prompt, client_id: client_id_for(prompt), images: refs, state_dir: @state_dir,
742
674
  delivery: delivery)
743
- unless result[:status] == :accepted
675
+ unless result.accepted?
744
676
  # A worker older than queueing would steer it: refused, not sent.
745
- word = result.dig(:ack, "error") == Delivery::QUEUE_UNAVAILABLE["error"] ? "refused" : "failed"
746
- @info.puts("#{short} #{word}: #{result.dig(:ack, "detail") || "could not queue it"}")
677
+ word = result.error == Delivery::QUEUE_UNAVAILABLE["error"] ? "refused" : "failed"
678
+ @info.puts("#{short} #{word}: #{result.detail || "could not queue it"}")
747
679
  return false
748
680
  end
749
681
 
750
- @sent_command = command_ack?(result[:ack])
682
+ @sent_command = result.command?
751
683
  if @sent_command
752
684
  @info.puts("#{short} #{SENT_COMMAND}")
753
685
  return true
754
686
  end
755
687
 
756
- ack = result[:ack].is_a?(Hash) ? result[:ack] : {}
757
- @sent_input_file = !ack.key?("delivery")
688
+ ack = result.ack
689
+ @delivered = result
758
690
  # What the worker applied, for --format json: its ack's delivery
759
691
  # (next_step for an idle session) and a cut's outcome.
760
692
  @sent_delivery = ack["delivery"] || delivery
761
693
  @sent_cut = ack["cut"]
762
- @sent_enqueued_id = (ack["enqueued_id"] || ack[:enqueued_id])&.to_s
763
694
  @info.puts("#{short} sent#{with_images}#{delivery_note(ack, delivery: delivery, running: running, owner: owner)}")
764
695
  true
765
696
  rescue SessionManager::OwnedByTUI