samagotchi 0.53.0 → 0.54.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 +32 -1
- data/README.md +1 -1
- data/docs/architecture.md +94 -54
- data/docs/configuration.md +1 -2
- data/docs/context.md +104 -9
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +73 -0
- data/docs/internals/context-telemetry.md +8 -5
- data/docs/internals/engine.md +16 -7
- data/docs/internals/tool-guardrails.md +8 -6
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
- data/lib/samagotchi/bundle_command.rb +31 -36
- data/lib/samagotchi/bundle_needs.rb +4 -5
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/child_move.rb +6 -4
- data/lib/samagotchi/config.rb +0 -1
- data/lib/samagotchi/context_command.rb +1 -1
- data/lib/samagotchi/context_providers.rb +11 -14
- data/lib/samagotchi/context_status.rb +10 -18
- data/lib/samagotchi/engine.rb +117 -134
- data/lib/samagotchi/extension_load.rb +102 -0
- data/lib/samagotchi/guardrail_wiring.rb +16 -16
- data/lib/samagotchi/kernel_loop.rb +5 -3
- data/lib/samagotchi/llm/chat_loop.rb +5 -4
- data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
- data/lib/samagotchi/memory_bundle/builder.rb +170 -178
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
- data/lib/samagotchi/memory_bundle/installer.rb +252 -488
- data/lib/samagotchi/memory_bundle/listing.rb +6 -6
- data/lib/samagotchi/memory_bundle/manifest.rb +4 -2
- data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
- data/lib/samagotchi/memory_bundle/profile.rb +21 -21
- data/lib/samagotchi/memory_bundle/provenance.rb +38 -40
- data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
- data/lib/samagotchi/memory_bundle/status.rb +22 -24
- data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
- data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
- data/lib/samagotchi/plugin/loader.rb +8 -8
- data/lib/samagotchi/self_report.rb +2 -2
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session_continue.rb +275 -0
- data/lib/samagotchi/session_manager.rb +9 -253
- data/lib/samagotchi/session_metrics.rb +3 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
- data/lib/samagotchi/terminal_ui.rb +12 -2
- data/lib/samagotchi/tool_activity.rb +84 -14
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +1 -1
- data/lib/samagotchi/web/public/activity.js +2 -0
- data/lib/samagotchi/web/public/app.js +6 -6
- data/lib/samagotchi/web/public/ctx.js +18 -4
- data/lib/samagotchi/web/public/index.html +4 -2
- data/lib/samagotchi/web/public/question_card.js +9 -1
- data/lib/samagotchi/web/public/question_cards.js +5 -3
- data/lib/samagotchi/web/public/timing.js +8 -2
- data/lib/samagotchi/web/public/turn_html.js +10 -6
- metadata +7 -1
|
@@ -339,7 +339,7 @@ module Samagotchi
|
|
|
339
339
|
thinking: options[:thinking]),
|
|
340
340
|
state_dir: @state_dir)
|
|
341
341
|
end
|
|
342
|
-
rescue
|
|
342
|
+
rescue SessionContinue::Refused, SessionManager::ArchiveRefused, SessionManager::OwnedByTUI => e
|
|
343
343
|
error_line("chi send: refused: #{e.message}")
|
|
344
344
|
return 1
|
|
345
345
|
rescue StandardError => e
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "session"
|
|
5
|
+
require_relative "session_setup"
|
|
6
|
+
require_relative "session_inbox"
|
|
7
|
+
require_relative "session_chain"
|
|
8
|
+
require_relative "child_move"
|
|
9
|
+
require_relative "archive_store"
|
|
10
|
+
require_relative "recap_store"
|
|
11
|
+
require_relative "bridge_client"
|
|
12
|
+
require_relative "model_profile"
|
|
13
|
+
require_relative "log"
|
|
14
|
+
require_relative "session_manager"
|
|
15
|
+
|
|
16
|
+
module Samagotchi
|
|
17
|
+
# The next link of a session chain (chi send --new --continues, web
|
|
18
|
+
# Continue →): SessionManager.continue_session runs .run. The archive,
|
|
19
|
+
# spawn, stop and wake it builds on are SessionManager's.
|
|
20
|
+
module SessionContinue
|
|
21
|
+
# A continue that can't happen (#run). reason: :continued
|
|
22
|
+
# (another session continues it already: +ids+ names it), :open_children
|
|
23
|
+
# (open children it can't move to the new link: on an older chi's
|
|
24
|
+
# worker or open in a chi REPL; +ids+ names them) or :folder_gone (its
|
|
25
|
+
# folder isn't there).
|
|
26
|
+
class Refused < StandardError
|
|
27
|
+
attr_reader :session_id, :reason, :ids
|
|
28
|
+
|
|
29
|
+
def initialize(session_id, reason, ids: [], detail: nil)
|
|
30
|
+
@session_id = session_id
|
|
31
|
+
@reason = reason
|
|
32
|
+
@ids = ids
|
|
33
|
+
super(continue_refused_message(detail))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def continue_refused_message(detail)
|
|
39
|
+
short = @session_id[0, 8]
|
|
40
|
+
case @reason
|
|
41
|
+
when :continued then "#{short} is continued already, by #{@ids.first[0, 8]}; continue that one (or last:#{short})"
|
|
42
|
+
when :open_children
|
|
43
|
+
"#{short} has delegates still open that can't move to the next link: #{detail}; " \
|
|
44
|
+
"then continue (or wait for them, stop them or archive them)"
|
|
45
|
+
else "#{short}'s folder #{detail} is gone"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# How long #run waits for the previous link's worker to write its
|
|
51
|
+
# recap (IdleRecap's own request timeout).
|
|
52
|
+
RECAP_WAIT = 30.0
|
|
53
|
+
# In the previous link's folder: one continue of a session at a time.
|
|
54
|
+
LOCK = "continue.lock"
|
|
55
|
+
# How often a last:<id> continue looks again when another continue
|
|
56
|
+
# moved the chain's end while it waited for the lock.
|
|
57
|
+
TRIES = 3
|
|
58
|
+
|
|
59
|
+
# Start the next link of a chain: a new session that continues
|
|
60
|
+
# +id_or_ref+ (Session#continues) in its folder, on its model (the name
|
|
61
|
+
# it was typed as, so an alias holds) and with its own setup (SessionSetup),
|
|
62
|
+
# starting with a context note from chi: the link and the previous
|
|
63
|
+
# link's recap (SessionChain.note_text). The previous link is archived
|
|
64
|
+
# first, with its finished delegates (archive_session's rules: a turn
|
|
65
|
+
# running, a prompt queued or the step-limit question refuse; an idle
|
|
66
|
+
# worker is stopped), so a refusal starts nothing. Its open delegates
|
|
67
|
+
# (running, waiting, live, an unreported reply, at any depth below them)
|
|
68
|
+
# move to the new link instead (ChildMove): their reports reach it.
|
|
69
|
+
# Before that its live worker is asked for a recap and given up to
|
|
70
|
+
# +recap_wait+ seconds to write it.
|
|
71
|
+
# One continue of a session at a time (a lock in its folder): a double
|
|
72
|
+
# click finds the first one's link and is refused; a last:<id> one
|
|
73
|
+
# follows the chain to its new end instead.
|
|
74
|
+
# @param id_or_ref [String] an id, a prefix, or last:<id> (SessionChain.resolve)
|
|
75
|
+
# @param prompt [String, nil] its first message (nil: it starts idle)
|
|
76
|
+
# @param title [String, nil] what the lists show before the first turn;
|
|
77
|
+
# by default the prompt, else the previous link's preview
|
|
78
|
+
# @return [Session] the new link (#model_warning as spawn_session's,
|
|
79
|
+
# #moved_children: the delegates it took over)
|
|
80
|
+
# @raise [ArgumentError] no such session (Session::AmbiguousId for a prefix of several)
|
|
81
|
+
# @raise [Refused] continued already, delegates open that can't
|
|
82
|
+
# move, or its folder is gone
|
|
83
|
+
# @raise [ArchiveRefused, OwnedByTUI] as archive_session
|
|
84
|
+
def self.run(id_or_ref, prompt: nil, title: nil, state_dir: nil, recap_wait: RECAP_WAIT, archive_wait: 5)
|
|
85
|
+
sd = state_dir || Session.default_state_dir
|
|
86
|
+
TRIES.times do |try|
|
|
87
|
+
id = SessionChain.resolve(id_or_ref, state_dir: sd)
|
|
88
|
+
result = with_lock(id, sd) do
|
|
89
|
+
follow = id_or_ref.to_s.strip.start_with?(SessionChain::LAST_PREFIX) && try < TRIES - 1
|
|
90
|
+
next :moved if follow && SessionChain.next_of(id, state_dir: sd)
|
|
91
|
+
|
|
92
|
+
run_locked(id, prompt: prompt, title: title, state_dir: sd, recap_wait: recap_wait,
|
|
93
|
+
archive_wait: archive_wait)
|
|
94
|
+
end
|
|
95
|
+
return result unless result == :moved
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private_class_method def self.with_lock(id, state_dir, &)
|
|
100
|
+
dir = Session.session_dir(id, state_dir: state_dir)
|
|
101
|
+
FileUtils.mkdir_p(dir)
|
|
102
|
+
File.open(File.join(dir, LOCK), File::RDWR | File::CREAT, 0o600) do |lock|
|
|
103
|
+
lock.flock(File::LOCK_EX)
|
|
104
|
+
yield
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private_class_method def self.run_locked(id, prompt:, title:, state_dir:, recap_wait:, archive_wait:)
|
|
109
|
+
previous = Session.load(id, state_dir: state_dir)
|
|
110
|
+
plan = check!(previous, state_dir)
|
|
111
|
+
# archive_session's refusals that can be known now, before the wait.
|
|
112
|
+
SessionManager.archivable!(id, state_dir, except: plan.move)
|
|
113
|
+
recap_before_archive(id, state_dir, wait: recap_wait)
|
|
114
|
+
# Again: a turn sent to it during the wait may have delegated (they
|
|
115
|
+
# move too), or a delegate finished.
|
|
116
|
+
plan = move_plan!(id, state_dir)
|
|
117
|
+
already = [id, *SessionManager.descendant_ids(id, state_dir, except: plan.move)].select do |sid|
|
|
118
|
+
ArchiveStore.archived?(Session.session_dir(sid, state_dir: state_dir))
|
|
119
|
+
end
|
|
120
|
+
archive = SessionManager.archive_session(id, state_dir: state_dir, wait: archive_wait, except: plan.move)
|
|
121
|
+
archived = archive[:archived] - already
|
|
122
|
+
begin
|
|
123
|
+
spawn_continuation(previous, prompt: prompt, title: title, state_dir: state_dir, move: plan.move)
|
|
124
|
+
rescue StandardError
|
|
125
|
+
# Nothing started: the delegates go back, what this archived goes
|
|
126
|
+
# back to the lists, and a new link saved before its worker failed
|
|
127
|
+
# to spawn goes (left, it would hold the chain: "continued already",
|
|
128
|
+
# last: to it).
|
|
129
|
+
undo_move(id, state_dir, moved: plan.move)
|
|
130
|
+
begin
|
|
131
|
+
discard_failed_link(id, state_dir)
|
|
132
|
+
ensure
|
|
133
|
+
archived.each { |sid| ArchiveStore.unarchive(sid, state_dir: state_dir) }
|
|
134
|
+
wake_for_rings(id, state_dir) unless plan.move.empty?
|
|
135
|
+
end
|
|
136
|
+
raise
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The delegates a failed #run moved, or began to (its
|
|
141
|
+
# intent names them), go back to +id+.
|
|
142
|
+
private_class_method def self.undo_move(id, state_dir, moved:)
|
|
143
|
+
link = SessionChain.next_of(id, state_dir: state_dir) or return
|
|
144
|
+
ids = ChildMove.read_intent(link, state_dir: state_dir)&.ids || moved
|
|
145
|
+
ChildMove.apply(ids, from: link, to: id, state_dir: state_dir, undo: true)
|
|
146
|
+
ChildMove.clear_intent(link, state_dir: state_dir)
|
|
147
|
+
rescue StandardError => e
|
|
148
|
+
Log.warn(:worker, "continue_undo_move_failed", sid: id, error: e.class.name, msg: e.message)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The link a failed #run saved (no other continues +id+:
|
|
152
|
+
# check! made sure under the lock): its worker stopped if one
|
|
153
|
+
# started, then the rings a moved delegate wrote into it just before
|
|
154
|
+
# the undo go back to +id+, then the link goes. A worker that outlives
|
|
155
|
+
# the stop is raised (SessionManager::DeleteRefused): the link stays,
|
|
156
|
+
# and the caller hears it.
|
|
157
|
+
private_class_method def self.discard_failed_link(id, state_dir)
|
|
158
|
+
orphan = SessionChain.next_of(id, state_dir: state_dir) or return
|
|
159
|
+
if SessionManager.refuse_tui!(orphan, state_dir: state_dir) &&
|
|
160
|
+
!SessionManager.stop_session(orphan, state_dir: state_dir, wait: 10)
|
|
161
|
+
raise SessionManager::DeleteRefused.new(orphan, :still_stopping)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
ChildMove.return_rings(from: orphan, to: id, state_dir: state_dir)
|
|
165
|
+
SessionManager.delete_session(orphan, state_dir: state_dir)
|
|
166
|
+
rescue SessionManager::DeleteRefused
|
|
167
|
+
raise
|
|
168
|
+
rescue StandardError => e
|
|
169
|
+
Log.warn(:worker, "continue_cleanup_failed", sid: id, error: e.class.name, msg: e.message)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# A failed continue's previous link, back in the lists, with rings that
|
|
173
|
+
# came in meanwhile: wake it for them, as the ring's own wake would have.
|
|
174
|
+
private_class_method def self.wake_for_rings(id, state_dir)
|
|
175
|
+
return if SessionInbox.find_ring_files(Session.session_dir(id, state_dir: state_dir)).empty?
|
|
176
|
+
|
|
177
|
+
SessionManager.wake_for_report(id, state_dir: state_dir)
|
|
178
|
+
rescue StandardError => e
|
|
179
|
+
Log.warn(:worker, "continue_wake_failed", sid: id, error: e.class.name, msg: e.message)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# The new link, holding off any worker but its own (ChildMove.starting?)
|
|
183
|
+
# while +move+ goes to it.
|
|
184
|
+
private_class_method def self.spawn_continuation(previous, prompt:, title:, state_dir:, move:)
|
|
185
|
+
recap = RecapStore.read(Session.session_dir(previous.id, state_dir: state_dir))
|
|
186
|
+
start = prompt.to_s.strip.empty? ? nil : prompt
|
|
187
|
+
title = previous.first_preview if title.to_s.strip.empty? && start.nil?
|
|
188
|
+
before_spawn = lambda do |link|
|
|
189
|
+
ChildMove.mark_starting(link.id, state_dir: state_dir)
|
|
190
|
+
ChildMove.apply(move, from: previous.id, to: link.id, state_dir: state_dir)
|
|
191
|
+
end
|
|
192
|
+
link = SessionManager.spawn_session(
|
|
193
|
+
prompt: start, title: title, working_directory: previous.working_directory,
|
|
194
|
+
model_name: previous.model_typed || previous.model_name, setup: SessionSetup.of(previous),
|
|
195
|
+
continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
|
|
196
|
+
note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn
|
|
197
|
+
)
|
|
198
|
+
link.moved_children = move
|
|
199
|
+
link
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The refusals that come before anything changes: continued already (no
|
|
203
|
+
# forks in a chain), an open delegate that can't move to the new link
|
|
204
|
+
# (ChildMove.plan), the folder gone (the worker would run elsewhere), a
|
|
205
|
+
# model whose host config.yml no longer has.
|
|
206
|
+
# @return [ChildMove::Plan]
|
|
207
|
+
# @raise [Refused, ModelProfile::MissingModel]
|
|
208
|
+
private_class_method def self.check!(previous, state_dir)
|
|
209
|
+
if (following = SessionChain.next_of(previous.id, state_dir: state_dir))
|
|
210
|
+
raise Refused.new(previous.id, :continued, ids: [following])
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
plan = move_plan!(previous.id, state_dir)
|
|
214
|
+
# Its model's host, as SessionManager.spawn_session checks it: before
|
|
215
|
+
# the archive stops the previous link's worker.
|
|
216
|
+
ModelProfile.check_host!(ModelProfile.required_model_name(previous.model_typed || previous.model_name))
|
|
217
|
+
dir = previous.working_directory.to_s
|
|
218
|
+
raise Refused.new(previous.id, :folder_gone, detail: dir) unless File.directory?(dir)
|
|
219
|
+
|
|
220
|
+
plan
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# A child still open below a session (#open_children); why: running,
|
|
224
|
+
# waiting, live, unreported reply, or why it can't move (ChildMove).
|
|
225
|
+
OpenChild = Data.define(:id, :short_id, :why)
|
|
226
|
+
|
|
227
|
+
# The delegates a continue of +id+ moves (ChildMove.plan).
|
|
228
|
+
# @return [ChildMove::Plan]
|
|
229
|
+
# @raise [Refused] for open ones it can't move
|
|
230
|
+
private_class_method def self.move_plan!(id, state_dir)
|
|
231
|
+
plan = ChildMove.plan(id, open: open_children(id, state_dir), state_dir: state_dir)
|
|
232
|
+
return plan if plan.refuse.empty?
|
|
233
|
+
|
|
234
|
+
raise Refused.new(id, :open_children, ids: plan.refuse.map(&:id),
|
|
235
|
+
detail: plan.refuse.map { |c| "#{c.short_id} (#{c.why})" }.join(", "))
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# The unarchived children, at any depth (the archive's cascade), of the
|
|
239
|
+
# session still running, waiting for an answer, with a live worker, or
|
|
240
|
+
# (a delegate) with a reply its parent wasn't given (ChildrenStatus).
|
|
241
|
+
# @return [Array<OpenChild>]
|
|
242
|
+
def self.open_children(id, state_dir)
|
|
243
|
+
require_relative "children_status"
|
|
244
|
+
nodes = [id, *SessionManager.descendant_ids(id, state_dir)]
|
|
245
|
+
nodes.flat_map { |node| ChildrenStatus.of(node, state_dir: state_dir) }.uniq(&:id).filter_map do |child|
|
|
246
|
+
why = if %w[waiting running].include?(child.state) then child.state
|
|
247
|
+
elsif child.live then "live"
|
|
248
|
+
elsif child.delegate && child.last_reply && !child.reported then "unreported reply"
|
|
249
|
+
end
|
|
250
|
+
why && OpenChild.new(id: child.id, short_id: child.short_id, why: why)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Ask the session's live worker for a recap now (Bridge POST /recap:
|
|
255
|
+
# IdleRecap#request_now) and wait, up to +wait+ seconds, for recap.json
|
|
256
|
+
# to change. A worker that is gone wrote its recap when it left; one
|
|
257
|
+
# that has nothing new to say, too short a conversation, or recaps off
|
|
258
|
+
# answers at once. Best effort: the continue goes on without.
|
|
259
|
+
private_class_method def self.recap_before_archive(id, state_dir, wait:)
|
|
260
|
+
return unless SessionManager.worker_live?(id, state_dir: state_dir)
|
|
261
|
+
|
|
262
|
+
dir = Session.session_dir(id, state_dir: state_dir)
|
|
263
|
+
client = BridgeClient.discover(id, session_dir: dir) or return
|
|
264
|
+
before = RecapStore.read(dir)
|
|
265
|
+
reply = client.request_recap
|
|
266
|
+
asked = reply.status == 200 && reply.json.is_a?(Hash) ? reply.json["request"] : nil
|
|
267
|
+
return unless %w[started in_flight].include?(asked)
|
|
268
|
+
|
|
269
|
+
BridgeClient.poll(wait, interval: 0.2) { RecapStore.read(dir) != before }
|
|
270
|
+
rescue StandardError => e
|
|
271
|
+
Log.info(:worker, "continue_recap_failed", sid: id, error: e.class.name, msg: e.message)
|
|
272
|
+
nil
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
@@ -65,6 +65,8 @@ module Samagotchi
|
|
|
65
65
|
autoload :Worker, File.expand_path("worker", __dir__)
|
|
66
66
|
# session_retention requires this file.
|
|
67
67
|
autoload :SessionRetention, File.expand_path("session_retention", __dir__)
|
|
68
|
+
# session_continue requires this file.
|
|
69
|
+
autoload :SessionContinue, File.expand_path("session_continue", __dir__)
|
|
68
70
|
|
|
69
71
|
class SessionManager
|
|
70
72
|
# Raised when the interactive TUI owns the session: it runs its own Engine
|
|
@@ -120,35 +122,6 @@ module Samagotchi
|
|
|
120
122
|
end
|
|
121
123
|
end
|
|
122
124
|
|
|
123
|
-
# A continue that can't happen (#continue_session). reason: :continued
|
|
124
|
-
# (another session continues it already: +ids+ names it), :open_children
|
|
125
|
-
# (open children it can't move to the new link: on an older chi's
|
|
126
|
-
# worker or open in a chi REPL; +ids+ names them) or :folder_gone (its
|
|
127
|
-
# folder isn't there).
|
|
128
|
-
class ContinueRefused < StandardError
|
|
129
|
-
attr_reader :session_id, :reason, :ids
|
|
130
|
-
|
|
131
|
-
def initialize(session_id, reason, ids: [], detail: nil)
|
|
132
|
-
@session_id = session_id
|
|
133
|
-
@reason = reason
|
|
134
|
-
@ids = ids
|
|
135
|
-
super(continue_refused_message(detail))
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
private
|
|
139
|
-
|
|
140
|
-
def continue_refused_message(detail)
|
|
141
|
-
short = @session_id[0, 8]
|
|
142
|
-
case @reason
|
|
143
|
-
when :continued then "#{short} is continued already, by #{@ids.first[0, 8]}; continue that one (or last:#{short})"
|
|
144
|
-
when :open_children
|
|
145
|
-
"#{short} has delegates still open that can't move to the next link: #{detail}; " \
|
|
146
|
-
"then continue (or wait for them, stop them or archive them)"
|
|
147
|
-
else "#{short}'s folder #{detail} is gone"
|
|
148
|
-
end
|
|
149
|
-
end
|
|
150
|
-
end
|
|
151
|
-
|
|
152
125
|
# Spawn a new background session that processes the given prompt (or,
|
|
153
126
|
# with none, waits idle for input).
|
|
154
127
|
#
|
|
@@ -168,7 +141,7 @@ module Samagotchi
|
|
|
168
141
|
# @param setup [SessionSetup] the session's own settings (chi
|
|
169
142
|
# --llm-context; a fork and a continue copy their session's)
|
|
170
143
|
# @param continues [String, nil] the session this one continues (a
|
|
171
|
-
# session field;
|
|
144
|
+
# session field; SessionContinue)
|
|
172
145
|
# @param note [String, nil] a context note the session starts with,
|
|
173
146
|
# from +note_source+: queued before its worker spawns, so its first
|
|
174
147
|
# turn sees it
|
|
@@ -223,227 +196,9 @@ module Samagotchi
|
|
|
223
196
|
session
|
|
224
197
|
end
|
|
225
198
|
|
|
226
|
-
#
|
|
227
|
-
#
|
|
228
|
-
|
|
229
|
-
# In the previous link's folder: one continue of a session at a time.
|
|
230
|
-
CONTINUE_LOCK = "continue.lock"
|
|
231
|
-
# How often a last:<id> continue looks again when another continue
|
|
232
|
-
# moved the chain's end while it waited for the lock.
|
|
233
|
-
CONTINUE_TRIES = 3
|
|
234
|
-
|
|
235
|
-
# Start the next link of a chain: a new session that continues
|
|
236
|
-
# +id_or_ref+ (Session#continues) in its folder, on its model (the name
|
|
237
|
-
# it was typed as, so an alias holds) and with its own setup (SessionSetup),
|
|
238
|
-
# starting with a context note from chi: the link and the previous
|
|
239
|
-
# link's recap (SessionChain.note_text). The previous link is archived
|
|
240
|
-
# first, with its finished delegates (archive_session's rules: a turn
|
|
241
|
-
# running, a prompt queued or the step-limit question refuse; an idle
|
|
242
|
-
# worker is stopped), so a refusal starts nothing. Its open delegates
|
|
243
|
-
# (running, waiting, live, an unreported reply, at any depth below them)
|
|
244
|
-
# move to the new link instead (ChildMove): their reports reach it.
|
|
245
|
-
# Before that its live worker is asked for a recap and given up to
|
|
246
|
-
# +recap_wait+ seconds to write it.
|
|
247
|
-
# One continue of a session at a time (a lock in its folder): a double
|
|
248
|
-
# click finds the first one's link and is refused; a last:<id> one
|
|
249
|
-
# follows the chain to its new end instead.
|
|
250
|
-
# @param id_or_ref [String] an id, a prefix, or last:<id> (SessionChain.resolve)
|
|
251
|
-
# @param prompt [String, nil] its first message (nil: it starts idle)
|
|
252
|
-
# @param title [String, nil] what the lists show before the first turn;
|
|
253
|
-
# by default the prompt, else the previous link's preview
|
|
254
|
-
# @return [Session] the new link (#model_warning as spawn_session's,
|
|
255
|
-
# #moved_children: the delegates it took over)
|
|
256
|
-
# @raise [ArgumentError] no such session (Session::AmbiguousId for a prefix of several)
|
|
257
|
-
# @raise [ContinueRefused] continued already, delegates open that can't
|
|
258
|
-
# move, or its folder is gone
|
|
259
|
-
# @raise [ArchiveRefused, OwnedByTUI] as archive_session
|
|
260
|
-
def self.continue_session(id_or_ref, prompt: nil, title: nil, state_dir: nil, recap_wait: CONTINUE_RECAP_WAIT,
|
|
261
|
-
archive_wait: 5)
|
|
262
|
-
sd = state_dir || Session.default_state_dir
|
|
263
|
-
CONTINUE_TRIES.times do |try|
|
|
264
|
-
id = SessionChain.resolve(id_or_ref, state_dir: sd)
|
|
265
|
-
result = with_continue_lock(id, sd) do
|
|
266
|
-
follow = id_or_ref.to_s.strip.start_with?(SessionChain::LAST_PREFIX) && try < CONTINUE_TRIES - 1
|
|
267
|
-
next :moved if follow && SessionChain.next_of(id, state_dir: sd)
|
|
268
|
-
|
|
269
|
-
continue_locked(id, prompt: prompt, title: title, state_dir: sd, recap_wait: recap_wait,
|
|
270
|
-
archive_wait: archive_wait)
|
|
271
|
-
end
|
|
272
|
-
return result unless result == :moved
|
|
273
|
-
end
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
private_class_method def self.with_continue_lock(id, state_dir, &)
|
|
277
|
-
dir = Session.session_dir(id, state_dir: state_dir)
|
|
278
|
-
FileUtils.mkdir_p(dir)
|
|
279
|
-
File.open(File.join(dir, CONTINUE_LOCK), File::RDWR | File::CREAT, 0o600) do |lock|
|
|
280
|
-
lock.flock(File::LOCK_EX)
|
|
281
|
-
yield
|
|
282
|
-
end
|
|
283
|
-
end
|
|
284
|
-
|
|
285
|
-
private_class_method def self.continue_locked(id, prompt:, title:, state_dir:, recap_wait:, archive_wait:)
|
|
286
|
-
previous = Session.load(id, state_dir: state_dir)
|
|
287
|
-
plan = check_continue!(previous, state_dir)
|
|
288
|
-
# archive_session's refusals that can be known now, before the wait.
|
|
289
|
-
archivable!(id, state_dir, except: plan.move)
|
|
290
|
-
recap_before_archive(id, state_dir, wait: recap_wait)
|
|
291
|
-
# Again: a turn sent to it during the wait may have delegated (they
|
|
292
|
-
# move too), or a delegate finished.
|
|
293
|
-
plan = move_plan!(id, state_dir)
|
|
294
|
-
already = [id, *descendant_ids(id, state_dir, except: plan.move)].select do |sid|
|
|
295
|
-
ArchiveStore.archived?(Session.session_dir(sid, state_dir: state_dir))
|
|
296
|
-
end
|
|
297
|
-
archived = archive_session(id, state_dir: state_dir, wait: archive_wait, except: plan.move)[:archived] - already
|
|
298
|
-
begin
|
|
299
|
-
spawn_continuation(previous, prompt: prompt, title: title, state_dir: state_dir, move: plan.move)
|
|
300
|
-
rescue StandardError
|
|
301
|
-
# Nothing started: the delegates go back, what this archived goes
|
|
302
|
-
# back to the lists, and a new link saved before its worker failed
|
|
303
|
-
# to spawn goes (left, it would hold the chain: "continued already",
|
|
304
|
-
# last: to it).
|
|
305
|
-
undo_move(id, state_dir, moved: plan.move)
|
|
306
|
-
begin
|
|
307
|
-
discard_failed_link(id, state_dir)
|
|
308
|
-
ensure
|
|
309
|
-
archived.each { |sid| ArchiveStore.unarchive(sid, state_dir: state_dir) }
|
|
310
|
-
wake_for_rings(id, state_dir) unless plan.move.empty?
|
|
311
|
-
end
|
|
312
|
-
raise
|
|
313
|
-
end
|
|
314
|
-
end
|
|
315
|
-
|
|
316
|
-
# The delegates a failed #continue_session moved, or began to (its
|
|
317
|
-
# intent names them), go back to +id+.
|
|
318
|
-
private_class_method def self.undo_move(id, state_dir, moved:)
|
|
319
|
-
link = SessionChain.next_of(id, state_dir: state_dir) or return
|
|
320
|
-
ids = ChildMove.read_intent(link, state_dir: state_dir)&.ids || moved
|
|
321
|
-
ChildMove.apply(ids, from: link, to: id, state_dir: state_dir, undo: true)
|
|
322
|
-
ChildMove.clear_intent(link, state_dir: state_dir)
|
|
323
|
-
rescue StandardError => e
|
|
324
|
-
Log.warn(:worker, "continue_undo_move_failed", sid: id, error: e.class.name, msg: e.message)
|
|
325
|
-
end
|
|
326
|
-
|
|
327
|
-
# The link a failed #continue_session saved (no other continues +id+:
|
|
328
|
-
# check_continue! made sure under the lock): its worker stopped if one
|
|
329
|
-
# started, then the rings a moved delegate wrote into it just before
|
|
330
|
-
# the undo go back to +id+, then the link goes. A worker that outlives
|
|
331
|
-
# the stop is raised (DeleteRefused): the link stays, and the caller
|
|
332
|
-
# hears it.
|
|
333
|
-
private_class_method def self.discard_failed_link(id, state_dir)
|
|
334
|
-
orphan = SessionChain.next_of(id, state_dir: state_dir) or return
|
|
335
|
-
if refuse_tui!(orphan, state_dir: state_dir) && !stop_session(orphan, state_dir: state_dir, wait: 10)
|
|
336
|
-
raise DeleteRefused.new(orphan, :still_stopping)
|
|
337
|
-
end
|
|
338
|
-
|
|
339
|
-
ChildMove.return_rings(from: orphan, to: id, state_dir: state_dir)
|
|
340
|
-
delete_session(orphan, state_dir: state_dir)
|
|
341
|
-
rescue DeleteRefused
|
|
342
|
-
raise
|
|
343
|
-
rescue StandardError => e
|
|
344
|
-
Log.warn(:worker, "continue_cleanup_failed", sid: id, error: e.class.name, msg: e.message)
|
|
345
|
-
end
|
|
346
|
-
|
|
347
|
-
# A failed continue's previous link, back in the lists, with rings that
|
|
348
|
-
# came in meanwhile: wake it for them, as the ring's own wake would have.
|
|
349
|
-
private_class_method def self.wake_for_rings(id, state_dir)
|
|
350
|
-
return if SessionInbox.find_ring_files(Session.session_dir(id, state_dir: state_dir)).empty?
|
|
351
|
-
|
|
352
|
-
wake_for_report(id, state_dir: state_dir)
|
|
353
|
-
rescue StandardError => e
|
|
354
|
-
Log.warn(:worker, "continue_wake_failed", sid: id, error: e.class.name, msg: e.message)
|
|
355
|
-
end
|
|
356
|
-
|
|
357
|
-
# The new link, holding off any worker but its own (ChildMove.starting?)
|
|
358
|
-
# while +move+ goes to it.
|
|
359
|
-
private_class_method def self.spawn_continuation(previous, prompt:, title:, state_dir:, move:)
|
|
360
|
-
recap = RecapStore.read(Session.session_dir(previous.id, state_dir: state_dir))
|
|
361
|
-
start = prompt.to_s.strip.empty? ? nil : prompt
|
|
362
|
-
title = previous.first_preview if title.to_s.strip.empty? && start.nil?
|
|
363
|
-
before_spawn = lambda do |link|
|
|
364
|
-
ChildMove.mark_starting(link.id, state_dir: state_dir)
|
|
365
|
-
ChildMove.apply(move, from: previous.id, to: link.id, state_dir: state_dir)
|
|
366
|
-
end
|
|
367
|
-
link = spawn_session(prompt: start, title: title, working_directory: previous.working_directory,
|
|
368
|
-
model_name: previous.model_typed || previous.model_name, setup: SessionSetup.of(previous),
|
|
369
|
-
continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
|
|
370
|
-
note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn)
|
|
371
|
-
link.moved_children = move
|
|
372
|
-
link
|
|
373
|
-
end
|
|
374
|
-
|
|
375
|
-
# The refusals that come before anything changes: continued already (no
|
|
376
|
-
# forks in a chain), an open delegate that can't move to the new link
|
|
377
|
-
# (ChildMove.plan), the folder gone (the worker would run elsewhere), a
|
|
378
|
-
# model whose host config.yml no longer has.
|
|
379
|
-
# @return [ChildMove::Plan]
|
|
380
|
-
# @raise [ContinueRefused, ModelProfile::MissingModel]
|
|
381
|
-
private_class_method def self.check_continue!(previous, state_dir)
|
|
382
|
-
if (following = SessionChain.next_of(previous.id, state_dir: state_dir))
|
|
383
|
-
raise ContinueRefused.new(previous.id, :continued, ids: [following])
|
|
384
|
-
end
|
|
385
|
-
|
|
386
|
-
plan = move_plan!(previous.id, state_dir)
|
|
387
|
-
# Its model's host, as spawn_session checks it: before the archive
|
|
388
|
-
# stops the previous link's worker.
|
|
389
|
-
ModelProfile.check_host!(ModelProfile.required_model_name(previous.model_typed || previous.model_name))
|
|
390
|
-
dir = previous.working_directory.to_s
|
|
391
|
-
raise ContinueRefused.new(previous.id, :folder_gone, detail: dir) unless File.directory?(dir)
|
|
392
|
-
|
|
393
|
-
plan
|
|
394
|
-
end
|
|
395
|
-
|
|
396
|
-
# A child still open below a session (#open_children); why: running,
|
|
397
|
-
# waiting, live, unreported reply, or why it can't move (ChildMove).
|
|
398
|
-
OpenChild = Data.define(:id, :short_id, :why)
|
|
399
|
-
|
|
400
|
-
# The delegates a continue of +id+ moves (ChildMove.plan).
|
|
401
|
-
# @return [ChildMove::Plan]
|
|
402
|
-
# @raise [ContinueRefused] for open ones it can't move
|
|
403
|
-
private_class_method def self.move_plan!(id, state_dir)
|
|
404
|
-
plan = ChildMove.plan(id, open: open_children(id, state_dir), state_dir: state_dir)
|
|
405
|
-
return plan if plan.refuse.empty?
|
|
406
|
-
|
|
407
|
-
raise ContinueRefused.new(id, :open_children, ids: plan.refuse.map(&:id),
|
|
408
|
-
detail: plan.refuse.map { |c| "#{c.short_id} (#{c.why})" }.join(", "))
|
|
409
|
-
end
|
|
410
|
-
|
|
411
|
-
# The unarchived children, at any depth (the archive's cascade), of the
|
|
412
|
-
# session still running, waiting for an answer, with a live worker, or
|
|
413
|
-
# (a delegate) with a reply its parent wasn't given (ChildrenStatus).
|
|
414
|
-
# @return [Array<OpenChild>]
|
|
415
|
-
def self.open_children(id, state_dir)
|
|
416
|
-
require_relative "children_status"
|
|
417
|
-
[id, *descendant_ids(id, state_dir)].flat_map { |node| ChildrenStatus.of(node, state_dir: state_dir) }.uniq(&:id)
|
|
418
|
-
.filter_map do |child|
|
|
419
|
-
why = if %w[waiting running].include?(child.state) then child.state
|
|
420
|
-
elsif child.live then "live"
|
|
421
|
-
elsif child.delegate && child.last_reply && !child.reported then "unreported reply"
|
|
422
|
-
end
|
|
423
|
-
why && OpenChild.new(id: child.id, short_id: child.short_id, why: why)
|
|
424
|
-
end
|
|
425
|
-
end
|
|
426
|
-
|
|
427
|
-
# Ask the session's live worker for a recap now (Bridge POST /recap:
|
|
428
|
-
# IdleRecap#request_now) and wait, up to +wait+ seconds, for recap.json
|
|
429
|
-
# to change. A worker that is gone wrote its recap when it left; one
|
|
430
|
-
# that has nothing new to say, too short a conversation, or recaps off
|
|
431
|
-
# answers at once. Best effort: the continue goes on without.
|
|
432
|
-
private_class_method def self.recap_before_archive(id, state_dir, wait:)
|
|
433
|
-
return unless worker_live?(id, state_dir: state_dir)
|
|
434
|
-
|
|
435
|
-
dir = Session.session_dir(id, state_dir: state_dir)
|
|
436
|
-
client = BridgeClient.discover(id, session_dir: dir) or return
|
|
437
|
-
before = RecapStore.read(dir)
|
|
438
|
-
reply = client.request_recap
|
|
439
|
-
asked = reply.status == 200 && reply.json.is_a?(Hash) ? reply.json["request"] : nil
|
|
440
|
-
return unless %w[started in_flight].include?(asked)
|
|
441
|
-
|
|
442
|
-
BridgeClient.poll(wait, interval: 0.2) { RecapStore.read(dir) != before }
|
|
443
|
-
rescue StandardError => e
|
|
444
|
-
Log.info(:worker, "continue_recap_failed", sid: id, error: e.class.name, msg: e.message)
|
|
445
|
-
nil
|
|
446
|
-
end
|
|
199
|
+
# Start the next link of a chain (SessionContinue.run, which has the
|
|
200
|
+
# rules and raises SessionContinue::Refused).
|
|
201
|
+
def self.continue_session(...) = SessionContinue.run(...)
|
|
447
202
|
|
|
448
203
|
# Build the opts hash passed to Process.spawn for a forked worker. The
|
|
449
204
|
# child inherits this process's ENV and reads config.yml itself (as it
|
|
@@ -637,10 +392,11 @@ module Samagotchi
|
|
|
637
392
|
end
|
|
638
393
|
|
|
639
394
|
# The session and its descendants, with their owners, when they can be
|
|
640
|
-
# archived now (#archive_refusal for each), else the refusal.
|
|
395
|
+
# archived now (#archive_refusal for each), else the refusal. Public for
|
|
396
|
+
# SessionContinue, which checks before its recap wait.
|
|
641
397
|
# @return [Array(Array<String>, Hash)] the ids, and id => OwnerLock::Owner or nil
|
|
642
398
|
# @raise [ArchiveRefused, OwnedByTUI]
|
|
643
|
-
|
|
399
|
+
def self.archivable!(id, state_dir, except: [])
|
|
644
400
|
raise ArchiveRefused.new(id, :scratch) if Session.load(id, state_dir: state_dir).scratch
|
|
645
401
|
|
|
646
402
|
tree = [id, *descendant_ids(id, state_dir, except: except)]
|
|
@@ -824,6 +824,8 @@ module Samagotchi
|
|
|
824
824
|
|
|
825
825
|
called_as = event.dig(:activity, :called_as)
|
|
826
826
|
active[:called_as] = called_as.to_s if called_as
|
|
827
|
+
# A grep that found nothing (ok): the reloaded web row says "no match".
|
|
828
|
+
active[:no_match] = true if event.dig(:activity, :no_match)
|
|
827
829
|
finish_tool_record(active, status.empty? ? "ok" : status, waited_ms: event[:waited_ms].to_i)
|
|
828
830
|
end
|
|
829
831
|
end
|
|
@@ -831,7 +833,7 @@ module Samagotchi
|
|
|
831
833
|
# Caller holds the mutex.
|
|
832
834
|
def finish_tool_record(active, status, waited_ms: 0)
|
|
833
835
|
record = active.slice(
|
|
834
|
-
:id, :turn_id, :iteration, :call_index, :tool, :called_as, :started_at
|
|
836
|
+
:id, :turn_id, :iteration, :call_index, :tool, :called_as, :no_match, :started_at
|
|
835
837
|
).merge(
|
|
836
838
|
status: status,
|
|
837
839
|
completed_at: now.iso8601(3),
|
|
@@ -1268,7 +1268,8 @@ module Samagotchi
|
|
|
1268
1268
|
# (it ended first).
|
|
1269
1269
|
def join_tool_line(part, duration_ms: nil)
|
|
1270
1270
|
output = part[:output]
|
|
1271
|
-
|
|
1271
|
+
command = part.dig(:view, :truncated) ? nil : part.dig(:view, :command)
|
|
1272
|
+
status = output.nil? ? "no result" : ToolActivity.tool_activity_status(output, part[:tool], command: command)
|
|
1272
1273
|
activity = { action: part[:label] || ToolActivity.tool_activity_action(part[:tool]), tool: part[:tool],
|
|
1273
1274
|
params: part[:params], status: status,
|
|
1274
1275
|
description: ToolView.description_title(part[:view] || {}) }
|
|
@@ -273,6 +273,16 @@ module Samagotchi
|
|
|
273
273
|
return run_one_shot_command(session) if @prompt && @non_interactive && command_registry.command?(@prompt)
|
|
274
274
|
|
|
275
275
|
if @prompt && @non_interactive
|
|
276
|
+
# A /cut TEXT / /queue TEXT prompt runs its text as the turn's
|
|
277
|
+
# prompt, the same as the idle REPL does (Formatting#steer_choice);
|
|
278
|
+
# a bare /cut / /queue (no text) is refused above as a UI command,
|
|
279
|
+
# so only the text here. The images are the text's, not the words'.
|
|
280
|
+
one_shot_prompt =
|
|
281
|
+
if (choice = steer_choice(@prompt)) && !choice.last.empty?
|
|
282
|
+
choice.last
|
|
283
|
+
else
|
|
284
|
+
@prompt
|
|
285
|
+
end
|
|
276
286
|
# Headless / CI mode: run directly without TTY rendering.
|
|
277
287
|
ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir)
|
|
278
288
|
# Retry lines and hook notices on stderr; stdout gets the answer below.
|
|
@@ -281,11 +291,11 @@ module Samagotchi
|
|
|
281
291
|
begin
|
|
282
292
|
result = @engine.run_turn(
|
|
283
293
|
session,
|
|
284
|
-
|
|
294
|
+
one_shot_prompt,
|
|
285
295
|
on_event: sink,
|
|
286
296
|
max_iterations: IterationLimit.for(no_interrupt: true),
|
|
287
297
|
cancel_controller: nil,
|
|
288
|
-
images: ImageInput.extract(
|
|
298
|
+
images: ImageInput.extract(one_shot_prompt)
|
|
289
299
|
)
|
|
290
300
|
rescue LLM::ProviderError, ImageStore::Error => e
|
|
291
301
|
return one_shot_failed(session, e)
|