samagotchi 0.17.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 +64 -1
- data/docs/architecture.md +25 -2
- data/docs/cli.md +34 -8
- data/docs/configuration.md +4 -2
- data/docs/guardrails.md +51 -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/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 +42 -14
- 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/trash.rb +81 -2
- 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/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 +25 -5
- data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
- data/lib/samagotchi/terminal_ui.rb +11 -2
- data/lib/samagotchi/tool_declarations.rb +2 -2
- 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/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 +8 -1
|
@@ -31,8 +31,10 @@ module Samagotchi
|
|
|
31
31
|
module_function
|
|
32
32
|
|
|
33
33
|
# @param result [ReplyWait::Result]
|
|
34
|
-
# @return [String] answered, question, failed, canceled,
|
|
35
|
-
#
|
|
34
|
+
# @return [String] answered, question, failed, canceled, limit (the
|
|
35
|
+
# turn ran out of iterations, nobody asked), not_continued (a Stop
|
|
36
|
+
# answered the step-limit question), no_answer, error, worker_gone,
|
|
37
|
+
# stopped or running
|
|
36
38
|
def status(result)
|
|
37
39
|
case result.status
|
|
38
40
|
when :done then "answered"
|
|
@@ -41,6 +43,8 @@ module Samagotchi
|
|
|
41
43
|
case result.outcome
|
|
42
44
|
when "failed" then "failed"
|
|
43
45
|
when "canceled" then "canceled"
|
|
46
|
+
when "exhausted" then "limit"
|
|
47
|
+
when "not_continued" then "not_continued"
|
|
44
48
|
else "no_answer"
|
|
45
49
|
end
|
|
46
50
|
when :error then "error"
|
|
@@ -52,11 +56,12 @@ module Samagotchi
|
|
|
52
56
|
end
|
|
53
57
|
end
|
|
54
58
|
|
|
55
|
-
# @return [Integer] 0 answered
|
|
56
|
-
# 1 anything
|
|
59
|
+
# @return [Integer] 0 answered (or not continued: the Stop asked for
|
|
60
|
+
# was carried out), 3 a question waits, 4 still running, 1 anything
|
|
61
|
+
# else
|
|
57
62
|
def exit_status(result)
|
|
58
63
|
case status(result)
|
|
59
|
-
when "answered" then CLI::Exit::OK
|
|
64
|
+
when "answered", "not_continued" then CLI::Exit::OK
|
|
60
65
|
when "question" then CLI::Exit::QUESTION
|
|
61
66
|
when "running" then CLI::Exit::RUNNING
|
|
62
67
|
else CLI::Exit::FAILED
|
|
@@ -116,7 +121,8 @@ module Samagotchi
|
|
|
116
121
|
|
|
117
122
|
# A pending question (Session#pending_question) as a parent sees it.
|
|
118
123
|
# kind is "question" for the model's own (the desk stores none),
|
|
119
|
-
# "hook" or "
|
|
124
|
+
# "hook", "approval" or "continue" (the step-limit question, with the
|
|
125
|
+
# turn's limit).
|
|
120
126
|
# @return [Hash]
|
|
121
127
|
def question(pending)
|
|
122
128
|
pending ||= {}
|
|
@@ -129,15 +135,18 @@ module Samagotchi
|
|
|
129
135
|
options: Array(pending[:options]).map(&:to_s),
|
|
130
136
|
multi_select: !!pending[:multi_select],
|
|
131
137
|
allow_freeform: !!pending[:allow_freeform],
|
|
138
|
+
limit: pending[:limit],
|
|
132
139
|
approval: approval&.slice(*APPROVAL_KEYS)
|
|
133
140
|
}.compact
|
|
134
141
|
end
|
|
135
142
|
|
|
136
143
|
# The command that answers +question+; N is the parent's to fill in.
|
|
137
144
|
# An approval's is a deny: allowing it is the user's
|
|
138
|
-
# (Guardrails::ParentApprovals).
|
|
145
|
+
# (Guardrails::ParentApprovals). A step-limit question's continues the
|
|
146
|
+
# turn (its Stop form is in #question_text).
|
|
139
147
|
def answer_with(session_id, question)
|
|
140
148
|
return "chi answer #{session_id} --question #{question[:id]} --option Deny --text WHY" if approval?(question)
|
|
149
|
+
return "chi answer #{session_id} --question #{question[:id]} --option Continue" if continue?(question)
|
|
141
150
|
|
|
142
151
|
"chi answer #{session_id} --question #{question[:id]} --option N"
|
|
143
152
|
end
|
|
@@ -156,6 +165,16 @@ module Samagotchi
|
|
|
156
165
|
question.is_a?(Hash) && (question[:kind] || question["kind"]).to_s == "approval"
|
|
157
166
|
end
|
|
158
167
|
|
|
168
|
+
# The step-limit question (ContinueOffer).
|
|
169
|
+
def continue?(question)
|
|
170
|
+
question.is_a?(Hash) && (question[:kind] || question["kind"]).to_s == "continue"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# What a message does to a step-limit question.
|
|
174
|
+
def message_drops(session_id)
|
|
175
|
+
"a message instead (chi send #{session_id} -m …) drops it and starts a new turn"
|
|
176
|
+
end
|
|
177
|
+
|
|
159
178
|
# What a wait that ended without an answer says, one line.
|
|
160
179
|
def detail(result, session_id:, timeout: nil)
|
|
161
180
|
attach = "chi --attach #{session_id}"
|
|
@@ -163,10 +182,13 @@ module Samagotchi
|
|
|
163
182
|
when :waiting_for_answer
|
|
164
183
|
if approval?(result.question)
|
|
165
184
|
"waiting for an approval: #{first_line(result.question[:question])}; #{DENY_AND_TELL}"
|
|
185
|
+
elsif continue?(result.question)
|
|
186
|
+
"waiting at the step limit: #{first_line(result.question[:question])}; " \
|
|
187
|
+
"answer Continue or Stop: chi answer #{session_id} --question #{result.question[:id]} --option Continue"
|
|
166
188
|
else
|
|
167
189
|
"waiting for an answer: #{first_line(result.question&.dig(:question))}; open it: #{attach} or the web"
|
|
168
190
|
end
|
|
169
|
-
when :no_reply then "#{no_reply_line(result)}; #{attach} shows it"
|
|
191
|
+
when :no_reply then "#{no_reply_line(result, session_id)}; #{attach} shows it"
|
|
170
192
|
when :error then "the worker failed: #{result.text}; #{attach} shows what happened"
|
|
171
193
|
when :worker_gone then "the worker is gone; #{attach} shows what happened"
|
|
172
194
|
when :stopped then "the session was stopped (chi sessions stop)"
|
|
@@ -188,6 +210,10 @@ module Samagotchi
|
|
|
188
210
|
lines << " allowing it is up to your user: #{DENY_AND_TELL}"
|
|
189
211
|
lines << " deny: #{answer_with(session_id, q)}"
|
|
190
212
|
lines << " #{leave_open(session_id)}"
|
|
213
|
+
elsif continue?(q)
|
|
214
|
+
lines << " continue: #{answer_with(session_id, q)}"
|
|
215
|
+
lines << " stop: chi answer #{session_id} --question #{q[:id]} --option Stop --text WHY (the text is optional; the model reads it)"
|
|
216
|
+
lines << " #{message_drops(session_id)}"
|
|
191
217
|
else
|
|
192
218
|
lines << " more than one allowed: repeat --option" if q[:multi_select]
|
|
193
219
|
lines << " free text allowed: --text" if q[:allow_freeform]
|
|
@@ -197,11 +223,15 @@ module Samagotchi
|
|
|
197
223
|
lines.map { |line| "#{line}\n" }.join
|
|
198
224
|
end
|
|
199
225
|
|
|
200
|
-
def no_reply_line(result)
|
|
226
|
+
def no_reply_line(result, session_id = nil)
|
|
201
227
|
case result.outcome
|
|
202
228
|
when "failed" then result.text.to_s.strip.empty? ? "the turn failed" : "the turn failed: #{result.text.strip}"
|
|
203
229
|
when "canceled" then "the turn was canceled"
|
|
204
230
|
when "completed" then "the turn ended with no visible answer"
|
|
231
|
+
when "not_continued" then "the turn was not continued (Stop); its work so far stays"
|
|
232
|
+
when "exhausted"
|
|
233
|
+
"the turn ran out of iterations#{" (#{result.limit} steps)" if result.limit} before it answered; " \
|
|
234
|
+
"chi send #{session_id} -m '/continue yes' continues it, a message drops it"
|
|
205
235
|
else "the turn ended without a reply (canceled, failed or empty)"
|
|
206
236
|
end
|
|
207
237
|
end
|
|
@@ -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
|
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
|
|
@@ -342,6 +342,9 @@ module Samagotchi
|
|
|
342
342
|
# The history keeps !cmds, as the REPL's does (prompts: #send_prompt).
|
|
343
343
|
persist_recent_history(text) if PromptHistory.shell_line?(text)
|
|
344
344
|
send_command(text)
|
|
345
|
+
elsif (hint = command_registry.unknown_command_hint(text))
|
|
346
|
+
# A typo of a command (/modle) is not a prompt: say so, send nothing.
|
|
347
|
+
@screen.commit(hint)
|
|
345
348
|
else
|
|
346
349
|
send_prompt(text)
|
|
347
350
|
end
|
|
@@ -522,6 +525,9 @@ module Samagotchi
|
|
|
522
525
|
# UI's answer shows as its command line (web> /continue yes) instead.
|
|
523
526
|
def continue_resolved(event)
|
|
524
527
|
@continue_offer = nil
|
|
528
|
+
# Asked as the step-limit question: its line says what became of it.
|
|
529
|
+
asked = @offer_asked
|
|
530
|
+
@offer_asked = false
|
|
525
531
|
sync_continue_slot
|
|
526
532
|
who = event[:client_id] ? CLIENT_LABELS.fetch(event[:client_id].to_s.split(":", 2).first, "another UI") : "another UI"
|
|
527
533
|
outcome = if event[:decision] == "dropped"
|
|
@@ -530,14 +536,14 @@ module Samagotchi
|
|
|
530
536
|
@continue_answer || CONTINUE_DECISIONS.fetch(event[:decision].to_s, event[:decision].to_s)
|
|
531
537
|
end
|
|
532
538
|
@continue_answer = nil
|
|
533
|
-
@screen.commit(QuestionSlot.continue_summary(outcome, paint: method(:paint))) if outcome
|
|
539
|
+
@screen.commit(QuestionSlot.continue_summary(outcome, paint: method(:paint))) if outcome && !asked
|
|
534
540
|
sync_prompt
|
|
535
541
|
end
|
|
536
542
|
|
|
537
543
|
# The notes slot shows the offer's choices while it waits (a question
|
|
538
544
|
# has the slot while it is open).
|
|
539
545
|
def sync_continue_slot
|
|
540
|
-
return if @question
|
|
546
|
+
return if @question || @offer_asked
|
|
541
547
|
|
|
542
548
|
if @continue_offer
|
|
543
549
|
@screen.set_slot(:notes, QuestionSlot.continue_offer(@continue_offer[:context], paint: method(:paint)))
|
|
@@ -692,6 +698,9 @@ module Samagotchi
|
|
|
692
698
|
@set_aside = @reader&.typed_text unless @question
|
|
693
699
|
@question = QuestionPrompt.new(pending)
|
|
694
700
|
@question_pending = pending
|
|
701
|
+
# The step-limit question stands for the continue offer's own slot
|
|
702
|
+
# until the offer is resolved.
|
|
703
|
+
@offer_asked = true if @question.continue?
|
|
695
704
|
# An edit's diff goes above, into the scrollback, once per question
|
|
696
705
|
# (a join gets it from the snapshot and may get the event too).
|
|
697
706
|
unless @previewed_id == @question.id
|
|
@@ -705,7 +714,9 @@ module Samagotchi
|
|
|
705
714
|
end
|
|
706
715
|
|
|
707
716
|
def answer_question(text)
|
|
708
|
-
|
|
717
|
+
# Enter alone at the step-limit question continues (it can't be
|
|
718
|
+
# dismissed), as at the continue prompt.
|
|
719
|
+
return dismiss_question if text.empty? && !@question.continue?
|
|
709
720
|
|
|
710
721
|
answer = @question.parse(text)
|
|
711
722
|
@screen.commit(answer.note) if answer.note
|
|
@@ -893,6 +904,8 @@ module Samagotchi
|
|
|
893
904
|
sync_continue_slot
|
|
894
905
|
end
|
|
895
906
|
render_current_turn(snapshot[:current_turn])
|
|
907
|
+
# A question between turns (the step-limit one) belongs to no turn.
|
|
908
|
+
ask(snapshot[:pending_question]) if snapshot[:current_turn].nil? && snapshot[:pending_question]
|
|
896
909
|
render_snapshot_cards(cards.select { |card| card[:current] }, joining: !reset)
|
|
897
910
|
Array(snapshot[:queued]).each do |entry|
|
|
898
911
|
next if own?(entry[:client_id])
|
|
@@ -951,8 +964,15 @@ module Samagotchi
|
|
|
951
964
|
return if text.empty?
|
|
952
965
|
|
|
953
966
|
@screen.commit("#{paint(PROMPT, 92)}#{text}")
|
|
954
|
-
# As if typed: a session command runs as the command
|
|
955
|
-
|
|
967
|
+
# As if typed: a session command runs as the command; a typo of one
|
|
968
|
+
# gets the hint, not a turn.
|
|
969
|
+
if command_registry.command?(text)
|
|
970
|
+
send_command(text)
|
|
971
|
+
elsif (hint = command_registry.unknown_command_hint(text))
|
|
972
|
+
@screen.commit(hint)
|
|
973
|
+
else
|
|
974
|
+
send_prompt(text)
|
|
975
|
+
end
|
|
956
976
|
end
|
|
957
977
|
|
|
958
978
|
def render_join_header(messages)
|