samagotchi 0.85.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 +60 -1
- data/docs/architecture.md +34 -5
- data/docs/bundle-authoring.md +125 -0
- data/docs/cli.md +38 -3
- data/docs/configuration.md +6 -5
- data/docs/guardrails.md +63 -35
- data/docs/hooks.md +19 -3
- 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 +1 -1
- data/docs/memory.md +1 -1
- data/docs/plugins.md +59 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +6 -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.rb +22 -11
- data/lib/samagotchi/bridge_client.rb +2 -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 +4 -4
- 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 +5 -2
- data/lib/samagotchi/llm/anthropic_assembly.rb +1 -1
- data/lib/samagotchi/memory_bundle/shas.rb +127 -0
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/parent_report.rb +87 -14
- data/lib/samagotchi/pending_question.rb +8 -4
- data/lib/samagotchi/plugin/api.rb +16 -0
- data/lib/samagotchi/plugin/context.rb +11 -5
- 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 +14 -5
- data/lib/samagotchi/send_command.rb +34 -8
- data/lib/samagotchi/session_commands.rb +14 -3
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +13 -11
- data/lib/samagotchi/terminal_ui/thinking_line.rb +5 -9
- data/lib/samagotchi/terminal_ui.rb +12 -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 +48 -10
- data/lib/samagotchi/web/public/chunk_router.js +8 -19
- data/lib/samagotchi/web/public/ctx.js +12 -26
- data/lib/samagotchi/web/public/stream_handlers.js +1 -1
- data/lib/samagotchi/web/server.rb +4 -0
- data/lib/samagotchi/worker.rb +19 -5
- data/lib/samagotchi/worker_inbound.rb +4 -4
- metadata +11 -2
- data/lib/samagotchi/guardrails/parent_continue.rb +0 -43
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "session"
|
|
4
4
|
require_relative "turn_note"
|
|
5
5
|
require_relative "session_inbox"
|
|
6
|
+
require_relative "turn_checkpoint"
|
|
6
7
|
require_relative "worker_sidecar"
|
|
7
8
|
|
|
8
9
|
module Samagotchi
|
|
@@ -32,9 +33,10 @@ module Samagotchi
|
|
|
32
33
|
# @!attribute question [PendingQuestion, nil] the pending question
|
|
33
34
|
# (:waiting_for_answer)
|
|
34
35
|
# @!attribute outcome [String, nil] how the turn ended when it left no
|
|
35
|
-
# reply (:no_reply): "failed", "canceled", "completed" (empty
|
|
36
|
+
# reply (:no_reply): "failed", "canceled", "completed" (empty turn or
|
|
37
|
+
# file-delivered command ran without a turn),
|
|
36
38
|
# "exhausted" (it ran out of iterations, with nobody asked whether to
|
|
37
|
-
# continue)
|
|
39
|
+
# continue), "not_continued" (a Stop answered the step-limit
|
|
38
40
|
# question; no turn ran), from the session's last_turn, or "removed"
|
|
39
41
|
# (turn:'s queued input was taken back before it ran, its tombstone:
|
|
40
42
|
# SessionInbox.removed?; no turn ran); nil when unknown. With
|
|
@@ -47,8 +49,9 @@ module Samagotchi
|
|
|
47
49
|
# retry
|
|
48
50
|
# @!attribute cancel_reason [String, nil] a "canceled" turn's reason
|
|
49
51
|
# (user, hook, ctrl_c, manual, session_stopped: the session was
|
|
50
|
-
# stopped mid-turn
|
|
51
|
-
#
|
|
52
|
+
# stopped mid-turn; worker_stopped: turn:'s worker died in it and the
|
|
53
|
+
# next one settled it, TurnCheckpoint.settled_turn), passed through
|
|
54
|
+
# as the worker saved it; any reason is a "canceled" outcome
|
|
52
55
|
# @!attribute stopped_by [String, nil] the hook that canceled it
|
|
53
56
|
# (loop-guard)
|
|
54
57
|
# @!attribute session [Session, nil] the session as the look that
|
|
@@ -156,6 +159,12 @@ module Samagotchi
|
|
|
156
159
|
reply.session = settled(session, id, state_dir: state_dir)
|
|
157
160
|
return reply
|
|
158
161
|
end
|
|
162
|
+
# Its worker died in it and the next one settled it, or an input
|
|
163
|
+
# command finished without a turn. After the reply check: a worker
|
|
164
|
+
# killed between writing the reply and its idle save left the reply.
|
|
165
|
+
if turn && (settled = TurnCheckpoint.settled_turn(session_dir, turn))
|
|
166
|
+
return Result.new(status: :no_reply, outcome: settled.outcome, session: session, **settled.stop_facts)
|
|
167
|
+
end
|
|
159
168
|
|
|
160
169
|
case session.status
|
|
161
170
|
when Session::STATUS_ERROR
|
|
@@ -314,7 +323,7 @@ module Samagotchi
|
|
|
314
323
|
|
|
315
324
|
running = session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == id
|
|
316
325
|
queued = SessionInbox.waiting_input(session_dir, id) == :turn_end
|
|
317
|
-
ended = session.last_turn&.enqueued_id == id
|
|
326
|
+
ended = session.last_turn&.enqueued_id == id || !TurnCheckpoint.settled_turn(session_dir, id).nil?
|
|
318
327
|
running || queued || ended
|
|
319
328
|
end
|
|
320
329
|
|
|
@@ -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|
|
|
@@ -254,8 +255,12 @@ module Samagotchi
|
|
|
254
255
|
# change asks first (CacheConfirm): true for a person at a web tab or
|
|
255
256
|
# a TUI; false applies it at once (chi send, a delegate, a plugin,
|
|
256
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)
|
|
257
261
|
# @return [Result, nil] nil when +line+ is not one of these commands
|
|
258
|
-
def run(line, confirm: true)
|
|
262
|
+
def run(line, confirm: true, as_parent: false)
|
|
263
|
+
@as_parent = as_parent
|
|
259
264
|
text = line.to_s.strip
|
|
260
265
|
entry = @registry.lookup(text)
|
|
261
266
|
return nil unless entry&.handler
|
|
@@ -268,9 +273,15 @@ module Samagotchi
|
|
|
268
273
|
end
|
|
269
274
|
|
|
270
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)
|
|
271
278
|
# @return [Result] resume: true on yes
|
|
272
|
-
def continue_answer(text)
|
|
279
|
+
def continue_answer(text, as_parent: false)
|
|
273
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
|
+
|
|
274
285
|
result = case decision
|
|
275
286
|
when :resume then Result.new(status: :ok, changed: [], model_name: model_name, resume: true, shell: false)
|
|
276
287
|
when :abort
|
|
@@ -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
|
|
@@ -105,7 +105,7 @@ module Samagotchi
|
|
|
105
105
|
# @param state_dir [String] the state dir the worker was found in: its
|
|
106
106
|
# session folder (@path images, saved records) is under it
|
|
107
107
|
def initialize(client:, screen:, client_id:, first_prompt: nil, first_command: nil, no_interrupt: false,
|
|
108
|
-
default_input: false, wait_at_eof: false,
|
|
108
|
+
default_input: false, wait_at_eof: false, as_parent: false,
|
|
109
109
|
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
110
110
|
delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) },
|
|
111
111
|
archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) },
|
|
@@ -121,8 +121,8 @@ module Samagotchi
|
|
|
121
121
|
@client_id = client_id
|
|
122
122
|
# A parent agent drives this attach (AttachLauncher:
|
|
123
123
|
# ParentApprovals.parent_process?): its answers go as chi answer's,
|
|
124
|
-
# so the worker holds them to
|
|
125
|
-
@
|
|
124
|
+
# so the worker holds them to Guardrails::ParentPolicy.
|
|
125
|
+
@as_parent = as_parent
|
|
126
126
|
@view = AttachedView.new(screen)
|
|
127
127
|
@renderer = EventRenderer.new(@view)
|
|
128
128
|
@shown_enqueued = Set.new
|
|
@@ -592,7 +592,10 @@ module Samagotchi
|
|
|
592
592
|
# The worker runs it and every UI renders its :command_ran, this one
|
|
593
593
|
# too, so nothing waits here.
|
|
594
594
|
def send_command(line)
|
|
595
|
-
|
|
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)
|
|
596
599
|
if reply.status == 202
|
|
597
600
|
# Input from a pipe ends once its output is in (#command_ran).
|
|
598
601
|
command_id = reply.json&.fetch("command_id", nil)
|
|
@@ -990,7 +993,7 @@ module Samagotchi
|
|
|
990
993
|
return @screen.commit(answer.error)
|
|
991
994
|
end
|
|
992
995
|
|
|
993
|
-
parent = @
|
|
996
|
+
parent = @as_parent ? { client_id: ClientId::CLI_ANSWER } : {}
|
|
994
997
|
reply = @client.answer(id: @question.id, selected: answer.selected, freeform: answer.freeform, **parent)
|
|
995
998
|
case reply.status
|
|
996
999
|
when 200
|
|
@@ -1554,14 +1557,13 @@ module Samagotchi
|
|
|
1554
1557
|
elsif !(own?(origin[:client_id]) || @shown_enqueued.include?(origin[:enqueued_id]))
|
|
1555
1558
|
@screen.commit(prompt_line(origin[:client_id], event[:prompt]))
|
|
1556
1559
|
end
|
|
1557
|
-
#
|
|
1558
|
-
#
|
|
1559
|
-
@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]))
|
|
1560
1563
|
end
|
|
1561
1564
|
|
|
1562
|
-
# The turn's start without its image lines when the prompt
|
|
1563
|
-
#
|
|
1564
|
-
# 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.
|
|
1565
1567
|
def without_named_images(event, shown_prompt)
|
|
1566
1568
|
ImageRef.placeholder_only?(shown_prompt) ? event.except(:images) : event
|
|
1567
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.
|
|
@@ -295,7 +295,7 @@ module Samagotchi
|
|
|
295
295
|
claim_session!(session.id) unless @owner_lock
|
|
296
296
|
# A turn a worker died in (TurnCheckpoint): ended as canceled, with its
|
|
297
297
|
# note, before this REPL's first turn appends after it.
|
|
298
|
-
session.save if @resume_session && TurnCheckpoint.settle(session)
|
|
298
|
+
session.save if @resume_session && TurnCheckpoint.settle(session, session_dir: Session.session_dir(session.id))
|
|
299
299
|
keep_cards(session)
|
|
300
300
|
# Saved at once, so a process killed before its first turn leaves a
|
|
301
301
|
# file the sweep knows to delete.
|
|
@@ -830,7 +830,7 @@ module Samagotchi
|
|
|
830
830
|
end
|
|
831
831
|
|
|
832
832
|
def answer_continue_offer(session, input)
|
|
833
|
-
answer = @commands.continue_answer(input)
|
|
833
|
+
answer = @commands.continue_answer(input, as_parent: as_parent?)
|
|
834
834
|
if answer.decision == :invalid
|
|
835
835
|
# The read left no echo: the line shows above its error.
|
|
836
836
|
@surface.commit("#{paint(QUESTION_PROMPT, 33)}#{input}")
|
|
@@ -876,7 +876,9 @@ module Samagotchi
|
|
|
876
876
|
command = if command_registry.anytime?(input)
|
|
877
877
|
@engine.running_anytime { @commands.run(input, confirm: confirm) }
|
|
878
878
|
else
|
|
879
|
-
|
|
879
|
+
# A parent's /continue yes is held to guardrails.parent_answers.
|
|
880
|
+
parent = TurnFlow.continue_yes?(input) && as_parent? ? { as_parent: true } : {}
|
|
881
|
+
@commands.run(input, confirm: confirm, **parent)
|
|
880
882
|
end
|
|
881
883
|
if command
|
|
882
884
|
show_command_result(command)
|
|
@@ -1579,15 +1581,16 @@ module Samagotchi
|
|
|
1579
1581
|
end
|
|
1580
1582
|
|
|
1581
1583
|
begin
|
|
1582
|
-
parent =
|
|
1584
|
+
parent = as_parent? ? { client_id: ClientId::CLI_ANSWER } : {}
|
|
1583
1585
|
@engine.answer_question(id: prompt.id, selected: answer.selected, freeform: answer.freeform, **parent)
|
|
1584
1586
|
close_question_widget(prompt, prompt.answer_text(answer))
|
|
1585
1587
|
return true
|
|
1586
1588
|
rescue QuestionDesk::Refused => e
|
|
1587
|
-
# A parent's
|
|
1588
|
-
# the question stays open for a deny
|
|
1589
|
+
# A parent's pick its strategy doesn't let through
|
|
1590
|
+
# (Guardrails::ParentPolicy): the question stays open for a deny
|
|
1591
|
+
# (or the end of the input).
|
|
1589
1592
|
echo_choice(raw)
|
|
1590
|
-
@surface.commit(
|
|
1593
|
+
@surface.commit(e.detail(typed: true))
|
|
1591
1594
|
next
|
|
1592
1595
|
rescue ArgumentError => e
|
|
1593
1596
|
@surface.commit("Invalid: #{e.message}. Try again.")
|
|
@@ -1600,8 +1603,8 @@ module Samagotchi
|
|
|
1600
1603
|
end
|
|
1601
1604
|
|
|
1602
1605
|
# Whether the answers typed here are a parent agent's (piped stdin, an
|
|
1603
|
-
# agent marker): QuestionDesk holds them to
|
|
1604
|
-
def
|
|
1606
|
+
# agent marker): QuestionDesk holds them to Guardrails::ParentPolicy.
|
|
1607
|
+
def as_parent?
|
|
1605
1608
|
Guardrails::ParentApprovals.parent_process?
|
|
1606
1609
|
end
|
|
1607
1610
|
|
|
@@ -79,9 +79,6 @@ module Samagotchi
|
|
|
79
79
|
"Samagotchi::Session.new_session" => "spec: #{SESSION} (ctx.delegate?, ctx.fork?, ctx.context read it)",
|
|
80
80
|
"Samagotchi::ContextSources.session_location" => "spec: reads and removes the session's sources (added_by), " \
|
|
81
81
|
"which ctx.context.list doesn't show",
|
|
82
|
-
'require "samagotchi/memory_bundle/installer"' => "spec: for Installer",
|
|
83
|
-
"Samagotchi::MemoryBundle::Installer.new" => "spec: installs the bundle so its context provider resolves: " \
|
|
84
|
-
"needs a kit install helper",
|
|
85
82
|
'require "samagotchi/context_providers"' => "spec: for ContextProviders",
|
|
86
83
|
"Samagotchi::ContextProviders.resolve" => "spec: the manifest's URL provider through chi's own resolver",
|
|
87
84
|
'require "samagotchi/answer_display"' => "spec: for AnswerDisplay",
|
|
@@ -240,6 +240,13 @@ module Samagotchi
|
|
|
240
240
|
failing { entry.handler.call({ name: name.to_s, args: stringify(args) }, nil) }
|
|
241
241
|
end
|
|
242
242
|
|
|
243
|
+
# Install this bundle's dir into its sandbox as chi does and load the
|
|
244
|
+
# installed copy with chi's loaders (Install.run, with its settings):
|
|
245
|
+
# what a test of the installed bundle needs (its scripts, context
|
|
246
|
+
# providers), and the install warnings and load failures.
|
|
247
|
+
# @return [Install::Result]
|
|
248
|
+
def install(force: false) = Install.run(@dir, settings: @settings, sandbox: @sandbox, force: force)
|
|
249
|
+
|
|
243
250
|
# Run the chi.init blocks in order, as chi's init tasks do.
|
|
244
251
|
# @return [Array] their results
|
|
245
252
|
def run_inits = failing { @inits.map { |init| init.block.call } }
|
|
@@ -27,8 +27,9 @@ module Samagotchi
|
|
|
27
27
|
messages: -> { recorder[:messages] },
|
|
28
28
|
messages_partial: -> { recorder[:messages_partial] },
|
|
29
29
|
notify: ->(text, level, label, fallback_for: nil) { recorder.notify(text, level, label, fallback_for) },
|
|
30
|
-
ask_user: lambda { |question:, options:, header:, allow_freeform:, hook
|
|
31
|
-
recorder.ask_user(question: question, options: options, header: header, allow_freeform: allow_freeform, hook: hook
|
|
30
|
+
ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:, effects: nil|
|
|
31
|
+
recorder.ask_user(question: question, options: options, header: header, allow_freeform: allow_freeform, hook: hook,
|
|
32
|
+
effects: effects)
|
|
32
33
|
},
|
|
33
34
|
cancelled: -> { recorder[:cancelled] },
|
|
34
35
|
card: ->(**card) { recorder.card(**card) },
|
|
@@ -88,8 +89,9 @@ module Samagotchi
|
|
|
88
89
|
def build(recorder)
|
|
89
90
|
Hooks::Runtime.new(
|
|
90
91
|
notify: ->(text:, level:, hook:, fallback_for: nil) { recorder.notify(text, level, hook, fallback_for) },
|
|
91
|
-
ask_user: lambda { |question:, options:, header:, allow_freeform:, hook
|
|
92
|
-
recorder.ask_user(question: question, options: options, header: header, allow_freeform: allow_freeform, hook: hook
|
|
92
|
+
ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:, effects: nil|
|
|
93
|
+
recorder.ask_user(question: question, options: options, header: header, allow_freeform: allow_freeform, hook: hook,
|
|
94
|
+
effects: effects)
|
|
93
95
|
},
|
|
94
96
|
stop_turn: ->(reason:, hook:) { recorder.stop(:turn, reason, hook) },
|
|
95
97
|
steer: ->(text:, hook:) { recorder.steer(text, hook) },
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../extension_load"
|
|
4
|
+
require_relative "../guardrails/load_failures"
|
|
5
|
+
require_relative "../memory_bundle/installer"
|
|
6
|
+
require_relative "../memory_bundle/provenance"
|
|
7
|
+
require_relative "../hooks"
|
|
8
|
+
require_relative "bundle"
|
|
9
|
+
require_relative "sandbox"
|
|
10
|
+
require_relative "recorder"
|
|
11
|
+
require_relative "host"
|
|
12
|
+
|
|
13
|
+
module Samagotchi
|
|
14
|
+
module Testing
|
|
15
|
+
# A bundle as chi installs and loads it, unlike Bundle (its source as it
|
|
16
|
+
# is): MemoryBundle::Installer copies it into the sandbox's state (sha256
|
|
17
|
+
# and requires_chi checked), then chi's own loaders load what is
|
|
18
|
+
# installed (ExtensionLoad: the bundle hooks, then the plugins). What
|
|
19
|
+
# failed names the bundle's source files, not the installed copies.
|
|
20
|
+
#
|
|
21
|
+
# result = Samagotchi::Testing::Install.run("path/to/bundle", settings: { "every" => 3 })
|
|
22
|
+
# result.warnings, result.plugin_failures, result.hook_failures, result.registered[:commands]
|
|
23
|
+
module Install
|
|
24
|
+
# warnings: the Installer's; plugin_failures / hook_failures:
|
|
25
|
+
# [Guardrails::LoadFailures::Failure] of this bundle, each location
|
|
26
|
+
# in the source dir; registered: what it registered (#registered).
|
|
27
|
+
Result = Data.define(:name, :warnings, :plugin_failures, :hook_failures, :registered) do
|
|
28
|
+
def failures = plugin_failures + hook_failures
|
|
29
|
+
def ok? = failures.empty?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# Install the bundle at +dir+ into +sandbox+ (default Sandbox.current,
|
|
35
|
+
# else one of its own, left before returning) and load it.
|
|
36
|
+
# @param settings [Hash] config.yml `bundles: <name>:` (written to the
|
|
37
|
+
# sandbox's config.yml)
|
|
38
|
+
# @param force [Boolean] the Installer's --force (a name chi ships)
|
|
39
|
+
# @return [Result]
|
|
40
|
+
# @raise [MemoryBundle::Installer::InstallError] what the Installer refused
|
|
41
|
+
def run(dir, settings: {}, sandbox: nil, force: false)
|
|
42
|
+
return Sandbox.with { |own| run(dir, settings: settings, sandbox: own, force: force) } if sandbox.nil? && Sandbox.current.nil?
|
|
43
|
+
|
|
44
|
+
sandbox ||= Sandbox.current
|
|
45
|
+
dir = File.expand_path(dir)
|
|
46
|
+
name = MemoryBundle::Manifest.read(dir: dir).name
|
|
47
|
+
sandbox.config("bundles" => { name => settings })
|
|
48
|
+
installer = MemoryBundle::Installer.new(source: dir, name: name, scope: "system", force: force)
|
|
49
|
+
installer.run
|
|
50
|
+
load_installed(name, dir, installer.warnings, sandbox)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def load_installed(name, dir, warnings, sandbox)
|
|
54
|
+
inits = []
|
|
55
|
+
# A session's values, as an Engine's host gives them (only what the loaders register matters).
|
|
56
|
+
host = Host.build(Recorder.new(cwd: sandbox.work_dir))
|
|
57
|
+
hook_failures = Guardrails::LoadFailures.new
|
|
58
|
+
plugin_failures = Guardrails::LoadFailures.new
|
|
59
|
+
extension = ExtensionLoad.new(hook_failures: hook_failures)
|
|
60
|
+
registries = Plugin::Registries.new(
|
|
61
|
+
commands: SessionCommands.register_builtins(Commands::Registry.new),
|
|
62
|
+
tools: Tools::Builtins.registry, hooks: extension.hooks,
|
|
63
|
+
context_for: lambda { |bundle, bundle_settings, label|
|
|
64
|
+
Plugin::Context.new(bundle: bundle, label: label, settings: bundle_settings, host: host)
|
|
65
|
+
},
|
|
66
|
+
services: Plugin::Services.new, stage_tools: ->(*) {},
|
|
67
|
+
init: ->(_bundle, label, _plugin_label, **, &_block) { inits << { label: label } },
|
|
68
|
+
toolkits: [], tool_index: Tools::ToolIndex.new, peer_sources: PeerSources.new
|
|
69
|
+
)
|
|
70
|
+
extension.load_plugins(registries, failures: plugin_failures)
|
|
71
|
+
installed = File.join(MemoryBundle::Provenance.bundles_dir, name)
|
|
72
|
+
Result.new(name: name, warnings: warnings.dup,
|
|
73
|
+
plugin_failures: own(plugin_failures, name, installed, dir),
|
|
74
|
+
hook_failures: own(hook_failures, name, installed, dir),
|
|
75
|
+
registered: registered(registries, name, inits))
|
|
76
|
+
ensure
|
|
77
|
+
registries&.services&.stop_all
|
|
78
|
+
end
|
|
79
|
+
private_class_method :load_installed
|
|
80
|
+
|
|
81
|
+
# What bundle +name+ registered, from the registries it loaded into:
|
|
82
|
+
# commands ([name, mid_turn, annotation labels]), tools, hooks per
|
|
83
|
+
# event ([bundle, hook, priority]; Hooks::EVENTS, then any other event
|
|
84
|
+
# the bundle registered for), inits, toolkits, tool sources and
|
|
85
|
+
# peer sources.
|
|
86
|
+
# @param inits [Array<Hash>] {label:} of each chi.init
|
|
87
|
+
# @return [Hash]
|
|
88
|
+
def registered(registries, name, inits)
|
|
89
|
+
{
|
|
90
|
+
commands: registries.commands.entries.select { |e| e.source == name }.map { |e| [e.name, e.mid_turn, e.annotations.map(&:label)] },
|
|
91
|
+
tools: registries.tools.by_source(name).map(&:name),
|
|
92
|
+
hooks: hooks_of(registries.hooks, name),
|
|
93
|
+
inits: inits.map { |init| init[:label] },
|
|
94
|
+
toolkits: Array(registries.toolkits).map(&:name),
|
|
95
|
+
tool_sources: registries.tool_index.sources.map(&:label),
|
|
96
|
+
peer_sources: registries.peer_sources.names
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def hooks_of(hooks, name)
|
|
101
|
+
own = ->(event) { hooks.bundle_hook_names(event).select { |bundle, _, _| bundle == name } }
|
|
102
|
+
others = (hooks.bundle_events - Hooks::EVENTS).to_h { |event| [event, own.call(event)] }.reject { |_, list| list.empty? }
|
|
103
|
+
Hooks::EVENTS.to_h { |event| [event, own.call(event)] }.merge(others)
|
|
104
|
+
end
|
|
105
|
+
private_class_method :hooks_of
|
|
106
|
+
|
|
107
|
+
# +failures+' entries for bundle +name+, their location moved from the
|
|
108
|
+
# installed copy to the source dir.
|
|
109
|
+
def own(failures, name, installed, dir)
|
|
110
|
+
failures.list.select { |f| f.what.end_with?("(bundle #{name})") }.map do |failure|
|
|
111
|
+
# The plugin is installed into plugin/, the hooks into hooks/.
|
|
112
|
+
location = failure.location&.sub(%r{\A#{Regexp.escape(installed)}/(?:plugin/)?}, "#{dir}/")
|
|
113
|
+
failure.dup.tap { |copy| copy.location = location }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
private_class_method :own
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../plugin/card"
|
|
4
4
|
require_relative "../plugin/context"
|
|
5
|
+
require_relative "../guardrails/parent_policy"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
module Testing
|
|
@@ -12,8 +13,11 @@ module Samagotchi
|
|
|
12
13
|
Steer = Data.define(:text, :hook)
|
|
13
14
|
# A stop_turn or stop_generation (+kind+ :turn or :generation).
|
|
14
15
|
Stop = Data.define(:kind, :reason, :hook)
|
|
15
|
-
# An ask_user question
|
|
16
|
-
|
|
16
|
+
# An ask_user question; effects: its options' tags as the bundle gave
|
|
17
|
+
# them (label => effect), nil for none.
|
|
18
|
+
Question = Data.define(:question, :options, :header, :allow_freeform, :hook, :effects) do
|
|
19
|
+
def initialize(effects: nil, **) = super
|
|
20
|
+
end
|
|
17
21
|
# An ask_model request: the request Context built (SideQuestion), its
|
|
18
22
|
# limits, cancel controller and the model: it named (nil: the session's;
|
|
19
23
|
# the kit doesn't check it against any hosts), and +source+, the asking
|
|
@@ -100,9 +104,14 @@ module Samagotchi
|
|
|
100
104
|
end
|
|
101
105
|
|
|
102
106
|
# The next queued answer (#answers), nil with none (no one to ask).
|
|
103
|
-
|
|
107
|
+
# Effects chi would refuse (a label the options don't have, an
|
|
108
|
+
# unknown effect: Guardrails::ParentPolicy.option_tags) ask no one, as
|
|
109
|
+
# in chi: nil, nothing recorded, no answer taken.
|
|
110
|
+
def ask_user(question:, options:, header:, allow_freeform:, hook:, effects: nil)
|
|
111
|
+
return nil if Guardrails::ParentPolicy.option_tags(options, effects) == false
|
|
112
|
+
|
|
104
113
|
record(@questions, Question.new(question: question, options: options, header: header,
|
|
105
|
-
allow_freeform: allow_freeform, hook: hook))
|
|
114
|
+
allow_freeform: allow_freeform, hook: hook, effects: effects))
|
|
106
115
|
answer = @mutex.synchronize { @answers.shift }
|
|
107
116
|
answer.respond_to?(:call) ? answer.call(@questions.last) : answer
|
|
108
117
|
end
|
data/lib/samagotchi/testing.rb
CHANGED
|
@@ -22,5 +22,6 @@ require_relative "testing/recording_api"
|
|
|
22
22
|
require_relative "testing/waiting"
|
|
23
23
|
require_relative "testing/fake_executable"
|
|
24
24
|
require_relative "testing/bundle"
|
|
25
|
+
require_relative "testing/install"
|
|
25
26
|
require_relative "testing/helpers"
|
|
26
27
|
require_relative "testing/boundary"
|
|
@@ -118,7 +118,8 @@ module Samagotchi
|
|
|
118
118
|
|
|
119
119
|
# What execute and task_create export into the commands they run: a
|
|
120
120
|
# chi started there answers as a parent agent
|
|
121
|
-
# (Guardrails::ParentApprovals.parent_process
|
|
121
|
+
# (Guardrails::ParentApprovals.parent_process?, held to
|
|
122
|
+
# Guardrails::ParentPolicy), even under a PTY
|
|
122
123
|
# wrapper. The session's id, "chi" without one. And the model the
|
|
123
124
|
# session runs on (SAMAGOTCHI_SESSION_MODEL, read live, so right after
|
|
124
125
|
# /model), which chi self reports; unset when unknown, so a value
|
|
@@ -305,10 +305,15 @@ module Samagotchi
|
|
|
305
305
|
# The tool's last line follows #reports_mode.
|
|
306
306
|
def waiting_text(child_id, pending, report: false, reports: "off")
|
|
307
307
|
text = "Child #{child_id} is #{ParentReport.question_text(pending, session_id: child_id)}"
|
|
308
|
-
if ParentReport.continue?(pending)
|
|
308
|
+
if ParentReport.continue?(pending) && ParentReport.may_continue?(pending)
|
|
309
309
|
text += "The child ran out of steps before it answered. Decide: continue it (run the chi answer command " \
|
|
310
310
|
"above with execute) if it is getting somewhere; send it a narrower follow-up with delegate " \
|
|
311
311
|
"session: #{child_id} (that drops the question); or stop it and report back to your user.\n"
|
|
312
|
+
elsif ParentReport.continue?(pending)
|
|
313
|
+
# guardrails.parent_answers: hard: continuing is the user's.
|
|
314
|
+
text += "The child ran out of steps before it answered. Decide: send it a narrower follow-up with delegate " \
|
|
315
|
+
"session: #{child_id} (that drops the question); or stop it (the chi answer Stop command above, " \
|
|
316
|
+
"with execute) and report back to your user, who may continue it.\n"
|
|
312
317
|
end
|
|
313
318
|
return "#{text}#{REPORT_NEXT_REPLY}" if report
|
|
314
319
|
return "#{text}delegate_result #{child_id} waits again once it is answered." if reports == "off"
|