samagotchi 0.12.0 → 0.13.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 +62 -1
- data/README.md +20 -7
- data/bin/chi +5 -2
- data/docs/architecture.md +4 -2
- data/docs/cli.md +34 -10
- data/docs/configuration.md +29 -8
- data/docs/desktop.md +10 -6
- data/docs/guardrails.md +73 -7
- data/docs/hooks.md +13 -9
- data/docs/memory.md +5 -3
- data/docs/plugins.md +4 -4
- data/docs/sessions.md +9 -6
- data/docs/sub-agent.md +128 -0
- data/lib/samagotchi/answer_command.rb +220 -0
- data/lib/samagotchi/bridge.rb +11 -1
- data/lib/samagotchi/bridge_client.rb +8 -3
- data/lib/samagotchi/bundle_command.rb +18 -10
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/exit.rb +23 -0
- data/lib/samagotchi/cli/parent_wait.rb +82 -0
- data/lib/samagotchi/config.rb +50 -18
- data/lib/samagotchi/desktop_command.rb +5 -3
- data/lib/samagotchi/engine.rb +40 -14
- data/lib/samagotchi/guardrails/parent_approvals.rb +155 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
- data/lib/samagotchi/host_registry.rb +0 -1
- data/lib/samagotchi/llm/openai_chat.rb +9 -1
- data/lib/samagotchi/memory_bundle/installer.rb +12 -0
- data/lib/samagotchi/memory_bundle/status.rb +9 -1
- data/lib/samagotchi/memory_paths.rb +8 -0
- data/lib/samagotchi/model_profile.rb +30 -16
- data/lib/samagotchi/parent_report.rb +213 -0
- data/lib/samagotchi/plugin/api.rb +15 -2
- data/lib/samagotchi/prompt_literal_guard.rb +4 -2
- data/lib/samagotchi/question_desk.rb +29 -2
- data/lib/samagotchi/recap_setup.rb +15 -5
- data/lib/samagotchi/reply_wait.rb +5 -1
- data/lib/samagotchi/send_command.rb +50 -75
- data/lib/samagotchi/session.rb +15 -0
- data/lib/samagotchi/session_manager.rb +34 -7
- data/lib/samagotchi/sessions_command.rb +13 -3
- data/lib/samagotchi/system_prompt.rb +10 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -5
- data/lib/samagotchi/terminal_ui.rb +15 -1
- data/lib/samagotchi/thought_stream_splitter.rb +4 -4
- data/lib/samagotchi/tool_call_parser.rb +5 -6
- data/lib/samagotchi/tools/builtin_calls.rb +14 -3
- data/lib/samagotchi/tools/builtins.rb +14 -2
- data/lib/samagotchi/tools/delegate_wait.rb +16 -4
- data/lib/samagotchi/tools/execute.rb +8 -8
- data/lib/samagotchi/tools/task_create.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +7 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +4 -3
- data/lib/samagotchi/web/message_parts.rb +2 -1
- data/lib/samagotchi/web/server.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +3 -11
- metadata +7 -1
|
@@ -7,6 +7,7 @@ require "time"
|
|
|
7
7
|
|
|
8
8
|
require_relative "log"
|
|
9
9
|
require_relative "tools/ask_user_question"
|
|
10
|
+
require_relative "guardrails/parent_approvals"
|
|
10
11
|
|
|
11
12
|
module Samagotchi
|
|
12
13
|
# An Engine's question flow: the one open question (ask_user_question, a
|
|
@@ -21,6 +22,20 @@ module Samagotchi
|
|
|
21
22
|
# of ArgumentError for existing callers; transports map it to 409 Conflict.
|
|
22
23
|
class NotPending < ArgumentError; end
|
|
23
24
|
|
|
25
|
+
# Raised by #answer when a parent agent's answer (chi answer's client
|
|
26
|
+
# id) allows an approval further than this worker's
|
|
27
|
+
# guardrails.parent_approvals lets it (Guardrails::ParentApprovals).
|
|
28
|
+
# The question stays open; transports map it to 403.
|
|
29
|
+
class Refused < StandardError
|
|
30
|
+
# @return [Symbol] :off or :once_only
|
|
31
|
+
attr_reader :reason
|
|
32
|
+
|
|
33
|
+
def initialize(reason)
|
|
34
|
+
@reason = reason
|
|
35
|
+
super("a parent may not allow this approval (#{reason})")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
24
39
|
DISMISSED_NOTE = "The user dismissed the question without answering. Don't do what you asked about, " \
|
|
25
40
|
"or anything else that changes files or state. Finish your reply with what you found " \
|
|
26
41
|
"and what you would do, and wait."
|
|
@@ -170,11 +185,16 @@ module Samagotchi
|
|
|
170
185
|
# @param id [String] pending id
|
|
171
186
|
# @param selected [Array<String>] values/labels
|
|
172
187
|
# @param freeform [String, nil]
|
|
188
|
+
# @param client_id [String, nil] who answers; chi answer's (a parent
|
|
189
|
+
# agent) is held to guardrails.parent_approvals on an approval
|
|
173
190
|
# @return [Hash] normalized answer
|
|
174
|
-
|
|
191
|
+
# @raise [NotPending, ArgumentError, Refused]
|
|
192
|
+
def answer(id:, selected:, freeform: nil, client_id: nil)
|
|
175
193
|
sel = Array(selected).map { |v| v.to_s.strip }.reject(&:empty?)
|
|
176
194
|
fm = freeform.to_s.strip
|
|
177
195
|
fm = nil if fm.empty?
|
|
196
|
+
# Read before the lock (config may touch the disk); this worker's own.
|
|
197
|
+
parent_setting = Guardrails::ParentApprovals.setting if client_id.to_s == Guardrails::ParentApprovals::CLIENT_ID
|
|
178
198
|
@lock.synchronize do
|
|
179
199
|
pending = @pending
|
|
180
200
|
raise NotPending, "no pending question" unless pending
|
|
@@ -197,10 +217,17 @@ module Samagotchi
|
|
|
197
217
|
if pending[:multi_select] == false && sel.empty? && fm.nil?
|
|
198
218
|
raise ArgumentError, "selection required"
|
|
199
219
|
end
|
|
220
|
+
indices = sel.map { |v| opts.index(v) }
|
|
221
|
+
# Checked against the question pending now, under the lock: the one
|
|
222
|
+
# whose answer settles the approval (Approval.settle, by index).
|
|
223
|
+
if parent_setting
|
|
224
|
+
reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: parent_setting)
|
|
225
|
+
raise Refused, reason if reason
|
|
226
|
+
end
|
|
200
227
|
# Persist pending cleared elsewhere; just set answer
|
|
201
228
|
answer = { id: id.to_s, selected: sel, freeform: fm }
|
|
202
229
|
# Derive indices for convenience
|
|
203
|
-
answer[:selected_indices] =
|
|
230
|
+
answer[:selected_indices] = indices.compact
|
|
204
231
|
@answer = answer
|
|
205
232
|
@cv.broadcast
|
|
206
233
|
answer
|
|
@@ -13,7 +13,8 @@ module Samagotchi
|
|
|
13
13
|
# host or model configured it asks the session's current model on its
|
|
14
14
|
# host, resolved at each attempt the way a turn does (a /model switch
|
|
15
15
|
# counts). An explicit `recap: {host_ref:, model:}` or `{base_url:,
|
|
16
|
-
# model:}` pins it;
|
|
16
|
+
# model:}` pins it; a model alone goes to its host, or where a bare
|
|
17
|
+
# --model goes; an incomplete one warns and leaves recap off.
|
|
17
18
|
#
|
|
18
19
|
# Single precedence path: explicit `recap:` kwarg > Config registry
|
|
19
20
|
# (CLI > ENV > file > default). An explicit disable (`recap: false` as
|
|
@@ -42,14 +43,23 @@ module Samagotchi
|
|
|
42
43
|
model = string_config(kwarg_config, :model) || registry_string("recap.model")
|
|
43
44
|
label = model
|
|
44
45
|
|
|
46
|
+
# The model as any model ref (ModelRef): its alias applied once, and
|
|
47
|
+
# its host (own or the alias's) picks the recap host when host_ref
|
|
48
|
+
# doesn't.
|
|
49
|
+
parsed = model && host_registry.model_ref(model)
|
|
45
50
|
target = nil
|
|
46
51
|
if base_url.nil? && host_ref.nil? && model.nil?
|
|
47
52
|
target = -> { session_target.call }
|
|
53
|
+
elsif base_url.nil? && host_ref.nil? && !parsed.host_name
|
|
54
|
+
# A model naming no host goes where a bare --model goes
|
|
55
|
+
# (HostRegistry#host_for_model: the default host unless another's
|
|
56
|
+
# list has it), resolved at each attempt.
|
|
57
|
+
target = lambda do
|
|
58
|
+
resolved = host_registry.resolve(model)
|
|
59
|
+
{ base_url: resolved.openai_base_url, api_key_env: resolved.entry.api_key_env,
|
|
60
|
+
model: resolved.bare_model, label: label.to_s.strip }
|
|
61
|
+
end
|
|
48
62
|
else
|
|
49
|
-
# The model as any model ref (ModelRef): its alias applied once, and
|
|
50
|
-
# its host (own or the alias's) picks the recap host when host_ref
|
|
51
|
-
# doesn't.
|
|
52
|
-
parsed = model && host_registry.model_ref(model)
|
|
53
63
|
host_ref ||= parsed.host_name if parsed && base_url.nil?
|
|
54
64
|
# If host_ref given, derive base_url (the host's OpenAI base) and its
|
|
55
65
|
# API key variable from the host_registry entry
|
|
@@ -72,8 +72,12 @@ module Samagotchi
|
|
|
72
72
|
return Result.new(status: :stopped)
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
+
# Only a question a live worker holds waits for an answer (the
|
|
76
|
+
# lists' rule): one a dead worker left waits for no one, and a chi
|
|
77
|
+
# REPL's is answered there.
|
|
75
78
|
pending = session.pending_question
|
|
76
|
-
if pending && !(baseline && pending[:id] == baseline[:question_id])
|
|
79
|
+
if pending && !(baseline && pending[:id] == baseline[:question_id]) &&
|
|
80
|
+
session.waiting_question(live: SessionManager.worker_live?(id, state_dir: state_dir))
|
|
77
81
|
return Result.new(status: :waiting_for_answer, question: pending)
|
|
78
82
|
end
|
|
79
83
|
|
|
@@ -5,12 +5,14 @@ require_relative "session_inbox"
|
|
|
5
5
|
require_relative "session_manager"
|
|
6
6
|
require_relative "context_quote"
|
|
7
7
|
require_relative "reply_wait"
|
|
8
|
+
require_relative "parent_report"
|
|
8
9
|
require_relative "image_store"
|
|
9
10
|
require_relative "host_registry"
|
|
10
11
|
require_relative "model_profile"
|
|
11
12
|
require_relative "vision_support"
|
|
12
13
|
require_relative "cli/command"
|
|
13
14
|
require_relative "cli/flags"
|
|
15
|
+
require_relative "cli/parent_wait"
|
|
14
16
|
|
|
15
17
|
module Samagotchi
|
|
16
18
|
# `chi send`: put text into sessions as the user's message, the same as
|
|
@@ -22,20 +24,17 @@ module Samagotchi
|
|
|
22
24
|
# prints it: an agent's one-shot the user can watch in the web.
|
|
23
25
|
class SendCommand
|
|
24
26
|
include CLI::Command
|
|
27
|
+
include CLI::ParentWait
|
|
25
28
|
|
|
26
29
|
CLIENT_ID = "cli:send"
|
|
27
|
-
POLL_INTERVAL = ReplyWait::POLL_INTERVAL
|
|
28
|
-
# No live worker this long while waiting: it died before it could mark
|
|
29
|
-
# the session (a worker takes well under a second to start).
|
|
30
|
-
WORKER_GONE_AFTER = 5
|
|
31
30
|
# The cap on one turn's images the Bridge checks.
|
|
32
31
|
MAX_IMAGES = ImageStore::MAX_TURN_REFS
|
|
33
32
|
|
|
34
33
|
USAGE = <<~TEXT
|
|
35
34
|
Usage: chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...
|
|
36
35
|
chi send --new [--dir DIR] [--model M] [-m TEXT] [--image PATH]...
|
|
37
|
-
chi send --wait [--timeout S] [-m TEXT] [--image PATH]... (--new | ID)
|
|
38
|
-
chi send --wait [--timeout S] ID
|
|
36
|
+
chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
|
|
37
|
+
chi send --wait [--timeout S] [--format json] ID
|
|
39
38
|
Sends a message to each session, as if typed in it: a turn starts,
|
|
40
39
|
or a running one picks it up. A stopped session's worker starts.
|
|
41
40
|
-m TEXT the message; stdin, when piped too, goes above it as a
|
|
@@ -53,11 +52,21 @@ module Samagotchi
|
|
|
53
52
|
--model M (--new) its model; default the configured one
|
|
54
53
|
--wait wait for the answer and print it (one session); the
|
|
55
54
|
other lines go to stderr. Exit 3: it waits for an
|
|
56
|
-
answer from you
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
answer from you: the question, its options and the
|
|
56
|
+
chi answer command go to stderr (or chi --attach ID,
|
|
57
|
+
the web). With no message (no -m, nothing piped)
|
|
58
|
+
nothing is sent: it waits for the session's next
|
|
59
|
+
reply, a running turn's too (after exit 3, 4 or 130,
|
|
60
|
+
wait again this way)
|
|
61
|
+
--timeout S (--wait) give up after S seconds, exit 4 (the turn
|
|
62
|
+
goes on); default no limit
|
|
63
|
+
--format json
|
|
64
|
+
(--wait) one JSON object on stdout, whatever the end:
|
|
65
|
+
status answered (text), question (question,
|
|
66
|
+
answer_with), running, or failed, canceled,
|
|
67
|
+
no_answer, error, worker_gone, stopped (detail)
|
|
68
|
+
Exit with --wait: 0 answered, 1 failed or gone, 2 usage, 3 a
|
|
69
|
+
question waits, 4 still running (--timeout), 130 Ctrl-C.
|
|
61
70
|
Only sessions on this machine. Answers show in the attached TUI
|
|
62
71
|
or web page, not here.
|
|
63
72
|
Find ids with: chi sessions list --live [--scope=all] [--format tsv]
|
|
@@ -71,6 +80,7 @@ module Samagotchi
|
|
|
71
80
|
f.value "--image", key: :images, repeat: true
|
|
72
81
|
f.switch "--wait"
|
|
73
82
|
f.value "--timeout"
|
|
83
|
+
f.value "--format"
|
|
74
84
|
# Starting a turn in every live session at once is too easy to do
|
|
75
85
|
# by accident.
|
|
76
86
|
f.refuse "--all", "there is no --all: name the sessions"
|
|
@@ -119,13 +129,13 @@ module Samagotchi
|
|
|
119
129
|
@state_dir = state_dir || Session.default_state_dir
|
|
120
130
|
end
|
|
121
131
|
|
|
122
|
-
|
|
123
|
-
# 2 usage; with --wait 0 answered, 3 waiting for an answer, 130
|
|
124
|
-
# Ctrl-C (the turn goes on)
|
|
125
|
-
def run
|
|
126
|
-
options = parse
|
|
127
|
-
return options if options.is_a?(Integer)
|
|
132
|
+
private
|
|
128
133
|
|
|
134
|
+
# Under #run (CLI::ParentWait).
|
|
135
|
+
# @return [Integer] exit status: 0 all sent, 1 any refused or failed,
|
|
136
|
+
# 2 usage; with --wait 0 answered, 3 waiting for an answer, 4 still
|
|
137
|
+
# running after --timeout, 130 Ctrl-C (the turn goes on)
|
|
138
|
+
def run_parsed(options)
|
|
129
139
|
# With --wait stdout is the answer alone.
|
|
130
140
|
@info = options[:wait] ? @stderr : @stdout
|
|
131
141
|
prompt = compose(utf8(read_stdin), utf8(options[:message]))
|
|
@@ -140,7 +150,9 @@ module Samagotchi
|
|
|
140
150
|
@stderr.puts("chi send: #{e.message}")
|
|
141
151
|
return 1
|
|
142
152
|
end
|
|
143
|
-
|
|
153
|
+
# A file that is not there or not an image: a run-time failure (1),
|
|
154
|
+
# reported in the --format json line too; --image itself was fine.
|
|
155
|
+
return CLI::Exit::FAILED unless ingest_images(options[:images])
|
|
144
156
|
|
|
145
157
|
return run_new(prompt, options) if options[:new]
|
|
146
158
|
return run_wait(prompt, options) if options[:wait]
|
|
@@ -160,8 +172,6 @@ module Samagotchi
|
|
|
160
172
|
FileUtils.rm_rf(@image_dir) if @image_dir
|
|
161
173
|
end
|
|
162
174
|
|
|
163
|
-
private
|
|
164
|
-
|
|
165
175
|
def command_name = "chi send"
|
|
166
176
|
|
|
167
177
|
# @return [Hash, Integer] the options, or the exit status after the
|
|
@@ -171,6 +181,10 @@ module Samagotchi
|
|
|
171
181
|
return parsed if parsed.is_a?(Integer)
|
|
172
182
|
|
|
173
183
|
options = parsed.options.merge(ids: parsed.args)
|
|
184
|
+
if options[:format]
|
|
185
|
+
return usage_error("--format needs --wait") unless options[:wait]
|
|
186
|
+
return usage_error("--format takes text or json") unless FORMATS.include?(options[:format])
|
|
187
|
+
end
|
|
174
188
|
if options[:timeout]
|
|
175
189
|
return usage_error("--timeout needs --wait") unless options[:wait]
|
|
176
190
|
|
|
@@ -220,16 +234,6 @@ module Samagotchi
|
|
|
220
234
|
@stdin.read
|
|
221
235
|
end
|
|
222
236
|
|
|
223
|
-
def resolve(given)
|
|
224
|
-
id = Session.resolve_id(given, state_dir: @state_dir)
|
|
225
|
-
Session.load(id, state_dir: @state_dir)
|
|
226
|
-
id
|
|
227
|
-
rescue ArgumentError => e
|
|
228
|
-
message = e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"
|
|
229
|
-
error_line("chi send: #{message}")
|
|
230
|
-
nil
|
|
231
|
-
end
|
|
232
|
-
|
|
233
237
|
# A worker session like the web start page's: saved as running with the
|
|
234
238
|
# message before its worker spawns, so lists and the web show it at
|
|
235
239
|
# once. The full id, so a script can pass it on. Only the model's host
|
|
@@ -257,6 +261,7 @@ module Samagotchi
|
|
|
257
261
|
else
|
|
258
262
|
@info.puts("#{session.id} started")
|
|
259
263
|
end
|
|
264
|
+
@session_id = session.id
|
|
260
265
|
return 0 unless options[:wait]
|
|
261
266
|
|
|
262
267
|
wait_for_reply(session.id, cursor: nil, baseline: baseline_of(session, question_id: nil),
|
|
@@ -272,8 +277,20 @@ module Samagotchi
|
|
|
272
277
|
def run_wait(prompt, options)
|
|
273
278
|
id = resolve(options[:ids].first) or return 1
|
|
274
279
|
session = Session.load(id, state_dir: @state_dir)
|
|
275
|
-
if session.status == Session::STATUS_RUNNING && SessionManager.session_owner(id, state_dir: @state_dir)
|
|
276
|
-
|
|
280
|
+
if session.status == Session::STATUS_RUNNING && (owner = SessionManager.session_owner(id, state_dir: @state_dir))
|
|
281
|
+
# The turn waits on a question: the message would only queue
|
|
282
|
+
# behind it, and the answer goes in with chi answer.
|
|
283
|
+
if (waiting = session.waiting_question(live: owner.worker?))
|
|
284
|
+
if ParentReport.approval?(waiting)
|
|
285
|
+
error_line("#{id[0, 8]} waiting for an approval: #{ParentReport::DENY_AND_TELL} " \
|
|
286
|
+
"(#{ParentReport.answer_with(id, waiting)})")
|
|
287
|
+
else
|
|
288
|
+
error_line("#{id[0, 8]} waiting for an answer: #{ParentReport.answer_with(id, waiting)} (or --text); " \
|
|
289
|
+
"chi --attach #{id} shows it")
|
|
290
|
+
end
|
|
291
|
+
else
|
|
292
|
+
error_line("#{id[0, 8]} busy: a turn is running; wait or attach")
|
|
293
|
+
end
|
|
277
294
|
return 1
|
|
278
295
|
end
|
|
279
296
|
|
|
@@ -298,7 +315,7 @@ module Samagotchi
|
|
|
298
315
|
live = session.status == Session::STATUS_RUNNING || SessionManager.session_owner(id, state_dir: @state_dir)
|
|
299
316
|
wait_for_reply(id, cursor: ReplyWait.newest_reply(id, state_dir: @state_dir),
|
|
300
317
|
baseline: baseline_of(session).merge(messages: nil),
|
|
301
|
-
timeout: options[:timeout], owner_grace: live ? WORKER_GONE_AFTER : nil)
|
|
318
|
+
timeout: options[:timeout], owner_grace: live ? self.class::WORKER_GONE_AFTER : nil)
|
|
302
319
|
end
|
|
303
320
|
|
|
304
321
|
# The session as it was before the message went in (ReplyWait's
|
|
@@ -307,48 +324,6 @@ module Samagotchi
|
|
|
307
324
|
ReplyWait.baseline_of(session, **options)
|
|
308
325
|
end
|
|
309
326
|
|
|
310
|
-
# @return [Integer] the exit status
|
|
311
|
-
def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: WORKER_GONE_AFTER)
|
|
312
|
-
result = ReplyWait.call(id, state_dir: @state_dir, cursor: cursor, timeout: timeout, baseline: baseline,
|
|
313
|
-
owner_grace: owner_grace, poll_interval: POLL_INTERVAL)
|
|
314
|
-
return reply(result.text) if result.status == :done
|
|
315
|
-
|
|
316
|
-
attach = "chi --attach #{id}"
|
|
317
|
-
line, status = case result.status
|
|
318
|
-
when :waiting_for_answer
|
|
319
|
-
question = result.question[:question].to_s.strip.lines.first.to_s.strip
|
|
320
|
-
["waiting for an answer: #{question}; open it: #{attach} or the web", 3]
|
|
321
|
-
when :no_reply then ["#{no_reply_line(result)}; #{attach} shows it", 1]
|
|
322
|
-
when :error then ["the worker failed: #{result.text}; #{attach} shows what happened", 1]
|
|
323
|
-
when :worker_gone then ["the worker is gone; #{attach} shows what happened", 1]
|
|
324
|
-
when :stopped then ["the session was stopped (chi sessions stop)", 1]
|
|
325
|
-
else ["still running after #{format("%g", timeout)} s: #{attach}", 1]
|
|
326
|
-
end
|
|
327
|
-
error_line("chi send: #{line}")
|
|
328
|
-
status
|
|
329
|
-
rescue Interrupt
|
|
330
|
-
error_line("chi send: still running: chi --attach #{id}")
|
|
331
|
-
130
|
|
332
|
-
rescue ArgumentError
|
|
333
|
-
error_line("chi send: the session is gone (deleted while waiting)")
|
|
334
|
-
1
|
|
335
|
-
end
|
|
336
|
-
|
|
337
|
-
def no_reply_line(result)
|
|
338
|
-
case result.outcome
|
|
339
|
-
when "failed" then result.text.to_s.strip.empty? ? "the turn failed" : "the turn failed: #{result.text.strip}"
|
|
340
|
-
when "canceled" then "the turn was canceled"
|
|
341
|
-
when "completed" then "the turn ended with no visible answer"
|
|
342
|
-
else "the turn ended without a reply (canceled, failed or empty)"
|
|
343
|
-
end
|
|
344
|
-
end
|
|
345
|
-
|
|
346
|
-
def reply(text)
|
|
347
|
-
@stdout.puts(utf8(text))
|
|
348
|
-
@stdout.flush
|
|
349
|
-
0
|
|
350
|
-
end
|
|
351
|
-
|
|
352
327
|
# A new idle session's first turn, with the images. Its worker's Bridge
|
|
353
328
|
# first: deliver_turn wakes a worker when none owns the session yet,
|
|
354
329
|
# and the spawned one takes its lock only once it runs.
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -99,6 +99,21 @@ module Samagotchi
|
|
|
99
99
|
@archived = false
|
|
100
100
|
end
|
|
101
101
|
|
|
102
|
+
# The question this session waits on, as the lists show it: {id:, kind:}
|
|
103
|
+
# with kind "question" (the model's), "approval" or "hook"; nil with
|
|
104
|
+
# none. Only while a worker runs it (+live+): a question saved by a
|
|
105
|
+
# worker that died waits for no one (the next worker drops it). The
|
|
106
|
+
# web's summaries and `chi sessions list` both ask this.
|
|
107
|
+
# @param live [Boolean] a worker owns the session now
|
|
108
|
+
# @return [Hash, nil]
|
|
109
|
+
def waiting_question(live:)
|
|
110
|
+
pending = pending_question
|
|
111
|
+
return nil unless live && pending.is_a?(Hash) && pending[:id]
|
|
112
|
+
|
|
113
|
+
kind = pending[:kind].to_s
|
|
114
|
+
{ id: pending[:id], kind: %w[approval hook].include?(kind) ? kind : "question" }
|
|
115
|
+
end
|
|
116
|
+
|
|
102
117
|
# The stored project root, else (a file from before the field) the
|
|
103
118
|
# project of the working directory now: nil when that is in no repo or
|
|
104
119
|
# gone. +cache+ is ProjectScope.root_for's, shared across one listing.
|
|
@@ -162,7 +162,9 @@ module Samagotchi
|
|
|
162
162
|
# child inherits this process's ENV and reads config.yml itself (as it
|
|
163
163
|
# is when the worker starts); opts[:env] adds to it (merged, not
|
|
164
164
|
# replaced) what it can't read there: this `chi`'s CLI settings
|
|
165
|
-
# (Config.cli_env), the hosts and the absolute log file.
|
|
165
|
+
# (Config.cli_env), the hosts and the absolute log file. It unsets
|
|
166
|
+
# SAMAGOTCHI_GUARDRAILS_*. XDG_CONFIG_HOME passes: whoever starts or
|
|
167
|
+
# wakes the worker picks which config.yml it reads (docs/guardrails.md).
|
|
166
168
|
private_class_method def self.spawn_options(session)
|
|
167
169
|
# Own process group: workers outlive `chi web`, and a Ctrl-C in its
|
|
168
170
|
# terminal must not reach them.
|
|
@@ -195,10 +197,17 @@ module Samagotchi
|
|
|
195
197
|
else
|
|
196
198
|
child_env["SAMAGOTCHI_LOG_DISABLE"] = "true"
|
|
197
199
|
end
|
|
200
|
+
# The spawner's environment must not reach the worker's guardrails (a
|
|
201
|
+
# parent agent starting or waking a session): Process.spawn unsets a
|
|
202
|
+
# key mapped to nil. config.yml no longer reads them; this keeps an
|
|
203
|
+
# older or future env-exposed one out as well.
|
|
204
|
+
ENV.each_key { |key| child_env[key] = nil if key.start_with?(GUARDRAILS_ENV_PREFIX) }
|
|
198
205
|
opts[:env] = child_env unless child_env.empty?
|
|
199
206
|
opts
|
|
200
207
|
end
|
|
201
208
|
|
|
209
|
+
GUARDRAILS_ENV_PREFIX = "SAMAGOTCHI_GUARDRAILS_"
|
|
210
|
+
|
|
202
211
|
# List all sessions, reading status from persisted session.json files.
|
|
203
212
|
# +project_root+: only that project's sessions (nil: every session).
|
|
204
213
|
# +include_archived+: archived sessions too (Session#archived says which).
|
|
@@ -226,9 +235,11 @@ module Samagotchi
|
|
|
226
235
|
# @param include_archived [Boolean] archived sessions too
|
|
227
236
|
# @return [Array<Hash>] {id:, short_id:, desc:, preview:, cwd:, project:,
|
|
228
237
|
# updated_at:, status:, live:, busy:, owner:, recap:, parent_id:,
|
|
229
|
-
# parent_short_id:, archived:, scratch:, test_run:}; busy = live with
|
|
238
|
+
# parent_short_id:, archived:, scratch:, test_run:, waiting:, waiting_id:}; busy = live with
|
|
230
239
|
# a turn running, recap = the saved recap's first sentence, project =
|
|
231
|
-
# Session#project_root
|
|
240
|
+
# Session#project_root, waiting = the kind of question it waits on
|
|
241
|
+
# ("question", "approval", "hook"; Session#waiting_question) or nil,
|
|
242
|
+
# waiting_id = that question's id or nil
|
|
232
243
|
def self.session_summaries(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
|
|
233
244
|
project_root: nil, include_archived: false, sort: nil, order: nil)
|
|
234
245
|
sd = state_dir || Session.default_state_dir
|
|
@@ -239,20 +250,27 @@ module Samagotchi
|
|
|
239
250
|
.reject { |s| (!include_tests && s.test_run) || s.id == exclude }
|
|
240
251
|
.select { |s| root.nil? || in_folder?(s.working_directory, root) }
|
|
241
252
|
.filter_map do |s|
|
|
242
|
-
owner = session_owner(s.id, state_dir: sd)
|
|
243
|
-
owned = owner
|
|
253
|
+
owner = session_owner(s.id, state_dir: sd)
|
|
254
|
+
owned = !!owner&.worker?
|
|
244
255
|
next if live && !owned
|
|
245
256
|
|
|
246
257
|
{ id: s.id, short_id: s.id[0, 8], desc: summary_desc(s), preview: summary_preview(s), cwd: s.working_directory,
|
|
247
258
|
project: s.project_root(cache: roots), updated_at: s.updated_at, status: s.status, live: owned, busy: owned && s.status == Session::STATUS_RUNNING,
|
|
248
|
-
owner: owner, recap: RecapStore.preview(Session.session_dir(s.id, state_dir: sd)),
|
|
259
|
+
owner: owner&.kind, recap: RecapStore.preview(Session.session_dir(s.id, state_dir: sd)),
|
|
249
260
|
ctx_pct: SessionMetrics.saved_context_pct(Session.session_dir(s.id, state_dir: sd))&.round(1),
|
|
250
261
|
parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8), archived: s.archived,
|
|
251
|
-
scratch: s.scratch, test_run: s.test_run }
|
|
262
|
+
scratch: s.scratch, test_run: s.test_run }.merge(waiting_fields(s.waiting_question(live: owned)))
|
|
252
263
|
end
|
|
253
264
|
(limit ? summaries.first(limit) : summaries.to_a)
|
|
254
265
|
end
|
|
255
266
|
|
|
267
|
+
# waiting: the question's kind, waiting_id: its id (what chi answer
|
|
268
|
+
# --question takes); both nil with none.
|
|
269
|
+
def self.waiting_fields(waiting)
|
|
270
|
+
{ waiting: waiting&.dig(:kind), waiting_id: waiting&.dig(:id) }
|
|
271
|
+
end
|
|
272
|
+
private_class_method :waiting_fields
|
|
273
|
+
|
|
256
274
|
# The sessions delegated by +parent_id+ (the `delegate` tool), newest
|
|
257
275
|
# first, as .session_summaries rows. A running one (busy) counts against
|
|
258
276
|
# session.max_children. Archived ones too.
|
|
@@ -831,6 +849,15 @@ module Samagotchi
|
|
|
831
849
|
OwnerLock.owner(Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir))
|
|
832
850
|
end
|
|
833
851
|
|
|
852
|
+
# A worker owns the session now: the one rule for whether a saved
|
|
853
|
+
# pending question waits for anyone (Session#waiting_question's live:).
|
|
854
|
+
# A chi REPL's question is its own (nothing else can answer it), and one
|
|
855
|
+
# a dead worker saved waits for no one.
|
|
856
|
+
# @return [Boolean]
|
|
857
|
+
def self.worker_live?(session_id, state_dir: nil)
|
|
858
|
+
!!session_owner(session_id, state_dir: state_dir)&.worker?
|
|
859
|
+
end
|
|
860
|
+
|
|
834
861
|
# The session's owner (#session_owner), unless it is the interactive
|
|
835
862
|
# TUI, which nothing else may resume, stop or delete.
|
|
836
863
|
# @return [OwnerLock::Owner, nil]
|
|
@@ -146,13 +146,20 @@ module Samagotchi
|
|
|
146
146
|
# scratch: a `chi scratch` session (deleted when its REPL ends, a
|
|
147
147
|
# leftover one at the next sweep)
|
|
148
148
|
# ctx_pct: how full the context was after the last turn, or nil
|
|
149
|
-
|
|
149
|
+
# waiting: the kind of question it waits on (question, approval,
|
|
150
|
+
# hook: chi answer or the web answers it), or nil; waiting_id: its
|
|
151
|
+
# id, for chi answer --question
|
|
152
|
+
keys = %i[id short_id desc cwd project updated_at live busy owner recap parent_id archived scratch ctx_pct waiting
|
|
153
|
+
waiting_id]
|
|
150
154
|
@stdout.puts JSON.generate(summaries.map { |summary| summary.slice(*keys) })
|
|
151
155
|
when "tsv"
|
|
152
156
|
summaries.each { |summary| @stdout.puts "#{summary[:id]}\t#{summary[:desc]}" }
|
|
153
157
|
else
|
|
154
158
|
summaries.each do |summary|
|
|
155
|
-
state =
|
|
159
|
+
state = if summary[:waiting] then "waiting"
|
|
160
|
+
elsif summary[:busy] then "running"
|
|
161
|
+
else summary[:live] ? "live" : summary[:status].to_s
|
|
162
|
+
end
|
|
156
163
|
# The saved recap's first sentence says more than the last prompt
|
|
157
164
|
# (text only: tsv and json keep desc for pickers).
|
|
158
165
|
desc = summary[:desc]
|
|
@@ -197,7 +204,10 @@ module Samagotchi
|
|
|
197
204
|
ctx = Samagotchi::SessionMetrics.context_label(
|
|
198
205
|
Samagotchi::SessionMetrics.saved_context_pct(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
|
|
199
206
|
)
|
|
200
|
-
|
|
207
|
+
# A question waits for an answer (chi answer, the web, chi --attach).
|
|
208
|
+
live = s.pending_question && Samagotchi::SessionManager.worker_live?(s.id, state_dir: state_dir)
|
|
209
|
+
status = s.waiting_question(live: !!live) ? "waiting" : s.status
|
|
210
|
+
"#{s.id} #{status.ljust(8)} #{ctx.ljust(8)} #{s.updated_at} #{list_text.call(s)}#{flag}#{child}"
|
|
201
211
|
end
|
|
202
212
|
if project
|
|
203
213
|
sessions.each { |s| @stdout.puts row.call(s) }
|
|
@@ -328,11 +328,12 @@ module Samagotchi
|
|
|
328
328
|
|
|
329
329
|
# ── Project / rg helpers ───────────────────────────────────────────────────
|
|
330
330
|
|
|
331
|
+
# AGENT.md in the cwd, else at the top of its git work tree.
|
|
331
332
|
def project_specific_description
|
|
332
333
|
return nil if skip_agent_description?
|
|
333
334
|
|
|
334
|
-
path =
|
|
335
|
-
return nil unless
|
|
335
|
+
path = agent_description_path
|
|
336
|
+
return nil unless path
|
|
336
337
|
|
|
337
338
|
content = File.read(path).strip
|
|
338
339
|
return nil if content.empty?
|
|
@@ -342,6 +343,13 @@ module Samagotchi
|
|
|
342
343
|
nil
|
|
343
344
|
end
|
|
344
345
|
|
|
346
|
+
def agent_description_path
|
|
347
|
+
cwd = Dir.pwd
|
|
348
|
+
[cwd, MemoryPaths.work_tree_root(cwd)].compact.uniq
|
|
349
|
+
.map { |dir| File.join(dir, AGENT_DESCRIPTION_FILE) }
|
|
350
|
+
.find { |path| File.file?(path) }
|
|
351
|
+
end
|
|
352
|
+
|
|
345
353
|
# Where the session runs and which project memory folder it uses. The root
|
|
346
354
|
# line appears only when it differs from the cwd (a worktree or subdir).
|
|
347
355
|
# The home directory is spelled out once so the model copies the right
|
|
@@ -5,6 +5,8 @@ require_relative "../config"
|
|
|
5
5
|
require_relative "../model_profile"
|
|
6
6
|
require_relative "../session_manager"
|
|
7
7
|
require_relative "../bridge_client"
|
|
8
|
+
require_relative "../parent_report"
|
|
9
|
+
require_relative "../cli/exit"
|
|
8
10
|
require_relative "live_region"
|
|
9
11
|
require_relative "plain_surface"
|
|
10
12
|
require_relative "attached_loop"
|
|
@@ -43,14 +45,40 @@ module Samagotchi
|
|
|
43
45
|
memories: memories, muted_memories: muted_memories)
|
|
44
46
|
first_command = model && (attach || resume) ? "/model #{model}" : nil
|
|
45
47
|
surface = open_surface
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
attached = AttachedLoop.new(client: client, screen: surface, client_id: "tui:#{Process.pid}", first_prompt: prompt,
|
|
49
|
+
first_command: first_command, no_interrupt: no_interrupt,
|
|
50
|
+
default_input: default_input && !prompt && !attach && !resume,
|
|
51
|
+
wait_at_eof: !$stdin.tty?,
|
|
52
|
+
parent_answers: Guardrails::ParentApprovals.parent_process?)
|
|
53
|
+
ended = begin
|
|
54
|
+
attached.run
|
|
51
55
|
ensure
|
|
52
56
|
close_surface(surface)
|
|
53
57
|
end
|
|
58
|
+
report_unanswered(attached.unanswered, session_id: client.session_id) if ended == :unanswered && attached.unanswered
|
|
59
|
+
ended
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A question left waiting (exit 3), in full on stderr as `chi send
|
|
63
|
+
# --wait` prints it (ParentReport), so a script or a parent agent
|
|
64
|
+
# can answer it with chi answer.
|
|
65
|
+
# @param pending [Hash] the pending question (symbol keys)
|
|
66
|
+
def report_unanswered(pending, session_id:, err: $stderr)
|
|
67
|
+
err.print("chi: #{ParentReport.question_text(pending, session_id: session_id)}")
|
|
68
|
+
err.flush
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# chi's exit status after #run: 0 detached, 3 a question left waiting
|
|
72
|
+
# for an answer (input from a pipe ran out; chi --attach or chi answer
|
|
73
|
+
# answers it), 1 anything else (a failed turn, the worker gone).
|
|
74
|
+
# @param ended [Symbol] what #run returned
|
|
75
|
+
# @return [Integer]
|
|
76
|
+
def exit_status(ended)
|
|
77
|
+
case ended
|
|
78
|
+
when :detached then CLI::Exit::OK
|
|
79
|
+
when :unanswered then CLI::Exit::QUESTION
|
|
80
|
+
else CLI::Exit::FAILED
|
|
81
|
+
end
|
|
54
82
|
end
|
|
55
83
|
|
|
56
84
|
# A live region (Screen, with Reline drawing into it) on a terminal
|
|
@@ -20,6 +20,7 @@ require_relative "../model_profile"
|
|
|
20
20
|
require_relative "../output_formatter"
|
|
21
21
|
require_relative "../session_commands"
|
|
22
22
|
require_relative "../session_manager"
|
|
23
|
+
require_relative "../guardrails/parent_approvals"
|
|
23
24
|
|
|
24
25
|
module Samagotchi
|
|
25
26
|
class TerminalUI
|
|
@@ -62,6 +63,10 @@ module Samagotchi
|
|
|
62
63
|
# @return [QuestionPrompt, nil] the question waiting for an answer
|
|
63
64
|
attr_reader :question
|
|
64
65
|
|
|
66
|
+
# @return [Hash, nil] the pending question (symbol keys) #run left
|
|
67
|
+
# waiting when it ended :unanswered
|
|
68
|
+
attr_reader :unanswered
|
|
69
|
+
|
|
65
70
|
# @param client [BridgeClient]
|
|
66
71
|
# @param screen [Surface] with #synchronize and #columns (a Screen, or a
|
|
67
72
|
# PlainSurface when the terminal can't show a live region)
|
|
@@ -81,7 +86,7 @@ module Samagotchi
|
|
|
81
86
|
# X </dev/null`): at their end, detach only once this run's prompts
|
|
82
87
|
# (the -p one too) have had their turns; if one failed, #run says so
|
|
83
88
|
def initialize(client:, screen:, client_id:, first_prompt: nil, first_command: nil, no_interrupt: false,
|
|
84
|
-
default_input: false, wait_at_eof: false,
|
|
89
|
+
default_input: false, wait_at_eof: false, parent_answers: false,
|
|
85
90
|
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
86
91
|
delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) },
|
|
87
92
|
archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) })
|
|
@@ -90,6 +95,10 @@ module Samagotchi
|
|
|
90
95
|
@archive_session = archive_session
|
|
91
96
|
@screen = screen
|
|
92
97
|
@client_id = client_id
|
|
98
|
+
# A parent agent drives this attach (AttachLauncher:
|
|
99
|
+
# ParentApprovals.parent_process?): its answers go as chi answer's,
|
|
100
|
+
# so the worker holds them to guardrails.parent_approvals.
|
|
101
|
+
@parent_answers = parent_answers
|
|
93
102
|
@view = AttachedView.new(screen)
|
|
94
103
|
@renderer = EventRenderer.new(@view)
|
|
95
104
|
@shown_enqueued = Set.new
|
|
@@ -158,7 +167,7 @@ module Samagotchi
|
|
|
158
167
|
if payload.nil? && @wait_at_eof && own_turns_pending?
|
|
159
168
|
# The pipe's end: this run's turns first. An open question
|
|
160
169
|
# has nothing left to answer it.
|
|
161
|
-
return unanswered_question if @question
|
|
170
|
+
return unanswered_question(@question_pending) if @question
|
|
162
171
|
|
|
163
172
|
next @input_ended = true
|
|
164
173
|
end
|
|
@@ -217,7 +226,7 @@ module Samagotchi
|
|
|
217
226
|
when :input_merged then merged_own_prompts(event)
|
|
218
227
|
when :question_requested
|
|
219
228
|
# Nothing is left to answer it with.
|
|
220
|
-
return unanswered_question if @input_ended
|
|
229
|
+
return unanswered_question(event[:pending_question]) if @input_ended
|
|
221
230
|
|
|
222
231
|
ask(event[:pending_question])
|
|
223
232
|
when :question_answered then question_answered(event)
|
|
@@ -557,7 +566,8 @@ module Samagotchi
|
|
|
557
566
|
@own_failed ? :turn_failed : :detached
|
|
558
567
|
end
|
|
559
568
|
|
|
560
|
-
def unanswered_question
|
|
569
|
+
def unanswered_question(pending)
|
|
570
|
+
@unanswered = pending
|
|
561
571
|
detach("A question waits for an answer: chi --attach #{@client.session_id}")
|
|
562
572
|
:unanswered
|
|
563
573
|
end
|
|
@@ -634,6 +644,7 @@ module Samagotchi
|
|
|
634
644
|
# What was typed at the prompt waits for the question to close.
|
|
635
645
|
@set_aside = @reader&.typed_text unless @question
|
|
636
646
|
@question = QuestionPrompt.new(pending)
|
|
647
|
+
@question_pending = pending
|
|
637
648
|
# An edit's diff goes above, into the scrollback, once per question
|
|
638
649
|
# (a join gets it from the snapshot and may get the event too).
|
|
639
650
|
unless @previewed_id == @question.id
|
|
@@ -656,7 +667,8 @@ module Samagotchi
|
|
|
656
667
|
return @screen.commit(answer.error)
|
|
657
668
|
end
|
|
658
669
|
|
|
659
|
-
|
|
670
|
+
parent = @parent_answers ? { client_id: Guardrails::ParentApprovals::CLIENT_ID } : {}
|
|
671
|
+
reply = @client.answer(id: @question.id, selected: answer.selected, freeform: answer.freeform, **parent)
|
|
660
672
|
case reply.status
|
|
661
673
|
when 200
|
|
662
674
|
@answered_ids << @question.id
|