samagotchi 0.85.0 → 0.87.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 +76 -1
- data/bin/chi +39 -2
- data/docs/architecture.md +36 -5
- data/docs/bundle-authoring.md +125 -0
- data/docs/cli.md +51 -3
- data/docs/configuration.md +6 -5
- data/docs/guardrails.md +112 -39
- 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 +19 -7
- data/docs/sub-agent.md +39 -25
- 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/known-names/hooks/known_names.rb +3 -3
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- 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/cli/parent_wait.rb +3 -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 +327 -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_answers_command.rb +80 -0
- data/lib/samagotchi/parent_report.rb +122 -30
- 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 +39 -25
- 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 +103 -12
- data/lib/samagotchi/session.rb +26 -2
- data/lib/samagotchi/session_commands.rb +45 -8
- data/lib/samagotchi/session_setup.rb +10 -8
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -12
- data/lib/samagotchi/terminal_ui/thinking_line.rb +5 -9
- data/lib/samagotchi/terminal_ui.rb +20 -11
- 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.rb +4 -1
- data/lib/samagotchi/tools/delegate_wait.rb +16 -7
- 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 +27 -5
- data/lib/samagotchi/worker_inbound.rb +4 -4
- metadata +12 -2
- data/lib/samagotchi/guardrails/parent_continue.rb +0 -43
|
@@ -10,8 +10,7 @@ require_relative "log"
|
|
|
10
10
|
require_relative "pending_question"
|
|
11
11
|
require_relative "question_answer"
|
|
12
12
|
require_relative "tools/ask_user_question"
|
|
13
|
-
require_relative "guardrails/
|
|
14
|
-
require_relative "guardrails/parent_continue"
|
|
13
|
+
require_relative "guardrails/parent_policy"
|
|
15
14
|
|
|
16
15
|
module Samagotchi
|
|
17
16
|
# An Engine's question flow: the one open question (ask_user_question, a
|
|
@@ -36,18 +35,25 @@ module Samagotchi
|
|
|
36
35
|
class NotPending < ArgumentError; end
|
|
37
36
|
|
|
38
37
|
# Raised by #answer when a parent agent's answer (chi answer's client
|
|
39
|
-
# id)
|
|
40
|
-
#
|
|
41
|
-
# continues a turn turn.parent_continue keeps it from
|
|
42
|
-
# (Guardrails::ParentContinue). The question stays open; transports map
|
|
38
|
+
# id) picks an option this worker's strategy keeps parents from
|
|
39
|
+
# (Guardrails::ParentPolicy). The question stays open; transports map
|
|
43
40
|
# it to 403.
|
|
44
41
|
class Refused < StandardError
|
|
45
|
-
# @return [Symbol] :
|
|
42
|
+
# @return [Symbol] ParentPolicy.refusal's: :user_only, :permit_wide,
|
|
43
|
+
# :permit or :spend
|
|
46
44
|
attr_reader :reason
|
|
47
45
|
|
|
48
|
-
def initialize(reason)
|
|
46
|
+
def initialize(reason, pending:, strategy:)
|
|
49
47
|
@reason = reason
|
|
50
|
-
|
|
48
|
+
@pending = pending
|
|
49
|
+
@strategy = strategy
|
|
50
|
+
super("a parent may not pick that option here (#{reason})")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The line for the parent (ParentPolicy.message).
|
|
54
|
+
# @param typed [Boolean] typed at a ? prompt (the REPL)
|
|
55
|
+
def detail(typed: false)
|
|
56
|
+
Guardrails::ParentPolicy.message(reason, pending: @pending, strategy: @strategy, typed: typed)
|
|
51
57
|
end
|
|
52
58
|
end
|
|
53
59
|
|
|
@@ -290,7 +296,7 @@ module Samagotchi
|
|
|
290
296
|
# @param freeform [String, nil]
|
|
291
297
|
# @param client_id [String, nil] who answers
|
|
292
298
|
# @param parent_agent [Boolean, nil] the answer is a parent agent's, so
|
|
293
|
-
# it is held to
|
|
299
|
+
# it is held to Guardrails::ParentPolicy and doesn't
|
|
294
300
|
# bring the session back to the lists; nil: chi answer's client id says so
|
|
295
301
|
# @return [QuestionAnswer] the answer; a parent agent's carries by: "parent_agent"
|
|
296
302
|
# @raise [NotPending, ArgumentError, Refused]
|
|
@@ -299,9 +305,9 @@ module Samagotchi
|
|
|
299
305
|
fm = freeform.to_s.strip
|
|
300
306
|
fm = nil if fm.empty?
|
|
301
307
|
parent_agent = client_id.to_s == ClientId::CLI_ANSWER if parent_agent.nil?
|
|
302
|
-
# Read before the lock (config may touch the disk); this worker's own
|
|
303
|
-
|
|
304
|
-
|
|
308
|
+
# Read before the lock (config may touch the disk); this worker's own
|
|
309
|
+
# config.yml, tightened by its session's own parent_answers.
|
|
310
|
+
strategy = Guardrails::ParentPolicy.for_session(@session_lookup.call) if parent_agent
|
|
305
311
|
standing = nil
|
|
306
312
|
answer = @lock.synchronize do
|
|
307
313
|
pending = @pending
|
|
@@ -313,10 +319,7 @@ module Samagotchi
|
|
|
313
319
|
raise NotPending, "question already answered" if @answer
|
|
314
320
|
raise NotPending, "question #{pending.status}" unless pending.status.to_s == "pending"
|
|
315
321
|
|
|
316
|
-
answer = validated_answer(pending, sel, fm, parent_agent: parent_agent,
|
|
317
|
-
if parent_agent && (reason = Guardrails::ParentContinue.refusal(pending, sel, allowed: parent_continue))
|
|
318
|
-
raise Refused, reason
|
|
319
|
-
end
|
|
322
|
+
answer = validated_answer(pending, sel, fm, parent_agent: parent_agent, strategy: strategy)
|
|
320
323
|
|
|
321
324
|
if @standing
|
|
322
325
|
# No waiter: cleared here, so a second answer finds none.
|
|
@@ -401,9 +404,13 @@ module Samagotchi
|
|
|
401
404
|
# web), single-select, kind "hook". A --non-interactive run has no one
|
|
402
405
|
# to ask: nil at once. Anything but an answer (a sync handler's text, no
|
|
403
406
|
# answer, cancelled) is nil too.
|
|
407
|
+
# @param effects [Hash, nil] label => effect for a parent agent's answer
|
|
408
|
+
# (Guardrails::ParentPolicy); a label left out is "answer". A label
|
|
409
|
+
# the options don't have, or an effect it doesn't know, makes the ask
|
|
410
|
+
# invalid (nil, warned), like bad options.
|
|
404
411
|
# @return [Hash, nil] {selected:, freeform:, selected_indices:} (a plain
|
|
405
412
|
# Hash: the bundle boundary)
|
|
406
|
-
def ask_for_hook(question, options, header, allow_freeform, hook)
|
|
413
|
+
def ask_for_hook(question, options, header, allow_freeform, hook, effects: nil)
|
|
407
414
|
return nil if @interface_lookup.call == :non_interactive
|
|
408
415
|
|
|
409
416
|
opts = Tools::AskUserQuestion.normalize_options(options)
|
|
@@ -411,9 +418,16 @@ module Samagotchi
|
|
|
411
418
|
Log.warn(:hooks, "ask_user_invalid", echo: "[samagotchi:hooks] #{hook} asked with invalid options (2-8 strings)", hook: hook.to_s)
|
|
412
419
|
return nil
|
|
413
420
|
end
|
|
421
|
+
tags = Guardrails::ParentPolicy.option_tags(opts, effects)
|
|
422
|
+
if tags == false
|
|
423
|
+
Log.warn(:hooks, "ask_user_invalid",
|
|
424
|
+
echo: "[samagotchi:hooks] #{hook} asked with invalid effects (option label => " \
|
|
425
|
+
"#{Guardrails::ParentPolicy::EFFECTS.join("|")})", hook: hook.to_s)
|
|
426
|
+
return nil
|
|
427
|
+
end
|
|
414
428
|
|
|
415
429
|
fields = { question: question.to_s, options: opts, header: header, multi_select: false,
|
|
416
|
-
allow_freeform: !!allow_freeform, kind: "hook", hook: hook.to_s }.compact
|
|
430
|
+
allow_freeform: !!allow_freeform, effects: tags, kind: "hook", hook: hook.to_s }.compact
|
|
417
431
|
answer = open_question(fields)
|
|
418
432
|
return nil unless answer.is_a?(QuestionAnswer)
|
|
419
433
|
|
|
@@ -493,11 +507,11 @@ module Samagotchi
|
|
|
493
507
|
|
|
494
508
|
# An answer to +pending+, checked: the selections are its options (value
|
|
495
509
|
# == label in v1), one for a single-select, one or a text when required,
|
|
496
|
-
# and a parent agent's within
|
|
510
|
+
# and a parent agent's within its strategy (ParentPolicy). Under the
|
|
497
511
|
# question lock: checked against the question pending now.
|
|
498
512
|
# @return [QuestionAnswer]
|
|
499
513
|
# @raise [ArgumentError, Refused]
|
|
500
|
-
def validated_answer(pending, sel, fm, parent_agent:,
|
|
514
|
+
def validated_answer(pending, sel, fm, parent_agent:, strategy:)
|
|
501
515
|
opts = Array(pending.options)
|
|
502
516
|
invalid = sel.reject { |v| opts.include?(v) }
|
|
503
517
|
raise ArgumentError, "invalid selection: #{invalid.join(", ")} (valid: #{opts.join(", ")})" unless invalid.empty?
|
|
@@ -505,10 +519,10 @@ module Samagotchi
|
|
|
505
519
|
raise ArgumentError, "selection required" if pending.multi_select == false && sel.empty? && fm.nil?
|
|
506
520
|
|
|
507
521
|
indices = sel.map { |v| opts.index(v) }
|
|
508
|
-
#
|
|
509
|
-
if
|
|
510
|
-
reason = Guardrails::
|
|
511
|
-
raise Refused,
|
|
522
|
+
# By index, as the effects (and Approval.settle) read it.
|
|
523
|
+
if strategy
|
|
524
|
+
reason = Guardrails::ParentPolicy.refusal(pending, indices, strategy: strategy)
|
|
525
|
+
raise Refused.new(reason, pending: pending, strategy: strategy) if reason
|
|
512
526
|
end
|
|
513
527
|
QuestionAnswer.new(id: pending.id.to_s, selected: sel, freeform: fm, selected_indices: indices.compact,
|
|
514
528
|
by: parent_agent ? "parent_agent" : nil)
|
|
@@ -9,10 +9,11 @@ module Samagotchi
|
|
|
9
9
|
# question's answer), whose line the UIs leave out. The worker adds
|
|
10
10
|
# where it waits (Worker#on_command): +mid_turn+ (the policy it got:
|
|
11
11
|
# :queue, :refuse, :loop), +after_seq+ (the event count then, so a
|
|
12
|
-
# turn's end knows it came before)
|
|
13
|
-
#
|
|
14
|
-
QueuedCommand = Data.define(:command_id, :client_id, :line, :card, :mid_turn, :after_seq, :after_file) do
|
|
15
|
-
def initialize(command_id:, line:, client_id: nil, card: false, mid_turn: nil, after_seq: nil, after_file: nil
|
|
12
|
+
# turn's end knows it came before), +after_file+ (the input file it runs
|
|
13
|
+
# after, a basename) and +enqueued_id+ (a file-delivered command's sender).
|
|
14
|
+
QueuedCommand = Data.define(:command_id, :client_id, :line, :card, :mid_turn, :after_seq, :after_file, :enqueued_id) do
|
|
15
|
+
def initialize(command_id:, line:, client_id: nil, card: false, mid_turn: nil, after_seq: nil, after_file: nil,
|
|
16
|
+
enqueued_id: nil) = super
|
|
16
17
|
|
|
17
18
|
# A new command with a fresh id.
|
|
18
19
|
def self.mint(line, client_id: nil, card: false)
|
data/lib/samagotchi/relay.rb
CHANGED
|
@@ -6,7 +6,9 @@ module Samagotchi
|
|
|
6
6
|
# A delegate's approval reopened as the parent session's own (the
|
|
7
7
|
# approval relay): the same kind "approval" and the same approval facts,
|
|
8
8
|
# so every UI shows it as it shows the parent's own approvals, the diff
|
|
9
|
-
# included, and Guardrails::
|
|
9
|
+
# included, and Guardrails::ParentPolicy judges it the same way: the
|
|
10
|
+
# child's effects and user_only come along (an older child's question
|
|
11
|
+
# has none; the policy derives them from the approval facts). The
|
|
10
12
|
# question text names the delegate and its task over the child's own
|
|
11
13
|
# text; `relay:` says which child and which of its questions it answers.
|
|
12
14
|
# A relay of a relay (a grandchild's) grows relay.chain.
|
|
@@ -37,6 +39,8 @@ module Samagotchi
|
|
|
37
39
|
header: header(who, more),
|
|
38
40
|
multi_select: false,
|
|
39
41
|
allow_freeform: pending.allow_freeform.nil? || !!pending.allow_freeform,
|
|
42
|
+
# Same order: only the session option's label changes.
|
|
43
|
+
**{ effects: pending.effects, user_only: pending.user_only }.compact,
|
|
40
44
|
kind: Guardrails::Approval::KIND,
|
|
41
45
|
approval: facts,
|
|
42
46
|
relay: { id: relay_id, child_id: child.id, child_short: short, child_question_id: pending.id.to_s,
|
|
@@ -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,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
|
|
|
@@ -5,6 +5,7 @@ require_relative "delivery"
|
|
|
5
5
|
require_relative "terminal_ui/formatting"
|
|
6
6
|
require_relative "session"
|
|
7
7
|
require_relative "session_commands"
|
|
8
|
+
require_relative "parent_answers_command"
|
|
8
9
|
require_relative "session_inbox"
|
|
9
10
|
require_relative "session_manager"
|
|
10
11
|
require_relative "bundle_session_settings"
|
|
@@ -12,6 +13,9 @@ require_relative "session_chain"
|
|
|
12
13
|
require_relative "context_quote"
|
|
13
14
|
require_relative "reply_wait"
|
|
14
15
|
require_relative "parent_report"
|
|
16
|
+
require_relative "turn_flow"
|
|
17
|
+
require_relative "guardrails/parent_approvals"
|
|
18
|
+
require_relative "guardrails/parent_policy"
|
|
15
19
|
require_relative "image_store"
|
|
16
20
|
require_relative "host_registry"
|
|
17
21
|
require_relative "model_profile"
|
|
@@ -40,7 +44,7 @@ module Samagotchi
|
|
|
40
44
|
|
|
41
45
|
USAGE = <<~TEXT
|
|
42
46
|
Usage: chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...
|
|
43
|
-
chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [--toolkit NAME] [--set BUNDLE.KEY=VALUE]... [-m TEXT] [--image PATH]...
|
|
47
|
+
chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [--toolkit NAME] [--set BUNDLE.KEY=VALUE]... [--parent-answers LEVEL] [-m TEXT] [--image PATH]...
|
|
44
48
|
chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT] [--image PATH]...
|
|
45
49
|
chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
|
|
46
50
|
chi send --queue --wait [--timeout S] [--format json] [-m TEXT] ID
|
|
@@ -92,6 +96,11 @@ module Samagotchi
|
|
|
92
96
|
manifest lists in session_settings:; 3, 2.5, true,
|
|
93
97
|
false and off are typed, the rest is text:
|
|
94
98
|
--set cost-cap.cap=3)
|
|
99
|
+
--parent-answers LEVEL
|
|
100
|
+
(--new) what a parent agent may answer on its
|
|
101
|
+
questions: hard, standard or soft, only stricter than
|
|
102
|
+
config.yml's guardrails.parent_answers (a looser one
|
|
103
|
+
warns; config.yml's applies)
|
|
95
104
|
--wait wait for the answer and print it (one session); the
|
|
96
105
|
other lines go to stderr. Exit 3: it waits for an
|
|
97
106
|
answer from you: the question, its options and the
|
|
@@ -150,6 +159,7 @@ module Samagotchi
|
|
|
150
159
|
f.value "--tool", key: :tools, repeat: true
|
|
151
160
|
f.value "--no-tool", key: :no_tools, repeat: true
|
|
152
161
|
f.value "--set", key: :sets, repeat: true
|
|
162
|
+
f.value "--parent-answers", key: :parent_answers
|
|
153
163
|
f.value "--image", key: :images, repeat: true
|
|
154
164
|
f.switch "--wait"
|
|
155
165
|
f.switch "--cut"
|
|
@@ -241,6 +251,23 @@ module Samagotchi
|
|
|
241
251
|
if prompt && (local = local_command(prompt))
|
|
242
252
|
return refuse_local_command(local, options)
|
|
243
253
|
end
|
|
254
|
+
|
|
255
|
+
# A parent agent's /continue yes is chi answer --option Continue by
|
|
256
|
+
# another route: held to guardrails.parent_answers (tightened by each
|
|
257
|
+
# target's own) here, and marked as chi answer's (#client_id_for) so
|
|
258
|
+
# the worker checks it again.
|
|
259
|
+
if prompt && parent_continue?(prompt) && (refusal = continue_refusal_for(options))
|
|
260
|
+
@stderr.puts("chi send: #{refusal}")
|
|
261
|
+
return CLI::Exit::FAILED
|
|
262
|
+
end
|
|
263
|
+
# chi send never loosens a session's own parent_answers (only a user
|
|
264
|
+
# at a UI does: the worker refuses it too); refused before anything
|
|
265
|
+
# is sent. Tightening goes.
|
|
266
|
+
if prompt && SessionCommands.builtin_registry.lookup(prompt)&.id == :parent_answers &&
|
|
267
|
+
target_parent_answers(options).any? { |value| ParentAnswersCommand.loosens?(prompt, value) }
|
|
268
|
+
error_line("chi send: refused: #{ParentAnswersCommand::REFUSED}")
|
|
269
|
+
return CLI::Exit::FAILED
|
|
270
|
+
end
|
|
244
271
|
# A file that is not there or not an image: a run-time failure (1),
|
|
245
272
|
# reported in the --format json line too; --image itself was fine.
|
|
246
273
|
return CLI::Exit::FAILED unless ingest_images(options[:images])
|
|
@@ -294,6 +321,7 @@ module Samagotchi
|
|
|
294
321
|
TOOLKIT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") unless Array(options[key]).empty? }
|
|
295
322
|
LLM_CONTEXT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") if options[key] }
|
|
296
323
|
return usage_error("--set needs --new") unless Array(options[:sets]).empty?
|
|
324
|
+
return usage_error("--parent-answers needs --new") if options[:parent_answers]
|
|
297
325
|
return usage_error("give session ids") if options[:ids].empty?
|
|
298
326
|
|
|
299
327
|
options
|
|
@@ -313,10 +341,10 @@ module Samagotchi
|
|
|
313
341
|
taken = [("--dir" if options[:dir])] +
|
|
314
342
|
LLM_CONTEXT_FLAGS.filter_map { |key, flag| flag if options[key] } + [("--thinking" if options[:thinking])] +
|
|
315
343
|
[("--toolkit" if options[:toolkit])] + TOOLKIT_FLAGS.filter_map { |key, flag| flag unless Array(options[key]).empty? } +
|
|
316
|
-
[("--set" unless Array(options[:sets]).empty?)]
|
|
344
|
+
[("--set" unless Array(options[:sets]).empty?), ("--parent-answers" if options[:parent_answers])]
|
|
317
345
|
unless taken.compact.empty?
|
|
318
346
|
return usage_error("--continues takes the previous session's folder, LLM context, thinking level, " \
|
|
319
|
-
"toolkit
|
|
347
|
+
"toolkit, bundle settings and parent_answers; " \
|
|
320
348
|
"leave out #{taken.compact.join(", ")}")
|
|
321
349
|
end
|
|
322
350
|
end
|
|
@@ -336,6 +364,13 @@ module Samagotchi
|
|
|
336
364
|
|
|
337
365
|
options[:thinking] = Thinking.session_level(word)
|
|
338
366
|
end
|
|
367
|
+
if options[:parent_answers]
|
|
368
|
+
word = options[:parent_answers].strip.downcase
|
|
369
|
+
levels = Guardrails::ParentPolicy::ORDER
|
|
370
|
+
return usage_error("--parent-answers takes #{levels.join(", ")}, not #{options[:parent_answers]}") unless levels.include?(word)
|
|
371
|
+
|
|
372
|
+
options[:parent_answers] = word
|
|
373
|
+
end
|
|
339
374
|
toolkit = Toolkit.from_flags(name: options[:toolkit], add: options[:tools], remove: options[:no_tools])
|
|
340
375
|
options[:toolkit_value] = toolkit if toolkit
|
|
341
376
|
begin
|
|
@@ -393,7 +428,8 @@ module Samagotchi
|
|
|
393
428
|
setup: SessionSetup.new(llm_context: options[:llm_context],
|
|
394
429
|
thinking: options[:thinking],
|
|
395
430
|
toolkit: options[:toolkit_value],
|
|
396
|
-
bundle_settings: options[:bundle_settings] || {}
|
|
431
|
+
bundle_settings: options[:bundle_settings] || {},
|
|
432
|
+
parent_answers: options[:parent_answers]),
|
|
397
433
|
state_dir: @state_dir)
|
|
398
434
|
end
|
|
399
435
|
rescue SessionContinue::Refused, SessionManager::ArchiveRefused, SessionManager::OwnedByTUI => e
|
|
@@ -404,6 +440,9 @@ module Samagotchi
|
|
|
404
440
|
return 1
|
|
405
441
|
end
|
|
406
442
|
error_line("chi send: warning: #{session.model_warning}") if session.model_warning
|
|
443
|
+
if (capped = Guardrails::ParentPolicy.capped_note(options[:parent_answers]))
|
|
444
|
+
error_line("chi send: warning: --parent-answers #{capped}")
|
|
445
|
+
end
|
|
407
446
|
if through_worker
|
|
408
447
|
return 1 unless deliver_new(session, prompt)
|
|
409
448
|
else
|
|
@@ -414,7 +453,8 @@ module Samagotchi
|
|
|
414
453
|
return report_command(session.id) if @sent_command
|
|
415
454
|
|
|
416
455
|
wait_for_reply(session.id, cursor: nil, baseline: baseline_of(session, question_id: nil),
|
|
417
|
-
timeout: options[:timeout],
|
|
456
|
+
timeout: options[:timeout], turn: @sent_input_file ? @sent_enqueued_id : nil,
|
|
457
|
+
report_turn_id: @sent_enqueued_id)
|
|
418
458
|
end
|
|
419
459
|
|
|
420
460
|
# The model the new session will run, for the images check: --model
|
|
@@ -461,11 +501,15 @@ module Samagotchi
|
|
|
461
501
|
# The turn waits on a question: the message would only queue
|
|
462
502
|
# behind it, and the answer goes in with chi answer.
|
|
463
503
|
if (waiting = session.waiting_question(live: owner.worker?))
|
|
504
|
+
# The whole question (its options, its tags) for what a parent may
|
|
505
|
+
# give: config.yml tightened by the session's own parent_answers.
|
|
506
|
+
strategy = Guardrails::ParentPolicy.for_session(session)
|
|
507
|
+
question = ParentReport.question(session.pending_question, strategy: strategy)
|
|
464
508
|
if ParentReport.approval?(waiting)
|
|
465
|
-
error_line("#{id[0, 8]} waiting for an approval: #{ParentReport
|
|
466
|
-
"(#{ParentReport.answer_with(id,
|
|
509
|
+
error_line("#{id[0, 8]} waiting for an approval: #{ParentReport.approval_advice(question, strategy: strategy)} " \
|
|
510
|
+
"(#{ParentReport.answer_with(id, question)})")
|
|
467
511
|
else
|
|
468
|
-
error_line("#{id[0, 8]} waiting for an answer: #{ParentReport.answer_with(id,
|
|
512
|
+
error_line("#{id[0, 8]} waiting for an answer: #{ParentReport.answer_with(id, question)} (or --text); " \
|
|
469
513
|
"chi --attach #{id} shows it")
|
|
470
514
|
end
|
|
471
515
|
else
|
|
@@ -485,7 +529,9 @@ module Samagotchi
|
|
|
485
529
|
# The ack's id names the queued message's turn, also when the turn
|
|
486
530
|
# running at the check ended before it went in (an idle worker's ack
|
|
487
531
|
# says next_step, and the file still runs as that turn).
|
|
488
|
-
|
|
532
|
+
# A file fallback has no delivery field; use its id to see terminal
|
|
533
|
+
# outcomes for commands that finish without starting a turn.
|
|
534
|
+
turn = @sent_enqueued_id if @queue || @sent_input_file
|
|
489
535
|
report_turn_id = if @queue
|
|
490
536
|
turn
|
|
491
537
|
elsif session.status == Session::STATUS_RUNNING
|
|
@@ -493,7 +539,8 @@ module Samagotchi
|
|
|
493
539
|
else
|
|
494
540
|
@sent_enqueued_id
|
|
495
541
|
end
|
|
496
|
-
wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], turn: turn,
|
|
542
|
+
wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], turn: turn,
|
|
543
|
+
queued: @queue && !turn.nil?,
|
|
497
544
|
report_turn_id: report_turn_id)
|
|
498
545
|
end
|
|
499
546
|
|
|
@@ -550,7 +597,7 @@ module Samagotchi
|
|
|
550
597
|
begin
|
|
551
598
|
refs = copy_images(dir)
|
|
552
599
|
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:
|
|
600
|
+
result = SessionManager.deliver_turn(session.id, prompt: prompt, client_id: client_id_for(prompt), images: refs,
|
|
554
601
|
state_dir: @state_dir)
|
|
555
602
|
end
|
|
556
603
|
rescue StandardError => e
|
|
@@ -569,6 +616,7 @@ module Samagotchi
|
|
|
569
616
|
end
|
|
570
617
|
|
|
571
618
|
ack = result[:ack].is_a?(Hash) ? result[:ack] : {}
|
|
619
|
+
@sent_input_file = !ack.key?("delivery")
|
|
572
620
|
@sent_enqueued_id = (ack["enqueued_id"] || ack[:enqueued_id])&.to_s
|
|
573
621
|
@sent_command = command_ack?(result[:ack])
|
|
574
622
|
@info.puts("#{session.id} started#{continued_words(session)}#{with_images}#{"; #{SENT_COMMAND}" if @sent_command}")
|
|
@@ -581,6 +629,48 @@ module Samagotchi
|
|
|
581
629
|
# /turn whose line is one answers with its command_id).
|
|
582
630
|
def command_ack?(ack) = ack.is_a?(Hash) && !ack["command_id"].to_s.empty?
|
|
583
631
|
|
|
632
|
+
# chi send's client id, or chi answer's for a parent agent's /continue
|
|
633
|
+
# yes (the worker holds that to its guardrails.parent_answers).
|
|
634
|
+
def client_id_for(prompt) = parent_continue?(prompt) ? ClientId::CLI_ANSWER : CLIENT_ID
|
|
635
|
+
|
|
636
|
+
# The first refusal of a parent's /continue yes among the targets, each
|
|
637
|
+
# by config.yml tightened by its own parent_answers; nil when none
|
|
638
|
+
# refuses.
|
|
639
|
+
def continue_refusal_for(options)
|
|
640
|
+
target_parent_answers(options).each do |value|
|
|
641
|
+
strategy = Guardrails::ParentPolicy.effective(value)
|
|
642
|
+
refusal = Guardrails::ParentPolicy.continue_refusal(strategy: strategy)
|
|
643
|
+
return refusal if refusal
|
|
644
|
+
end
|
|
645
|
+
nil
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
# The targets' own parent_answers values: a --new session's
|
|
649
|
+
# --parent-answers, a --continues link's previous session's (the link
|
|
650
|
+
# keeps it), each id's. An id that doesn't resolve is skipped (#resolve
|
|
651
|
+
# reports it later); none left is config.yml's alone ([nil]).
|
|
652
|
+
def target_parent_answers(options)
|
|
653
|
+
return [options[:parent_answers]] if options[:new] && !options[:continues]
|
|
654
|
+
|
|
655
|
+
sessions = if options[:new]
|
|
656
|
+
[target_session { SessionChain.resolve(options[:continues], state_dir: @state_dir) }]
|
|
657
|
+
else
|
|
658
|
+
options[:ids].map { |given| target_session { Session.resolve_id(given, state_dir: @state_dir) } }
|
|
659
|
+
end
|
|
660
|
+
values = sessions.compact.map(&:parent_answers)
|
|
661
|
+
values.empty? ? [nil] : values
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
# The session the block's id names, or nil when there is none.
|
|
665
|
+
def target_session
|
|
666
|
+
Session.load(yield, state_dir: @state_dir)
|
|
667
|
+
rescue ArgumentError
|
|
668
|
+
nil
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
# A /continue yes (or bare /continue) from a parent agent's process.
|
|
672
|
+
def parent_continue?(prompt) = TurnFlow.continue_yes?(prompt) && Guardrails::ParentApprovals.parent_process?
|
|
673
|
+
|
|
584
674
|
# Whether +text+ may be a session command, which only its worker knows
|
|
585
675
|
# (a bundle's too).
|
|
586
676
|
def command_like?(text) = text.to_s.lstrip.start_with?("/", "!")
|
|
@@ -648,7 +738,7 @@ module Samagotchi
|
|
|
648
738
|
delivery = if @queue then Delivery::QUEUE
|
|
649
739
|
elsif @cut && running then Delivery::CUT
|
|
650
740
|
end
|
|
651
|
-
result = SessionManager.deliver_turn(id, prompt: prompt, client_id:
|
|
741
|
+
result = SessionManager.deliver_turn(id, prompt: prompt, client_id: client_id_for(prompt), images: refs, state_dir: @state_dir,
|
|
652
742
|
delivery: delivery)
|
|
653
743
|
unless result[:status] == :accepted
|
|
654
744
|
# A worker older than queueing would steer it: refused, not sent.
|
|
@@ -664,6 +754,7 @@ module Samagotchi
|
|
|
664
754
|
end
|
|
665
755
|
|
|
666
756
|
ack = result[:ack].is_a?(Hash) ? result[:ack] : {}
|
|
757
|
+
@sent_input_file = !ack.key?("delivery")
|
|
667
758
|
# What the worker applied, for --format json: its ack's delivery
|
|
668
759
|
# (next_step for an idle session) and a cut's outcome.
|
|
669
760
|
@sent_delivery = ack["delivery"] || delivery
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -220,6 +220,27 @@ module Samagotchi
|
|
|
220
220
|
@bundle_settings = BundleSessionSettings.normalize(value)
|
|
221
221
|
end
|
|
222
222
|
|
|
223
|
+
# The session's own guardrails.parent_answers (hard|standard|soft), nil
|
|
224
|
+
# for none (config.yml's). It may only tighten config.yml's
|
|
225
|
+
# (Guardrails::ParentPolicy.effective); only the user loosens it
|
|
226
|
+
# (/parent-answers in a UI). Given at creation (chi --parent-answers,
|
|
227
|
+
# chi send --new --parent-answers); a respawn, a continue and a fork keep
|
|
228
|
+
# it (SessionSetup.of), a delegate child takes its parent's.
|
|
229
|
+
attr_reader :parent_answers
|
|
230
|
+
|
|
231
|
+
# @param value [String, Symbol, nil] stripped and downcased; "" and
|
|
232
|
+
# "default" are none; a value that isn't a level is kept as written
|
|
233
|
+
# (read as hard: ParentPolicy.session_level)
|
|
234
|
+
def parent_answers=(value)
|
|
235
|
+
word = value.to_s.strip
|
|
236
|
+
word = word.downcase if PARENT_ANSWERS_WORDS.include?(word.downcase)
|
|
237
|
+
@parent_answers = word.empty? || word == "default" ? nil : word
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# The levels and "default" (Guardrails::ParentPolicy::ORDER and UNSET;
|
|
241
|
+
# not required here: that file loads the guardrails).
|
|
242
|
+
PARENT_ANSWERS_WORDS = %w[hard standard soft default].freeze
|
|
243
|
+
|
|
223
244
|
# The hook that stopped the last turn (stop_turn, or a cut with no retry
|
|
224
245
|
# left), e.g. "loop-guard"; nil when it ended otherwise.
|
|
225
246
|
def stopped_by
|
|
@@ -254,7 +275,7 @@ module Samagotchi
|
|
|
254
275
|
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
|
|
255
276
|
last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
|
|
256
277
|
continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
|
|
257
|
-
toolkit: nil, bundle_settings: {}, interrupted_turn: nil)
|
|
278
|
+
toolkit: nil, bundle_settings: {}, interrupted_turn: nil, parent_answers: nil)
|
|
258
279
|
@id = id
|
|
259
280
|
@metadata_version = metadata_version
|
|
260
281
|
@mode = mode
|
|
@@ -285,6 +306,7 @@ module Samagotchi
|
|
|
285
306
|
self.appended_prompt_files = appended_prompt_files
|
|
286
307
|
self.toolkit = toolkit
|
|
287
308
|
self.bundle_settings = bundle_settings
|
|
309
|
+
self.parent_answers = parent_answers
|
|
288
310
|
self.interrupted_turn = interrupted_turn
|
|
289
311
|
@turn_checkpoint = nil
|
|
290
312
|
@archived = false
|
|
@@ -383,6 +405,7 @@ module Samagotchi
|
|
|
383
405
|
appended_prompt_files: setup.appended_prompt_files,
|
|
384
406
|
toolkit: setup.toolkit,
|
|
385
407
|
bundle_settings: setup.bundle_settings,
|
|
408
|
+
parent_answers: setup.parent_answers,
|
|
386
409
|
continues: continues
|
|
387
410
|
)
|
|
388
411
|
end
|
|
@@ -449,7 +472,8 @@ module Samagotchi
|
|
|
449
472
|
"appended_prompt_files" => [],
|
|
450
473
|
"toolkit" => nil,
|
|
451
474
|
"bundle_settings" => {},
|
|
452
|
-
"interrupted_turn" => nil
|
|
475
|
+
"interrupted_turn" => nil,
|
|
476
|
+
"parent_answers" => nil
|
|
453
477
|
}.freeze
|
|
454
478
|
|
|
455
479
|
# A session from a parsed session file. +messages+ false leaves the
|