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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -1
- data/README.md +1 -1
- data/bin/chi +4 -4
- data/docs/architecture.md +4 -4
- data/docs/cli.md +1 -1
- data/docs/guardrails.md +4 -5
- data/docs/hooks.md +1 -1
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +6 -0
- data/docs/memory.md +26 -1
- data/docs/plugins.md +37 -0
- data/docs/releasing.md +2 -2
- data/docs/sessions.md +1 -1
- data/docs/testing.md +11 -2
- data/lib/samagotchi/bootstrap/probe.rb +2 -2
- data/lib/samagotchi/bootstrap_command.rb +1 -0
- data/lib/samagotchi/bundle_command.rb +1 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/model-notes/manifest.yml +11 -0
- data/lib/samagotchi/bundles/model-notes/model_notes_deepseek.md +7 -0
- data/lib/samagotchi/bundles/model-notes/notes/README.md +37 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +12 -14
- data/lib/samagotchi/client_id.rb +18 -0
- data/lib/samagotchi/config/restart_watch.rb +112 -0
- data/lib/samagotchi/config.rb +18 -59
- data/lib/samagotchi/engine.rb +4 -14
- data/lib/samagotchi/guardrails/data_readers.rb +34 -30
- data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
- data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
- data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
- data/lib/samagotchi/hook_question.rb +21 -0
- data/lib/samagotchi/hook_routes.rb +6 -6
- data/lib/samagotchi/hooks/registry.rb +5 -5
- data/lib/samagotchi/host_registry.rb +1 -1
- data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
- data/lib/samagotchi/memory_bundle/profile.rb +22 -4
- data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -1
- data/lib/samagotchi/parent_answers_command.rb +6 -6
- data/lib/samagotchi/plugin/context.rb +6 -5
- data/lib/samagotchi/plugin/http_transport.rb +14 -3
- data/lib/samagotchi/plugin/sessions.rb +2 -3
- data/lib/samagotchi/question_desk.rb +12 -9
- data/lib/samagotchi/reply_wait/poll.rb +267 -0
- data/lib/samagotchi/reply_wait.rb +47 -190
- data/lib/samagotchi/send_command/parent_gate.rb +83 -0
- data/lib/samagotchi/send_command/wait_target.rb +55 -0
- data/lib/samagotchi/send_command.rb +31 -100
- data/lib/samagotchi/session.rb +10 -9
- data/lib/samagotchi/session_inbox.rb +31 -8
- data/lib/samagotchi/session_manager.rb +48 -15
- data/lib/samagotchi/session_metrics.rb +22 -3
- data/lib/samagotchi/sessions_command.rb +4 -4
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
- data/lib/samagotchi/testing/bundle.rb +1 -1
- data/lib/samagotchi/testing/host.rb +2 -8
- data/lib/samagotchi/testing/recorder.rb +6 -9
- data/lib/samagotchi/testing/sandbox.rb +26 -0
- data/lib/samagotchi/tools/delegate.rb +10 -10
- data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
- data/lib/samagotchi/tools/delegate_relay.rb +31 -16
- data/lib/samagotchi/tools/delegate_wait.rb +4 -4
- data/lib/samagotchi/update_command.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +46 -63
- data/lib/samagotchi/web/public/app.js +137 -638
- data/lib/samagotchi/web/public/dock_reserve.js +103 -0
- data/lib/samagotchi/web/public/index.html +4 -4
- data/lib/samagotchi/web/public/send_turn.js +173 -0
- data/lib/samagotchi/web/public/session_actions.js +203 -0
- data/lib/samagotchi/web/public/start_page.js +236 -0
- data/lib/samagotchi/web/public/toast_place.js +1 -1
- 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 [
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 [
|
|
148
|
+
# @return [Baseline]
|
|
281
149
|
def baseline_of(session, question_id: session.pending_question&.id)
|
|
282
|
-
|
|
150
|
+
Baseline.new(messages: session.messages.size, question_id: question_id, last_turn: session.last_turn&.ended_at)
|
|
283
151
|
end
|
|
284
152
|
|
|
285
|
-
#
|
|
286
|
-
#
|
|
287
|
-
|
|
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,
|
|
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
|
|
215
|
+
return true unless baseline
|
|
361
216
|
|
|
362
|
-
session.last_turn&.ended_at
|
|
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
|
-
|
|
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? ||
|
|
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
|
|
256
|
-
#
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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] =
|
|
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],
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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.
|
|
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
|
-
|
|
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)
|
|
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
|
|
614
|
-
@info.puts("#{session.id} failed: #{result.
|
|
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
|
-
|
|
619
|
-
@
|
|
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
|
|
675
|
+
unless result.accepted?
|
|
744
676
|
# A worker older than queueing would steer it: refused, not sent.
|
|
745
|
-
word = result.
|
|
746
|
-
@info.puts("#{short} #{word}: #{result.
|
|
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 =
|
|
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
|
|
757
|
-
@
|
|
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
|