samagotchi 0.17.0 → 0.18.1
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 +98 -1
- data/bin/chi +5 -2
- data/docs/architecture.md +25 -2
- data/docs/cli.md +41 -9
- data/docs/configuration.md +4 -2
- data/docs/guardrails.md +59 -8
- data/docs/hooks.md +4 -1
- data/docs/memory.md +5 -3
- data/docs/plugins.md +3 -2
- data/docs/sessions.md +6 -5
- data/docs/sub-agent.md +42 -4
- data/lib/samagotchi/answer_command.rb +23 -2
- data/lib/samagotchi/answer_tail.rb +52 -0
- data/lib/samagotchi/bridge.rb +49 -8
- data/lib/samagotchi/bridge_client.rb +18 -3
- data/lib/samagotchi/bundle_command.rb +108 -2
- 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 +2 -2
- data/lib/samagotchi/client.rb +58 -10
- 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 +68 -22
- data/lib/samagotchi/guardrail_wiring.rb +56 -3
- 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/http.rb +9 -3
- data/lib/samagotchi/llm/native_backend.rb +2 -1
- data/lib/samagotchi/memory_bundle/trash.rb +81 -2
- data/lib/samagotchi/model_profile.rb +2 -2
- data/lib/samagotchi/output_formatter.rb +9 -4
- data/lib/samagotchi/parent_report.rb +39 -9
- data/lib/samagotchi/question_desk.rb +205 -46
- data/lib/samagotchi/reply_wait.rb +10 -4
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/served_model.rb +14 -4
- 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/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +33 -8
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +63 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
- data/lib/samagotchi/terminal_ui.rb +75 -17
- data/lib/samagotchi/tool_declarations.rb +4 -4
- data/lib/samagotchi/tools/delegate_wait.rb +14 -5
- data/lib/samagotchi/tools/edit.rb +6 -1
- data/lib/samagotchi/tools/execute.rb +102 -23
- data/lib/samagotchi/tools/read.rb +2 -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 +187 -42
- data/lib/samagotchi/web/public/app.js +122 -25
- 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 +5 -2
- 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/timing.js +41 -0
- data/lib/samagotchi/web/public/turn_events.js +61 -0
- data/lib/samagotchi/web/public/update_notice.js +15 -0
- data/lib/samagotchi/worker.rb +30 -58
- data/lib/samagotchi/worker_sidecar.rb +6 -3
- metadata +9 -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
|
|
@@ -66,7 +66,8 @@ module Samagotchi
|
|
|
66
66
|
(--wait) one JSON object on stdout, whatever the end:
|
|
67
67
|
status answered (text), question (question,
|
|
68
68
|
answer_with), running, or failed, canceled,
|
|
69
|
-
no_answer, error, worker_gone, stopped,
|
|
69
|
+
limit, no_answer, error, worker_gone, stopped,
|
|
70
|
+
command (detail)
|
|
70
71
|
Exit with --wait: 0 answered, 1 failed or gone, 2 usage, 3 a
|
|
71
72
|
question waits, 4 still running (--timeout), 130 Ctrl-C.
|
|
72
73
|
Only sessions on this machine. Answers show in the attached TUI
|
|
@@ -17,16 +17,26 @@ module Samagotchi
|
|
|
17
17
|
name.is_a?(String) && !name.strip.empty? ? name : nil
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# The separators a provider uses when it decorates a model name: a tag
|
|
21
|
+
# (`qwen3:latest`), a date or quant suffix (`gpt-4o-2024-08-06`), an
|
|
22
|
+
# owner (`@org/name`) or a path (`meta-llama/Llama-3`). Anything else
|
|
23
|
+
# after the name is a different model (`gpt-4` is not `gpt-4o`).
|
|
24
|
+
EXTENSION_SEPARATORS = [":", "-", "@", "/"].freeze
|
|
25
|
+
|
|
20
26
|
# Whether +served+ is another model than +asked+. Names that are equal
|
|
21
|
-
# (any case) or where one extends the other
|
|
22
|
-
# `:free`, or adding a date) are the
|
|
23
|
-
# differ.
|
|
27
|
+
# (any case), or where one extends the other at a tag/date/owner
|
|
28
|
+
# separator (a provider dropping `:free`, or adding a date), are the
|
|
29
|
+
# same model; unknown names never differ.
|
|
24
30
|
def differs?(asked, served)
|
|
25
31
|
asked = asked.to_s.strip.downcase
|
|
26
32
|
served = served.to_s.strip.downcase
|
|
27
33
|
return false if asked.empty? || served.empty?
|
|
34
|
+
return false if asked == served
|
|
35
|
+
|
|
36
|
+
shorter, longer = asked.length <= served.length ? [asked, served] : [served, asked]
|
|
37
|
+
return true unless longer.start_with?(shorter)
|
|
28
38
|
|
|
29
|
-
!
|
|
39
|
+
!longer[shorter.length..].start_with?(*EXTENSION_SEPARATORS)
|
|
30
40
|
end
|
|
31
41
|
end
|
|
32
42
|
end
|
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
|
|
|
@@ -191,11 +191,12 @@ module Samagotchi
|
|
|
191
191
|
order ||= "desc"
|
|
192
192
|
sessions = Samagotchi::SessionManager.list_sessions(sort: sort, order: order, limit: limit, project_root: project,
|
|
193
193
|
include_archived: include_archived)
|
|
194
|
-
# The saved recap's first sentence, else the last prompt
|
|
194
|
+
# The saved recap's first sentence, else the last prompt (or the first
|
|
195
|
+
# message, before any turn), cut as before.
|
|
195
196
|
state_dir = Samagotchi::Session.default_state_dir
|
|
196
197
|
list_text = lambda do |s|
|
|
197
198
|
recap = Samagotchi::RecapStore.preview(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
|
|
198
|
-
(recap || Samagotchi::SessionManager.
|
|
199
|
+
(recap || Samagotchi::SessionManager.summary_text(s))[0, 60]
|
|
199
200
|
end
|
|
200
201
|
# A delegated session points at its parent: ↳ <parent's short id>.
|
|
201
202
|
row = lambda do |s|
|
|
@@ -381,7 +381,7 @@ module Samagotchi
|
|
|
381
381
|
root = MemoryPaths.project_root(cwd)
|
|
382
382
|
lines = ["Current working directory:", cwd]
|
|
383
383
|
unless root == cwd
|
|
384
|
-
lines << "Project root (project memories
|
|
384
|
+
lines << "Project root (only where shared project memories come from; read, edit, run and commit in the current working directory above):"
|
|
385
385
|
lines << root
|
|
386
386
|
end
|
|
387
387
|
home = Dir.home
|
|
@@ -237,6 +237,8 @@ module Samagotchi
|
|
|
237
237
|
end_turn
|
|
238
238
|
own_turn_ended(event)
|
|
239
239
|
when :prompt_restored then restore_prompt(event)
|
|
240
|
+
# The after_turn hooks are done (their notices came before it).
|
|
241
|
+
when :answer_display then @display_pending = false
|
|
240
242
|
when :context_status, :used_memories_updated then @status.take_event(event)
|
|
241
243
|
# Another client's anytime command: its line now, before the cards
|
|
242
244
|
# it shows (its command_ran comes when it's done).
|
|
@@ -342,6 +344,9 @@ module Samagotchi
|
|
|
342
344
|
# The history keeps !cmds, as the REPL's does (prompts: #send_prompt).
|
|
343
345
|
persist_recent_history(text) if PromptHistory.shell_line?(text)
|
|
344
346
|
send_command(text)
|
|
347
|
+
elsif (hint = command_registry.unknown_command_hint(text))
|
|
348
|
+
# A typo of a command (/modle) is not a prompt: say so, send nothing.
|
|
349
|
+
@screen.commit(hint)
|
|
345
350
|
else
|
|
346
351
|
send_prompt(text)
|
|
347
352
|
end
|
|
@@ -522,6 +527,9 @@ module Samagotchi
|
|
|
522
527
|
# UI's answer shows as its command line (web> /continue yes) instead.
|
|
523
528
|
def continue_resolved(event)
|
|
524
529
|
@continue_offer = nil
|
|
530
|
+
# Asked as the step-limit question: its line says what became of it.
|
|
531
|
+
asked = @offer_asked
|
|
532
|
+
@offer_asked = false
|
|
525
533
|
sync_continue_slot
|
|
526
534
|
who = event[:client_id] ? CLIENT_LABELS.fetch(event[:client_id].to_s.split(":", 2).first, "another UI") : "another UI"
|
|
527
535
|
outcome = if event[:decision] == "dropped"
|
|
@@ -530,14 +538,14 @@ module Samagotchi
|
|
|
530
538
|
@continue_answer || CONTINUE_DECISIONS.fetch(event[:decision].to_s, event[:decision].to_s)
|
|
531
539
|
end
|
|
532
540
|
@continue_answer = nil
|
|
533
|
-
@screen.commit(QuestionSlot.continue_summary(outcome, paint: method(:paint))) if outcome
|
|
541
|
+
@screen.commit(QuestionSlot.continue_summary(outcome, paint: method(:paint))) if outcome && !asked
|
|
534
542
|
sync_prompt
|
|
535
543
|
end
|
|
536
544
|
|
|
537
545
|
# The notes slot shows the offer's choices while it waits (a question
|
|
538
546
|
# has the slot while it is open).
|
|
539
547
|
def sync_continue_slot
|
|
540
|
-
return if @question
|
|
548
|
+
return if @question || @offer_asked
|
|
541
549
|
|
|
542
550
|
if @continue_offer
|
|
543
551
|
@screen.set_slot(:notes, QuestionSlot.continue_offer(@continue_offer[:context], paint: method(:paint)))
|
|
@@ -574,9 +582,10 @@ module Samagotchi
|
|
|
574
582
|
@screen.commit("could not send the prompt (#{worker_down(e)})")
|
|
575
583
|
end
|
|
576
584
|
|
|
577
|
-
# Whether the input's end must wait: the -p prompt isn't sent yet,
|
|
578
|
-
#
|
|
579
|
-
|
|
585
|
+
# Whether the input's end must wait: the -p prompt isn't sent yet, a
|
|
586
|
+
# prompt this run sent hasn't had its turn, or its turn's after_turn
|
|
587
|
+
# hooks still run (their notices, source-links' sources:, come next).
|
|
588
|
+
def own_turns_pending? = !@first_prompt.to_s.strip.empty? || !@open_ids.empty? || @display_pending == true
|
|
580
589
|
|
|
581
590
|
# A turn ended: this run's prompt (and ours merged into it) had its turn.
|
|
582
591
|
def own_turn_ended(event)
|
|
@@ -586,6 +595,8 @@ module Samagotchi
|
|
|
586
595
|
ours = @open_ids.include?(id) || !@merged_ids.empty?
|
|
587
596
|
@own_failed = true if ours && event[:type] == :turn_failed
|
|
588
597
|
@own_empty = true if ours && event[:type] == :turn_completed && event.dig(:turn_summary, :empty_answer)
|
|
598
|
+
# The worker sends answer_display once its after_turn hooks ran.
|
|
599
|
+
@display_pending = true if ours && event[:type] == :turn_completed && event[:display_pending]
|
|
589
600
|
@open_ids.delete(id)
|
|
590
601
|
@open_ids.subtract(@merged_ids)
|
|
591
602
|
@merged_ids.clear
|
|
@@ -692,6 +703,9 @@ module Samagotchi
|
|
|
692
703
|
@set_aside = @reader&.typed_text unless @question
|
|
693
704
|
@question = QuestionPrompt.new(pending)
|
|
694
705
|
@question_pending = pending
|
|
706
|
+
# The step-limit question stands for the continue offer's own slot
|
|
707
|
+
# until the offer is resolved.
|
|
708
|
+
@offer_asked = true if @question.continue?
|
|
695
709
|
# An edit's diff goes above, into the scrollback, once per question
|
|
696
710
|
# (a join gets it from the snapshot and may get the event too).
|
|
697
711
|
unless @previewed_id == @question.id
|
|
@@ -705,7 +719,9 @@ module Samagotchi
|
|
|
705
719
|
end
|
|
706
720
|
|
|
707
721
|
def answer_question(text)
|
|
708
|
-
|
|
722
|
+
# Enter alone at the step-limit question continues (it can't be
|
|
723
|
+
# dismissed), as at the continue prompt.
|
|
724
|
+
return dismiss_question if text.empty? && !@question.continue?
|
|
709
725
|
|
|
710
726
|
answer = @question.parse(text)
|
|
711
727
|
@screen.commit(answer.note) if answer.note
|
|
@@ -893,6 +909,8 @@ module Samagotchi
|
|
|
893
909
|
sync_continue_slot
|
|
894
910
|
end
|
|
895
911
|
render_current_turn(snapshot[:current_turn])
|
|
912
|
+
# A question between turns (the step-limit one) belongs to no turn.
|
|
913
|
+
ask(snapshot[:pending_question]) if snapshot[:current_turn].nil? && snapshot[:pending_question]
|
|
896
914
|
render_snapshot_cards(cards.select { |card| card[:current] }, joining: !reset)
|
|
897
915
|
Array(snapshot[:queued]).each do |entry|
|
|
898
916
|
next if own?(entry[:client_id])
|
|
@@ -951,8 +969,15 @@ module Samagotchi
|
|
|
951
969
|
return if text.empty?
|
|
952
970
|
|
|
953
971
|
@screen.commit("#{paint(PROMPT, 92)}#{text}")
|
|
954
|
-
# As if typed: a session command runs as the command
|
|
955
|
-
|
|
972
|
+
# As if typed: a session command runs as the command; a typo of one
|
|
973
|
+
# gets the hint, not a turn.
|
|
974
|
+
if command_registry.command?(text)
|
|
975
|
+
send_command(text)
|
|
976
|
+
elsif (hint = command_registry.unknown_command_hint(text))
|
|
977
|
+
@screen.commit(hint)
|
|
978
|
+
else
|
|
979
|
+
send_prompt(text)
|
|
980
|
+
end
|
|
956
981
|
end
|
|
957
982
|
|
|
958
983
|
def render_join_header(messages)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "formatting"
|
|
4
|
+
require_relative "event_renderer"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
class TerminalUI
|
|
8
|
+
# The turn sink of `chi -p … --non-interactive` (Engine#run_turn's
|
|
9
|
+
# on_event): the few lines a reader wants while the turn runs, on
|
|
10
|
+
# stderr. stdout is the answer's alone, printed after the turn: a
|
|
11
|
+
# script or a parent agent reads it.
|
|
12
|
+
#
|
|
13
|
+
# It prints the retry lines the REPL shows: the loop asking again after
|
|
14
|
+
# an empty answer (:empty_answer_retry), and a generation retried after
|
|
15
|
+
# a failed request (:generation_retrying). And the hooks' notices
|
|
16
|
+
# (:hook_notice): one during the turn as it comes, one the after_turn
|
|
17
|
+
# hooks give (after :turn_completed; source-links' `sources:`) kept for
|
|
18
|
+
# #flush, which the caller runs after it has printed the answer.
|
|
19
|
+
class OneShotSink
|
|
20
|
+
include Formatting
|
|
21
|
+
|
|
22
|
+
# @param err [IO, nil] nil writes to $stderr as it is at each write
|
|
23
|
+
def initialize(err: nil)
|
|
24
|
+
@err = err
|
|
25
|
+
@turn_over = false
|
|
26
|
+
@held = []
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call(event)
|
|
30
|
+
case event[:type]
|
|
31
|
+
when :empty_answer_retry then line(format_empty_retry_line(event))
|
|
32
|
+
when :generation_retrying then line(format_generation_retry_line(event))
|
|
33
|
+
when :turn_completed, :turn_canceled then @turn_over = true
|
|
34
|
+
when :hook_notice
|
|
35
|
+
text = EventRenderer.hook_notice_line(event)
|
|
36
|
+
@turn_over ? @held << text : line(text)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Print the notices that came after the turn's end.
|
|
41
|
+
def flush
|
|
42
|
+
line(@held.shift) until @held.empty?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def line(text)
|
|
48
|
+
err.puts(text)
|
|
49
|
+
err.flush
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def err = @err || $stderr
|
|
53
|
+
|
|
54
|
+
# Colour when stderr, where the lines go, is a colour terminal.
|
|
55
|
+
def color_output?
|
|
56
|
+
return false unless err.tty?
|
|
57
|
+
return false if ENV.key?("NO_COLOR")
|
|
58
|
+
|
|
59
|
+
ENV.fetch("TERM", "") != "dumb"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|