samagotchi 0.16.0 → 0.18.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 +149 -1
- data/README.md +2 -0
- data/docs/architecture.md +38 -2
- data/docs/cli.md +37 -9
- data/docs/configuration.md +8 -6
- data/docs/guardrails.md +51 -8
- data/docs/hooks.md +4 -1
- data/docs/internals/background-tasks.md +1 -0
- data/docs/memory.md +41 -0
- data/docs/plugins.md +5 -2
- data/docs/sessions.md +8 -6
- data/docs/sub-agent.md +43 -4
- data/docs/testing.md +47 -0
- data/lib/samagotchi/answer_command.rb +23 -2
- data/lib/samagotchi/answer_tail.rb +52 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +20 -5
- data/lib/samagotchi/bridge.rb +86 -14
- data/lib/samagotchi/bridge_client.rb +18 -3
- data/lib/samagotchi/bundle_command.rb +121 -4
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
- data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
- data/lib/samagotchi/bundles/system/self_map.md +1 -0
- data/lib/samagotchi/commands/registry.rb +28 -0
- data/lib/samagotchi/config.rb +7 -1
- data/lib/samagotchi/continue_offer.rb +177 -0
- data/lib/samagotchi/engine.rb +73 -19
- data/lib/samagotchi/guardrails/outside.rb +46 -0
- data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
- data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
- data/lib/samagotchi/guardrails/rules.rb +17 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
- data/lib/samagotchi/guardrails/targets.rb +30 -13
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/iteration_limit.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +2 -1
- data/lib/samagotchi/llm/backend.rb +2 -1
- data/lib/samagotchi/llm/chat_loop.rb +2 -1
- data/lib/samagotchi/llm/native_backend.rb +2 -1
- data/lib/samagotchi/memory_bundle/builder.rb +22 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
- data/lib/samagotchi/memory_bundle/installer.rb +60 -21
- data/lib/samagotchi/memory_bundle/profile.rb +9 -4
- data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
- data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
- data/lib/samagotchi/memory_bundle/trash.rb +136 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
- data/lib/samagotchi/parent_report.rb +39 -9
- data/lib/samagotchi/plugin/context.rb +14 -2
- data/lib/samagotchi/question_desk.rb +205 -46
- data/lib/samagotchi/reply_wait.rb +10 -4
- data/lib/samagotchi/self_report.rb +49 -3
- data/lib/samagotchi/send_command.rb +46 -6
- data/lib/samagotchi/session.rb +3 -2
- data/lib/samagotchi/session_commands.rb +3 -2
- data/lib/samagotchi/session_manager.rb +4 -1
- data/lib/samagotchi/session_metrics.rb +137 -48
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +46 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
- data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
- data/lib/samagotchi/terminal_ui.rb +35 -2
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +11 -3
- data/lib/samagotchi/tools/delegate_relay.rb +4 -0
- data/lib/samagotchi/tools/delegate_wait.rb +14 -5
- data/lib/samagotchi/tools/edit.rb +6 -1
- data/lib/samagotchi/tools/execute.rb +5 -4
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/read.rb +2 -1
- data/lib/samagotchi/tools/task_runtime.rb +1 -1
- data/lib/samagotchi/turn_flow.rb +8 -7
- data/lib/samagotchi/turn_note.rb +7 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +189 -43
- data/lib/samagotchi/web/public/app.js +157 -51
- data/lib/samagotchi/web/public/data.js +12 -13
- data/lib/samagotchi/web/public/index.html +6 -1
- data/lib/samagotchi/web/public/notify.js +12 -5
- data/lib/samagotchi/web/public/question_card.js +28 -1
- data/lib/samagotchi/web/public/question_cards.js +74 -3
- data/lib/samagotchi/web/public/sessions_list.js +1 -0
- data/lib/samagotchi/web/public/stage_view.js +13 -1
- data/lib/samagotchi/web/public/strip.js +11 -0
- data/lib/samagotchi/web/public/timing.js +67 -12
- data/lib/samagotchi/web/public/turn_events.js +74 -3
- data/lib/samagotchi/web/public/update_notice.js +15 -0
- data/lib/samagotchi/worker.rb +58 -60
- data/lib/samagotchi/worker_sidecar.rb +7 -1
- metadata +10 -1
|
@@ -8,6 +8,7 @@ require "time"
|
|
|
8
8
|
require_relative "log"
|
|
9
9
|
require_relative "tools/ask_user_question"
|
|
10
10
|
require_relative "guardrails/parent_approvals"
|
|
11
|
+
require_relative "guardrails/parent_continue"
|
|
11
12
|
|
|
12
13
|
module Samagotchi
|
|
13
14
|
# An Engine's question flow: the one open question (ask_user_question, a
|
|
@@ -15,6 +16,15 @@ module Samagotchi
|
|
|
15
16
|
# by every UI. The turn thread blocks in #open_question; the UIs answer or
|
|
16
17
|
# cancel from theirs (a REPL answers inline through the sync handler).
|
|
17
18
|
#
|
|
19
|
+
# A standing question (#post: a worker's step-limit question, kind
|
|
20
|
+
# "continue") blocks nobody: it waits between turns, its answer goes to
|
|
21
|
+
# its on_answer, and its poster withdraws it (#withdraw). It can't be
|
|
22
|
+
# dismissed. One question stays pending at a time: a question opened
|
|
23
|
+
# while one stands supersedes it (withdrawn, reason "superseded"), and
|
|
24
|
+
# once that one closes the standing one is offered again
|
|
25
|
+
# (on_superseded_close); one posted while another is open waits for it
|
|
26
|
+
# the same way.
|
|
27
|
+
#
|
|
18
28
|
# What it needs from the Engine comes through lookups, read at call time.
|
|
19
29
|
class QuestionDesk
|
|
20
30
|
# Raised by #answer when the question it targets is no longer open
|
|
@@ -24,18 +34,28 @@ module Samagotchi
|
|
|
24
34
|
|
|
25
35
|
# Raised by #answer when a parent agent's answer (chi answer's client
|
|
26
36
|
# id) allows an approval further than this worker's
|
|
27
|
-
# guardrails.parent_approvals lets it (Guardrails::ParentApprovals)
|
|
28
|
-
#
|
|
37
|
+
# guardrails.parent_approvals lets it (Guardrails::ParentApprovals), or
|
|
38
|
+
# continues a turn turn.parent_continue keeps it from
|
|
39
|
+
# (Guardrails::ParentContinue). The question stays open; transports map
|
|
40
|
+
# it to 403.
|
|
29
41
|
class Refused < StandardError
|
|
30
|
-
# @return [Symbol] :off or :
|
|
42
|
+
# @return [Symbol] :off, :once_only, :protected or :stop_only
|
|
31
43
|
attr_reader :reason
|
|
32
44
|
|
|
33
45
|
def initialize(reason)
|
|
34
46
|
@reason = reason
|
|
35
|
-
super("a parent may not allow this approval (#{reason})")
|
|
47
|
+
super(reason == :stop_only ? "a parent may not continue this turn" : "a parent may not allow this approval (#{reason})")
|
|
36
48
|
end
|
|
37
49
|
end
|
|
38
50
|
|
|
51
|
+
# Raised by #cancel for a standing question (#post): its poster closes
|
|
52
|
+
# it, an answer settles it. Transports map it to 409 not_dismissable.
|
|
53
|
+
class NotDismissable < StandardError; end
|
|
54
|
+
|
|
55
|
+
# A step-limit question's options (kind "continue").
|
|
56
|
+
CONTINUE_KIND = "continue"
|
|
57
|
+
CONTINUE_OPTION = "Continue"
|
|
58
|
+
|
|
39
59
|
# Seconds between two looks of an open question's watch (#open_question).
|
|
40
60
|
WATCH_INTERVAL = 0.5
|
|
41
61
|
|
|
@@ -62,6 +82,11 @@ module Samagotchi
|
|
|
62
82
|
@pending = nil
|
|
63
83
|
@answer = nil
|
|
64
84
|
@sync_handler = nil
|
|
85
|
+
# The standing question while it is @pending: {id:, fields:,
|
|
86
|
+
# on_answer:, on_superseded_close:}; @shelved, one superseded by (or
|
|
87
|
+
# posted during) another question, offered again when that closes.
|
|
88
|
+
@standing = nil
|
|
89
|
+
@shelved = nil
|
|
65
90
|
end
|
|
66
91
|
|
|
67
92
|
# @return [Hash, nil] current pending question (thread-safe copy)
|
|
@@ -99,23 +124,68 @@ module Samagotchi
|
|
|
99
124
|
# @return [Hash, String] the answer {id:, selected:, freeform:, selected_indices:},
|
|
100
125
|
# or {error:, …}; a String when a sync handler returned text itself
|
|
101
126
|
def open_question(fields, watch: nil)
|
|
102
|
-
|
|
103
|
-
|
|
127
|
+
supersede_standing
|
|
128
|
+
ask(fields, watch)
|
|
129
|
+
ensure
|
|
130
|
+
offer_shelved
|
|
131
|
+
end
|
|
104
132
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
133
|
+
# Publish a standing question: pending (saved, announced with
|
|
134
|
+
# standing: true) without blocking anyone. An answer to it is checked
|
|
135
|
+
# as any other and handed to +on_answer+ (answer, client_id:) on the
|
|
136
|
+
# answering thread, after it is cleared and :question_answered is out.
|
|
137
|
+
# Posted while another question is open, it waits for that one to
|
|
138
|
+
# close (as a superseded one does). A standing one already up is
|
|
139
|
+
# replaced (withdrawn, reason "replaced").
|
|
140
|
+
# @param fields [Hash] as for #open_question
|
|
141
|
+
# @param on_answer [#call] (answer, client_id:)
|
|
142
|
+
# @param on_superseded_close [#call, nil] called when a question that
|
|
143
|
+
# superseded it closes (its poster posts it again if it still
|
|
144
|
+
# stands); nil: posted again as it was
|
|
145
|
+
# @return [Hash, nil] the pending question; nil when it waits for another
|
|
146
|
+
def post(fields, on_answer:, on_superseded_close: nil)
|
|
147
|
+
standing = { fields: fields, on_answer: on_answer, on_superseded_close: on_superseded_close }
|
|
148
|
+
shelved = @lock.synchronize do
|
|
149
|
+
next false unless @pending && !@standing
|
|
150
|
+
|
|
151
|
+
@shelved = standing
|
|
152
|
+
true
|
|
108
153
|
end
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
154
|
+
return nil if shelved
|
|
155
|
+
|
|
156
|
+
withdraw("replaced")
|
|
157
|
+
publish(fields, standing: standing)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Withdraw the standing question (its poster's close: the offer went,
|
|
161
|
+
# was answered by a command, the worker leaves), announced as
|
|
162
|
+
# :question_cancelled with +reason+. A shelved one just goes.
|
|
163
|
+
# @param id [String, nil] only this one
|
|
164
|
+
# @return [Boolean] whether one was pending
|
|
165
|
+
def withdraw(reason, id: nil)
|
|
166
|
+
withdrawn = @lock.synchronize do
|
|
167
|
+
@shelved = nil if id.nil?
|
|
168
|
+
standing = @standing
|
|
169
|
+
next nil unless standing && @pending && @pending[:id] == standing[:id]
|
|
170
|
+
next nil if id && standing[:id].to_s != id.to_s
|
|
171
|
+
|
|
172
|
+
@standing = nil
|
|
173
|
+
@pending = nil
|
|
174
|
+
standing[:id]
|
|
113
175
|
end
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
176
|
+
return false unless withdrawn
|
|
177
|
+
|
|
178
|
+
clear_saved_question
|
|
179
|
+
emit({ type: :question_cancelled, id: withdrawn, reason: reason.to_s })
|
|
180
|
+
true
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# @return [Boolean] the pending question is a standing one
|
|
184
|
+
def standing? = @lock.synchronize { !@standing.nil? }
|
|
185
|
+
|
|
186
|
+
private def ask(fields, watch)
|
|
187
|
+
pending = publish(fields)
|
|
188
|
+
id = pending[:id]
|
|
119
189
|
|
|
120
190
|
# If a synchronous UI handler is registered (TUI), invoke it inline on the
|
|
121
191
|
# SAME thread that called request_question (TerminalUI's REPL thread is the
|
|
@@ -202,7 +272,9 @@ module Samagotchi
|
|
|
202
272
|
parent_agent = client_id.to_s == Guardrails::ParentApprovals::CLIENT_ID if parent_agent.nil?
|
|
203
273
|
# Read before the lock (config may touch the disk); this worker's own.
|
|
204
274
|
parent_setting = Guardrails::ParentApprovals.setting if parent_agent
|
|
205
|
-
|
|
275
|
+
parent_continue = Guardrails::ParentContinue.allowed? if parent_agent
|
|
276
|
+
standing = nil
|
|
277
|
+
answer = @lock.synchronize do
|
|
206
278
|
pending = @pending
|
|
207
279
|
raise NotPending, "no pending question" unless pending
|
|
208
280
|
raise NotPending, "id mismatch" unless pending[:id].to_s == id.to_s
|
|
@@ -212,37 +284,25 @@ module Samagotchi
|
|
|
212
284
|
raise NotPending, "question already answered" if @answer
|
|
213
285
|
raise NotPending, "question #{pending[:status]}" unless pending[:status].to_s == "pending"
|
|
214
286
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
unless invalid.empty?
|
|
219
|
-
raise ArgumentError, "invalid selection: #{invalid.join(', ')} (valid: #{opts.join(', ')})"
|
|
287
|
+
answer = validated_answer(pending, sel, fm, parent_agent: parent_agent, parent_setting: parent_setting)
|
|
288
|
+
if parent_agent && (reason = Guardrails::ParentContinue.refusal(pending, sel, allowed: parent_continue))
|
|
289
|
+
raise Refused, reason
|
|
220
290
|
end
|
|
221
|
-
if
|
|
222
|
-
|
|
291
|
+
if @standing
|
|
292
|
+
# No waiter: cleared here, so a second answer finds none.
|
|
293
|
+
standing = @standing
|
|
294
|
+
@standing = nil
|
|
295
|
+
@pending = nil
|
|
296
|
+
else
|
|
297
|
+
@answer = answer
|
|
298
|
+
@cv.broadcast
|
|
223
299
|
end
|
|
224
|
-
if pending[:multi_select] == false && sel.empty? && fm.nil?
|
|
225
|
-
raise ArgumentError, "selection required"
|
|
226
|
-
end
|
|
227
|
-
indices = sel.map { |v| opts.index(v) }
|
|
228
|
-
# Checked against the question pending now, under the lock: the one
|
|
229
|
-
# whose answer settles the approval (Approval.settle, by index).
|
|
230
|
-
if parent_setting
|
|
231
|
-
reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: parent_setting)
|
|
232
|
-
raise Refused, reason if reason
|
|
233
|
-
end
|
|
234
|
-
# Persist pending cleared elsewhere; just set answer
|
|
235
|
-
answer = { id: id.to_s, selected: sel, freeform: fm }
|
|
236
|
-
# Derive indices for convenience
|
|
237
|
-
answer[:selected_indices] = indices.compact
|
|
238
|
-
answer[:by] = "parent_agent" if parent_agent
|
|
239
|
-
@answer = answer
|
|
240
|
-
@cv.broadcast
|
|
241
300
|
answer
|
|
242
|
-
end.tap do
|
|
243
|
-
# A human answered: the session is back in the lists (ArchiveStore).
|
|
244
|
-
@user_input.call(session&.id) unless parent_agent
|
|
245
301
|
end
|
|
302
|
+
settle_standing(standing, answer, client_id) if standing
|
|
303
|
+
# A human answered: the session is back in the lists (ArchiveStore).
|
|
304
|
+
@user_input.call(session&.id) unless parent_agent
|
|
305
|
+
answer
|
|
246
306
|
end
|
|
247
307
|
|
|
248
308
|
# Mark the pending question as relayed to a parent session's user, or
|
|
@@ -287,12 +347,16 @@ module Samagotchi
|
|
|
287
347
|
# names the one it showed)
|
|
288
348
|
# @return [Boolean] whether it was cancelled (true with none pending and
|
|
289
349
|
# no id, as before)
|
|
350
|
+
# @raise [NotDismissable] +id+ names a standing question (#post)
|
|
290
351
|
def cancel(reason = "user", id: nil)
|
|
291
352
|
cancelled_id = @lock.synchronize do
|
|
292
353
|
pending = @pending
|
|
293
354
|
next unless pending
|
|
294
355
|
next if id && pending[:id].to_s != id.to_s
|
|
295
356
|
next if @answer || pending[:status].to_s != "pending"
|
|
357
|
+
# Its poster closes it; nothing waits on a cancel.
|
|
358
|
+
raise NotDismissable, "answer #{Array(pending[:options]).join(' or ')}" if @standing && id
|
|
359
|
+
next if @standing
|
|
296
360
|
|
|
297
361
|
pending[:status] = "cancelled"
|
|
298
362
|
@cv.broadcast
|
|
@@ -330,6 +394,101 @@ module Samagotchi
|
|
|
330
394
|
|
|
331
395
|
private
|
|
332
396
|
|
|
397
|
+
# Make +fields+ the pending question: set under the lock, saved to the
|
|
398
|
+
# session file (the web's stub, a resume, the lists) and announced as
|
|
399
|
+
# :question_requested to every UI. Observers that stash the event (e.g.
|
|
400
|
+
# TerminalUI handle_question_event) discard it as stale if a sync
|
|
401
|
+
# handler answers and clears it first (drain_pending_question?).
|
|
402
|
+
# @return [Hash] the pending question
|
|
403
|
+
# @param standing [Hash, nil] a standing question's record (#post)
|
|
404
|
+
def publish(fields, standing: nil)
|
|
405
|
+
pending = { id: SecureRandom.uuid, **fields, status: "pending", created_at: Time.now.iso8601(3) }.compact
|
|
406
|
+
@lock.synchronize do
|
|
407
|
+
@pending = pending
|
|
408
|
+
@answer = nil
|
|
409
|
+
@standing = standing&.merge(id: pending[:id])
|
|
410
|
+
end
|
|
411
|
+
if session
|
|
412
|
+
session.pending_question = pending.dup
|
|
413
|
+
begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
|
|
414
|
+
end
|
|
415
|
+
event = { type: :question_requested, pending_question: pending }
|
|
416
|
+
event[:standing] = true if standing
|
|
417
|
+
emit(event)
|
|
418
|
+
pending
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# A question is about to open: a standing one gives way (withdrawn,
|
|
422
|
+
# reason "superseded") and is shelved until the new one closes.
|
|
423
|
+
def supersede_standing
|
|
424
|
+
standing = @lock.synchronize do
|
|
425
|
+
next nil unless @standing
|
|
426
|
+
|
|
427
|
+
@shelved = @standing
|
|
428
|
+
@standing = nil
|
|
429
|
+
@pending = nil
|
|
430
|
+
@shelved[:id]
|
|
431
|
+
end
|
|
432
|
+
emit({ type: :question_cancelled, id: standing, reason: "superseded" }) if standing
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# The question that superseded a standing one closed: offer it again.
|
|
436
|
+
def offer_shelved
|
|
437
|
+
shelved = @lock.synchronize do
|
|
438
|
+
next nil if @pending || @shelved.nil?
|
|
439
|
+
|
|
440
|
+
@shelved.tap { @shelved = nil }
|
|
441
|
+
end
|
|
442
|
+
return unless shelved
|
|
443
|
+
|
|
444
|
+
if shelved[:on_superseded_close]
|
|
445
|
+
shelved[:on_superseded_close].call
|
|
446
|
+
else
|
|
447
|
+
post(shelved[:fields], on_answer: shelved[:on_answer])
|
|
448
|
+
end
|
|
449
|
+
rescue StandardError => e
|
|
450
|
+
Log.warn(:turn, "standing_question_repost_failed", error: e.class.name, message: e.message)
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# A standing question's answer: cleared from the file, announced, then
|
|
454
|
+
# handed to its poster with who answered.
|
|
455
|
+
def settle_standing(standing, answer, client_id)
|
|
456
|
+
clear_saved_question
|
|
457
|
+
emit({ type: :question_answered, id: answer[:id], answer: answer })
|
|
458
|
+
standing[:on_answer].call(answer.except(:by), client_id: client_id)
|
|
459
|
+
rescue StandardError => e
|
|
460
|
+
Log.warn(:turn, "standing_answer_failed", error: e.class.name, message: e.message)
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# An answer to +pending+, checked: the selections are its options (value
|
|
464
|
+
# == label in v1), one for a single-select, one or a text when required,
|
|
465
|
+
# and a parent agent's within guardrails.parent_approvals. Under the
|
|
466
|
+
# question lock: checked against the question pending now.
|
|
467
|
+
# @return [Hash] {id:, selected:, freeform:, selected_indices:, by:}
|
|
468
|
+
# @raise [ArgumentError, Refused]
|
|
469
|
+
def validated_answer(pending, sel, fm, parent_agent:, parent_setting:)
|
|
470
|
+
opts = Array(pending[:options])
|
|
471
|
+
invalid = sel.reject { |v| opts.include?(v) }
|
|
472
|
+
raise ArgumentError, "invalid selection: #{invalid.join(', ')} (valid: #{opts.join(', ')})" unless invalid.empty?
|
|
473
|
+
raise ArgumentError, "single-select question: got #{sel.size} selections" if !pending[:multi_select] && sel.size > 1
|
|
474
|
+
raise ArgumentError, "selection required" if pending[:multi_select] == false && sel.empty? && fm.nil?
|
|
475
|
+
# A step-limit question: Continue resumes the turn as it was; a text
|
|
476
|
+
# goes with Stop (steering a continue is not a thing).
|
|
477
|
+
if pending[:kind].to_s == CONTINUE_KIND && sel == [CONTINUE_OPTION] && fm
|
|
478
|
+
raise ArgumentError, "Continue takes no text (a text goes with Stop)"
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
indices = sel.map { |v| opts.index(v) }
|
|
482
|
+
# The one whose answer settles the approval (Approval.settle, by index).
|
|
483
|
+
if parent_setting
|
|
484
|
+
reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: parent_setting)
|
|
485
|
+
raise Refused, reason if reason
|
|
486
|
+
end
|
|
487
|
+
answer = { id: pending[:id].to_s, selected: sel, freeform: fm, selected_indices: indices.compact }
|
|
488
|
+
answer[:by] = "parent_agent" if parent_agent
|
|
489
|
+
answer
|
|
490
|
+
end
|
|
491
|
+
|
|
333
492
|
# Wait until the question is answered, cancelled or closed by +watch+.
|
|
334
493
|
# @return [String, nil] the watch's close reason
|
|
335
494
|
def wait_for_answer(watch)
|
|
@@ -26,10 +26,14 @@ module Samagotchi
|
|
|
26
26
|
# @!attribute question [Hash, nil] the pending question
|
|
27
27
|
# (:waiting_for_answer)
|
|
28
28
|
# @!attribute outcome [String, nil] how the turn ended when it left no
|
|
29
|
-
# reply (:no_reply): "failed", "canceled"
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
|
|
29
|
+
# reply (:no_reply): "failed", "canceled", "completed" (empty),
|
|
30
|
+
# "exhausted" (it ran out of iterations, with nobody asked whether to
|
|
31
|
+
# continue) or "not_continued" (a Stop answered the step-limit
|
|
32
|
+
# question; no turn ran), from the session's last_turn; nil when unknown. With
|
|
33
|
+
# "failed", text is the failure's summary when the turn note has one.
|
|
34
|
+
# @!attribute limit [Integer, nil] the iteration limit an "exhausted"
|
|
35
|
+
# turn ran out at
|
|
36
|
+
Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, keyword_init: true)
|
|
33
37
|
|
|
34
38
|
module_function
|
|
35
39
|
|
|
@@ -144,6 +148,8 @@ module Samagotchi
|
|
|
144
148
|
last = session.last_turn.is_a?(Hash) ? session.last_turn : {}
|
|
145
149
|
fresh = baseline.nil? || !baseline.key?(:last_turn) || (last["ended_at"] && last["ended_at"] != baseline[:last_turn])
|
|
146
150
|
outcome = fresh ? last["outcome"] : nil
|
|
151
|
+
return Result.new(status: :no_reply, outcome: "exhausted", limit: last["limit"]) if fresh && last["exhausted"]
|
|
152
|
+
|
|
147
153
|
text = outcome == "failed" ? failure_summary(session.messages) : nil
|
|
148
154
|
Result.new(status: :no_reply, outcome: outcome, text: text)
|
|
149
155
|
end
|
|
@@ -7,6 +7,7 @@ require_relative "context_window"
|
|
|
7
7
|
require_relative "session"
|
|
8
8
|
require_relative "log_path"
|
|
9
9
|
require_relative "model_profile"
|
|
10
|
+
require_relative "model_overlay"
|
|
10
11
|
require_relative "thinking"
|
|
11
12
|
require_relative "served_model"
|
|
12
13
|
require_relative "host_registry"
|
|
@@ -39,7 +40,8 @@ module Samagotchi
|
|
|
39
40
|
end
|
|
40
41
|
|
|
41
42
|
def fields(env: ENV)
|
|
42
|
-
|
|
43
|
+
default = model_name
|
|
44
|
+
model = session_model(env) || default
|
|
43
45
|
[
|
|
44
46
|
["version", "#{VERSION} (ruby #{RUBY_VERSION})"],
|
|
45
47
|
["source", "#{SOURCE_DIR} (#{install_kind})"],
|
|
@@ -49,7 +51,8 @@ module Samagotchi
|
|
|
49
51
|
["project memories", Tools::MemoryRead.memories_dir("project", env: env)],
|
|
50
52
|
["sessions", Session.default_state_dir(env: env)],
|
|
51
53
|
["log", log_path(env)],
|
|
52
|
-
["model", model
|
|
54
|
+
["model", model ? model_label(model, default, env) : "(not configured)"],
|
|
55
|
+
["model key", model ? model_key_for(model, env) : "-"],
|
|
53
56
|
["host", model ? host_for(model, env) : "-"],
|
|
54
57
|
["api key", model ? api_key_for(model, env) : "-"],
|
|
55
58
|
["loop", model ? loop_for(model, env) : "-"],
|
|
@@ -125,6 +128,46 @@ module Samagotchi
|
|
|
125
128
|
nil
|
|
126
129
|
end
|
|
127
130
|
|
|
131
|
+
SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
|
|
132
|
+
PARENT_SESSION_ENV = "SAMAGOTCHI_PARENT_SESSION"
|
|
133
|
+
|
|
134
|
+
# The model of the session this chi self runs in (its execute exports
|
|
135
|
+
# SAMAGOTCHI_SESSION_MODEL, the resolved ref, live after /model); nil
|
|
136
|
+
# outside a session's commands.
|
|
137
|
+
def session_model(env)
|
|
138
|
+
name = env[SESSION_MODEL_ENV].to_s.strip
|
|
139
|
+
name.empty? ? nil : name
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# "splash:x (this session abcd1234; default main:y)", "main:y (this
|
|
143
|
+
# session abcd1234, the default)", or outside a session "main:y (default)".
|
|
144
|
+
def model_label(model, default, env)
|
|
145
|
+
return "#{model} (default)" unless session_model(env)
|
|
146
|
+
|
|
147
|
+
id = env[PARENT_SESSION_ENV].to_s
|
|
148
|
+
session = id.empty? || id == "chi" ? "this session" : "this session #{id[0, 8]}"
|
|
149
|
+
return "#{model} (#{session}, the default)" if default && same_model?(model, default, env)
|
|
150
|
+
|
|
151
|
+
"#{model} (#{session}; default #{default || "not configured"})"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def same_model?(one, other, env)
|
|
155
|
+
return true if one == other
|
|
156
|
+
|
|
157
|
+
ConfigFile.model_ref(one, env: env).ref == ConfigFile.model_ref(other, env: env).ref
|
|
158
|
+
rescue StandardError
|
|
159
|
+
false
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The memory overlay key (ModelOverlay.key_for the bare id, as the
|
|
163
|
+
# engine keys memory_write current_model_only): which `<name>.<key>.md`
|
|
164
|
+
# overlays are this model's.
|
|
165
|
+
def model_key_for(model, env)
|
|
166
|
+
ModelOverlay.key_for(HostRegistry.new(env: env).bare_name(model)) || "-"
|
|
167
|
+
rescue StandardError
|
|
168
|
+
ModelOverlay.key_for(model) || "-"
|
|
169
|
+
end
|
|
170
|
+
|
|
128
171
|
def model_name
|
|
129
172
|
ModelProfile.required_model_name
|
|
130
173
|
rescue ArgumentError
|
|
@@ -147,6 +190,8 @@ module Samagotchi
|
|
|
147
190
|
entry.chat? ? "chat (api: openai)" : "native (raw prompt)"
|
|
148
191
|
end
|
|
149
192
|
|
|
193
|
+
NO_PROPS = "reported per turn (the server has no /props)"
|
|
194
|
+
|
|
150
195
|
# chi self's one server call: a single-model llama.cpp answers any name
|
|
151
196
|
# with the model it loaded, and its /props names it (model_alias). One
|
|
152
197
|
# GET with the probe's short timeouts; a remote host isn't asked.
|
|
@@ -157,10 +202,11 @@ module Samagotchi
|
|
|
157
202
|
bare = target.bare_model
|
|
158
203
|
return "-" unless entry
|
|
159
204
|
return "reported per turn (remote host)" if entry.remote?
|
|
205
|
+
return NO_PROPS if entry.chat?
|
|
160
206
|
|
|
161
207
|
client = registry.client_for(entry)
|
|
162
208
|
props = client.server_props(model: bare)
|
|
163
|
-
return
|
|
209
|
+
return NO_PROPS if props.nil? || props.status == :http_error
|
|
164
210
|
return "unknown (the server didn't answer; is it running?)" if props.status == :network_error
|
|
165
211
|
|
|
166
212
|
served = ServedModel.from_props(props)
|
|
@@ -37,6 +37,8 @@ module Samagotchi
|
|
|
37
37
|
chi send --wait [--timeout S] [--format json] ID
|
|
38
38
|
Sends a message to each session, as if typed in it: a turn starts,
|
|
39
39
|
or a running one picks it up. A stopped session's worker starts.
|
|
40
|
+
A session command (/model x, !cmd) runs as the command; --wait
|
|
41
|
+
then has no reply to wait for (exit 0, status command).
|
|
40
42
|
-m TEXT the message; stdin, when piped too, goes above it as a
|
|
41
43
|
quote (context); without -m, stdin is the message
|
|
42
44
|
--image PATH
|
|
@@ -64,7 +66,8 @@ module Samagotchi
|
|
|
64
66
|
(--wait) one JSON object on stdout, whatever the end:
|
|
65
67
|
status answered (text), question (question,
|
|
66
68
|
answer_with), running, or failed, canceled,
|
|
67
|
-
no_answer, error, worker_gone, stopped
|
|
69
|
+
limit, no_answer, error, worker_gone, stopped,
|
|
70
|
+
command (detail)
|
|
68
71
|
Exit with --wait: 0 answered, 1 failed or gone, 2 usage, 3 a
|
|
69
72
|
question waits, 4 still running (--timeout), 130 Ctrl-C.
|
|
70
73
|
Only sessions on this machine. Answers show in the attached TUI
|
|
@@ -246,23 +249,27 @@ module Samagotchi
|
|
|
246
249
|
error_line("chi send: refused: #{refusal}")
|
|
247
250
|
return 1
|
|
248
251
|
end
|
|
252
|
+
# With images the web's way: idle (the message names it in the
|
|
253
|
+
# lists), the images copied in, then the message as a turn. So too a
|
|
254
|
+
# line that may be a session command (/model x): its worker's answer
|
|
255
|
+
# says whether it ran as one.
|
|
256
|
+
through_worker = images || command_like?(prompt)
|
|
249
257
|
begin
|
|
250
|
-
|
|
251
|
-
# lists), the images copied in, then the message as a turn.
|
|
252
|
-
start = images ? { prompt: nil, title: prompt } : { prompt: prompt }
|
|
258
|
+
start = through_worker ? { prompt: nil, title: prompt } : { prompt: prompt }
|
|
253
259
|
session = SessionManager.spawn_session(**start, working_directory: options[:dir],
|
|
254
260
|
model_name: options[:model], state_dir: @state_dir)
|
|
255
261
|
rescue StandardError => e
|
|
256
262
|
error_line("chi send: could not start a session: #{e.message}")
|
|
257
263
|
return 1
|
|
258
264
|
end
|
|
259
|
-
if
|
|
265
|
+
if through_worker
|
|
260
266
|
return 1 unless deliver_new(session, prompt)
|
|
261
267
|
else
|
|
262
268
|
@info.puts("#{session.id} started")
|
|
263
269
|
end
|
|
264
270
|
@session_id = session.id
|
|
265
271
|
return 0 unless options[:wait]
|
|
272
|
+
return report_command(session.id) if @sent_command
|
|
266
273
|
|
|
267
274
|
wait_for_reply(session.id, cursor: nil, baseline: baseline_of(session, question_id: nil),
|
|
268
275
|
timeout: options[:timeout])
|
|
@@ -297,6 +304,7 @@ module Samagotchi
|
|
|
297
304
|
cursor = ReplyWait.newest_reply(id, state_dir: @state_dir)
|
|
298
305
|
baseline = baseline_of(session)
|
|
299
306
|
return 1 unless deliver(id, prompt)
|
|
307
|
+
return report_command(id) if @sent_command
|
|
300
308
|
|
|
301
309
|
wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout])
|
|
302
310
|
end
|
|
@@ -352,10 +360,36 @@ module Samagotchi
|
|
|
352
360
|
return false
|
|
353
361
|
end
|
|
354
362
|
|
|
355
|
-
@
|
|
363
|
+
@sent_command = command_ack?(result[:ack])
|
|
364
|
+
@info.puts("#{session.id} started#{with_images}#{"; #{SENT_COMMAND}" if @sent_command}")
|
|
356
365
|
true
|
|
357
366
|
end
|
|
358
367
|
|
|
368
|
+
SENT_COMMAND = "sent as a session command"
|
|
369
|
+
|
|
370
|
+
# Whether the worker took the message as a session command (a POST
|
|
371
|
+
# /turn whose line is one answers with its command_id).
|
|
372
|
+
def command_ack?(ack) = ack.is_a?(Hash) && !ack["command_id"].to_s.empty?
|
|
373
|
+
|
|
374
|
+
# Whether +text+ may be a session command, which only its worker knows
|
|
375
|
+
# (a bundle's too).
|
|
376
|
+
def command_like?(text) = text.to_s.lstrip.start_with?("/", "!")
|
|
377
|
+
|
|
378
|
+
# --wait after a message that ran as a session command: no reply comes
|
|
379
|
+
# for it. Its output shows where the session is open.
|
|
380
|
+
# @return [Integer] 0
|
|
381
|
+
def report_command(id)
|
|
382
|
+
detail = "#{SENT_COMMAND}: no reply to wait for (its output shows in chi --attach #{id} or the web)"
|
|
383
|
+
if @json
|
|
384
|
+
@stdout.puts(JSON.generate({ status: "command", session_id: id, detail: detail }))
|
|
385
|
+
@stdout.flush
|
|
386
|
+
@reported = true
|
|
387
|
+
else
|
|
388
|
+
@stderr.puts("#{id[0, 8]} #{detail}")
|
|
389
|
+
end
|
|
390
|
+
CLI::Exit::OK
|
|
391
|
+
end
|
|
392
|
+
|
|
359
393
|
# @return [Boolean] whether the message was queued
|
|
360
394
|
def deliver(id, prompt)
|
|
361
395
|
short = id[0, 8]
|
|
@@ -373,6 +407,12 @@ module Samagotchi
|
|
|
373
407
|
return false
|
|
374
408
|
end
|
|
375
409
|
|
|
410
|
+
@sent_command = command_ack?(result[:ack])
|
|
411
|
+
if @sent_command
|
|
412
|
+
@info.puts("#{short} #{SENT_COMMAND}")
|
|
413
|
+
return true
|
|
414
|
+
end
|
|
415
|
+
|
|
376
416
|
# A busy worker runs a message with images as its own next turn
|
|
377
417
|
# rather than merging it into the running one.
|
|
378
418
|
note = if owner.nil? then " (started its worker)"
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -100,7 +100,8 @@ module Samagotchi
|
|
|
100
100
|
end
|
|
101
101
|
|
|
102
102
|
# The question this session waits on, as the lists show it: {id:, kind:}
|
|
103
|
-
# with kind "question" (the model's), "approval"
|
|
103
|
+
# with kind "question" (the model's), "approval", "hook" or "continue"
|
|
104
|
+
# (the step-limit question, asked between turns); nil with
|
|
104
105
|
# none. Only while a worker runs it (+live+): a question saved by a
|
|
105
106
|
# worker that died waits for no one (the next worker drops it). The
|
|
106
107
|
# web's summaries and `chi sessions list` both ask this.
|
|
@@ -113,7 +114,7 @@ module Samagotchi
|
|
|
113
114
|
kind = pending[:kind].to_s
|
|
114
115
|
relayed = pending[:relayed_to]
|
|
115
116
|
relayed_to = relayed.is_a?(Hash) ? (relayed[:parent_short] || relayed["parent_short"]) : nil
|
|
116
|
-
{ id: pending[:id], kind: %w[approval hook].include?(kind) ? kind : "question", relayed_to: relayed_to }.compact
|
|
117
|
+
{ id: pending[:id], kind: %w[approval hook continue].include?(kind) ? kind : "question", relayed_to: relayed_to }.compact
|
|
117
118
|
end
|
|
118
119
|
|
|
119
120
|
# The stored project root, else (a file from before the field) the
|
|
@@ -158,11 +158,11 @@ module Samagotchi
|
|
|
158
158
|
when :abort
|
|
159
159
|
@turn_flow.abort_continue!
|
|
160
160
|
save_session
|
|
161
|
-
reply("
|
|
161
|
+
reply("turn not continued; its work so far stays (!rollback erases it)", changed: [:messages])
|
|
162
162
|
when :abort_with_reason
|
|
163
163
|
@turn_flow.abort_continue!(reason: reason)
|
|
164
164
|
save_session
|
|
165
|
-
reply("
|
|
165
|
+
reply("turn not continued; noted your reason", changed: [:messages])
|
|
166
166
|
else
|
|
167
167
|
reply("answer yes, no, or no, <reason>", status: :error)
|
|
168
168
|
end
|
|
@@ -252,6 +252,7 @@ module Samagotchi
|
|
|
252
252
|
("tool #{rule.tools.join(",")}" if rule.tools),
|
|
253
253
|
("command /#{shorten_pattern(rule.command.source)}/" if rule.command),
|
|
254
254
|
("path #{rule.path}" if rule.path),
|
|
255
|
+
("git #{rule.git}" if rule.git),
|
|
255
256
|
("models #{rule.models.join(",")}" if rule.models)
|
|
256
257
|
].compact.join(", ")
|
|
257
258
|
off = "disabled (guardrails.disable) — " if rules.disabled?(rule)
|
|
@@ -364,7 +364,10 @@ module Samagotchi
|
|
|
364
364
|
cut(summary_text(session), SUMMARY_PREVIEW_LIMIT)
|
|
365
365
|
end
|
|
366
366
|
|
|
367
|
-
|
|
367
|
+
# A session as one line of text for the lists: its last prompt, else the
|
|
368
|
+
# first message it was started with (a session command like `/model x`,
|
|
369
|
+
# or a first turn not saved yet).
|
|
370
|
+
def self.summary_text(session)
|
|
368
371
|
one_line(session.last_prompt.to_s.strip.empty? ? session.first_preview.to_s : session.last_prompt.to_s)
|
|
369
372
|
end
|
|
370
373
|
|