samagotchi 0.86.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 +17 -1
- data/bin/chi +39 -2
- data/docs/architecture.md +5 -3
- data/docs/cli.md +15 -2
- data/docs/configuration.md +1 -1
- data/docs/guardrails.md +47 -2
- data/docs/hooks.md +1 -1
- data/docs/plugins.md +1 -1
- data/docs/sessions.md +16 -4
- data/docs/sub-agent.md +6 -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/cli/parent_wait.rb +3 -1
- data/lib/samagotchi/guardrails/parent_policy.rb +74 -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/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 +17 -7
- data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -5
- data/lib/samagotchi/terminal_ui.rb +11 -5
- data/lib/samagotchi/tools/delegate.rb +4 -1
- data/lib/samagotchi/tools/delegate_wait.rb +11 -7
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/worker.rb +9 -1
- metadata +2 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "guardrails/parent_policy"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# /parent-answers, the session's own guardrails.parent_answers
|
|
7
|
+
# (Session#parent_answers): with no argument, the level that applies, the
|
|
8
|
+
# session's own and config.yml's; `/parent-answers LEVEL` (hard, standard,
|
|
9
|
+
# soft) or `default` (none of its own) sets it, saved at once, for the next
|
|
10
|
+
# answer a parent gives. It may only tighten config.yml's
|
|
11
|
+
# (Guardrails::ParentPolicy.effective). Tightening goes from anyone;
|
|
12
|
+
# loosening (toward soft, or default) only from a user at a UI: the REPL,
|
|
13
|
+
# an attached TUI, the web (+user_ui+). A parent agent's line (chi send,
|
|
14
|
+
# a piped or marked attach, a delegate's follow-up) is refused.
|
|
15
|
+
class ParentAnswersCommand
|
|
16
|
+
NAME = "/parent-answers"
|
|
17
|
+
USAGE = "usage: #{NAME} [hard|standard|soft|default]".freeze
|
|
18
|
+
REFUSED = "loosening parent_answers is the user's: #{NAME} in chi --attach, the REPL or the web " \
|
|
19
|
+
"(not chi send or a parent agent)".freeze
|
|
20
|
+
|
|
21
|
+
# What +args+ (the text after the name) asks for.
|
|
22
|
+
# @return [Array] [:show], [:set, level or nil (default)] or [:usage]
|
|
23
|
+
def self.parse(args)
|
|
24
|
+
words = args.to_s.split
|
|
25
|
+
return [:show] if words.empty?
|
|
26
|
+
return [:usage] if words.size > 1
|
|
27
|
+
|
|
28
|
+
word = words.first.downcase
|
|
29
|
+
return [:set, nil] if word == Guardrails::ParentPolicy::UNSET
|
|
30
|
+
|
|
31
|
+
Guardrails::ParentPolicy::ORDER.include?(word) ? [:set, word] : [:usage]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Whether the command line +line+ loosens a session whose own value is
|
|
35
|
+
# +current+ (chi send's early refusal, before it sends the line).
|
|
36
|
+
def self.loosens?(line, current)
|
|
37
|
+
kind, value = parse(line.to_s.strip.delete_prefix(NAME))
|
|
38
|
+
kind == :set && Guardrails::ParentPolicy.looser?(current, value)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param engine [Engine] #session
|
|
42
|
+
# @param save [#call] saves the session after a change
|
|
43
|
+
def initialize(engine:, save:)
|
|
44
|
+
@engine = engine
|
|
45
|
+
@save = save
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param args [String] what follows /parent-answers
|
|
49
|
+
# @param user_ui [Boolean] a user at a UI typed it: it may loosen
|
|
50
|
+
# @return [Array(String, Boolean, Symbol)] the reply, whether the
|
|
51
|
+
# session's value changed, and :ok or :error
|
|
52
|
+
def run(args, user_ui:)
|
|
53
|
+
session = @engine.session or return ["no session yet", false, :error]
|
|
54
|
+
kind, value = self.class.parse(args)
|
|
55
|
+
return [USAGE, false, :error] if kind == :usage
|
|
56
|
+
return [listing(session), false, :ok] if kind == :show
|
|
57
|
+
return ["#{listing(session)}\n(no change)", false, :ok] if value == session.parent_answers
|
|
58
|
+
return [REFUSED, false, :error] if Guardrails::ParentPolicy.looser?(session.parent_answers, value) && !user_ui
|
|
59
|
+
|
|
60
|
+
session.parent_answers = value
|
|
61
|
+
@save.call(session)
|
|
62
|
+
capped = Guardrails::ParentPolicy.capped_note(value)
|
|
63
|
+
[[listing(session), ("(#{capped})" if capped)].compact.join("\n"), true, :ok]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# "parent answers: standard (this session: standard; config.yml
|
|
69
|
+
# guardrails.parent_answers: soft)"
|
|
70
|
+
def listing(session)
|
|
71
|
+
config = Guardrails::ParentPolicy.current
|
|
72
|
+
effective = Guardrails::ParentPolicy.for_session(session)
|
|
73
|
+
own = session.parent_answers || "none"
|
|
74
|
+
# A hand-edited value that isn't a level: fail-closed, said here.
|
|
75
|
+
own = "#{own} (unreadable value, read as hard)" if session.parent_answers &&
|
|
76
|
+
!Guardrails::ParentPolicy::ORDER.include?(session.parent_answers)
|
|
77
|
+
"parent answers: #{effective.name} (this session: #{own}; config.yml #{config.label})"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -5,6 +5,7 @@ require "json"
|
|
|
5
5
|
|
|
6
6
|
require_relative "cli/exit"
|
|
7
7
|
require_relative "pending_question"
|
|
8
|
+
require_relative "session"
|
|
8
9
|
require_relative "guardrails/parent_policy"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
@@ -77,18 +78,20 @@ module Samagotchi
|
|
|
77
78
|
# @param extra [Hash] more keys for the object (chi answer's
|
|
78
79
|
# answered_here: false)
|
|
79
80
|
# @return [Hash] the --format json object
|
|
80
|
-
|
|
81
|
+
# @param strategy [Guardrails::ParentPolicy::Strategy, nil] what a parent
|
|
82
|
+
# may pick on the waited-for session (#strategy_for); nil: config.yml's
|
|
83
|
+
def json(result, session_id:, timeout: nil, extra: {}, strategy: nil)
|
|
81
84
|
report = { status: status(result), session_id: session_id }
|
|
82
85
|
report[:turn_id] = result.turn_id unless result.turn_id.to_s.empty?
|
|
83
86
|
case report[:status]
|
|
84
87
|
when "answered" then report[:text] = result.text.to_s
|
|
85
88
|
when "question"
|
|
86
|
-
question = question(result.question)
|
|
89
|
+
question = question(result.question, strategy: strategy)
|
|
87
90
|
report[:question] = question
|
|
88
91
|
report[:answer_with] = answer_with(session_id, question)
|
|
89
92
|
else
|
|
90
93
|
report.merge!(stop_facts(result))
|
|
91
|
-
report[:detail] = detail(result, session_id: session_id, timeout: timeout)
|
|
94
|
+
report[:detail] = detail(result, session_id: session_id, timeout: timeout, strategy: strategy)
|
|
92
95
|
end
|
|
93
96
|
report.merge(extra)
|
|
94
97
|
end
|
|
@@ -106,8 +109,8 @@ module Samagotchi
|
|
|
106
109
|
end
|
|
107
110
|
|
|
108
111
|
# @return [String] the JSON object as one line
|
|
109
|
-
def json_line(result, session_id:, timeout: nil, extra: {})
|
|
110
|
-
JSON.generate(json(result, session_id: session_id, timeout: timeout, extra: extra))
|
|
112
|
+
def json_line(result, session_id:, timeout: nil, extra: {}, strategy: nil)
|
|
113
|
+
JSON.generate(json(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy))
|
|
111
114
|
end
|
|
112
115
|
|
|
113
116
|
# Print a wait's end the way the commands do: the JSON object on
|
|
@@ -116,17 +119,17 @@ module Samagotchi
|
|
|
116
119
|
# @param command [String] "chi send", "chi answer"
|
|
117
120
|
# @param extra [Hash] more keys for the JSON object (#json)
|
|
118
121
|
# @return [Integer] the exit status
|
|
119
|
-
def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil, extra: {})
|
|
122
|
+
def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil, extra: {}, strategy: nil)
|
|
120
123
|
if json
|
|
121
|
-
stdout.puts(json_line(result, session_id: session_id, timeout: timeout, extra: extra))
|
|
124
|
+
stdout.puts(json_line(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy))
|
|
122
125
|
elsif result.status == :done
|
|
123
126
|
stdout.puts(result.text)
|
|
124
127
|
else
|
|
125
128
|
stdout.flush
|
|
126
129
|
text = if result.status == :waiting_for_answer
|
|
127
|
-
question_text(result.question, session_id: session_id)
|
|
130
|
+
question_text(result.question, session_id: session_id, strategy: strategy)
|
|
128
131
|
else
|
|
129
|
-
"#{detail(result, session_id: session_id, timeout: timeout, turn_id: result.turn_id)}\n"
|
|
132
|
+
"#{detail(result, session_id: session_id, timeout: timeout, turn_id: result.turn_id, strategy: strategy)}\n"
|
|
130
133
|
end
|
|
131
134
|
stderr.print("#{command}: #{text}")
|
|
132
135
|
end
|
|
@@ -146,14 +149,14 @@ module Samagotchi
|
|
|
146
149
|
# kind is "question" for the model's own (the desk stores none),
|
|
147
150
|
# "hook", "approval" or "continue" (the step-limit question, with the
|
|
148
151
|
# turn's limit). allowed_options: the options a parent may pick, by
|
|
149
|
-
# this process's guardrails.parent_answers
|
|
150
|
-
# when that is not all of them.
|
|
152
|
+
# +strategy+ (nil: this process's guardrails.parent_answers;
|
|
153
|
+
# Guardrails::ParentPolicy), when that is not all of them.
|
|
151
154
|
# @return [Hash]
|
|
152
|
-
def question(pending)
|
|
155
|
+
def question(pending, strategy: nil)
|
|
153
156
|
pending = PendingQuestion.from_file(pending) || PendingQuestion.new
|
|
154
157
|
approval = pending.approval.is_a?(Hash) ? symbolize(pending.approval) : nil
|
|
155
158
|
options = Array(pending.options).map(&:to_s)
|
|
156
|
-
allowed = allowed_options(pending)
|
|
159
|
+
allowed = allowed_options(pending, strategy: strategy)
|
|
157
160
|
{
|
|
158
161
|
id: pending.id.to_s,
|
|
159
162
|
kind: (pending.kind || "question").to_s,
|
|
@@ -189,33 +192,49 @@ module Samagotchi
|
|
|
189
192
|
DENY_AND_TELL = "deny it, and tell your user"
|
|
190
193
|
|
|
191
194
|
# The labels a parent may pick on +pending+ (a PendingQuestion, or a
|
|
192
|
-
# Hash with its fields) by this process's
|
|
195
|
+
# Hash with its fields) by +strategy+ (nil: this process's).
|
|
193
196
|
# @return [Array<String>]
|
|
194
|
-
def allowed_options(pending)
|
|
197
|
+
def allowed_options(pending, strategy: nil)
|
|
195
198
|
pending = PendingQuestion.from_file(pending) || PendingQuestion.new
|
|
196
|
-
Guardrails::ParentPolicy.allowed_options(pending, strategy:
|
|
199
|
+
Guardrails::ParentPolicy.allowed_options(pending, strategy: policy(strategy))
|
|
197
200
|
end
|
|
198
201
|
|
|
202
|
+
# What a parent may pick on the session +session_id+: config.yml's
|
|
203
|
+
# level tightened by the session file's own parent_answers
|
|
204
|
+
# (Guardrails::ParentPolicy.for_session); config.yml's when there is no
|
|
205
|
+
# such session, hard (the strictest) when its file can't be read.
|
|
206
|
+
# @return [Guardrails::ParentPolicy::Strategy]
|
|
207
|
+
def strategy_for(session_id, state_dir:)
|
|
208
|
+
return Guardrails::ParentPolicy.current unless Session.exist?(session_id, state_dir: state_dir)
|
|
209
|
+
|
|
210
|
+
Guardrails::ParentPolicy.for_session(Session.load(session_id, state_dir: state_dir))
|
|
211
|
+
rescue ArgumentError, KeyError, TypeError, NoMethodError, SystemCallError, JSON::ParserError
|
|
212
|
+
Guardrails::ParentPolicy.strategy(Guardrails::ParentPolicy::HARD)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# +strategy+, or this process's config.yml's.
|
|
216
|
+
def policy(strategy) = strategy || Guardrails::ParentPolicy.current
|
|
217
|
+
|
|
199
218
|
# An approval's options a parent may allow (Allow once under soft).
|
|
200
|
-
def allowed_allows(question)
|
|
201
|
-
question = question(question) unless question.is_a?(Hash) && question.key?(:text)
|
|
219
|
+
def allowed_allows(question, strategy: nil)
|
|
220
|
+
question = question(question, strategy: strategy) unless question.is_a?(Hash) && question.key?(:text)
|
|
202
221
|
deny = Guardrails::Approval::DENY
|
|
203
222
|
question.fetch(:allowed_options, question[:options]).reject { |label| label == deny }
|
|
204
223
|
end
|
|
205
224
|
|
|
206
225
|
# What a parent does with an approval: deny it and tell its user, or
|
|
207
226
|
# give the Allow once its strategy lets it.
|
|
208
|
-
def approval_advice(question)
|
|
209
|
-
allows = allowed_allows(question)
|
|
227
|
+
def approval_advice(question, strategy: nil)
|
|
228
|
+
allows = allowed_allows(question, strategy: strategy)
|
|
210
229
|
return DENY_AND_TELL if allows.empty?
|
|
211
230
|
|
|
212
|
-
"#{allows.join(" or ")} is yours to give (#{
|
|
231
|
+
"#{allows.join(" or ")} is yours to give (#{policy(strategy).label}); " \
|
|
213
232
|
"anything wider is up to your user: #{DENY_AND_TELL}"
|
|
214
233
|
end
|
|
215
234
|
|
|
216
235
|
# Whether a parent may answer Continue to a step-limit question.
|
|
217
|
-
def may_continue?(question)
|
|
218
|
-
question = question(question) unless question.is_a?(Hash) && question.key?(:text)
|
|
236
|
+
def may_continue?(question, strategy: nil)
|
|
237
|
+
question = question(question, strategy: strategy) unless question.is_a?(Hash) && question.key?(:text)
|
|
219
238
|
question.fetch(:allowed_options, question[:options]).include?(Guardrails::ParentPolicy::CONTINUE)
|
|
220
239
|
end
|
|
221
240
|
|
|
@@ -252,23 +271,23 @@ module Samagotchi
|
|
|
252
271
|
end
|
|
253
272
|
|
|
254
273
|
# What a wait that ended without an answer says, one line.
|
|
255
|
-
def detail(result, session_id:, timeout: nil, turn_id: nil)
|
|
274
|
+
def detail(result, session_id:, timeout: nil, turn_id: nil, strategy: nil)
|
|
256
275
|
attach = "chi --attach #{session_id}"
|
|
257
276
|
case result.status
|
|
258
277
|
when :waiting_for_answer
|
|
259
|
-
q = question(result.question)
|
|
278
|
+
q = question(result.question, strategy: strategy)
|
|
260
279
|
if approval?(q)
|
|
261
|
-
"waiting for an approval: #{first_line(q[:text])}; #{approval_advice(q)}"
|
|
280
|
+
"waiting for an approval: #{first_line(q[:text])}; #{approval_advice(q, strategy: strategy)}"
|
|
262
281
|
elsif continue?(q) && !may_continue?(q)
|
|
263
282
|
"waiting at the step limit: #{first_line(q[:text])}; a parent may only stop it here " \
|
|
264
|
-
"(#{
|
|
283
|
+
"(#{policy(strategy).label}): #{stop_command(session_id, q)}, or tell your user it waits"
|
|
265
284
|
elsif continue?(q)
|
|
266
285
|
"waiting at the step limit: #{first_line(q[:text])}; " \
|
|
267
286
|
"answer Continue or Stop: chi answer #{session_id} --question #{q[:id]} --option Continue"
|
|
268
287
|
else
|
|
269
288
|
"waiting for an answer: #{first_line(q[:text])}; open it: #{attach} or the web"
|
|
270
289
|
end
|
|
271
|
-
when :no_reply then "#{no_reply_line(result, session_id)}; #{attach} shows it"
|
|
290
|
+
when :no_reply then "#{no_reply_line(result, session_id, strategy: strategy)}; #{attach} shows it"
|
|
272
291
|
when :error then "the worker failed: #{result.text}; #{attach} shows what happened"
|
|
273
292
|
when :worker_gone then "the worker is gone; #{attach} shows what happened"
|
|
274
293
|
when :stopped then "the session was stopped (chi sessions stop)"
|
|
@@ -282,8 +301,8 @@ module Samagotchi
|
|
|
282
301
|
# The whole question for stderr: header, text, numbered options, what
|
|
283
302
|
# else it takes, and the commands that answer it.
|
|
284
303
|
# @return [String] lines, newline-terminated
|
|
285
|
-
def question_text(pending, session_id:)
|
|
286
|
-
q = question(pending)
|
|
304
|
+
def question_text(pending, session_id:, strategy: nil)
|
|
305
|
+
q = question(pending, strategy: strategy)
|
|
287
306
|
lines = ["waiting for an answer (#{q[:kind]}): #{q[:header] || first_line(q[:text])}"]
|
|
288
307
|
text_lines = q[:text].strip.lines.map(&:rstrip)
|
|
289
308
|
text_lines = text_lines.drop(1) unless q[:header]
|
|
@@ -294,7 +313,7 @@ module Samagotchi
|
|
|
294
313
|
if allows.empty?
|
|
295
314
|
lines << " allowing it is up to your user: #{DENY_AND_TELL}"
|
|
296
315
|
else
|
|
297
|
-
lines << " #{approval_advice(q)}"
|
|
316
|
+
lines << " #{approval_advice(q, strategy: strategy)}"
|
|
298
317
|
allows.each { |label| lines << " allow: chi answer #{session_id} --question #{q[:id]} --option #{option_arg(label)}" }
|
|
299
318
|
end
|
|
300
319
|
lines << " deny: #{answer_with(session_id, q)}"
|
|
@@ -303,7 +322,7 @@ module Samagotchi
|
|
|
303
322
|
lines << if may_continue?(q)
|
|
304
323
|
" continue: #{answer_with(session_id, q)}"
|
|
305
324
|
else
|
|
306
|
-
" continuing it is up to your user (#{
|
|
325
|
+
" continuing it is up to your user (#{policy(strategy).label}): stop it, or tell your user it waits"
|
|
307
326
|
end
|
|
308
327
|
lines << " stop: #{stop_command(session_id, q)} (the text is optional; the model reads it)"
|
|
309
328
|
lines << " #{message_drops(session_id)}"
|
|
@@ -319,7 +338,7 @@ module Samagotchi
|
|
|
319
338
|
lines.map { |line| "#{line}\n" }.join
|
|
320
339
|
end
|
|
321
340
|
|
|
322
|
-
def no_reply_line(result, session_id = nil)
|
|
341
|
+
def no_reply_line(result, session_id = nil, strategy: nil)
|
|
323
342
|
case result.outcome
|
|
324
343
|
when "failed" then failed_line(result)
|
|
325
344
|
when "canceled"
|
|
@@ -330,7 +349,7 @@ module Samagotchi
|
|
|
330
349
|
when "removed" then "the queued message was removed before it ran; no turn ran for it"
|
|
331
350
|
when "exhausted"
|
|
332
351
|
ran_out = "the turn ran out of iterations#{" (#{result.limit} steps)" if result.limit} before it answered"
|
|
333
|
-
strategy =
|
|
352
|
+
strategy = policy(strategy)
|
|
334
353
|
if strategy.allow?(Guardrails::ParentPolicy::SPEND)
|
|
335
354
|
"#{ran_out}; chi send #{session_id} -m '/continue yes' continues it, a message drops it"
|
|
336
355
|
else
|
|
@@ -305,8 +305,9 @@ module Samagotchi
|
|
|
305
305
|
fm = freeform.to_s.strip
|
|
306
306
|
fm = nil if fm.empty?
|
|
307
307
|
parent_agent = client_id.to_s == ClientId::CLI_ANSWER if parent_agent.nil?
|
|
308
|
-
# Read before the lock (config may touch the disk); this worker's own
|
|
309
|
-
|
|
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
|
|
310
311
|
standing = nil
|
|
311
312
|
answer = @lock.synchronize do
|
|
312
313
|
pending = @pending
|
|
@@ -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)
|