samagotchi 0.84.0 → 0.86.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 +80 -1
- data/docs/architecture.md +44 -6
- data/docs/bundle-authoring.md +125 -0
- data/docs/cli.md +54 -4
- data/docs/configuration.md +8 -5
- data/docs/guardrails.md +63 -35
- data/docs/hooks.md +20 -4
- data/docs/internals/anthropic.md +2 -2
- data/docs/internals/bundles.md +11 -2
- data/docs/internals/context-telemetry.md +19 -1
- data/docs/internals/engine.md +4 -3
- data/docs/memory.md +1 -1
- data/docs/plugins.md +59 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +8 -6
- data/docs/sub-agent.md +35 -24
- data/docs/testing.md +7 -2
- data/lib/samagotchi/answer_command.rb +21 -25
- data/lib/samagotchi/bridge/client_event.rb +50 -0
- data/lib/samagotchi/bridge/sse_writer.rb +6 -2
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -0
- data/lib/samagotchi/bridge.rb +76 -12
- data/lib/samagotchi/bridge_client.rb +14 -2
- data/lib/samagotchi/bundle_command/check.rb +348 -0
- data/lib/samagotchi/bundle_command/scaffold.rb +135 -0
- data/lib/samagotchi/bundle_command/test_run.rb +89 -0
- data/lib/samagotchi/bundle_command.rb +125 -2
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -2
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/ruby/manifest.yml +11 -0
- data/lib/samagotchi/bundles/ruby/plugin.rb +74 -0
- 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/self_map.md +2 -1
- data/lib/samagotchi/client_id.rb +1 -1
- data/lib/samagotchi/config.rb +195 -115
- data/lib/samagotchi/context_status.rb +1 -1
- data/lib/samagotchi/continue_offer.rb +2 -1
- data/lib/samagotchi/engine.rb +5 -5
- data/lib/samagotchi/guardrail_wiring.rb +4 -0
- data/lib/samagotchi/guardrails/approval.rb +12 -2
- data/lib/samagotchi/guardrails/load_failures.rb +17 -3
- data/lib/samagotchi/guardrails/parent_approvals.rb +14 -70
- data/lib/samagotchi/guardrails/parent_policy.rb +258 -0
- data/lib/samagotchi/guardrails/verdict.rb +6 -3
- data/lib/samagotchi/hook_routes.rb +5 -3
- data/lib/samagotchi/hooks/bundle_loader.rb +3 -1
- data/lib/samagotchi/hooks/registry.rb +13 -6
- data/lib/samagotchi/hooks.rb +5 -0
- data/lib/samagotchi/kernel_loop.rb +10 -3
- data/lib/samagotchi/llm/anthropic_assembly.rb +1 -1
- data/lib/samagotchi/llm/chat_loop.rb +5 -1
- data/lib/samagotchi/log_subscriber.rb +1 -0
- data/lib/samagotchi/memory_bundle/shas.rb +127 -0
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/parent_report.rb +92 -16
- data/lib/samagotchi/pending_question.rb +8 -4
- data/lib/samagotchi/plugin/api.rb +16 -0
- data/lib/samagotchi/plugin/context.rb +22 -12
- data/lib/samagotchi/plugin/loader.rb +5 -3
- data/lib/samagotchi/question_desk.rb +37 -24
- data/lib/samagotchi/queued_command.rb +5 -4
- data/lib/samagotchi/relay.rb +5 -1
- data/lib/samagotchi/relay_verifier.rb +4 -5
- data/lib/samagotchi/reply_wait.rb +22 -6
- data/lib/samagotchi/send_command.rb +34 -8
- data/lib/samagotchi/session_commands.rb +21 -7
- data/lib/samagotchi/session_inbox.rb +68 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +102 -12
- data/lib/samagotchi/terminal_ui/thinking_line.rb +5 -9
- data/lib/samagotchi/terminal_ui.rb +15 -9
- data/lib/samagotchi/testing/boundary.rb +0 -3
- data/lib/samagotchi/testing/bundle.rb +7 -0
- data/lib/samagotchi/testing/host.rb +6 -4
- data/lib/samagotchi/testing/install.rb +119 -0
- data/lib/samagotchi/testing/recorder.rb +13 -4
- data/lib/samagotchi/testing.rb +1 -0
- data/lib/samagotchi/tools/builtins.rb +2 -1
- data/lib/samagotchi/tools/delegate_wait.rb +6 -1
- data/lib/samagotchi/tools/task_runtime.rb +5 -3
- data/lib/samagotchi/turn_checkpoint.rb +64 -2
- data/lib/samagotchi/turn_flow.rb +10 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +76 -10
- data/lib/samagotchi/web/public/app.js +17 -0
- data/lib/samagotchi/web/public/bubbles.js +9 -0
- data/lib/samagotchi/web/public/chunk_router.js +8 -19
- data/lib/samagotchi/web/public/commands.js +1 -0
- data/lib/samagotchi/web/public/ctx.js +12 -26
- data/lib/samagotchi/web/public/data.js +11 -0
- data/lib/samagotchi/web/public/index.html +9 -0
- data/lib/samagotchi/web/public/stage_view.js +6 -0
- data/lib/samagotchi/web/public/stream_handlers.js +4 -1
- data/lib/samagotchi/web/public/turn_events.js +4 -0
- data/lib/samagotchi/web/public/turn_html.js +9 -1
- data/lib/samagotchi/web/public/unqueue.js +56 -0
- data/lib/samagotchi/web/server.rb +4 -0
- data/lib/samagotchi/worker.rb +19 -5
- data/lib/samagotchi/worker_inbound.rb +4 -4
- metadata +12 -2
- data/lib/samagotchi/guardrails/parent_continue.rb +0 -43
|
@@ -5,7 +5,6 @@ require_relative "session"
|
|
|
5
5
|
require_relative "bridge_client"
|
|
6
6
|
require_relative "worker_sidecar"
|
|
7
7
|
require_relative "question_desk"
|
|
8
|
-
require_relative "guardrails/parent_approvals"
|
|
9
8
|
require_relative "log"
|
|
10
9
|
|
|
11
10
|
module Samagotchi
|
|
@@ -24,8 +23,8 @@ module Samagotchi
|
|
|
24
23
|
end
|
|
25
24
|
|
|
26
25
|
# @return [Array(Hash, Integer, Hash)] headers, status, body: 200
|
|
27
|
-
# answered, 409 no longer pending, 403 refused (a parent agent's
|
|
28
|
-
# beyond this worker's
|
|
26
|
+
# answered, 409 no longer pending, 403 refused (a parent agent's pick
|
|
27
|
+
# beyond this worker's Guardrails::ParentPolicy), 422
|
|
29
28
|
# relay_unverified (logged; the question stays open)
|
|
30
29
|
def answer(relay_id:, question_id:)
|
|
31
30
|
parent_id = own_parent_id or return unverified("this session has no parent", relay_id)
|
|
@@ -59,8 +58,8 @@ module Samagotchi
|
|
|
59
58
|
not_pending(question_id)
|
|
60
59
|
rescue QuestionDesk::Refused => e
|
|
61
60
|
log("relay_refused", relay_id, id: question_id, reason: e.reason)
|
|
62
|
-
[{}, 403, { error: "
|
|
63
|
-
detail:
|
|
61
|
+
[{}, 403, { error: "parent_answer_refused", reason: e.reason.to_s,
|
|
62
|
+
detail: e.detail }]
|
|
64
63
|
rescue ArgumentError => e
|
|
65
64
|
unverified("the answer doesn't fit the question: #{e.message}", relay_id)
|
|
66
65
|
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "session"
|
|
4
4
|
require_relative "turn_note"
|
|
5
5
|
require_relative "session_inbox"
|
|
6
|
+
require_relative "turn_checkpoint"
|
|
6
7
|
require_relative "worker_sidecar"
|
|
7
8
|
|
|
8
9
|
module Samagotchi
|
|
@@ -32,10 +33,13 @@ module Samagotchi
|
|
|
32
33
|
# @!attribute question [PendingQuestion, nil] the pending question
|
|
33
34
|
# (:waiting_for_answer)
|
|
34
35
|
# @!attribute outcome [String, nil] how the turn ended when it left no
|
|
35
|
-
# reply (:no_reply): "failed", "canceled", "completed" (empty
|
|
36
|
+
# reply (:no_reply): "failed", "canceled", "completed" (empty turn or
|
|
37
|
+
# file-delivered command ran without a turn),
|
|
36
38
|
# "exhausted" (it ran out of iterations, with nobody asked whether to
|
|
37
|
-
# continue)
|
|
38
|
-
# question; no turn ran), from the session's last_turn
|
|
39
|
+
# continue), "not_continued" (a Stop answered the step-limit
|
|
40
|
+
# question; no turn ran), from the session's last_turn, or "removed"
|
|
41
|
+
# (turn:'s queued input was taken back before it ran, its tombstone:
|
|
42
|
+
# SessionInbox.removed?; no turn ran); nil when unknown. With
|
|
39
43
|
# "failed", text is the failure's summary when the turn note has one.
|
|
40
44
|
# @!attribute limit [Integer, nil] the iteration limit an "exhausted"
|
|
41
45
|
# turn ran out at
|
|
@@ -45,8 +49,9 @@ module Samagotchi
|
|
|
45
49
|
# retry
|
|
46
50
|
# @!attribute cancel_reason [String, nil] a "canceled" turn's reason
|
|
47
51
|
# (user, hook, ctrl_c, manual, session_stopped: the session was
|
|
48
|
-
# stopped mid-turn
|
|
49
|
-
#
|
|
52
|
+
# stopped mid-turn; worker_stopped: turn:'s worker died in it and the
|
|
53
|
+
# next one settled it, TurnCheckpoint.settled_turn), passed through
|
|
54
|
+
# as the worker saved it; any reason is a "canceled" outcome
|
|
50
55
|
# @!attribute stopped_by [String, nil] the hook that canceled it
|
|
51
56
|
# (loop-guard)
|
|
52
57
|
# @!attribute session [Session, nil] the session as the look that
|
|
@@ -132,6 +137,11 @@ module Samagotchi
|
|
|
132
137
|
claimed = waiting.nil?
|
|
133
138
|
end
|
|
134
139
|
session = Session.load(id, state_dir: state_dir)
|
|
140
|
+
# Taken back before it ran (POST turn/dequeue): no turn will come.
|
|
141
|
+
if turn && claimed && !seen_own && SessionInbox.removed?(session_dir, turn)
|
|
142
|
+
return Result.new(status: :no_reply, outcome: "removed", session: session)
|
|
143
|
+
end
|
|
144
|
+
|
|
135
145
|
if turn && !seen_own && session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == turn
|
|
136
146
|
seen_own = true
|
|
137
147
|
own_base = session.last_turn&.ended_at
|
|
@@ -149,6 +159,12 @@ module Samagotchi
|
|
|
149
159
|
reply.session = settled(session, id, state_dir: state_dir)
|
|
150
160
|
return reply
|
|
151
161
|
end
|
|
162
|
+
# Its worker died in it and the next one settled it, or an input
|
|
163
|
+
# command finished without a turn. After the reply check: a worker
|
|
164
|
+
# killed between writing the reply and its idle save left the reply.
|
|
165
|
+
if turn && (settled = TurnCheckpoint.settled_turn(session_dir, turn))
|
|
166
|
+
return Result.new(status: :no_reply, outcome: settled.outcome, session: session, **settled.stop_facts)
|
|
167
|
+
end
|
|
152
168
|
|
|
153
169
|
case session.status
|
|
154
170
|
when Session::STATUS_ERROR
|
|
@@ -307,7 +323,7 @@ module Samagotchi
|
|
|
307
323
|
|
|
308
324
|
running = session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == id
|
|
309
325
|
queued = SessionInbox.waiting_input(session_dir, id) == :turn_end
|
|
310
|
-
ended = session.last_turn&.enqueued_id == id
|
|
326
|
+
ended = session.last_turn&.enqueued_id == id || !TurnCheckpoint.settled_turn(session_dir, id).nil?
|
|
311
327
|
running || queued || ended
|
|
312
328
|
end
|
|
313
329
|
|
|
@@ -12,6 +12,9 @@ require_relative "session_chain"
|
|
|
12
12
|
require_relative "context_quote"
|
|
13
13
|
require_relative "reply_wait"
|
|
14
14
|
require_relative "parent_report"
|
|
15
|
+
require_relative "turn_flow"
|
|
16
|
+
require_relative "guardrails/parent_approvals"
|
|
17
|
+
require_relative "guardrails/parent_policy"
|
|
15
18
|
require_relative "image_store"
|
|
16
19
|
require_relative "host_registry"
|
|
17
20
|
require_relative "model_profile"
|
|
@@ -241,6 +244,14 @@ module Samagotchi
|
|
|
241
244
|
if prompt && (local = local_command(prompt))
|
|
242
245
|
return refuse_local_command(local, options)
|
|
243
246
|
end
|
|
247
|
+
|
|
248
|
+
# A parent agent's /continue yes is chi answer --option Continue by
|
|
249
|
+
# another route: held to guardrails.parent_answers here, and marked
|
|
250
|
+
# as chi answer's (#client_id_for) so the worker checks it again.
|
|
251
|
+
if prompt && parent_continue?(prompt) && (refusal = Guardrails::ParentPolicy.continue_refusal)
|
|
252
|
+
@stderr.puts("chi send: #{refusal}")
|
|
253
|
+
return CLI::Exit::FAILED
|
|
254
|
+
end
|
|
244
255
|
# A file that is not there or not an image: a run-time failure (1),
|
|
245
256
|
# reported in the --format json line too; --image itself was fine.
|
|
246
257
|
return CLI::Exit::FAILED unless ingest_images(options[:images])
|
|
@@ -414,7 +425,8 @@ module Samagotchi
|
|
|
414
425
|
return report_command(session.id) if @sent_command
|
|
415
426
|
|
|
416
427
|
wait_for_reply(session.id, cursor: nil, baseline: baseline_of(session, question_id: nil),
|
|
417
|
-
timeout: options[:timeout],
|
|
428
|
+
timeout: options[:timeout], turn: @sent_input_file ? @sent_enqueued_id : nil,
|
|
429
|
+
report_turn_id: @sent_enqueued_id)
|
|
418
430
|
end
|
|
419
431
|
|
|
420
432
|
# The model the new session will run, for the images check: --model
|
|
@@ -461,11 +473,13 @@ module Samagotchi
|
|
|
461
473
|
# The turn waits on a question: the message would only queue
|
|
462
474
|
# behind it, and the answer goes in with chi answer.
|
|
463
475
|
if (waiting = session.waiting_question(live: owner.worker?))
|
|
476
|
+
# The whole question (its options, its tags) for what a parent may give.
|
|
477
|
+
question = ParentReport.question(session.pending_question)
|
|
464
478
|
if ParentReport.approval?(waiting)
|
|
465
|
-
error_line("#{id[0, 8]} waiting for an approval: #{ParentReport
|
|
466
|
-
"(#{ParentReport.answer_with(id,
|
|
479
|
+
error_line("#{id[0, 8]} waiting for an approval: #{ParentReport.approval_advice(question)} " \
|
|
480
|
+
"(#{ParentReport.answer_with(id, question)})")
|
|
467
481
|
else
|
|
468
|
-
error_line("#{id[0, 8]} waiting for an answer: #{ParentReport.answer_with(id,
|
|
482
|
+
error_line("#{id[0, 8]} waiting for an answer: #{ParentReport.answer_with(id, question)} (or --text); " \
|
|
469
483
|
"chi --attach #{id} shows it")
|
|
470
484
|
end
|
|
471
485
|
else
|
|
@@ -485,7 +499,9 @@ module Samagotchi
|
|
|
485
499
|
# The ack's id names the queued message's turn, also when the turn
|
|
486
500
|
# running at the check ended before it went in (an idle worker's ack
|
|
487
501
|
# says next_step, and the file still runs as that turn).
|
|
488
|
-
|
|
502
|
+
# A file fallback has no delivery field; use its id to see terminal
|
|
503
|
+
# outcomes for commands that finish without starting a turn.
|
|
504
|
+
turn = @sent_enqueued_id if @queue || @sent_input_file
|
|
489
505
|
report_turn_id = if @queue
|
|
490
506
|
turn
|
|
491
507
|
elsif session.status == Session::STATUS_RUNNING
|
|
@@ -493,7 +509,8 @@ module Samagotchi
|
|
|
493
509
|
else
|
|
494
510
|
@sent_enqueued_id
|
|
495
511
|
end
|
|
496
|
-
wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], turn: turn,
|
|
512
|
+
wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], turn: turn,
|
|
513
|
+
queued: @queue && !turn.nil?,
|
|
497
514
|
report_turn_id: report_turn_id)
|
|
498
515
|
end
|
|
499
516
|
|
|
@@ -550,7 +567,7 @@ module Samagotchi
|
|
|
550
567
|
begin
|
|
551
568
|
refs = copy_images(dir)
|
|
552
569
|
if BridgeClient.wait_for(session.id, session_dir: dir, timeout: SessionManager::TURN_BRIDGE_WAIT)
|
|
553
|
-
result = SessionManager.deliver_turn(session.id, prompt: prompt, client_id:
|
|
570
|
+
result = SessionManager.deliver_turn(session.id, prompt: prompt, client_id: client_id_for(prompt), images: refs,
|
|
554
571
|
state_dir: @state_dir)
|
|
555
572
|
end
|
|
556
573
|
rescue StandardError => e
|
|
@@ -569,6 +586,7 @@ module Samagotchi
|
|
|
569
586
|
end
|
|
570
587
|
|
|
571
588
|
ack = result[:ack].is_a?(Hash) ? result[:ack] : {}
|
|
589
|
+
@sent_input_file = !ack.key?("delivery")
|
|
572
590
|
@sent_enqueued_id = (ack["enqueued_id"] || ack[:enqueued_id])&.to_s
|
|
573
591
|
@sent_command = command_ack?(result[:ack])
|
|
574
592
|
@info.puts("#{session.id} started#{continued_words(session)}#{with_images}#{"; #{SENT_COMMAND}" if @sent_command}")
|
|
@@ -581,6 +599,13 @@ module Samagotchi
|
|
|
581
599
|
# /turn whose line is one answers with its command_id).
|
|
582
600
|
def command_ack?(ack) = ack.is_a?(Hash) && !ack["command_id"].to_s.empty?
|
|
583
601
|
|
|
602
|
+
# chi send's client id, or chi answer's for a parent agent's /continue
|
|
603
|
+
# yes (the worker holds that to its guardrails.parent_answers).
|
|
604
|
+
def client_id_for(prompt) = parent_continue?(prompt) ? ClientId::CLI_ANSWER : CLIENT_ID
|
|
605
|
+
|
|
606
|
+
# A /continue yes (or bare /continue) from a parent agent's process.
|
|
607
|
+
def parent_continue?(prompt) = TurnFlow.continue_yes?(prompt) && Guardrails::ParentApprovals.parent_process?
|
|
608
|
+
|
|
584
609
|
# Whether +text+ may be a session command, which only its worker knows
|
|
585
610
|
# (a bundle's too).
|
|
586
611
|
def command_like?(text) = text.to_s.lstrip.start_with?("/", "!")
|
|
@@ -648,7 +673,7 @@ module Samagotchi
|
|
|
648
673
|
delivery = if @queue then Delivery::QUEUE
|
|
649
674
|
elsif @cut && running then Delivery::CUT
|
|
650
675
|
end
|
|
651
|
-
result = SessionManager.deliver_turn(id, prompt: prompt, client_id:
|
|
676
|
+
result = SessionManager.deliver_turn(id, prompt: prompt, client_id: client_id_for(prompt), images: refs, state_dir: @state_dir,
|
|
652
677
|
delivery: delivery)
|
|
653
678
|
unless result[:status] == :accepted
|
|
654
679
|
# A worker older than queueing would steer it: refused, not sent.
|
|
@@ -664,6 +689,7 @@ module Samagotchi
|
|
|
664
689
|
end
|
|
665
690
|
|
|
666
691
|
ack = result[:ack].is_a?(Hash) ? result[:ack] : {}
|
|
692
|
+
@sent_input_file = !ack.key?("delivery")
|
|
667
693
|
# What the worker applied, for --format json: its ack's delivery
|
|
668
694
|
# (next_step for an idle session) and a cut's outcome.
|
|
669
695
|
@sent_delivery = ack["delivery"] || delivery
|
|
@@ -14,6 +14,7 @@ require_relative "llm_context_command"
|
|
|
14
14
|
require_relative "thinking_command"
|
|
15
15
|
require_relative "toolkit_command"
|
|
16
16
|
require_relative "reasoning_history_command"
|
|
17
|
+
require_relative "guardrails/parent_policy"
|
|
17
18
|
|
|
18
19
|
module Samagotchi
|
|
19
20
|
# The session commands a REPL and a session worker both run: /model,
|
|
@@ -80,7 +81,7 @@ module Samagotchi
|
|
|
80
81
|
next reply("nothing to continue") unless @turn_flow.awaiting_continue?
|
|
81
82
|
|
|
82
83
|
answer = text.delete_prefix(CONTINUE_COMMAND).strip
|
|
83
|
-
continue_answer(answer.empty? ? CONTINUE_COMMAND : answer)
|
|
84
|
+
continue_answer(answer.empty? ? CONTINUE_COMMAND : answer, as_parent: @as_parent)
|
|
84
85
|
end
|
|
85
86
|
# It reads the hosts only (the registry is mutex-guarded): mid-turn too.
|
|
86
87
|
registry.register(MODELS_COMMAND, "list the hosts' models (/models <text> filters)", anytime: true) do |text|
|
|
@@ -123,13 +124,16 @@ module Samagotchi
|
|
|
123
124
|
# /cut TEXT and /queue TEXT while a turn runs: a delivery the UIs parse
|
|
124
125
|
# themselves (ComposerRoute.steer_choice) before any command check; the
|
|
125
126
|
# entries exist for Tab completion and /help. The web composer sends
|
|
126
|
-
# /cut too
|
|
127
|
-
#
|
|
128
|
-
# it is offered there.
|
|
127
|
+
# /cut too (its queue is Alt+Enter). Both terminals queue: the REPL in
|
|
128
|
+
# its own list, the attached TUI's worker in its input files.
|
|
129
129
|
registry.register("/cut", "cut the running generation and go in now (/cut TEXT)",
|
|
130
130
|
local: true, uis: %i[repl attached web], match: ->(text) { text.casecmp?("/cut") || text.match?(%r{\A/cut\s+\S}) })
|
|
131
131
|
registry.register("/queue", "run as the next turn after this one (/queue TEXT)",
|
|
132
|
-
local: true, uis: [
|
|
132
|
+
local: true, uis: %i[repl attached], match: ->(text) { text.casecmp?("/queue") || text.match?(%r{\A/queue\s+\S}) })
|
|
133
|
+
# Takes back the newest message this attached TUI queued, before it
|
|
134
|
+
# runs (the worker's POST turn/dequeue); the web has × on the bubble.
|
|
135
|
+
registry.register("/unqueue", "remove your newest queued message before it runs", local: true, uis: [:attached],
|
|
136
|
+
match: ->(text) { text.casecmp?("/unqueue") })
|
|
133
137
|
registry
|
|
134
138
|
end
|
|
135
139
|
|
|
@@ -251,8 +255,12 @@ module Samagotchi
|
|
|
251
255
|
# change asks first (CacheConfirm): true for a person at a web tab or
|
|
252
256
|
# a TUI; false applies it at once (chi send, a delegate, a plugin,
|
|
253
257
|
# chi -p)
|
|
258
|
+
# @param as_parent [Boolean] a parent agent sent it (client cli:answer):
|
|
259
|
+
# a /continue yes is held to guardrails.parent_answers, as chi
|
|
260
|
+
# answer --option Continue is (Guardrails::ParentPolicy)
|
|
254
261
|
# @return [Result, nil] nil when +line+ is not one of these commands
|
|
255
|
-
def run(line, confirm: true)
|
|
262
|
+
def run(line, confirm: true, as_parent: false)
|
|
263
|
+
@as_parent = as_parent
|
|
256
264
|
text = line.to_s.strip
|
|
257
265
|
entry = @registry.lookup(text)
|
|
258
266
|
return nil unless entry&.handler
|
|
@@ -265,9 +273,15 @@ module Samagotchi
|
|
|
265
273
|
end
|
|
266
274
|
|
|
267
275
|
# Answer the pending continue offer (yes / no / no, <reason>).
|
|
276
|
+
# @param as_parent [Boolean] a parent agent's answer: a yes its strategy
|
|
277
|
+
# doesn't allow is refused (the offer stays; decision :invalid)
|
|
268
278
|
# @return [Result] resume: true on yes
|
|
269
|
-
def continue_answer(text)
|
|
279
|
+
def continue_answer(text, as_parent: false)
|
|
270
280
|
decision, reason = TurnFlow.continue_decision(text)
|
|
281
|
+
if decision == :resume && as_parent && (refusal = Guardrails::ParentPolicy.continue_refusal)
|
|
282
|
+
return reply(refusal, status: :error).tap { |result| result.decision = :invalid }
|
|
283
|
+
end
|
|
284
|
+
|
|
271
285
|
result = case decision
|
|
272
286
|
when :resume then Result.new(status: :ok, changed: [], model_name: model_name, resume: true, shell: false)
|
|
273
287
|
when :abort
|
|
@@ -6,6 +6,7 @@ require "time"
|
|
|
6
6
|
require "securerandom"
|
|
7
7
|
require_relative "atomic_file"
|
|
8
8
|
require_relative "delivery"
|
|
9
|
+
require_relative "log"
|
|
9
10
|
require_relative "session"
|
|
10
11
|
|
|
11
12
|
module Samagotchi
|
|
@@ -276,5 +277,72 @@ module Samagotchi
|
|
|
276
277
|
rescue Errno::ENOENT, Errno::EACCES
|
|
277
278
|
nil
|
|
278
279
|
end
|
|
280
|
+
|
|
281
|
+
# Where a removed queued input leaves its tombstone: input/removed/<id>,
|
|
282
|
+
# an empty file outside the *.json glob, so a wait for that input's
|
|
283
|
+
# turn (ReplyWait's turn:) learns it will never run (#removed?).
|
|
284
|
+
REMOVED_DIR = "removed"
|
|
285
|
+
# The worker's sidecar feature for POST turn/dequeue (Bridge::FEATURES).
|
|
286
|
+
UNQUEUE_FEATURE = "unqueue"
|
|
287
|
+
|
|
288
|
+
# Take back the unclaimed input file carrying +enqueued_id+ before a
|
|
289
|
+
# turn takes it (the Bridge's POST turn/dequeue, under the event log):
|
|
290
|
+
# only one that waits for the turn's end (a queue or an image message,
|
|
291
|
+
# #waits_for_turn_end?); a next-step file may be merged at any boundary.
|
|
292
|
+
# The file is claimed first, as the drain does (the rename is the race's
|
|
293
|
+
# one winner), then deleted, and its tombstone written.
|
|
294
|
+
# @return [Array(Symbol, Input)] [:removed, the input it held],
|
|
295
|
+
# [:not_queued, nil] for a next-step file, [:claimed, nil] when a
|
|
296
|
+
# turn took it (its *.json.processing is there), [:none, nil] for
|
|
297
|
+
# none
|
|
298
|
+
def self.take_queued_input(session_dir, enqueued_id)
|
|
299
|
+
id = enqueued_id.to_s
|
|
300
|
+
return [:none, nil] if id.empty?
|
|
301
|
+
|
|
302
|
+
find_new_input_files(session_dir).sort.each do |file|
|
|
303
|
+
data = JSON.parse(File.read(file))
|
|
304
|
+
next unless data.is_a?(Hash) && data["enqueued_id"] == id
|
|
305
|
+
return [:not_queued, nil] unless Array(data["images"]).any? || Delivery.queue?(data["delivery"])
|
|
306
|
+
|
|
307
|
+
claimed = claim_input_file(file) or return [:claimed, nil]
|
|
308
|
+
input = read_input(claimed)
|
|
309
|
+
mark_removed(session_dir, id)
|
|
310
|
+
FileUtils.rm_f(claimed)
|
|
311
|
+
return [:removed, input]
|
|
312
|
+
rescue JSON::ParserError, SystemCallError
|
|
313
|
+
next
|
|
314
|
+
end
|
|
315
|
+
claimed_input?(session_dir, id) ? [:claimed, nil] : [:none, nil]
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Whether a claimed input file (*.json.processing: a turn's, now or
|
|
319
|
+
# left by a crash) carries +id+.
|
|
320
|
+
def self.claimed_input?(session_dir, id)
|
|
321
|
+
Dir.glob(File.join(session_dir, INPUT_DIR, "*.json.processing")).any? do |file|
|
|
322
|
+
data = JSON.parse(File.read(file))
|
|
323
|
+
data.is_a?(Hash) && data["enqueued_id"] == id
|
|
324
|
+
rescue JSON::ParserError, SystemCallError
|
|
325
|
+
false
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
private_class_method :claimed_input?
|
|
329
|
+
|
|
330
|
+
def self.mark_removed(session_dir, id)
|
|
331
|
+
return unless ENQUEUED_ID_NAME.match?(id)
|
|
332
|
+
|
|
333
|
+
dir = File.join(session_dir, INPUT_DIR, REMOVED_DIR)
|
|
334
|
+
FileUtils.mkdir_p(dir)
|
|
335
|
+
AtomicFile.write(File.join(dir, id), "")
|
|
336
|
+
rescue SystemCallError => e
|
|
337
|
+
Log.warn(:bridge, "removed_tombstone_write_failed", error: e.class.name, msg: e.message)
|
|
338
|
+
end
|
|
339
|
+
private_class_method :mark_removed
|
|
340
|
+
|
|
341
|
+
# @return [Boolean] whether the queued input +enqueued_id+ was removed
|
|
342
|
+
# before it ran (#take_queued_input's tombstone)
|
|
343
|
+
def self.removed?(session_dir, enqueued_id)
|
|
344
|
+
id = enqueued_id.to_s
|
|
345
|
+
ENQUEUED_ID_NAME.match?(id) && File.exist?(File.join(session_dir, INPUT_DIR, REMOVED_DIR, id))
|
|
346
|
+
end
|
|
279
347
|
end
|
|
280
348
|
end
|
|
@@ -77,7 +77,7 @@ module Samagotchi
|
|
|
77
77
|
first_command: first_command, no_interrupt: no_interrupt,
|
|
78
78
|
default_input: default_input && !prompt && !attach && !resume,
|
|
79
79
|
wait_at_eof: !$stdin.tty?,
|
|
80
|
-
|
|
80
|
+
as_parent: Guardrails::ParentApprovals.parent_process?, state_dir: state_dir)
|
|
81
81
|
ended = begin
|
|
82
82
|
attached.run
|
|
83
83
|
ensure
|
|
@@ -5,6 +5,7 @@ require_relative "../composer_route"
|
|
|
5
5
|
require_relative "event_renderer"
|
|
6
6
|
require_relative "formatting"
|
|
7
7
|
require_relative "../prompt_history"
|
|
8
|
+
require_relative "../session_inbox"
|
|
8
9
|
require_relative "attached_view"
|
|
9
10
|
require_relative "status_row"
|
|
10
11
|
require_relative "input_support"
|
|
@@ -104,7 +105,7 @@ module Samagotchi
|
|
|
104
105
|
# @param state_dir [String] the state dir the worker was found in: its
|
|
105
106
|
# session folder (@path images, saved records) is under it
|
|
106
107
|
def initialize(client:, screen:, client_id:, first_prompt: nil, first_command: nil, no_interrupt: false,
|
|
107
|
-
default_input: false, wait_at_eof: false,
|
|
108
|
+
default_input: false, wait_at_eof: false, as_parent: false,
|
|
108
109
|
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
109
110
|
delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) },
|
|
110
111
|
archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) },
|
|
@@ -120,8 +121,8 @@ module Samagotchi
|
|
|
120
121
|
@client_id = client_id
|
|
121
122
|
# A parent agent drives this attach (AttachLauncher:
|
|
122
123
|
# ParentApprovals.parent_process?): its answers go as chi answer's,
|
|
123
|
-
# so the worker holds them to
|
|
124
|
-
@
|
|
124
|
+
# so the worker holds them to Guardrails::ParentPolicy.
|
|
125
|
+
@as_parent = as_parent
|
|
125
126
|
@view = AttachedView.new(screen)
|
|
126
127
|
@renderer = EventRenderer.new(@view)
|
|
127
128
|
@shown_enqueued = Set.new
|
|
@@ -149,6 +150,10 @@ module Samagotchi
|
|
|
149
150
|
# Prompts this run sent, by enqueued_id: only those come back into
|
|
150
151
|
# the input when their turn fails (a replayed event must not).
|
|
151
152
|
@sent_ids = Set.new
|
|
153
|
+
# This run's prompts queued for after the running turn (their ack
|
|
154
|
+
# said queue), oldest first, until their turn starts or they are
|
|
155
|
+
# removed: /unqueue takes back the newest.
|
|
156
|
+
@my_queued = []
|
|
152
157
|
@wait_at_eof = wait_at_eof
|
|
153
158
|
# Sent and not ended yet (by enqueued_id), the ones merged into the
|
|
154
159
|
# running turn, whether the input has ended, and whether one of this
|
|
@@ -260,6 +265,7 @@ module Samagotchi
|
|
|
260
265
|
take_session_state(event[:session_state_snapshot] || {})
|
|
261
266
|
return render_snapshot(event[:snapshot] || {}, reset: event[:type] == :reset)
|
|
262
267
|
when :turn_enqueued then show_enqueued(event)
|
|
268
|
+
when :turn_dequeued then turn_dequeued(event)
|
|
263
269
|
when :turn_started
|
|
264
270
|
@status.take_event(event)
|
|
265
271
|
start_turn(event)
|
|
@@ -419,6 +425,8 @@ module Samagotchi
|
|
|
419
425
|
show_stats
|
|
420
426
|
elsif local == :recap
|
|
421
427
|
show_recap
|
|
428
|
+
elsif local == :unqueue
|
|
429
|
+
unqueue_newest
|
|
422
430
|
elsif route.kind == :command
|
|
423
431
|
# The history keeps !cmds, as the REPL's does (prompts: #send_prompt).
|
|
424
432
|
persist_recent_history(text) if PromptHistory.shell_line?(text)
|
|
@@ -584,7 +592,10 @@ module Samagotchi
|
|
|
584
592
|
# The worker runs it and every UI renders its :command_ran, this one
|
|
585
593
|
# too, so nothing waits here.
|
|
586
594
|
def send_command(line)
|
|
587
|
-
|
|
595
|
+
# A parent agent's /continue yes goes as chi answer's: the worker
|
|
596
|
+
# holds it to guardrails.parent_answers.
|
|
597
|
+
client_id = @as_parent && TurnFlow.continue_yes?(line) ? ClientId::CLI_ANSWER : @client_id
|
|
598
|
+
reply = @client.post_command(line: line, client_id: client_id)
|
|
588
599
|
if reply.status == 202
|
|
589
600
|
# Input from a pipe ends once its output is in (#command_ran).
|
|
590
601
|
command_id = reply.json&.fetch("command_id", nil)
|
|
@@ -620,6 +631,58 @@ module Samagotchi
|
|
|
620
631
|
QUEUE_RUNS_NOW = "(the turn had ended: runs as the next turn)"
|
|
621
632
|
# /queue to a worker from before queueing, which would steer it.
|
|
622
633
|
QUEUE_NEEDS_NEWER_WORKER = "(/queue needs a newer worker: restart it with chi sessions restart, then send again)"
|
|
634
|
+
UNQUEUE_NEEDS_NEWER_WORKER = "(/unqueue needs a newer worker: restart it with chi sessions restart)"
|
|
635
|
+
NOTHING_QUEUED = "(nothing queued: /queue TEXT queues a message for after the running turn)"
|
|
636
|
+
UNQUEUE_REFUSED = { "already_started" => "(not removed: it already started)",
|
|
637
|
+
"unknown_enqueued_id" => "(not removed: it is already gone)",
|
|
638
|
+
"not_queued" => "(not removed: it goes in at the next step)" }.freeze
|
|
639
|
+
|
|
640
|
+
# /unqueue: take back the newest prompt this run queued for after the
|
|
641
|
+
# turn, before it runs (POST turn/dequeue). Its :turn_dequeued comes
|
|
642
|
+
# to every UI; here the reply says what became of it. A worker whose
|
|
643
|
+
# sidecar lacks "unqueue" is not asked.
|
|
644
|
+
def unqueue_newest
|
|
645
|
+
return @screen.commit(UNQUEUE_NEEDS_NEWER_WORKER) unless worker_unqueues?
|
|
646
|
+
|
|
647
|
+
id = @my_queued.last
|
|
648
|
+
return @screen.commit(NOTHING_QUEUED) unless id
|
|
649
|
+
|
|
650
|
+
reply = @client.dequeue(enqueued_id: id, client_id: @client_id)
|
|
651
|
+
error = reply.json&.fetch("error", nil)
|
|
652
|
+
return @screen.commit(UNQUEUE_NEEDS_NEWER_WORKER) if reply.status == 404 && error == "not_found"
|
|
653
|
+
|
|
654
|
+
# Removed, started or gone: no longer this run's to take back.
|
|
655
|
+
@my_queued.delete(id) if [200, 404, 409].include?(reply.status)
|
|
656
|
+
return unqueued(reply.json&.fetch("prompt", nil).to_s) if reply.status == 200
|
|
657
|
+
|
|
658
|
+
@screen.commit(UNQUEUE_REFUSED[error] || "could not remove the queued message (#{[reply.status, error].compact.join(" ")})")
|
|
659
|
+
rescue SystemCallError, IOError => e
|
|
660
|
+
@screen.commit("could not remove the queued message (#{worker_down(e)})")
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
# The removal's line, and its text back in the input to change and
|
|
664
|
+
# send again (as a failed turn's prompt comes back, #restore_prompt):
|
|
665
|
+
# not over typed text, and not for lines from a pipe. Its images are
|
|
666
|
+
# not put back.
|
|
667
|
+
def unqueued(prompt)
|
|
668
|
+
@screen.commit("removed: #{first_line_clip(prompt)}")
|
|
669
|
+
return if @wait_at_eof || prompt.strip.empty?
|
|
670
|
+
|
|
671
|
+
@screen.commit("(the removed message is in the input history: ↑)") unless @reader&.prefill(prompt)
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
def first_line_clip(text, max = 60)
|
|
675
|
+
line = text.to_s.strip.lines.first.to_s.strip
|
|
676
|
+
line.length > max ? "#{line[0, max]}…" : line
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# Whether the attached worker takes queued messages back (its sidecar
|
|
680
|
+
# names SessionInbox::UNQUEUE_FEATURE). No sidecar to read: true, its Bridge
|
|
681
|
+
# answers (an older one's 404 not_found says so).
|
|
682
|
+
def worker_unqueues?
|
|
683
|
+
sidecar = WorkerSidecar.read(Session.session_dir(@client.session_id, state_dir: @state_dir))
|
|
684
|
+
sidecar.nil? || sidecar.features.include?(SessionInbox::UNQUEUE_FEATURE)
|
|
685
|
+
end
|
|
623
686
|
|
|
624
687
|
# A command queued for after the running turn (command_queued with
|
|
625
688
|
# waits): its line now (another client's) and a note; its command_ran
|
|
@@ -760,6 +823,7 @@ module Samagotchi
|
|
|
760
823
|
enqueued_id = reply.json&.fetch("enqueued_id", nil)
|
|
761
824
|
@sent_ids << enqueued_id if enqueued_id
|
|
762
825
|
@open_ids << enqueued_id if enqueued_id && @wait_at_eof
|
|
826
|
+
@my_queued << enqueued_id if enqueued_id && reply.json&.fetch("delivery", nil) == Delivery::QUEUE
|
|
763
827
|
note = sent_note(reply.json, delivery: delivery, images: images, running: running)
|
|
764
828
|
@screen.commit(note) if note
|
|
765
829
|
return
|
|
@@ -929,7 +993,7 @@ module Samagotchi
|
|
|
929
993
|
return @screen.commit(answer.error)
|
|
930
994
|
end
|
|
931
995
|
|
|
932
|
-
parent = @
|
|
996
|
+
parent = @as_parent ? { client_id: ClientId::CLI_ANSWER } : {}
|
|
933
997
|
reply = @client.answer(id: @question.id, selected: answer.selected, freeform: answer.freeform, **parent)
|
|
934
998
|
case reply.status
|
|
935
999
|
when 200
|
|
@@ -1095,6 +1159,12 @@ module Samagotchi
|
|
|
1095
1159
|
|
|
1096
1160
|
def render_snapshot(snapshot, reset:)
|
|
1097
1161
|
@view.finish_thinking_spinner
|
|
1162
|
+
queued = Array(snapshot[:queued])
|
|
1163
|
+
known_queued = @my_queued.to_set
|
|
1164
|
+
@my_queued = queued.filter_map do |entry|
|
|
1165
|
+
id = entry[:enqueued_id]
|
|
1166
|
+
id if id && (known_queued.include?(id) || (entry.key?(:client_id) && own?(entry[:client_id])))
|
|
1167
|
+
end
|
|
1098
1168
|
# A new worker's first snapshot is the session as it now stands.
|
|
1099
1169
|
moved = @worker_changed
|
|
1100
1170
|
reset ||= moved
|
|
@@ -1131,7 +1201,7 @@ module Samagotchi
|
|
|
1131
1201
|
# A question between turns (the step-limit one) belongs to no turn.
|
|
1132
1202
|
ask(snapshot[:pending_question]) if snapshot[:current_turn].nil? && snapshot[:pending_question]
|
|
1133
1203
|
render_snapshot_cards(cards.select { |card| card[:current] }, joining: !reset)
|
|
1134
|
-
|
|
1204
|
+
queued.each do |entry|
|
|
1135
1205
|
next if own?(entry[:client_id])
|
|
1136
1206
|
|
|
1137
1207
|
@shown_enqueued << entry[:enqueued_id]
|
|
@@ -1453,10 +1523,31 @@ module Samagotchi
|
|
|
1453
1523
|
@screen.commit(event[:delivery] == Delivery::QUEUE ? "#{line} #{QUEUED_NOTE}" : line)
|
|
1454
1524
|
end
|
|
1455
1525
|
|
|
1526
|
+
# A queued prompt was taken back before it ran (the web's ×, /unqueue
|
|
1527
|
+
# in a terminal). One of this run's (wait_at_eof) will have no turn:
|
|
1528
|
+
# it ends the wait, as a failed one does. Another client's shown
|
|
1529
|
+
# line says so.
|
|
1530
|
+
def turn_dequeued(event)
|
|
1531
|
+
id = event[:enqueued_id]
|
|
1532
|
+
@my_queued.delete(id)
|
|
1533
|
+
if @open_ids.delete?(id)
|
|
1534
|
+
if own?(event[:client_id])
|
|
1535
|
+
@own_empty = true
|
|
1536
|
+
@screen.commit("(removed before it ran)")
|
|
1537
|
+
else
|
|
1538
|
+
@own_failed = true
|
|
1539
|
+
@screen.commit("(your queued message was removed before it ran)")
|
|
1540
|
+
end
|
|
1541
|
+
elsif @shown_enqueued.delete?(id)
|
|
1542
|
+
@screen.commit("(a queued message was removed)")
|
|
1543
|
+
end
|
|
1544
|
+
end
|
|
1545
|
+
|
|
1456
1546
|
def start_turn(event)
|
|
1457
1547
|
@running = true
|
|
1458
1548
|
@joined_mid_turn = false
|
|
1459
1549
|
origin = event[:origin] || {}
|
|
1550
|
+
@my_queued.delete(origin[:enqueued_id])
|
|
1460
1551
|
if event[:prompt].nil?
|
|
1461
1552
|
# A continue turn (after the offer's yes) has no prompt to show; a
|
|
1462
1553
|
# reminder turn shows its reminders (:reminder_injected).
|
|
@@ -1466,14 +1557,13 @@ module Samagotchi
|
|
|
1466
1557
|
elsif !(own?(origin[:client_id]) || @shown_enqueued.include?(origin[:enqueued_id]))
|
|
1467
1558
|
@screen.commit(prompt_line(origin[:client_id], event[:prompt]))
|
|
1468
1559
|
end
|
|
1469
|
-
#
|
|
1470
|
-
#
|
|
1471
|
-
@renderer.call(
|
|
1560
|
+
# The own-send echo already names an image-only prompt; another
|
|
1561
|
+
# client's prompt line does too, here or at its enqueue.
|
|
1562
|
+
@renderer.call(without_named_images(event, event[:prompt]))
|
|
1472
1563
|
end
|
|
1473
1564
|
|
|
1474
|
-
# The turn's start without its image lines when the prompt
|
|
1475
|
-
#
|
|
1476
|
-
# that line names them already.
|
|
1565
|
+
# The turn's start without its image lines when the shown prompt is
|
|
1566
|
+
# the image-only "[image: name]" placeholder: that line names them.
|
|
1477
1567
|
def without_named_images(event, shown_prompt)
|
|
1478
1568
|
ImageRef.placeholder_only?(shown_prompt) ? event.except(:images) : event
|
|
1479
1569
|
end
|
|
@@ -11,9 +11,9 @@ module Samagotchi
|
|
|
11
11
|
# ThinkingTicker (so it changes at most once per dwell). Both TUIs use it
|
|
12
12
|
# in place of the raw tail of the stream.
|
|
13
13
|
#
|
|
14
|
-
# A chunk carries :text
|
|
15
|
-
# for every profile
|
|
16
|
-
#
|
|
14
|
+
# A chunk carries :text and :thinking: both loops split their stream,
|
|
15
|
+
# for every profile (the raw :content stays in the worker,
|
|
16
|
+
# Bridge::ClientEvent). A switch of lane starts the next sentence from the new
|
|
17
17
|
# lane's text; the line shown keeps the label of the lane it came from.
|
|
18
18
|
class ThinkingLine
|
|
19
19
|
include Formatting
|
|
@@ -43,12 +43,8 @@ module Samagotchi
|
|
|
43
43
|
# @return [Boolean] whether the line changed
|
|
44
44
|
def chunk(event)
|
|
45
45
|
# The Bridge's TurnAccumulator reads the lanes by the same rule.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
add(:writing, event[:text]) || changed
|
|
49
|
-
else
|
|
50
|
-
add(:thinking, event[:content], raw: true)
|
|
51
|
-
end
|
|
46
|
+
changed = add(:thinking, event[:thinking])
|
|
47
|
+
add(:writing, event[:text]) || changed
|
|
52
48
|
end
|
|
53
49
|
|
|
54
50
|
# A turn joined mid-way: the newest thinking or text part so far.
|