samagotchi 0.62.0 → 0.64.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 +78 -1
- data/README.md +4 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +12 -2
- data/docs/cli.md +20 -9
- data/docs/configuration.md +16 -9
- data/docs/context.md +13 -3
- data/docs/hooks.md +6 -3
- data/docs/internals/engine.md +10 -3
- data/docs/memory.md +1 -1
- data/docs/plugins.md +96 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +11 -8
- data/docs/sub-agent.md +27 -4
- data/docs/testing.md +13 -4
- data/lib/samagotchi/answer_command.rb +5 -1
- data/lib/samagotchi/answer_refs.rb +18 -26
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +41 -5
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +22 -0
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +406 -0
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +104 -0
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +66 -0
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +60 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -6
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/child_reports.rb +7 -2
- data/lib/samagotchi/cli/parent_wait.rb +9 -2
- data/lib/samagotchi/composer_route.rb +90 -0
- data/lib/samagotchi/config.rb +6 -3
- data/lib/samagotchi/context_absorber.rb +25 -6
- data/lib/samagotchi/continue_offer.rb +17 -3
- data/lib/samagotchi/delivery.rb +13 -8
- data/lib/samagotchi/engine.rb +2 -0
- data/lib/samagotchi/host_config.rb +6 -6
- data/lib/samagotchi/host_model.rb +2 -2
- data/lib/samagotchi/idle_recap.rb +2 -3
- data/lib/samagotchi/image_store.rb +8 -0
- data/lib/samagotchi/interrupted_turn.rb +7 -3
- data/lib/samagotchi/last_turn.rb +5 -2
- data/lib/samagotchi/pending_input_queue.rb +10 -3
- data/lib/samagotchi/plugin/attached_context.rb +7 -4
- data/lib/samagotchi/plugin/sessions.rb +27 -5
- data/lib/samagotchi/recap_setup.rb +3 -3
- data/lib/samagotchi/reply_wait.rb +171 -6
- data/lib/samagotchi/row_refs.rb +3 -4
- data/lib/samagotchi/send_command.rb +63 -19
- data/lib/samagotchi/session.rb +12 -0
- data/lib/samagotchi/session_commands.rb +1 -1
- data/lib/samagotchi/session_inbox.rb +63 -7
- data/lib/samagotchi/session_manager.rb +49 -20
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +6 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +73 -35
- data/lib/samagotchi/terminal_ui/formatting.rb +3 -19
- data/lib/samagotchi/terminal_ui.rb +103 -68
- data/lib/samagotchi/tool_activity.rb +5 -5
- data/lib/samagotchi/tools/delegate.rb +28 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
- data/lib/samagotchi/tools/delegate_wait.rb +17 -5
- data/lib/samagotchi/tools/execute.rb +33 -8
- data/lib/samagotchi/turn_checkpoint.rb +9 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +6 -4
- data/lib/samagotchi/web/public/activity.js +2 -2
- data/lib/samagotchi/web/public/app.js +53 -91
- data/lib/samagotchi/web/public/card.js +73 -23
- data/lib/samagotchi/web/public/chains.js +6 -4
- data/lib/samagotchi/web/public/command_view.js +39 -35
- data/lib/samagotchi/web/public/commands.js +8 -8
- data/lib/samagotchi/web/public/composer.js +3 -3
- data/lib/samagotchi/web/public/copy.js +7 -15
- data/lib/samagotchi/web/public/diff_view.js +20 -23
- data/lib/samagotchi/web/public/format.js +0 -48
- data/lib/samagotchi/web/public/images.js +12 -16
- data/lib/samagotchi/web/public/question_cards.js +3 -2
- data/lib/samagotchi/web/public/read_view.js +10 -9
- data/lib/samagotchi/web/public/scope.js +23 -0
- data/lib/samagotchi/web/public/timing.js +2 -8
- data/lib/samagotchi/web/public/turn_html.js +119 -59
- data/lib/samagotchi/web/public/turn_model.js +13 -3
- data/lib/samagotchi/web/public/turn_view.js +73 -116
- data/lib/samagotchi/web/public/vendor/directive.js +8 -0
- data/lib/samagotchi/web/public/vendor/directives/unsafe-html.js +8 -0
- data/lib/samagotchi/worker.rb +53 -35
- data/lib/samagotchi/worker_inbound.rb +12 -11
- metadata +9 -1
|
@@ -25,6 +25,7 @@ require_relative "tools/memory"
|
|
|
25
25
|
require_relative "output_formatter"
|
|
26
26
|
require_relative "turn_flow"
|
|
27
27
|
require_relative "session_commands"
|
|
28
|
+
require_relative "composer_route"
|
|
28
29
|
require_relative "terminal_ui/attached_view"
|
|
29
30
|
require_relative "terminal_ui/between_turns"
|
|
30
31
|
require_relative "terminal_ui/event_renderer"
|
|
@@ -121,6 +122,7 @@ module Samagotchi
|
|
|
121
122
|
|
|
122
123
|
claim_session!(session_id) if session_id
|
|
123
124
|
@resume_session = session_id ? Session.load(session_id) : nil
|
|
125
|
+
drop_dead_question(@resume_session) if @resume_session
|
|
124
126
|
if @resume_session
|
|
125
127
|
# --model overrides resumed session's model (runtime only, default unchanged)
|
|
126
128
|
if flag_model_name
|
|
@@ -186,7 +188,6 @@ module Samagotchi
|
|
|
186
188
|
memories: @requested_memories,
|
|
187
189
|
muted_memories: @muted_memory_names,
|
|
188
190
|
appended_prompt_files: @appended_prompt_files,
|
|
189
|
-
recap: scratch ? nil : recap_config,
|
|
190
191
|
scratch: scratch,
|
|
191
192
|
reminders: {
|
|
192
193
|
callback: lambda { |due_names|
|
|
@@ -249,7 +250,8 @@ module Samagotchi
|
|
|
249
250
|
# post-turn conversation.
|
|
250
251
|
# @return [Symbol, nil] :turn_failed when lines came from a pipe and a
|
|
251
252
|
# turn failed, :empty_answer when a -p --non-interactive turn ended
|
|
252
|
-
# with no answer
|
|
253
|
+
# with no answer, :iteration_limit when the iteration limit cut that
|
|
254
|
+
# turn short (bin/chi exits 1 on all three), :interrupted when Ctrl-C
|
|
253
255
|
# cancelled that turn (bin/chi exits 130)
|
|
254
256
|
def run
|
|
255
257
|
# --non-interactive with no --prompt is a harmless no-op exit: build
|
|
@@ -295,11 +297,11 @@ module Samagotchi
|
|
|
295
297
|
|
|
296
298
|
if @prompt && @non_interactive
|
|
297
299
|
# A /cut TEXT / /queue TEXT prompt runs its text as the turn's
|
|
298
|
-
# prompt, the same as the idle REPL does (
|
|
300
|
+
# prompt, the same as the idle REPL does (ComposerRoute.steer_choice);
|
|
299
301
|
# a bare /cut / /queue (no text) is refused above as a UI command,
|
|
300
302
|
# so only the text here. The images are the text's, not the words'.
|
|
301
303
|
one_shot_prompt =
|
|
302
|
-
if (choice = steer_choice(@prompt)) && !choice.last.empty?
|
|
304
|
+
if (choice = ComposerRoute.steer_choice(@prompt)) && !choice.last.empty?
|
|
303
305
|
choice.last
|
|
304
306
|
else
|
|
305
307
|
@prompt
|
|
@@ -309,12 +311,13 @@ module Samagotchi
|
|
|
309
311
|
# Retry lines and hook notices on stderr; stdout gets the answer below.
|
|
310
312
|
sink = OneShotSink.new
|
|
311
313
|
save_before_first_turn(session)
|
|
314
|
+
max_iterations = IterationLimit.for(no_interrupt: true)
|
|
312
315
|
begin
|
|
313
316
|
result = @engine.run_turn(
|
|
314
317
|
session,
|
|
315
318
|
one_shot_prompt,
|
|
316
319
|
on_event: sink,
|
|
317
|
-
max_iterations:
|
|
320
|
+
max_iterations: max_iterations,
|
|
318
321
|
cancel_controller: nil,
|
|
319
322
|
images: ImageInput.extract(one_shot_prompt)
|
|
320
323
|
)
|
|
@@ -337,6 +340,11 @@ module Samagotchi
|
|
|
337
340
|
session.save
|
|
338
341
|
# The after_turn hooks' notices, after the answer as the REPL shows them.
|
|
339
342
|
sink.flush
|
|
343
|
+
# The limit cut the turn short: what came so far is no answer, so say
|
|
344
|
+
# so and fail (bin/chi exits 1), as the REPL's "iteration limit
|
|
345
|
+
# reached" line does there.
|
|
346
|
+
return one_shot_exhausted(session, max_iterations) if result.resumable?
|
|
347
|
+
|
|
340
348
|
return
|
|
341
349
|
end
|
|
342
350
|
|
|
@@ -390,6 +398,16 @@ module Samagotchi
|
|
|
390
398
|
:turn_failed
|
|
391
399
|
end
|
|
392
400
|
|
|
401
|
+
# A -p --non-interactive turn the iteration limit cut short: a line on
|
|
402
|
+
# stderr, with how to go on unless the session goes with this run.
|
|
403
|
+
# @return [Symbol] :iteration_limit
|
|
404
|
+
def one_shot_exhausted(session, max_iterations)
|
|
405
|
+
line = "chi: the iteration limit (#{max_iterations}) was reached before the turn finished"
|
|
406
|
+
line += "; continue it with: chi --resume #{session.id}" unless @scratch
|
|
407
|
+
warn line
|
|
408
|
+
:iteration_limit
|
|
409
|
+
end
|
|
410
|
+
|
|
393
411
|
# `chi -p "/models" --non-interactive`: the command's output; a refused
|
|
394
412
|
# one fails the run (bin/chi exits 1). The session is saved only when
|
|
395
413
|
# the command changed it. Nobody is there to answer a confirm: a
|
|
@@ -518,6 +536,17 @@ module Samagotchi
|
|
|
518
536
|
@pending_card_handle = @engine.subscribe(observer: pending)
|
|
519
537
|
end
|
|
520
538
|
|
|
539
|
+
# A question the session's last owner saved and died with (a worker's,
|
|
540
|
+
# or another REPL's): this TUI owns the session now, so nobody waits on
|
|
541
|
+
# it. Dropped and saved, as the worker does (Worker#drop_dead_question).
|
|
542
|
+
def drop_dead_question(session)
|
|
543
|
+
id = session.drop_dead_question
|
|
544
|
+
return unless id
|
|
545
|
+
|
|
546
|
+
Log.info(:repl, "dead_question_dropped", id: id)
|
|
547
|
+
session.save
|
|
548
|
+
end
|
|
549
|
+
|
|
521
550
|
# Take the session's OwnerLock for this process's lifetime: the TUI runs
|
|
522
551
|
# its own Engine, so no worker may run the session meanwhile.
|
|
523
552
|
# @raise [SessionBusy] when a worker or another TUI owns it
|
|
@@ -806,34 +835,35 @@ module Samagotchi
|
|
|
806
835
|
return if input.empty?
|
|
807
836
|
|
|
808
837
|
# /cut TEXT and /queue TEXT are deliveries only while a turn runs; at
|
|
809
|
-
# the prompt both run the text as a normal turn, as their usage says
|
|
810
|
-
|
|
811
|
-
|
|
838
|
+
# the prompt both run the text as a normal turn, as their usage says
|
|
839
|
+
# (the route is the text's).
|
|
840
|
+
route = ComposerRoute.call(input, running: false, registry: command_registry)
|
|
841
|
+
return @surface.commit(STEER_CHOICE_USAGE) if route.kind == :usage
|
|
812
842
|
|
|
813
|
-
|
|
814
|
-
end
|
|
843
|
+
input = route.text if route.choice
|
|
815
844
|
|
|
816
845
|
# /model, /models, !rollback, !cmd, /continue (shared with workers);
|
|
817
846
|
# an anytime command's cards print as it shows them (btw's
|
|
818
847
|
# "thinking…" before the answer). Piped input has nobody to answer a
|
|
819
848
|
# confirm (the next line is already written): a costly /thinking or
|
|
820
849
|
# /reasoning-history applies at once.
|
|
821
|
-
|
|
822
|
-
command
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
850
|
+
case route.kind
|
|
851
|
+
when :command
|
|
852
|
+
confirm = !piped_input?
|
|
853
|
+
command = if command_registry.anytime?(input)
|
|
854
|
+
@engine.running_anytime { @commands.run(input, confirm: confirm) }
|
|
855
|
+
else
|
|
856
|
+
@commands.run(input, confirm: confirm)
|
|
857
|
+
end
|
|
858
|
+
if command
|
|
859
|
+
show_command_result(command)
|
|
860
|
+
persist_recent_history(input) if command.shell
|
|
861
|
+
return
|
|
862
|
+
end
|
|
863
|
+
when :local then return if show_local_command(input)
|
|
834
864
|
# A typo of a command (/modle) is not a prompt: say so, send nothing.
|
|
835
|
-
|
|
836
|
-
|
|
865
|
+
when :typo then return @surface.commit(route.reply)
|
|
866
|
+
end
|
|
837
867
|
|
|
838
868
|
@turn_flow.before_prompt_turn
|
|
839
869
|
persist_recent_history(input)
|
|
@@ -1136,29 +1166,42 @@ module Samagotchi
|
|
|
1136
1166
|
#
|
|
1137
1167
|
# A command that waits for the turn's end (/model X, !cmd) and every line
|
|
1138
1168
|
# after it wait in @after_turn, in the order typed (D1), with the image
|
|
1139
|
-
# lines (which never steer): once the turn ends they come
|
|
1140
|
-
# steering it didn't merge
|
|
1141
|
-
# after
|
|
1169
|
+
# lines (which never steer) and /queue's: once the turn ends they come
|
|
1170
|
+
# back with the steering it didn't merge, in the order sent (each line
|
|
1171
|
+
# the turn takes gets the next @input_seq), ahead of anything sent after
|
|
1172
|
+
# Ctrl-C.
|
|
1142
1173
|
def with_steering(&)
|
|
1143
1174
|
return yield unless @repl_input
|
|
1144
1175
|
|
|
1145
1176
|
@after_turn = []
|
|
1146
1177
|
@command_waits = false
|
|
1147
|
-
@
|
|
1178
|
+
@input_seq = 0
|
|
1179
|
+
@repl_input.during_turn(method(:steer_line), leftovers: -> { leftover_lines }, &)
|
|
1148
1180
|
end
|
|
1149
1181
|
|
|
1150
|
-
# The
|
|
1151
|
-
#
|
|
1152
|
-
|
|
1153
|
-
|
|
1182
|
+
# The turn's leftovers in send order: steering it didn't merge and the
|
|
1183
|
+
# lines that waited for its end.
|
|
1184
|
+
def leftover_lines
|
|
1185
|
+
(@pending_input_queue.drain_entries + lines_after_turn).sort_by { |seq, _| seq || 0 }.map(&:last)
|
|
1186
|
+
end
|
|
1187
|
+
|
|
1188
|
+
# The next line's place in send order (#leftover_lines).
|
|
1189
|
+
def next_input_seq
|
|
1190
|
+
@input_seq = (@input_seq || 0) + 1
|
|
1191
|
+
end
|
|
1192
|
+
|
|
1193
|
+
# The [seq, line] entries that waited for the turn's end, taken from
|
|
1194
|
+
# @after_turn. After a canceled or failed turn (Ctrl-C raises through
|
|
1195
|
+
# here; a failure too) a !cmd among them is dropped (D4): running it
|
|
1196
|
+
# would close the !rollback the turn left.
|
|
1154
1197
|
def lines_after_turn
|
|
1155
|
-
|
|
1198
|
+
entries = @after_turn.slice!(0..)
|
|
1156
1199
|
ended = if @active_cancel_controller&.cancelled? || $!.is_a?(Interrupt) then "canceled"
|
|
1157
1200
|
elsif $! then "failed"
|
|
1158
1201
|
end
|
|
1159
|
-
return
|
|
1202
|
+
return entries unless ended
|
|
1160
1203
|
|
|
1161
|
-
|
|
1204
|
+
entries.reject do |_seq, line|
|
|
1162
1205
|
next false unless command_registry.lookup(line)&.id == :shell
|
|
1163
1206
|
|
|
1164
1207
|
@surface.commit("turn #{ended}: #{line.strip} not run; send it again")
|
|
@@ -1179,28 +1222,32 @@ module Samagotchi
|
|
|
1179
1222
|
# to end and runs as a turn of its own.
|
|
1180
1223
|
# @return [Boolean, :back] whether the turn took it, :back to put it back
|
|
1181
1224
|
def steer_line(line)
|
|
1182
|
-
|
|
1225
|
+
route = ComposerRoute.call(line, running: true, registry: command_registry) unless line.nil?
|
|
1226
|
+
local = route&.local
|
|
1183
1227
|
return exit_after_turn(delete: SessionCommands.delete_on_exit?(line)) if line.nil? || local == :exit
|
|
1184
1228
|
return archive_after_turn if local == :archive
|
|
1185
1229
|
return detach_note if local == :detach
|
|
1186
1230
|
return false if @active_cancel_controller&.cancelled?
|
|
1187
1231
|
|
|
1188
|
-
|
|
1189
|
-
#
|
|
1190
|
-
|
|
1191
|
-
|
|
1232
|
+
case route.kind
|
|
1233
|
+
# Before every command check (ComposerRoute): /cut and /queue are the
|
|
1234
|
+
# UIs' own words for a delivery (listed for Tab and /help, never run).
|
|
1235
|
+
when :usage
|
|
1236
|
+
@surface.commit(STEER_CHOICE_USAGE)
|
|
1237
|
+
return true
|
|
1238
|
+
when :deliver then return submit_steer_choice(route.delivery, route.text)
|
|
1239
|
+
when :command
|
|
1240
|
+
policy = command_registry.mid_turn(line)
|
|
1241
|
+
return start_anytime_command(line) if policy == :anytime
|
|
1242
|
+
return command_queued(line) if policy == :queue
|
|
1192
1243
|
end
|
|
1193
|
-
|
|
1194
|
-
policy = command_registry.mid_turn(line)
|
|
1195
|
-
return start_anytime_command(line) if policy == :anytime
|
|
1196
|
-
return command_queued(line) if policy == :queue
|
|
1197
1244
|
# A command, never steering text.
|
|
1198
|
-
return command_during_turn(line) if local
|
|
1199
|
-
return true if
|
|
1245
|
+
return command_during_turn(line) if %i[local command].include?(route.kind)
|
|
1246
|
+
return true if route.kind == :empty
|
|
1200
1247
|
|
|
1201
1248
|
# A typo of a command (/modle) is not steering text: say so, take it.
|
|
1202
|
-
if
|
|
1203
|
-
@surface.commit(
|
|
1249
|
+
if route.kind == :typo
|
|
1250
|
+
@surface.commit(route.reply)
|
|
1204
1251
|
return true
|
|
1205
1252
|
end
|
|
1206
1253
|
# Steering merges text only: a line with images runs as the next turn.
|
|
@@ -1208,7 +1255,7 @@ module Samagotchi
|
|
|
1208
1255
|
# Behind a command that waits for the turn's end, a line waits too.
|
|
1209
1256
|
return wait_for_turn_end(line) if @command_waits
|
|
1210
1257
|
|
|
1211
|
-
@pending_input_queue.push(
|
|
1258
|
+
@pending_input_queue.push(route.text, seq: next_input_seq)
|
|
1212
1259
|
@surface.commit(NEXT_STEP_NOTE)
|
|
1213
1260
|
true
|
|
1214
1261
|
end
|
|
@@ -1217,25 +1264,21 @@ module Samagotchi
|
|
|
1217
1264
|
# otherwise read as prompts (SessionCommands registers them for Tab and
|
|
1218
1265
|
# help only). A cut asks the engine to cut the generation only thinking
|
|
1219
1266
|
# too long; a queue waits for the turn's end (the REPL's own after-turn
|
|
1220
|
-
# list).
|
|
1221
|
-
#
|
|
1267
|
+
# list). A line with images never steers (as a plain one doesn't).
|
|
1268
|
+
# @param delivery [String] Delivery::CUT or Delivery::QUEUE (ComposerRoute)
|
|
1222
1269
|
# @return [Boolean] whether the turn took it
|
|
1223
|
-
def submit_steer_choice(
|
|
1224
|
-
if text.empty?
|
|
1225
|
-
@surface.commit(STEER_CHOICE_USAGE)
|
|
1226
|
-
return true
|
|
1227
|
-
end
|
|
1270
|
+
def submit_steer_choice(delivery, text)
|
|
1228
1271
|
return image_line_waits(text) if ImageInput.extract(text).any?
|
|
1229
1272
|
|
|
1230
1273
|
# A /queue waits for the turn's end; a /cut behind a command that
|
|
1231
1274
|
# already waits there goes after it too.
|
|
1232
|
-
if
|
|
1275
|
+
if Delivery.queue?(delivery) || @command_waits
|
|
1233
1276
|
@surface.commit(COMMAND_QUEUED)
|
|
1234
1277
|
return wait_for_turn_end(text)
|
|
1235
1278
|
end
|
|
1236
1279
|
|
|
1237
1280
|
epoch = @engine.input_epoch
|
|
1238
|
-
@pending_input_queue.push(text)
|
|
1281
|
+
@pending_input_queue.push(text, seq: next_input_seq)
|
|
1239
1282
|
# With no session running the cut is :off; the text still waits for
|
|
1240
1283
|
# the running turn's next step.
|
|
1241
1284
|
@surface.commit(steer_cut_note(@engine.cut_for_steer(nil, epoch: epoch), @engine.steer_cut_after))
|
|
@@ -1250,7 +1293,7 @@ module Samagotchi
|
|
|
1250
1293
|
# +line+ runs once the turn has ended, in the order typed (#with_steering).
|
|
1251
1294
|
# @return [true] the turn took it
|
|
1252
1295
|
def wait_for_turn_end(line)
|
|
1253
|
-
@after_turn << line
|
|
1296
|
+
@after_turn << [next_input_seq, line]
|
|
1254
1297
|
true
|
|
1255
1298
|
end
|
|
1256
1299
|
|
|
@@ -1359,14 +1402,6 @@ module Samagotchi
|
|
|
1359
1402
|
Log.debug(:repl, "children_counts_failed", error: e.class.name)
|
|
1360
1403
|
end
|
|
1361
1404
|
|
|
1362
|
-
# Resolve the recap config (on by default). Returns false when explicitly
|
|
1363
|
-
# disabled, nil when nothing is configured, or a Hash when enabled so the
|
|
1364
|
-
# Engine can build the detector. Single precedence path via the Config
|
|
1365
|
-
# registry: CLI > ENV (SAMAGOTCHI_RECAP_*) > file (recap:) > default.
|
|
1366
|
-
def recap_config
|
|
1367
|
-
ConfigFile.recap_config
|
|
1368
|
-
end
|
|
1369
|
-
|
|
1370
1405
|
# Reset the Engine's shared inactivity clock when a prompt opens (a
|
|
1371
1406
|
# Reline.pre_input_hook) and on each key typed (RelineSeam.key_handler). This is the
|
|
1372
1407
|
# single seam the Engine-owned idle recap detector reads, so the idle clock
|
|
@@ -211,14 +211,14 @@ module Samagotchi
|
|
|
211
211
|
# "stopped" for a
|
|
212
212
|
# task_wait the user's Stop ended (the task itself runs on), for one
|
|
213
213
|
# whose task was stopped (the stop-task button, or the model's task_stop),
|
|
214
|
-
# and for a command the user's Stop (or a signal to chi
|
|
215
|
-
# from starting: a cancel, not a failure (its text still
|
|
216
|
-
# for the model).
|
|
214
|
+
# and for a command the user's Stop (or a signal to chi, a session stop)
|
|
215
|
+
# killed or kept from starting: a cancel, not a failure (its text still
|
|
216
|
+
# starts "Error:" for the model).
|
|
217
217
|
def tool_activity_status(result, tool_name = nil, command: nil)
|
|
218
218
|
text = result.to_s
|
|
219
|
-
return "stopped" if [Tools::Execute::NOT_RUN_ON_STOP, Tools::Execute::
|
|
219
|
+
return "stopped" if [Tools::Execute::NOT_RUN_ON_STOP, *Tools::Execute::NOT_RUN.values].include?(text)
|
|
220
220
|
if tool_name == Tools::Execute::NAME &&
|
|
221
|
-
text.start_with?(Tools::Execute::STOPPED_BY_USER, Tools::Execute::
|
|
221
|
+
text.start_with?(Tools::Execute::STOPPED_BY_USER, *Tools::Execute::STOPPED_BY.values)
|
|
222
222
|
return "stopped"
|
|
223
223
|
end
|
|
224
224
|
return "error" if text.start_with?("Error:")
|
|
@@ -203,7 +203,9 @@ module Samagotchi
|
|
|
203
203
|
delivered = SessionManager.deliver_turn(id, prompt: task, client_id: "#{ClientId::DELEGATE_PREFIX}#{parent.id[0, 8]}",
|
|
204
204
|
state_dir: state_dir)
|
|
205
205
|
case delivered[:status]
|
|
206
|
-
when :accepted
|
|
206
|
+
when :accepted
|
|
207
|
+
DelegateWait.mark_turn(parent.id, id, follow_up_turn(id, delivered[:ack], state_dir: state_dir), state_dir: state_dir)
|
|
208
|
+
id
|
|
207
209
|
when :refused then "Error: session #{id[0, 8]} refused the message (#{delivered.dig(:ack, "error")})"
|
|
208
210
|
when :timeout then "Error: the worker of session #{id[0, 8]} did not answer in time, so the message was not sent"
|
|
209
211
|
else "Error: the message to session #{id[0, 8]} could not be written"
|
|
@@ -215,6 +217,31 @@ module Samagotchi
|
|
|
215
217
|
end
|
|
216
218
|
private_class_method :follow_up
|
|
217
219
|
|
|
220
|
+
# The child's turn a delivered follow-up joins, for the wait
|
|
221
|
+
# (DelegateCursor#turn): the running turn's (its checkpoint mark), the
|
|
222
|
+
# follow-up merging into it; else the ack's enqueued_id, the follow-up
|
|
223
|
+
# running as a turn of its own. Only from a worker that names
|
|
224
|
+
# turn_ids; nil otherwise, or when the running turn has no id (a
|
|
225
|
+
# delegate's first turn): the wait takes the next reply.
|
|
226
|
+
# A follow-up steered into a turn that ended before it merged runs as
|
|
227
|
+
# its own turn, yet the wait is on the ended one (LOW, as before).
|
|
228
|
+
# One the child's idle look missed being merged into a turn starting
|
|
229
|
+
# then never runs as its own: ReplyWait falls back to that turn's
|
|
230
|
+
# reply (merged_elsewhere?).
|
|
231
|
+
# @return [String, nil]
|
|
232
|
+
def self.follow_up_turn(id, ack, state_dir:)
|
|
233
|
+
return nil unless ReplyWait.turn_ids?(Session.session_dir(id, state_dir: state_dir))
|
|
234
|
+
|
|
235
|
+
child = Session.load(id, state_dir: state_dir)
|
|
236
|
+
return child.interrupted_turn&.enqueued_id if child.status == Session::STATUS_RUNNING
|
|
237
|
+
|
|
238
|
+
enqueued_id = ack.is_a?(Hash) ? (ack["enqueued_id"] || ack[:enqueued_id]) : nil
|
|
239
|
+
enqueued_id.to_s.empty? ? nil : enqueued_id.to_s
|
|
240
|
+
rescue ArgumentError
|
|
241
|
+
nil
|
|
242
|
+
end
|
|
243
|
+
private_class_method :follow_up_turn
|
|
244
|
+
|
|
218
245
|
# The model as typed (spawn_session stores its resolved ref), else the
|
|
219
246
|
# parent's. No existence check here: spawn_session checks the host
|
|
220
247
|
# (check_host!, its UnknownHost comes back as this tool's Error: line)
|
|
@@ -10,9 +10,14 @@ module Samagotchi
|
|
|
10
10
|
# What a parent was already told about one delegate child: the newest
|
|
11
11
|
# reply it was given (+reply_file+, ReplyWait's cursor) and the child as
|
|
12
12
|
# it was then (+messages+, +question_id+, +last_turn+: ReplyWait's
|
|
13
|
-
# baseline). nil +messages+ means no baseline.
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
# baseline). nil +messages+ means no baseline. +turn+: the enqueued_id
|
|
14
|
+
# of the child's turn the last follow-up went to (Delegate.follow_up),
|
|
15
|
+
# whose own reply a wait takes (ReplyWait's turn:); nil once that turn
|
|
16
|
+
# was reported, or when unknown (a new child, an older worker).
|
|
17
|
+
DelegateCursor = Data.define(:reply_file, :last_turn, :question_id, :messages, :turn) do
|
|
18
|
+
def initialize(reply_file: nil, last_turn: nil, question_id: nil, messages: nil, turn: nil) = super
|
|
19
|
+
|
|
20
|
+
def self.empty = new
|
|
16
21
|
|
|
17
22
|
# @param baseline [Hash, nil] ReplyWait.baseline_of's
|
|
18
23
|
def with_baseline(baseline)
|
|
@@ -93,7 +98,7 @@ module Samagotchi
|
|
|
93
98
|
return DelegateCursor.empty unless data.is_a?(Hash)
|
|
94
99
|
|
|
95
100
|
DelegateCursor.new(reply_file: data["reply_file"], last_turn: data["last_turn"],
|
|
96
|
-
question_id: data["question_id"], messages: data["messages"])
|
|
101
|
+
question_id: data["question_id"], messages: data["messages"], turn: data["turn"])
|
|
97
102
|
end
|
|
98
103
|
|
|
99
104
|
def path(parent_id, state_dir:)
|
|
@@ -72,7 +72,8 @@ module Samagotchi
|
|
|
72
72
|
started = monotonic
|
|
73
73
|
wait = ReplyWait.call(child_id, state_dir: sd, cursor: cursor.reply_file, timeout: [left, 0].max,
|
|
74
74
|
poll_interval: poll_interval, cancelled: cancelled, baseline: baseline,
|
|
75
|
-
owner_grace: owner_grace, interject: others && -> { others.poll }
|
|
75
|
+
owner_grace: owner_grace, interject: others && -> { others.poll },
|
|
76
|
+
turn: cursor.turn)
|
|
76
77
|
left -= monotonic - started
|
|
77
78
|
unless wait.status == :waiting_for_answer && DelegateRelay.relayable?(relay, wait.question)
|
|
78
79
|
outcomes.concat(relayed_outcomes.delete(key) || [])
|
|
@@ -143,7 +144,9 @@ module Samagotchi
|
|
|
143
144
|
# gave, and the child as that same look loaded it (a fresh load could
|
|
144
145
|
# swallow a turn that ended in between), so a later wait looks for a
|
|
145
146
|
# later turn and a question already reported isn't reported again.
|
|
146
|
-
#
|
|
147
|
+
# The follow-up's turn (DelegateCursor#turn) is done with once its end
|
|
148
|
+
# was reported; a question keeps it. Nothing moves on a timeout or a
|
|
149
|
+
# cancel.
|
|
147
150
|
# @param wait [ReplyWait::Result]
|
|
148
151
|
# @return [DelegateCursor, nil] the new cursor
|
|
149
152
|
def advance(parent_id, child_id, wait, state_dir:)
|
|
@@ -151,6 +154,7 @@ module Samagotchi
|
|
|
151
154
|
|
|
152
155
|
DelegateCursors.update(parent_id, child_id, state_dir: state_dir) do |cursor|
|
|
153
156
|
cursor = cursor.with(reply_file: wait.file) if wait.status == :done
|
|
157
|
+
cursor = cursor.with(turn: nil) unless wait.status == :waiting_for_answer
|
|
154
158
|
cursor.with_baseline(ReplyWait.baseline_of(wait.session))
|
|
155
159
|
end
|
|
156
160
|
end
|
|
@@ -187,20 +191,28 @@ module Samagotchi
|
|
|
187
191
|
|
|
188
192
|
# Point the cursor at the newest reply now, so only a later one counts
|
|
189
193
|
# (a follow-up sent to a child that already answered), and take the
|
|
190
|
-
# child's baseline before the follow-up goes in.
|
|
194
|
+
# child's baseline before the follow-up goes in. No turn yet
|
|
195
|
+
# (#mark_turn, once it went in).
|
|
191
196
|
# @param child [Session] the child as loaded before sending
|
|
192
197
|
def mark_seen(parent_id, child, state_dir:)
|
|
193
198
|
newest = ReplyWait.newest_reply(child.id, state_dir: state_dir)
|
|
194
199
|
DelegateCursors.update(parent_id, child.id, state_dir: state_dir) do |cursor|
|
|
195
|
-
cursor.with(reply_file: newest).with_baseline(ReplyWait.baseline_of(child))
|
|
200
|
+
cursor.with(reply_file: newest, turn: nil).with_baseline(ReplyWait.baseline_of(child))
|
|
196
201
|
end
|
|
197
202
|
end
|
|
198
203
|
|
|
204
|
+
# The follow-up went in: the child's turn it joins, whose reply the
|
|
205
|
+
# wait takes (DelegateCursor#turn).
|
|
206
|
+
# @param turn [String, nil]
|
|
207
|
+
def mark_turn(parent_id, child_id, turn, state_dir:)
|
|
208
|
+
DelegateCursors.update(parent_id, child_id, state_dir: state_dir) { |cursor| cursor.with(turn: turn) }
|
|
209
|
+
end
|
|
210
|
+
|
|
199
211
|
# A new child's baseline: as spawned, before its first turn ran.
|
|
200
212
|
# @param child [Session] the session spawn_session returned
|
|
201
213
|
def mark_started(parent_id, child, state_dir:)
|
|
202
214
|
DelegateCursors.update(parent_id, child.id, state_dir: state_dir) do |cursor|
|
|
203
|
-
cursor.with_baseline(ReplyWait.baseline_of(child, question_id: nil))
|
|
215
|
+
cursor.with(turn: nil).with_baseline(ReplyWait.baseline_of(child, question_id: nil))
|
|
204
216
|
end
|
|
205
217
|
end
|
|
206
218
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require_relative "../config"
|
|
5
|
+
require_relative "../log"
|
|
5
6
|
require_relative "../paths"
|
|
6
7
|
require_relative "../process_group"
|
|
7
8
|
require_relative "output_guardrails"
|
|
@@ -31,6 +32,10 @@ module Samagotchi
|
|
|
31
32
|
# stopped the turn: its cancel reason is :signal.
|
|
32
33
|
NOT_RUN_ON_SIGNAL = "Error: not run, a signal to chi stopped the turn"
|
|
33
34
|
STOPPED_BY_SIGNAL = "Error: command stopped by a signal to chi"
|
|
35
|
+
# The same two when a session stop (chi sessions stop, the web's stop,
|
|
36
|
+
# a parent stopping its delegate) canceled the turn: :session_stopped.
|
|
37
|
+
NOT_RUN_ON_SESSION_STOP = "Error: not run, the session was stopped"
|
|
38
|
+
STOPPED_BY_SESSION_STOP = "Error: command stopped as the session was stopped"
|
|
34
39
|
# The first words of a command killed at the limit in kill mode (hooks
|
|
35
40
|
# match on it).
|
|
36
41
|
TIMEOUT_PREFIX = "Error: command timed out after"
|
|
@@ -78,7 +83,8 @@ module Samagotchi
|
|
|
78
83
|
# @param cancelled [#call] true once the user stopped the turn: the
|
|
79
84
|
# command is killed (or not started) and its output so far returned
|
|
80
85
|
# @param stop_reason [#call] the stop's reason (:signal: a signal to
|
|
81
|
-
# chi, which the result then names
|
|
86
|
+
# chi, :session_stopped: a session stop, which the result then names
|
|
87
|
+
# instead of the user)
|
|
82
88
|
# @param env [Hash, nil] added to the command's environment
|
|
83
89
|
# @param on_timeout [Symbol, String, nil] :task or :kill (ON_TIMEOUT);
|
|
84
90
|
# nil reads execute.on_timeout
|
|
@@ -115,16 +121,18 @@ module Samagotchi
|
|
|
115
121
|
# a long command, and return whatever it printed before it was killed.
|
|
116
122
|
stopped_result("#{TIMEOUT_PREFIX} #{timeout_sec}s\n#{TIMEOUT_HINT}", e)
|
|
117
123
|
rescue CommandCancelled => e
|
|
118
|
-
first =
|
|
124
|
+
first = STOPPED_BY.fetch(stop_reason.call, STOPPED_BY_USER)
|
|
119
125
|
stopped_result("#{first} after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
|
|
120
126
|
rescue StandardError => e
|
|
121
127
|
"Error: #{e.message}"
|
|
122
128
|
end
|
|
123
129
|
|
|
124
130
|
# What a command the turn's stop kept from starting returns.
|
|
125
|
-
def self.not_run(stop_reason) =
|
|
131
|
+
def self.not_run(stop_reason) = NOT_RUN.fetch(stop_reason.call, NOT_RUN_ON_STOP)
|
|
126
132
|
|
|
127
|
-
|
|
133
|
+
# The texts above by the stop's reason; any other is the user's Stop.
|
|
134
|
+
NOT_RUN = { signal: NOT_RUN_ON_SIGNAL, session_stopped: NOT_RUN_ON_SESSION_STOP }.freeze
|
|
135
|
+
STOPPED_BY = { signal: STOPPED_BY_SIGNAL, session_stopped: STOPPED_BY_SESSION_STOP }.freeze
|
|
128
136
|
|
|
129
137
|
# execute.on_timeout, or the caller's: :task (the default) or :kill.
|
|
130
138
|
def self.on_timeout_mode(value)
|
|
@@ -169,10 +177,14 @@ module Samagotchi
|
|
|
169
177
|
exit_code_path = File.join(dir, TaskRuntime::EXIT_CODE_FILENAME)
|
|
170
178
|
begin
|
|
171
179
|
pid = spawn_logged(command, env: env, cwd: cwd, logs: logs, exit_code_path: exit_code_path)
|
|
172
|
-
rescue StandardError
|
|
180
|
+
rescue StandardError => e
|
|
181
|
+
Log.info(:turn, "execute_spawn_failed", id: id, error: e.class.name)
|
|
173
182
|
FileUtils.rm_rf(dir)
|
|
174
183
|
raise
|
|
175
184
|
end
|
|
185
|
+
# Never the command line or its output: a command may carry a
|
|
186
|
+
# secret, and its output is the tool result's.
|
|
187
|
+
Log.debug(:turn, "execute_started", id: id, pid: pid, timeout_sec: timeout_sec, on_timeout: on_timeout)
|
|
176
188
|
wait_thr = Process.detach(pid)
|
|
177
189
|
# A stop is done once the shell is reaped and nothing holds the logs:
|
|
178
190
|
# a child that ignores TERM keeps them, so the stop goes on to KILL.
|
|
@@ -195,6 +207,7 @@ module Samagotchi
|
|
|
195
207
|
timeout_sec: timeout_sec, dir: dir, logs: logs, exit_code_path: exit_code_path)
|
|
196
208
|
if text
|
|
197
209
|
handed_over = true
|
|
210
|
+
Log.info(:turn, "execute_handed_over", id: id, after_sec: timeout_sec)
|
|
198
211
|
raise HandedOver, text
|
|
199
212
|
end
|
|
200
213
|
# Not adopted because it ended meanwhile: finished too.
|
|
@@ -205,10 +218,11 @@ module Samagotchi
|
|
|
205
218
|
status = wait_thr.value
|
|
206
219
|
rescue HandedOver
|
|
207
220
|
raise
|
|
208
|
-
rescue Exception # rubocop:disable Lint/RescueException -- kill the child, then re-raise
|
|
221
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- kill the child, then re-raise
|
|
209
222
|
# An Interrupt (or anything else) mid-wait: the child is in its own
|
|
210
223
|
# process group, so it never saw the SIGINT. Kill it.
|
|
211
224
|
aborted = true
|
|
225
|
+
Log.info(:turn, "execute_aborted", id: id, error: e.class.name)
|
|
212
226
|
terminate_process_tree(pid, stopped: stopped) if wait_thr.alive?
|
|
213
227
|
raise
|
|
214
228
|
ensure
|
|
@@ -230,9 +244,18 @@ module Samagotchi
|
|
|
230
244
|
end
|
|
231
245
|
end
|
|
232
246
|
|
|
233
|
-
|
|
234
|
-
|
|
247
|
+
elapsed_ms = ((monotonic_time - started) * 1000).round
|
|
248
|
+
if timed_out
|
|
249
|
+
Log.info(:turn, "execute_timed_out", id: id, after_sec: timeout_sec)
|
|
250
|
+
raise CommandTimedOut.new(stdout_text, stderr_text)
|
|
251
|
+
end
|
|
252
|
+
if was_cancelled
|
|
253
|
+
Log.info(:turn, "execute_stopped", id: id, ms: elapsed_ms)
|
|
254
|
+
raise CommandCancelled.new(stdout_text, stderr_text, monotonic_time - started)
|
|
255
|
+
end
|
|
235
256
|
|
|
257
|
+
Log.debug(:turn, "execute_finished", id: id, exit: status&.exitstatus, ms: elapsed_ms,
|
|
258
|
+
background: held_open || nil)
|
|
236
259
|
[stdout_text, stderr_text, status, held_open]
|
|
237
260
|
end
|
|
238
261
|
private_class_method :run_command
|
|
@@ -348,6 +371,8 @@ module Samagotchi
|
|
|
348
371
|
Thread.current.report_on_exception = false
|
|
349
372
|
code = waiter.value&.exitstatus
|
|
350
373
|
File.write(path, code.to_s) if code && !File.exist?(path) && Dir.exist?(File.dirname(path))
|
|
374
|
+
rescue StandardError => e
|
|
375
|
+
Log.debug(:turn, "execute_exit_code_failed", error: e.class.name, msg: e.message)
|
|
351
376
|
end
|
|
352
377
|
end
|
|
353
378
|
private_class_method :write_missing_exit_code
|
|
@@ -67,6 +67,7 @@ module Samagotchi
|
|
|
67
67
|
@boundaries = 0
|
|
68
68
|
@written_at = nil
|
|
69
69
|
@started_at = nil
|
|
70
|
+
@enqueued_id = nil
|
|
70
71
|
end
|
|
71
72
|
|
|
72
73
|
# A turn begins (before Engine#run_turn and the worker's running save):
|
|
@@ -74,14 +75,18 @@ module Samagotchi
|
|
|
74
75
|
# images, no turn id), marked at iteration 0, so a worker killed before
|
|
75
76
|
# the loop's first boundary leaves the prompt and a mark the next one
|
|
76
77
|
# settles. A continue turn (no prompt) is the history, marked.
|
|
78
|
+
# Every mark of the turn carries +enqueued_id+ (InterruptedTurn's).
|
|
77
79
|
# @param prompt [String, nil]
|
|
78
|
-
|
|
80
|
+
# @param enqueued_id [String, nil] the input the turn runs
|
|
81
|
+
def begin_turn(prompt: nil, enqueued_id: nil)
|
|
79
82
|
@boundaries = 0
|
|
83
|
+
@enqueued_id = enqueued_id.to_s.empty? ? nil : enqueued_id.to_s
|
|
80
84
|
@written_at = nil
|
|
81
85
|
@started_at = Time.now.iso8601(3)
|
|
82
86
|
messages = Array(@session.messages).dup
|
|
83
87
|
messages << { role: "user", content: prompt } unless prompt.to_s.empty?
|
|
84
|
-
@session.turn_checkpoint = { messages: messages, marker: InterruptedTurn.new(started_at: @started_at, at: @started_at, iteration: 0
|
|
88
|
+
@session.turn_checkpoint = { messages: messages, marker: InterruptedTurn.new(started_at: @started_at, at: @started_at, iteration: 0,
|
|
89
|
+
enqueued_id: @enqueued_id) }
|
|
85
90
|
end
|
|
86
91
|
|
|
87
92
|
# The turn ended (or never began): the session saves its own messages.
|
|
@@ -104,7 +109,8 @@ module Samagotchi
|
|
|
104
109
|
# One level deep: the loop may edit an entry in place later (a
|
|
105
110
|
# forget stub) while a save on another thread serializes this one.
|
|
106
111
|
messages = conversation.map { |message| message.is_a?(Hash) ? message.dup : message } + merged
|
|
107
|
-
marker = InterruptedTurn.new(id: turn_id, started_at: @started_at, at: Time.now.iso8601(3), iteration: @boundaries
|
|
112
|
+
marker = InterruptedTurn.new(id: turn_id, started_at: @started_at, at: Time.now.iso8601(3), iteration: @boundaries,
|
|
113
|
+
enqueued_id: @enqueued_id)
|
|
108
114
|
@session.turn_checkpoint = { messages: messages, marker: marker }
|
|
109
115
|
return false unless due? || !merged.empty?
|
|
110
116
|
|