samagotchi 0.86.0 → 0.88.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 +32 -1
- data/bin/chi +39 -2
- data/docs/architecture.md +5 -3
- data/docs/cli.md +18 -5
- data/docs/configuration.md +1 -1
- data/docs/guardrails.md +53 -3
- data/docs/hooks.md +1 -1
- data/docs/plugins.md +1 -1
- data/docs/sessions.md +19 -7
- data/docs/sub-agent.md +15 -3
- data/lib/samagotchi/answer_command.rb +6 -6
- 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/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +100 -7
- data/lib/samagotchi/cli/parent_wait.rb +3 -1
- data/lib/samagotchi/guardrails/parent_policy.rb +94 -5
- data/lib/samagotchi/parent_answers_command.rb +80 -0
- data/lib/samagotchi/parent_report.rb +53 -34
- data/lib/samagotchi/question_desk.rb +3 -2
- data/lib/samagotchi/relay.rb +34 -2
- data/lib/samagotchi/relay_desk.rb +13 -0
- data/lib/samagotchi/send_command.rb +75 -10
- data/lib/samagotchi/session.rb +26 -2
- data/lib/samagotchi/session_commands.rb +35 -9
- 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 +16 -5
- data/lib/samagotchi/terminal_ui/question_prompt.rb +2 -1
- data/lib/samagotchi/terminal_ui.rb +12 -6
- data/lib/samagotchi/tools/delegate.rb +4 -1
- data/lib/samagotchi/tools/delegate_relay.rb +69 -15
- data/lib/samagotchi/tools/delegate_wait.rb +14 -8
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/notify.js +6 -2
- data/lib/samagotchi/web/public/question_card.js +2 -0
- data/lib/samagotchi/web/public/question_cards.js +25 -18
- data/lib/samagotchi/worker.rb +60 -10
- metadata +2 -1
data/lib/samagotchi/relay.rb
CHANGED
|
@@ -16,6 +16,8 @@ module Samagotchi
|
|
|
16
16
|
# How much of the child's task the card keeps, and the question line.
|
|
17
17
|
TASK_CHARS = 200
|
|
18
18
|
TASK_LINE_CHARS = 80
|
|
19
|
+
# A hook's question (QuestionDesk#ask_for_hook), relayed as such (P4).
|
|
20
|
+
HOOK_KIND = "hook"
|
|
19
21
|
|
|
20
22
|
module_function
|
|
21
23
|
|
|
@@ -33,6 +35,14 @@ module Samagotchi
|
|
|
33
35
|
asked = inner ? inner[:asked].to_s : pending.question.to_s
|
|
34
36
|
task = inner ? inner[:task].to_s : task_of(child)
|
|
35
37
|
who = chain.reverse.join(" → ")
|
|
38
|
+
relay = { id: relay_id, child_id: child.id, child_short: short, child_question_id: pending.id.to_s,
|
|
39
|
+
task: task, chain: chain, asked: asked, more: more.to_i }
|
|
40
|
+
if pending.kind.to_s == HOOK_KIND
|
|
41
|
+
# The asking hook's own header, kept down a chain (a relay of a relay).
|
|
42
|
+
relay[:header] = inner ? inner[:header].to_s : pending.header.to_s
|
|
43
|
+
return hook_card(pending, who, more, relay)
|
|
44
|
+
end
|
|
45
|
+
|
|
36
46
|
{
|
|
37
47
|
question: question_text(who, task, asked),
|
|
38
48
|
options: relabel(Array(pending.options), facts[:scopes], who),
|
|
@@ -43,11 +53,33 @@ module Samagotchi
|
|
|
43
53
|
**{ effects: pending.effects, user_only: pending.user_only }.compact,
|
|
44
54
|
kind: Guardrails::Approval::KIND,
|
|
45
55
|
approval: facts,
|
|
46
|
-
relay:
|
|
47
|
-
task: task, chain: chain, asked: asked, more: more.to_i }
|
|
56
|
+
relay: relay
|
|
48
57
|
}
|
|
49
58
|
end
|
|
50
59
|
|
|
60
|
+
# A hook's question (P4: one its parent can't usefully answer): its
|
|
61
|
+
# kind, hook, options and effects as the child asked them, and no
|
|
62
|
+
# approval facts (ParentApprovals.approval? stays false on it, so the
|
|
63
|
+
# policy judges it by its own tags, as on the child).
|
|
64
|
+
def hook_card(pending, who, more, relay)
|
|
65
|
+
{
|
|
66
|
+
question: question_text(who, relay[:task], relay[:asked]),
|
|
67
|
+
options: Array(pending.options).map(&:to_s),
|
|
68
|
+
header: hook_header(who, relay[:header], more),
|
|
69
|
+
multi_select: !!pending.multi_select,
|
|
70
|
+
allow_freeform: !!pending.allow_freeform,
|
|
71
|
+
**{ effects: pending.effects, user_only: pending.user_only, hook: pending.hook }.compact,
|
|
72
|
+
kind: HOOK_KIND,
|
|
73
|
+
relay: relay
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# delegate ab12 asks: Cost cap (+1 more delegate waiting)
|
|
78
|
+
def hook_header(who, header, more)
|
|
79
|
+
text = "delegate #{who} asks: #{header.to_s.strip.empty? ? "a question" : header}"
|
|
80
|
+
more.to_i.positive? ? "#{text} (+#{more} more delegate#{"s" if more.to_i > 1} waiting)" : text
|
|
81
|
+
end
|
|
82
|
+
|
|
51
83
|
# delegate ab12 ("fix the flaky spec in …") asks:
|
|
52
84
|
# execute: git push origin main
|
|
53
85
|
# in /path/repo (repo samagotchi, branch main)
|
|
@@ -63,6 +63,19 @@ module Samagotchi
|
|
|
63
63
|
end
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
+
# Whether the parent's user (or a parent agent) answered or dismissed a
|
|
67
|
+
# relay of this child's question: it isn't relayed again (a refused
|
|
68
|
+
# answer leaves it on the child's card). A closed one (a Stop, the
|
|
69
|
+
# child gone) is not settled. Forgotten after KEEP_SECONDS or a restart.
|
|
70
|
+
def settled?(child_id, child_question_id)
|
|
71
|
+
@mutex.synchronize do
|
|
72
|
+
prune
|
|
73
|
+
@relays.each_value.any? do |relay|
|
|
74
|
+
relay[:state] == "answered" && relay[:child_id] == child_id.to_s && relay[:child_question_id] == child_question_id.to_s
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
66
79
|
# Whether a relay is open, or settled recently enough that its child
|
|
67
80
|
# may still ask about it: a restart would drop them.
|
|
68
81
|
def active?
|
|
@@ -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"
|
|
@@ -43,7 +44,7 @@ module Samagotchi
|
|
|
43
44
|
|
|
44
45
|
USAGE = <<~TEXT
|
|
45
46
|
Usage: chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...
|
|
46
|
-
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]...
|
|
47
48
|
chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT] [--image PATH]...
|
|
48
49
|
chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
|
|
49
50
|
chi send --queue --wait [--timeout S] [--format json] [-m TEXT] ID
|
|
@@ -95,6 +96,11 @@ module Samagotchi
|
|
|
95
96
|
manifest lists in session_settings:; 3, 2.5, true,
|
|
96
97
|
false and off are typed, the rest is text:
|
|
97
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)
|
|
98
104
|
--wait wait for the answer and print it (one session); the
|
|
99
105
|
other lines go to stderr. Exit 3: it waits for an
|
|
100
106
|
answer from you: the question, its options and the
|
|
@@ -153,6 +159,7 @@ module Samagotchi
|
|
|
153
159
|
f.value "--tool", key: :tools, repeat: true
|
|
154
160
|
f.value "--no-tool", key: :no_tools, repeat: true
|
|
155
161
|
f.value "--set", key: :sets, repeat: true
|
|
162
|
+
f.value "--parent-answers", key: :parent_answers
|
|
156
163
|
f.value "--image", key: :images, repeat: true
|
|
157
164
|
f.switch "--wait"
|
|
158
165
|
f.switch "--cut"
|
|
@@ -246,12 +253,21 @@ module Samagotchi
|
|
|
246
253
|
end
|
|
247
254
|
|
|
248
255
|
# A parent agent's /continue yes is chi answer --option Continue by
|
|
249
|
-
# another route: held to guardrails.parent_answers
|
|
250
|
-
# as chi answer's (#client_id_for) so
|
|
251
|
-
|
|
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))
|
|
252
260
|
@stderr.puts("chi send: #{refusal}")
|
|
253
261
|
return CLI::Exit::FAILED
|
|
254
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
|
|
255
271
|
# A file that is not there or not an image: a run-time failure (1),
|
|
256
272
|
# reported in the --format json line too; --image itself was fine.
|
|
257
273
|
return CLI::Exit::FAILED unless ingest_images(options[:images])
|
|
@@ -305,6 +321,7 @@ module Samagotchi
|
|
|
305
321
|
TOOLKIT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") unless Array(options[key]).empty? }
|
|
306
322
|
LLM_CONTEXT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") if options[key] }
|
|
307
323
|
return usage_error("--set needs --new") unless Array(options[:sets]).empty?
|
|
324
|
+
return usage_error("--parent-answers needs --new") if options[:parent_answers]
|
|
308
325
|
return usage_error("give session ids") if options[:ids].empty?
|
|
309
326
|
|
|
310
327
|
options
|
|
@@ -324,10 +341,10 @@ module Samagotchi
|
|
|
324
341
|
taken = [("--dir" if options[:dir])] +
|
|
325
342
|
LLM_CONTEXT_FLAGS.filter_map { |key, flag| flag if options[key] } + [("--thinking" if options[:thinking])] +
|
|
326
343
|
[("--toolkit" if options[:toolkit])] + TOOLKIT_FLAGS.filter_map { |key, flag| flag unless Array(options[key]).empty? } +
|
|
327
|
-
[("--set" unless Array(options[:sets]).empty?)]
|
|
344
|
+
[("--set" unless Array(options[:sets]).empty?), ("--parent-answers" if options[:parent_answers])]
|
|
328
345
|
unless taken.compact.empty?
|
|
329
346
|
return usage_error("--continues takes the previous session's folder, LLM context, thinking level, " \
|
|
330
|
-
"toolkit
|
|
347
|
+
"toolkit, bundle settings and parent_answers; " \
|
|
331
348
|
"leave out #{taken.compact.join(", ")}")
|
|
332
349
|
end
|
|
333
350
|
end
|
|
@@ -347,6 +364,13 @@ module Samagotchi
|
|
|
347
364
|
|
|
348
365
|
options[:thinking] = Thinking.session_level(word)
|
|
349
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
|
|
350
374
|
toolkit = Toolkit.from_flags(name: options[:toolkit], add: options[:tools], remove: options[:no_tools])
|
|
351
375
|
options[:toolkit_value] = toolkit if toolkit
|
|
352
376
|
begin
|
|
@@ -404,7 +428,8 @@ module Samagotchi
|
|
|
404
428
|
setup: SessionSetup.new(llm_context: options[:llm_context],
|
|
405
429
|
thinking: options[:thinking],
|
|
406
430
|
toolkit: options[:toolkit_value],
|
|
407
|
-
bundle_settings: options[:bundle_settings] || {}
|
|
431
|
+
bundle_settings: options[:bundle_settings] || {},
|
|
432
|
+
parent_answers: options[:parent_answers]),
|
|
408
433
|
state_dir: @state_dir)
|
|
409
434
|
end
|
|
410
435
|
rescue SessionContinue::Refused, SessionManager::ArchiveRefused, SessionManager::OwnedByTUI => e
|
|
@@ -415,6 +440,9 @@ module Samagotchi
|
|
|
415
440
|
return 1
|
|
416
441
|
end
|
|
417
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
|
|
418
446
|
if through_worker
|
|
419
447
|
return 1 unless deliver_new(session, prompt)
|
|
420
448
|
else
|
|
@@ -473,10 +501,12 @@ module Samagotchi
|
|
|
473
501
|
# The turn waits on a question: the message would only queue
|
|
474
502
|
# behind it, and the answer goes in with chi answer.
|
|
475
503
|
if (waiting = session.waiting_question(live: owner.worker?))
|
|
476
|
-
# The whole question (its options, its tags) for what a parent may
|
|
477
|
-
|
|
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)
|
|
478
508
|
if ParentReport.approval?(waiting)
|
|
479
|
-
error_line("#{id[0, 8]} waiting for an approval: #{ParentReport.approval_advice(question)} " \
|
|
509
|
+
error_line("#{id[0, 8]} waiting for an approval: #{ParentReport.approval_advice(question, strategy: strategy)} " \
|
|
480
510
|
"(#{ParentReport.answer_with(id, question)})")
|
|
481
511
|
else
|
|
482
512
|
error_line("#{id[0, 8]} waiting for an answer: #{ParentReport.answer_with(id, question)} (or --text); " \
|
|
@@ -603,6 +633,41 @@ module Samagotchi
|
|
|
603
633
|
# yes (the worker holds that to its guardrails.parent_answers).
|
|
604
634
|
def client_id_for(prompt) = parent_continue?(prompt) ? ClientId::CLI_ANSWER : CLIENT_ID
|
|
605
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
|
+
|
|
606
671
|
# A /continue yes (or bare /continue) from a parent agent's process.
|
|
607
672
|
def parent_continue?(prompt) = TurnFlow.continue_yes?(prompt) && Guardrails::ParentApprovals.parent_process?
|
|
608
673
|
|
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
|
|
@@ -13,6 +13,7 @@ require_relative "commands/registry"
|
|
|
13
13
|
require_relative "llm_context_command"
|
|
14
14
|
require_relative "thinking_command"
|
|
15
15
|
require_relative "toolkit_command"
|
|
16
|
+
require_relative "parent_answers_command"
|
|
16
17
|
require_relative "reasoning_history_command"
|
|
17
18
|
require_relative "guardrails/parent_policy"
|
|
18
19
|
|
|
@@ -35,6 +36,7 @@ module Samagotchi
|
|
|
35
36
|
LLM_CONTEXT_COMMAND = LLMContextCommand::NAME
|
|
36
37
|
THINKING_COMMAND = ThinkingCommand::NAME
|
|
37
38
|
TOOLKIT_COMMAND = ToolkitCommand::NAME
|
|
39
|
+
PARENT_ANSWERS_COMMAND = ParentAnswersCommand::NAME
|
|
38
40
|
REASONING_HISTORY_COMMAND = ReasoningHistoryCommand::NAME
|
|
39
41
|
# A remote catalog has hundreds of ids (OpenRouter ~380): plain /models
|
|
40
42
|
# shows this many per host; /models <text> lists every match.
|
|
@@ -77,12 +79,8 @@ module Samagotchi
|
|
|
77
79
|
id: :rollback) { |_text| rollback }
|
|
78
80
|
registry.register("!", "run a shell command; its output goes into the conversation",
|
|
79
81
|
id: :shell, mid_turn: :queue, match: ->(text) { text.match?(/\A!\s*\S/) }) { |text| shell(text) }
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
answer = text.delete_prefix(CONTINUE_COMMAND).strip
|
|
84
|
-
continue_answer(answer.empty? ? CONTINUE_COMMAND : answer, as_parent: @as_parent)
|
|
85
|
-
end
|
|
82
|
+
# #run passes who sent it (as_parent:) per call.
|
|
83
|
+
registry.register(CONTINUE_COMMAND, "answer the continue offer (yes, no or no, <reason>)") { |text| continue_command(text) }
|
|
86
84
|
# It reads the hosts only (the registry is mutex-guarded): mid-turn too.
|
|
87
85
|
registry.register(MODELS_COMMAND, "list the hosts' models (/models <text> filters)", anytime: true) do |text|
|
|
88
86
|
reply(models_listing(text.delete_prefix(MODELS_COMMAND).strip))
|
|
@@ -109,6 +107,11 @@ module Samagotchi
|
|
|
109
107
|
registry.register(TOOLKIT_COMMAND, "show or change the tools this session declares (NAME, add TOOL, remove TOOL)",
|
|
110
108
|
id: :toolkit, mid_turn: show_mid_turn(TOOLKIT_COMMAND),
|
|
111
109
|
match: ->(text) { text.match?(%r{\A/toolkit(?:\s+.*)?\z}) }) { |text| toolkit(text) }
|
|
110
|
+
# Tighten-only from anyone; loosening is a user's at a UI (#run's user_ui:).
|
|
111
|
+
registry.register(PARENT_ANSWERS_COMMAND, "show or set what a parent agent may answer here (hard|standard|soft|default; " \
|
|
112
|
+
"never looser than config.yml's)",
|
|
113
|
+
id: :parent_answers, mid_turn: show_mid_turn(PARENT_ANSWERS_COMMAND),
|
|
114
|
+
match: ->(text) { text.match?(%r{\A/parent-answers(?:\s+.*)?\z}) }) { |text| parent_answers(text) }
|
|
112
115
|
registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
|
|
113
116
|
registry.register("/stats", "show the session's stats", local: true)
|
|
114
117
|
registry.register("/recap", "show the session's recap", local: true)
|
|
@@ -258,9 +261,11 @@ module Samagotchi
|
|
|
258
261
|
# @param as_parent [Boolean] a parent agent sent it (client cli:answer):
|
|
259
262
|
# a /continue yes is held to guardrails.parent_answers, as chi
|
|
260
263
|
# answer --option Continue is (Guardrails::ParentPolicy)
|
|
264
|
+
# @param user_ui [Boolean] a user at a UI sent it (the REPL, an attached
|
|
265
|
+
# TUI, the web; not a piped or marked one): only such a line loosens
|
|
266
|
+
# /parent-answers
|
|
261
267
|
# @return [Result, nil] nil when +line+ is not one of these commands
|
|
262
|
-
def run(line, confirm: true, as_parent: false)
|
|
263
|
-
@as_parent = as_parent
|
|
268
|
+
def run(line, confirm: true, as_parent: false, user_ui: false)
|
|
264
269
|
text = line.to_s.strip
|
|
265
270
|
entry = @registry.lookup(text)
|
|
266
271
|
return nil unless entry&.handler
|
|
@@ -268,17 +273,30 @@ module Samagotchi
|
|
|
268
273
|
|
|
269
274
|
# The two that may ask take the flag; the rest never ask.
|
|
270
275
|
return send(entry.id, text, confirm: confirm) if CONFIRMING.include?(entry.id)
|
|
276
|
+
# Who sent it goes to its handler per call, never through an ivar:
|
|
277
|
+
# an anytime command runs on another thread against this instance.
|
|
278
|
+
return continue_command(text, as_parent: as_parent) if entry.id == :continue
|
|
279
|
+
return parent_answers(text, user_ui: user_ui) if entry.id == :parent_answers
|
|
271
280
|
|
|
272
281
|
instance_exec(text, &entry.handler)
|
|
273
282
|
end
|
|
274
283
|
|
|
284
|
+
# /continue [yes|no|no, <reason>]: the continue offer's answer.
|
|
285
|
+
def continue_command(text, as_parent: false)
|
|
286
|
+
return reply("nothing to continue") unless @turn_flow.awaiting_continue?
|
|
287
|
+
|
|
288
|
+
answer = text.delete_prefix(CONTINUE_COMMAND).strip
|
|
289
|
+
continue_answer(answer.empty? ? CONTINUE_COMMAND : answer, as_parent: as_parent)
|
|
290
|
+
end
|
|
291
|
+
|
|
275
292
|
# Answer the pending continue offer (yes / no / no, <reason>).
|
|
276
293
|
# @param as_parent [Boolean] a parent agent's answer: a yes its strategy
|
|
277
294
|
# doesn't allow is refused (the offer stays; decision :invalid)
|
|
278
295
|
# @return [Result] resume: true on yes
|
|
279
296
|
def continue_answer(text, as_parent: false)
|
|
280
297
|
decision, reason = TurnFlow.continue_decision(text)
|
|
281
|
-
if decision == :resume && as_parent &&
|
|
298
|
+
if decision == :resume && as_parent &&
|
|
299
|
+
(refusal = Guardrails::ParentPolicy.continue_refusal(strategy: Guardrails::ParentPolicy.for_session(@engine.session)))
|
|
282
300
|
return reply(refusal, status: :error).tap { |result| result.decision = :invalid }
|
|
283
301
|
end
|
|
284
302
|
|
|
@@ -496,6 +514,14 @@ module Samagotchi
|
|
|
496
514
|
reply(e.message, status: :error)
|
|
497
515
|
end
|
|
498
516
|
|
|
517
|
+
# /parent-answers (ParentAnswersCommand); :parent_answers in changed
|
|
518
|
+
# when it set the session's own level.
|
|
519
|
+
def parent_answers(text, user_ui: false)
|
|
520
|
+
output, changed, status = ParentAnswersCommand.new(engine: @engine, save: ->(session) { save_setup(session) })
|
|
521
|
+
.run(text.delete_prefix(PARENT_ANSWERS_COMMAND), user_ui: user_ui)
|
|
522
|
+
reply(output, status: status, changed: changed ? [:parent_answers] : [])
|
|
523
|
+
end
|
|
524
|
+
|
|
499
525
|
# A session setting changed (/llm-context, /thinking, /reasoning-history):
|
|
500
526
|
# saved with its model.
|
|
501
527
|
def save_setup(session)
|
|
@@ -18,24 +18,26 @@ module Samagotchi
|
|
|
18
18
|
# +toolkit+ a Toolkit or nil (SessionManager.spawn_session gives the
|
|
19
19
|
# session config's toolkit.default then), +bundle_settings+ the bundle
|
|
20
20
|
# settings chi --set gave ({bundle => {key => value}}, frozen; {} with
|
|
21
|
-
# none), which Plugin::Context#settings lays over config.yml's
|
|
21
|
+
# none), which Plugin::Context#settings lays over config.yml's,
|
|
22
|
+
# +parent_answers+ the session's own guardrails.parent_answers (a level
|
|
23
|
+
# String, tighten-only: Guardrails::ParentPolicy.effective) or nil.
|
|
22
24
|
#
|
|
23
25
|
# It doesn't save itself: the session keeps each setting in its own field
|
|
24
26
|
# and file key, and resolves it on its own. A delegate child starts
|
|
25
|
-
# without one (but the toolkit its delegate call named
|
|
26
|
-
# fork copy their session's (.of).
|
|
27
|
+
# without one (but the toolkit its delegate call named, and its parent's
|
|
28
|
+
# parent_answers); a continue and a fork copy their session's (.of).
|
|
27
29
|
SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history, :appended_prompt_files, :toolkit,
|
|
28
|
-
:bundle_settings) do
|
|
30
|
+
:bundle_settings, :parent_answers) do
|
|
29
31
|
def initialize(llm_context: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [], toolkit: nil,
|
|
30
|
-
bundle_settings: {})
|
|
32
|
+
bundle_settings: {}, parent_answers: nil)
|
|
31
33
|
super(llm_context: llm_context, thinking: thinking, reasoning_history: reasoning_history,
|
|
32
34
|
appended_prompt_files: Array(appended_prompt_files).map(&:to_s).freeze, toolkit: toolkit,
|
|
33
|
-
bundle_settings: BundleSessionSettings.normalize(bundle_settings))
|
|
35
|
+
bundle_settings: BundleSessionSettings.normalize(bundle_settings), parent_answers: parent_answers&.to_s)
|
|
34
36
|
end
|
|
35
37
|
|
|
36
38
|
def empty?
|
|
37
39
|
llm_context.nil? && thinking.nil? && reasoning_history.nil? && appended_prompt_files.empty? && toolkit.nil? &&
|
|
38
|
-
bundle_settings.empty?
|
|
40
|
+
bundle_settings.empty? && parent_answers.nil?
|
|
39
41
|
end
|
|
40
42
|
|
|
41
43
|
# A session's own setup, for a session that starts from it. Its
|
|
@@ -48,7 +50,7 @@ module Samagotchi
|
|
|
48
50
|
|
|
49
51
|
new(llm_context: session.llm_context, thinking: session.thinking, reasoning_history: session.reasoning_history,
|
|
50
52
|
appended_prompt_files: session.appended_prompt_files, toolkit: Toolkit.of(session.toolkit),
|
|
51
|
-
bundle_settings: session.bundle_settings)
|
|
53
|
+
bundle_settings: session.bundle_settings, parent_answers: session.parent_answers)
|
|
52
54
|
end
|
|
53
55
|
end
|
|
54
56
|
end
|
|
@@ -53,6 +53,10 @@ module Samagotchi
|
|
|
53
53
|
# the line an existing session's run prints (--set too)
|
|
54
54
|
# @param bundle_settings [Hash] --set's values (BundleSessionSettings):
|
|
55
55
|
# a new session's setup carries them; an existing session keeps its own
|
|
56
|
+
# @param parent_answers [String, nil] --parent-answers: a new session's
|
|
57
|
+
# own guardrails.parent_answers; an existing session keeps its own
|
|
58
|
+
# (never sent as a /parent-answers command: that would be a
|
|
59
|
+
# loosening route from a command line)
|
|
56
60
|
# @param state_dir [String, nil] where the sessions are (the default
|
|
57
61
|
# state dir by default): the worker's, for the attached loop too
|
|
58
62
|
# @return [Symbol] :detached, :closed when the worker went away,
|
|
@@ -60,12 +64,12 @@ module Samagotchi
|
|
|
60
64
|
# pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
|
|
61
65
|
def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
|
|
62
66
|
memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], toolkit: nil,
|
|
63
|
-
toolkit_flags: [], bundle_settings: {}, state_dir: nil)
|
|
67
|
+
toolkit_flags: [], bundle_settings: {}, parent_answers: nil, state_dir: nil)
|
|
64
68
|
state_dir ||= Session.default_state_dir
|
|
65
69
|
client = connect(attach: attach, shared: shared, resume: resume, model: model, state_dir: state_dir,
|
|
66
70
|
memories: memories, muted_memories: muted_memories, llm_context: llm_context, thinking: thinking,
|
|
67
71
|
appended_prompt_files: appended_prompt_files, toolkit: toolkit, toolkit_flags: toolkit_flags,
|
|
68
|
-
bundle_settings: bundle_settings)
|
|
72
|
+
bundle_settings: bundle_settings, parent_answers: parent_answers)
|
|
69
73
|
commands = []
|
|
70
74
|
commands << "/model #{model}" if model && (attach || resume)
|
|
71
75
|
commands << LLMContextOverride.command_line(llm_context) if (attach || resume) && !llm_context.empty?
|
|
@@ -83,7 +87,9 @@ module Samagotchi
|
|
|
83
87
|
ensure
|
|
84
88
|
close_surface(surface)
|
|
85
89
|
end
|
|
86
|
-
|
|
90
|
+
if ended == :unanswered && attached.unanswered
|
|
91
|
+
report_unanswered(attached.unanswered, session_id: client.session_id, state_dir: state_dir)
|
|
92
|
+
end
|
|
87
93
|
report_ended(ended)
|
|
88
94
|
ended
|
|
89
95
|
end
|
|
@@ -92,9 +98,12 @@ module Samagotchi
|
|
|
92
98
|
# --wait` prints it (ParentReport), so a script or a parent agent
|
|
93
99
|
# can answer it with chi answer.
|
|
94
100
|
# @param pending [Hash] the pending question as the Bridge sent it
|
|
95
|
-
|
|
101
|
+
# @param state_dir [String, nil] where the session file is: what a
|
|
102
|
+
# parent may pick follows its own parent_answers too
|
|
103
|
+
def report_unanswered(pending, session_id:, err: $stderr, state_dir: nil)
|
|
96
104
|
question = PendingQuestion.from_file(pending)
|
|
97
|
-
|
|
105
|
+
strategy = ParentReport.strategy_for(session_id, state_dir: state_dir || Session.default_state_dir)
|
|
106
|
+
err.print("chi: #{ParentReport.question_text(question, session_id: session_id, strategy: strategy)}")
|
|
98
107
|
err.flush
|
|
99
108
|
end
|
|
100
109
|
|
|
@@ -134,7 +143,7 @@ module Samagotchi
|
|
|
134
143
|
# @raise [Error]
|
|
135
144
|
def connect(attach: nil, shared: false, resume: nil, model: nil, state_dir: nil, wait: BRIDGE_WAIT,
|
|
136
145
|
memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], toolkit: nil,
|
|
137
|
-
toolkit_flags: [], bundle_settings: {}, err: $stderr)
|
|
146
|
+
toolkit_flags: [], bundle_settings: {}, parent_answers: nil, err: $stderr)
|
|
138
147
|
sd = state_dir || Session.default_state_dir
|
|
139
148
|
if attach || resume
|
|
140
149
|
warn_memory_flags_ignored(attach || resume, memories, muted_memories, err, appended_prompt_files: appended_prompt_files,
|
|
@@ -156,7 +165,8 @@ module Samagotchi
|
|
|
156
165
|
setup: SessionSetup.new(llm_context: llm_context.empty? ? nil : LLMContextOverride.update(nil, llm_context),
|
|
157
166
|
thinking: Thinking.session_level(thinking),
|
|
158
167
|
appended_prompt_files: appended_prompt_files,
|
|
159
|
-
toolkit: toolkit, bundle_settings: bundle_settings
|
|
168
|
+
toolkit: toolkit, bundle_settings: bundle_settings,
|
|
169
|
+
parent_answers: parent_answers))
|
|
160
170
|
end
|
|
161
171
|
client = BridgeClient.wait_for(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
|
|
162
172
|
client || raise(Error, "the worker for session #{session.id} did not start its Bridge in time")
|
|
@@ -185,7 +195,7 @@ module Samagotchi
|
|
|
185
195
|
|
|
186
196
|
verb = flags.size == 1 ? "applies" : "apply"
|
|
187
197
|
names = flags.size > 2 ? "#{flags[0..-2].join(", ")} and #{flags.last}" : flags.join(" and ")
|
|
188
|
-
err.puts "(#{names} #{verb} to a new session; #{session_id}
|
|
198
|
+
err.puts "(#{names} #{verb} to a new session; #{session_id} keeps its own)"
|
|
189
199
|
end
|
|
190
200
|
|
|
191
201
|
# @return [BridgeClient, nil] the running worker's, or nil when none runs
|
|
@@ -592,10 +592,7 @@ 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
|
-
|
|
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)
|
|
595
|
+
reply = @client.post_command(line: line, client_id: client_id_for(line))
|
|
599
596
|
if reply.status == 202
|
|
600
597
|
# Input from a pipe ends once its output is in (#command_ran).
|
|
601
598
|
command_id = reply.json&.fetch("command_id", nil)
|
|
@@ -808,13 +805,27 @@ module Samagotchi
|
|
|
808
805
|
steer_cut_note(ack["cut"].to_s.to_sym, ack["cut_after"])
|
|
809
806
|
end
|
|
810
807
|
|
|
808
|
+
# The client id +line+ goes with, as a command (#send_command) or as a
|
|
809
|
+
# message the Bridge may run as one (#send_prompt: /cut and /queue
|
|
810
|
+
# text). A parent agent's /continue yes and /parent-answers go as chi
|
|
811
|
+
# answer's: the worker holds the first to guardrails.parent_answers and
|
|
812
|
+
# never takes the second as a user's loosening. The command is the
|
|
813
|
+
# registry's own match (the worker's), not a pattern of ours.
|
|
814
|
+
def client_id_for(line)
|
|
815
|
+
return @client_id unless @as_parent
|
|
816
|
+
return ClientId::CLI_ANSWER if TurnFlow.continue_yes?(line)
|
|
817
|
+
return ClientId::CLI_ANSWER if command_registry.lookup(line)&.id == :parent_answers
|
|
818
|
+
|
|
819
|
+
@client_id
|
|
820
|
+
end
|
|
821
|
+
|
|
811
822
|
def send_prompt(text, delivery: nil)
|
|
812
823
|
persist_recent_history(text)
|
|
813
824
|
images = attach_images(text)
|
|
814
825
|
return own_prompt_failed if images.nil?
|
|
815
826
|
|
|
816
827
|
running = @running
|
|
817
|
-
options = { prompt: text, client_id:
|
|
828
|
+
options = { prompt: text, client_id: client_id_for(text) }
|
|
818
829
|
options[:no_interrupt] = true if @no_interrupt
|
|
819
830
|
options[:images] = images unless images.empty?
|
|
820
831
|
options[:delivery] = delivery if delivery
|
|
@@ -96,7 +96,8 @@ module Samagotchi
|
|
|
96
96
|
case reason.to_s
|
|
97
97
|
when "answered_on_child" then "(answered in #{@delegate})"
|
|
98
98
|
when "child_gone" then "(#{@delegate}'s worker is gone)"
|
|
99
|
-
|
|
99
|
+
# A relayed hook question's dismissal: the hook decides what it means.
|
|
100
|
+
when "dismissed" then approval? ? "(denied)" : "(dismissed)"
|
|
100
101
|
else "(left open in #{@delegate})"
|
|
101
102
|
end
|
|
102
103
|
end
|
|
@@ -105,10 +105,14 @@ module Samagotchi
|
|
|
105
105
|
# @param bundle_settings [Hash] --set's values (BundleSessionSettings):
|
|
106
106
|
# a new session's own; a resumed one keeps its own (--set is in
|
|
107
107
|
# +toolkit_flags+ then)
|
|
108
|
+
# @param parent_answers [String, nil] --parent-answers: a new session's
|
|
109
|
+
# own guardrails.parent_answers; a resumed one keeps its own (named in
|
|
110
|
+
# +toolkit_flags+ then)
|
|
108
111
|
def initialize(prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
|
|
109
112
|
spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {}, thinking: nil,
|
|
110
|
-
appended_prompt_files: [], toolkit: nil, toolkit_flags: [], bundle_settings: {})
|
|
113
|
+
appended_prompt_files: [], toolkit: nil, toolkit_flags: [], bundle_settings: {}, parent_answers: nil)
|
|
111
114
|
@scratch = scratch
|
|
115
|
+
@parent_answers = parent_answers
|
|
112
116
|
@toolkit = toolkit
|
|
113
117
|
@toolkit_flags = toolkit_flags
|
|
114
118
|
@bundle_settings = bundle_settings
|
|
@@ -281,7 +285,7 @@ module Samagotchi
|
|
|
281
285
|
muted_memory_names: @muted_memory_names,
|
|
282
286
|
scratch: @scratch,
|
|
283
287
|
setup: SessionSetup.new(appended_prompt_files: @appended_prompt_files, toolkit: @toolkit || Toolkit.configured,
|
|
284
|
-
bundle_settings: @bundle_settings)
|
|
288
|
+
bundle_settings: @bundle_settings, parent_answers: @parent_answers)
|
|
285
289
|
))
|
|
286
290
|
# --llm-context*: the session's own values, over a resumed one's.
|
|
287
291
|
# Saved at once on a resumed one: leaving before a turn keeps them.
|
|
@@ -733,7 +737,7 @@ module Samagotchi
|
|
|
733
737
|
return if @toolkit_flags.empty?
|
|
734
738
|
|
|
735
739
|
names = @toolkit_flags.size > 1 ? "#{@toolkit_flags[0..-2].join(", ")} and #{@toolkit_flags.last}" : @toolkit_flags.first
|
|
736
|
-
warn "(#{names} #{@toolkit_flags.size == 1 ? "applies" : "apply"} to a new session; #{session.id}
|
|
740
|
+
warn "(#{names} #{@toolkit_flags.size == 1 ? "applies" : "apply"} to a new session; #{session.id} keeps its own)"
|
|
737
741
|
end
|
|
738
742
|
private :warn_toolkit_flags_ignored
|
|
739
743
|
|
|
@@ -876,9 +880,11 @@ module Samagotchi
|
|
|
876
880
|
command = if command_registry.anytime?(input)
|
|
877
881
|
@engine.running_anytime { @commands.run(input, confirm: confirm) }
|
|
878
882
|
else
|
|
879
|
-
# A parent's /continue yes is held to guardrails.parent_answers
|
|
880
|
-
|
|
881
|
-
|
|
883
|
+
# A parent's /continue yes is held to guardrails.parent_answers;
|
|
884
|
+
# only a person here (not piped, no agent marker) loosens
|
|
885
|
+
# /parent-answers.
|
|
886
|
+
parent = as_parent?
|
|
887
|
+
@commands.run(input, confirm: confirm, as_parent: parent && TurnFlow.continue_yes?(input), user_ui: !parent)
|
|
882
888
|
end
|
|
883
889
|
if command
|
|
884
890
|
show_command_result(command)
|