samagotchi 0.29.0 → 0.31.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 +99 -1
- data/docs/cli.md +4 -4
- data/docs/configuration.md +4 -0
- data/docs/hooks.md +1 -1
- data/docs/internals/engine.md +1 -1
- data/docs/plugins.md +1 -1
- data/docs/sessions.md +11 -4
- data/lib/samagotchi/bridge/card_store.rb +1 -1
- data/lib/samagotchi/bridge/turn_notice.rb +1 -1
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +4 -3
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +17 -4
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/delegated.md +1 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/child_reports.rb +214 -0
- data/lib/samagotchi/client.rb +35 -4
- data/lib/samagotchi/command_steps.rb +35 -15
- data/lib/samagotchi/config.rb +5 -0
- data/lib/samagotchi/engine.rb +2 -0
- data/lib/samagotchi/host_registry.rb +4 -0
- data/lib/samagotchi/idle_recap.rb +4 -0
- data/lib/samagotchi/kernel_loop.rb +63 -11
- data/lib/samagotchi/llm/chat_loop.rb +14 -7
- data/lib/samagotchi/llm/errors.rb +9 -0
- data/lib/samagotchi/log_subscriber.rb +1 -1
- data/lib/samagotchi/model_list_store.rb +117 -0
- data/lib/samagotchi/model_profile.rb +43 -0
- data/lib/samagotchi/prompt_warmup.rb +30 -5
- data/lib/samagotchi/reply_wait.rb +39 -8
- data/lib/samagotchi/send_command.rb +5 -3
- data/lib/samagotchi/session.rb +31 -5
- data/lib/samagotchi/session_inbox.rb +34 -1
- data/lib/samagotchi/session_manager.rb +41 -5
- data/lib/samagotchi/session_metrics.rb +6 -3
- data/lib/samagotchi/steer.rb +39 -7
- data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +38 -8
- data/lib/samagotchi/terminal_ui.rb +44 -5
- data/lib/samagotchi/tool_activity.rb +6 -2
- data/lib/samagotchi/tool_call_parser.rb +40 -0
- data/lib/samagotchi/tool_declarations.rb +7 -7
- data/lib/samagotchi/tools/delegate.rb +26 -13
- data/lib/samagotchi/tools/delegate_cursor.rb +91 -0
- data/lib/samagotchi/tools/delegate_wait.rb +50 -31
- data/lib/samagotchi/tools/execute.rb +3 -1
- data/lib/samagotchi/tools/task_runtime.rb +21 -2
- data/lib/samagotchi/tools/task_wait.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +8 -2
- data/lib/samagotchi/web/public/activity.js +2 -1
- data/lib/samagotchi/web/public/app.js +21 -11
- data/lib/samagotchi/web/public/command_view.js +19 -5
- data/lib/samagotchi/web/public/format.js +8 -1
- data/lib/samagotchi/web/public/index.html +14 -6
- data/lib/samagotchi/web/public/notify.js +4 -2
- data/lib/samagotchi/web/public/stage_model.js +18 -0
- data/lib/samagotchi/web/public/stage_view.js +16 -11
- data/lib/samagotchi/web/public/task_stop.js +11 -2
- data/lib/samagotchi/web/public/timing.js +12 -7
- data/lib/samagotchi/web/public/turn_events.js +29 -9
- data/lib/samagotchi/web/public/turn_model.js +16 -11
- data/lib/samagotchi/web/public/turn_view.js +49 -7
- data/lib/samagotchi/worker.rb +240 -23
- metadata +4 -1
|
@@ -20,9 +20,18 @@ module Samagotchi
|
|
|
20
20
|
# another slot (12 s);
|
|
21
21
|
# - once the warm-up is done, nothing is pinned: the server finds the
|
|
22
22
|
# state itself, and pinning to a slot it has since cleared skips its
|
|
23
|
-
# prompt cache (12 s again). #take_pin forgets the slot either way
|
|
23
|
+
# prompt cache (12 s again). #take_pin forgets the slot either way;
|
|
24
|
+
# - no warm-up when /slots says that slot is busy (Client#slot_status):
|
|
25
|
+
# another session took it (a delegate child, often), and llama.cpp
|
|
26
|
+
# defers a pinned request until the slot is free, so the warm-up and
|
|
27
|
+
# then this session's next turn would wait out that generation. The
|
|
28
|
+
# next request then goes unpinned, and the server's prompt cache
|
|
29
|
+
# restores the state. When /slots can't say, the warm-up runs.
|
|
24
30
|
class PromptWarmup
|
|
25
31
|
MODES = %w[auto off].freeze
|
|
32
|
+
# Seconds #take_pin waits for the busy-slot check (Client#slot_status
|
|
33
|
+
# gives up within 3 s).
|
|
34
|
+
DECIDE_TIMEOUT = 3
|
|
26
35
|
|
|
27
36
|
# Whether cache.warmup allows a warm-up (auto, the default; off, false,
|
|
28
37
|
# no or 0 turn it off; anything else warns once and is auto).
|
|
@@ -52,8 +61,15 @@ module Samagotchi
|
|
|
52
61
|
# nothing to pin the next request to)
|
|
53
62
|
# @return [Thread]
|
|
54
63
|
def start(client:, prompt:, model:, slot:, images: [])
|
|
64
|
+
# Whether the warm-up went to its slot (true) or was skipped: the
|
|
65
|
+
# first value pushed is the answer.
|
|
66
|
+
sent = Queue.new
|
|
55
67
|
thread = Thread.new do
|
|
56
68
|
Thread.current.report_on_exception = false
|
|
69
|
+
busy = slot.is_a?(Integer) && client.slot_status(slot, model: model) == :busy
|
|
70
|
+
sent << !busy
|
|
71
|
+
next skip(slot) if busy
|
|
72
|
+
|
|
57
73
|
result = client.warm_up(prompt, model: model, slot: slot, images: images)
|
|
58
74
|
Log.info(:model, "warmup", slot: result&.slot, cached: result&.cache_n, prefilled: result&.prompt_n,
|
|
59
75
|
prefill_ms: result&.prompt_ms, chars: prompt.length)
|
|
@@ -61,25 +77,34 @@ module Samagotchi
|
|
|
61
77
|
rescue StandardError => e
|
|
62
78
|
Log.warn(:model, "warmup_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
63
79
|
nil
|
|
80
|
+
ensure
|
|
81
|
+
sent << false
|
|
64
82
|
end
|
|
65
83
|
@mutex.synchronize do
|
|
66
84
|
@thread = thread
|
|
67
|
-
@state = { thread: thread, client: client, slot: slot }
|
|
85
|
+
@state = { thread: thread, client: client, slot: slot, sent: sent }
|
|
68
86
|
end
|
|
69
87
|
thread
|
|
70
88
|
end
|
|
71
89
|
|
|
72
90
|
# The slot the next request on +client+ must run on: the warm-up's,
|
|
73
|
-
# while it still runs there; nil otherwise (done, failed,
|
|
74
|
-
# another host). Called once per request; the first call
|
|
75
|
-
# warm-up, so no later request is ever pinned.
|
|
91
|
+
# while it still runs there; nil otherwise (done, failed, skipped,
|
|
92
|
+
# unpinned, or another host). Called once per request; the first call
|
|
93
|
+
# forgets the warm-up, so no later request is ever pinned. Waits for
|
|
94
|
+
# the busy-slot check (DECIDE_TIMEOUT at most).
|
|
76
95
|
def take_pin(client)
|
|
77
96
|
state = @mutex.synchronize { @state.tap { @state = nil } }
|
|
78
97
|
return nil unless state && state[:slot].is_a?(Integer) && state[:client].equal?(client)
|
|
98
|
+
return nil unless state[:sent].pop(timeout: DECIDE_TIMEOUT)
|
|
79
99
|
|
|
80
100
|
state[:thread].alive? ? state[:slot] : nil
|
|
81
101
|
end
|
|
82
102
|
|
|
103
|
+
private def skip(slot)
|
|
104
|
+
Log.info(:model, "warmup_skipped", slot: slot, why: "slot busy")
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
83
108
|
# Whether a warm-up is running (specs, measurements).
|
|
84
109
|
def running?
|
|
85
110
|
thread = @mutex.synchronize { @thread }
|
|
@@ -41,8 +41,11 @@ module Samagotchi
|
|
|
41
41
|
# (user, hook, ctrl_c, manual)
|
|
42
42
|
# @!attribute stopped_by [String, nil] the hook that canceled it
|
|
43
43
|
# (loop-guard)
|
|
44
|
+
# @!attribute session [Session, nil] the session as the look that
|
|
45
|
+
# decided the outcome loaded it (a caller's next baseline); nil for
|
|
46
|
+
# :canceled and :timeout
|
|
44
47
|
Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, :error_kind, :retryable, :cancel_reason,
|
|
45
|
-
:stopped_by, keyword_init: true)
|
|
48
|
+
:stopped_by, :session, keyword_init: true)
|
|
46
49
|
|
|
47
50
|
module_function
|
|
48
51
|
|
|
@@ -78,14 +81,15 @@ module Samagotchi
|
|
|
78
81
|
session = Session.load(id, state_dir: state_dir)
|
|
79
82
|
|
|
80
83
|
if (reply = reply_past(id, state_dir: state_dir, cursor: cursor))
|
|
84
|
+
reply.session = settled(session, id, state_dir: state_dir)
|
|
81
85
|
return reply
|
|
82
86
|
end
|
|
83
87
|
|
|
84
88
|
case session.status
|
|
85
89
|
when Session::STATUS_ERROR
|
|
86
|
-
return Result.new(status: :error, text: session.last_prompt.to_s.strip)
|
|
90
|
+
return Result.new(status: :error, text: session.last_prompt.to_s.strip, session: session)
|
|
87
91
|
when Session::STATUS_STOPPED
|
|
88
|
-
return Result.new(status: :stopped)
|
|
92
|
+
return Result.new(status: :stopped, session: session)
|
|
89
93
|
end
|
|
90
94
|
|
|
91
95
|
# Only a question a live worker holds waits for an answer (the
|
|
@@ -94,7 +98,7 @@ module Samagotchi
|
|
|
94
98
|
pending = session.pending_question
|
|
95
99
|
if pending && !(baseline && pending[:id] == baseline[:question_id]) &&
|
|
96
100
|
session.waiting_question(live: SessionManager.worker_live?(id, state_dir: state_dir))
|
|
97
|
-
return Result.new(status: :waiting_for_answer, question: pending)
|
|
101
|
+
return Result.new(status: :waiting_for_answer, question: pending, session: session)
|
|
98
102
|
end
|
|
99
103
|
|
|
100
104
|
return Result.new(status: :canceled) if cancelled.call
|
|
@@ -107,14 +111,15 @@ module Samagotchi
|
|
|
107
111
|
# file-delivered message its worker has not picked up yet.
|
|
108
112
|
# The worker writes the reply before its idle save, so the read
|
|
109
113
|
# above has it; one more look costs nothing should that change.
|
|
110
|
-
return reply_past(id, state_dir: state_dir, cursor: cursor)
|
|
114
|
+
return reply_past(id, state_dir: state_dir, cursor: cursor)&.tap { |r| r.session = session } ||
|
|
115
|
+
no_reply(session, baseline)
|
|
111
116
|
end
|
|
112
117
|
|
|
113
118
|
if owner_grace
|
|
114
119
|
if SessionManager.session_owner(id, state_dir: state_dir)
|
|
115
120
|
gone_since = nil
|
|
116
121
|
elsif monotonic - (gone_since ||= monotonic) > owner_grace
|
|
117
|
-
return Result.new(status: :worker_gone)
|
|
122
|
+
return Result.new(status: :worker_gone, session: session)
|
|
118
123
|
end
|
|
119
124
|
end
|
|
120
125
|
|
|
@@ -157,11 +162,37 @@ module Samagotchi
|
|
|
157
162
|
last = session.last_turn.is_a?(Hash) ? session.last_turn : {}
|
|
158
163
|
fresh = baseline.nil? || !baseline.key?(:last_turn) || (last["ended_at"] && last["ended_at"] != baseline[:last_turn])
|
|
159
164
|
outcome = fresh ? last["outcome"] : nil
|
|
160
|
-
|
|
165
|
+
if fresh && last["exhausted"]
|
|
166
|
+
return Result.new(status: :no_reply, outcome: "exhausted", limit: last["limit"], session: session)
|
|
167
|
+
end
|
|
161
168
|
|
|
162
169
|
text = outcome == "failed" ? failure_summary(session.messages) : nil
|
|
163
170
|
why = fresh ? last.slice("error_kind", "retryable", "cancel_reason", "stopped_by").transform_keys(&:to_sym) : {}
|
|
164
|
-
Result.new(status: :no_reply, outcome: outcome, text: text, **why)
|
|
171
|
+
Result.new(status: :no_reply, outcome: outcome, text: text, session: session, **why)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
SETTLE_SECONDS = 1.0
|
|
175
|
+
|
|
176
|
+
# The session after the turn that wrote a reply was saved. The worker
|
|
177
|
+
# writes the reply just before that save, so a look between the two
|
|
178
|
+
# loaded the session still running, with the turn before's last_turn: a
|
|
179
|
+
# baseline from it would read the reply's own turn as a later one that
|
|
180
|
+
# left no reply. Loads again (briefly) until it shows idle or its
|
|
181
|
+
# last_turn moved on; a session running a next turn by then has the
|
|
182
|
+
# reply's turn as its last_turn, which is right too.
|
|
183
|
+
def settled(session, id, state_dir:)
|
|
184
|
+
return session unless session.status == Session::STATUS_RUNNING
|
|
185
|
+
|
|
186
|
+
before = session.last_turn.is_a?(Hash) ? session.last_turn["ended_at"] : nil
|
|
187
|
+
deadline = monotonic + SETTLE_SECONDS
|
|
188
|
+
loop do
|
|
189
|
+
sleep(0.02)
|
|
190
|
+
fresh = Session.load(id, state_dir: state_dir)
|
|
191
|
+
ended = fresh.last_turn.is_a?(Hash) ? fresh.last_turn["ended_at"] : nil
|
|
192
|
+
return fresh if fresh.status != Session::STATUS_RUNNING || ended != before || monotonic > deadline
|
|
193
|
+
end
|
|
194
|
+
rescue ArgumentError
|
|
195
|
+
session
|
|
165
196
|
end
|
|
166
197
|
|
|
167
198
|
# The summary in the failed-turn note at the conversation's tail, nil
|
|
@@ -241,9 +241,10 @@ module Samagotchi
|
|
|
241
241
|
|
|
242
242
|
# A worker session like the web start page's: saved as running with the
|
|
243
243
|
# message before its worker spawns, so lists and the web show it at
|
|
244
|
-
# once. The full id, so a script can pass it on.
|
|
245
|
-
#
|
|
246
|
-
#
|
|
244
|
+
# once. The full id, so a script can pass it on. The model's host is
|
|
245
|
+
# checked here (in spawn_session, as in the web): an unknown one is
|
|
246
|
+
# refused before anything is spawned; an id the host's saved model list
|
|
247
|
+
# doesn't have starts with a warning on stderr.
|
|
247
248
|
# @return [Integer] the exit status
|
|
248
249
|
def run_new(prompt, options)
|
|
249
250
|
images = !@images.empty?
|
|
@@ -264,6 +265,7 @@ module Samagotchi
|
|
|
264
265
|
error_line("chi send: could not start a session: #{e.message}")
|
|
265
266
|
return 1
|
|
266
267
|
end
|
|
268
|
+
error_line("chi send: warning: #{session.model_warning}") if session.model_warning
|
|
267
269
|
if through_worker
|
|
268
270
|
return 1 unless deliver_new(session, prompt)
|
|
269
271
|
else
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -30,6 +30,8 @@ module Samagotchi
|
|
|
30
30
|
# resume removes it. A file from before the marker says "stopped" in
|
|
31
31
|
# its status field, which reads the same.
|
|
32
32
|
STOPPED_FILE = "stopped"
|
|
33
|
+
# The memory a delegate child starts with (Tools::Delegate::CHILD_MEMORIES).
|
|
34
|
+
DELEGATE_MEMORY = "system/delegated"
|
|
33
35
|
|
|
34
36
|
SORT_KEYS = %w[created_at updated_at].freeze
|
|
35
37
|
SORT_ORDERS = %w[asc desc].freeze
|
|
@@ -54,13 +56,18 @@ module Samagotchi
|
|
|
54
56
|
# plugin forked it from (ctx.sessions.fork), else nil.
|
|
55
57
|
# Set before the spawn and kept on respawns, like preloaded_memory_names.
|
|
56
58
|
attr_accessor :parent_id
|
|
59
|
+
# Started by the `delegate` tool (a fork has a parent_id too, but isn't
|
|
60
|
+
# one): see #delegate?.
|
|
61
|
+
attr_writer :delegate
|
|
57
62
|
# A `chi scratch` session: deleted when its REPL ends, and by the next
|
|
58
63
|
# sweep (or `chi sessions clean`) when the process died first.
|
|
59
64
|
attr_accessor :scratch
|
|
60
65
|
# How the last turn ended, for the web's notifications (the hub sends
|
|
61
66
|
# it in the summary): {"outcome" => "completed"|"failed"|"canceled",
|
|
62
67
|
# "ended_at" => iso8601, "seconds" => Float, "origin" =>
|
|
63
|
-
# "client"|"reminder"|"delegate"}; nil before the
|
|
68
|
+
# "client"|"reminder"|"delegate"|"delegate_report"}; nil before the
|
|
69
|
+
# first turn. delegate_report: a turn a parent ran for its delegate
|
|
70
|
+
# children's reports (ChildReports).
|
|
64
71
|
attr_accessor :last_turn
|
|
65
72
|
|
|
66
73
|
# The hook that stopped the last turn (stop_turn, or a cut with no retry
|
|
@@ -79,12 +86,17 @@ module Samagotchi
|
|
|
79
86
|
# sets it); not saved.
|
|
80
87
|
attr_accessor :seed_images_dropped
|
|
81
88
|
|
|
89
|
+
# The warning for a model id its host's saved list doesn't have
|
|
90
|
+
# (ModelProfile.model_warning; SessionManager.spawn_session sets it);
|
|
91
|
+
# not saved.
|
|
92
|
+
attr_accessor :model_warning
|
|
93
|
+
|
|
82
94
|
def initialize(id:, mode:, model_name:, working_directory:, messages:, created_at:, updated_at:,
|
|
83
95
|
metadata_version: METADATA_VERSION, status: STATUS_IDLE, last_prompt: "",
|
|
84
96
|
first_preview: "", test_run: false, pending_question: nil,
|
|
85
97
|
used_memory_names: [], project_root: nil,
|
|
86
98
|
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
|
|
87
|
-
last_turn: nil, model_typed: nil)
|
|
99
|
+
last_turn: nil, model_typed: nil, delegate: false)
|
|
88
100
|
@id = id
|
|
89
101
|
@metadata_version = metadata_version
|
|
90
102
|
@mode = mode
|
|
@@ -106,9 +118,20 @@ module Samagotchi
|
|
|
106
118
|
@parent_id = parent_id&.to_s
|
|
107
119
|
@scratch = !!scratch
|
|
108
120
|
@last_turn = last_turn
|
|
121
|
+
@delegate = !!delegate
|
|
109
122
|
@archived = false
|
|
110
123
|
end
|
|
111
124
|
|
|
125
|
+
# A `delegate` child: its turns report to its parent (ChildReports). A
|
|
126
|
+
# child saved before the field is known by the memory every delegate
|
|
127
|
+
# starts with; a fork is neither.
|
|
128
|
+
def delegate?
|
|
129
|
+
return true if @delegate
|
|
130
|
+
return false unless @parent_id
|
|
131
|
+
|
|
132
|
+
@preloaded_memory_names.include?(DELEGATE_MEMORY)
|
|
133
|
+
end
|
|
134
|
+
|
|
112
135
|
# The question this session waits on, as the lists show it: {id:, kind:}
|
|
113
136
|
# with kind "question" (the model's), "approval", "hook" or "continue"
|
|
114
137
|
# (the step-limit question, asked between turns); nil with
|
|
@@ -146,7 +169,7 @@ module Samagotchi
|
|
|
146
169
|
# seed); [] by default
|
|
147
170
|
def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
|
|
148
171
|
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [],
|
|
149
|
-
scratch: false, model_typed: nil)
|
|
172
|
+
scratch: false, model_typed: nil, delegate: false)
|
|
150
173
|
now = Time.now.iso8601(3)
|
|
151
174
|
resolved_test = if test_run.nil?
|
|
152
175
|
test_session_env?
|
|
@@ -169,7 +192,8 @@ module Samagotchi
|
|
|
169
192
|
preloaded_memory_names: preloaded_memory_names,
|
|
170
193
|
muted_memory_names: muted_memory_names,
|
|
171
194
|
parent_id: parent_id,
|
|
172
|
-
scratch: scratch
|
|
195
|
+
scratch: scratch,
|
|
196
|
+
delegate: delegate
|
|
173
197
|
)
|
|
174
198
|
end
|
|
175
199
|
|
|
@@ -223,7 +247,8 @@ module Samagotchi
|
|
|
223
247
|
"muted_memory_names" => [],
|
|
224
248
|
"parent_id" => nil,
|
|
225
249
|
"scratch" => false,
|
|
226
|
-
"last_turn" => nil
|
|
250
|
+
"last_turn" => nil,
|
|
251
|
+
"delegate" => false
|
|
227
252
|
}.freeze
|
|
228
253
|
|
|
229
254
|
# A session from a parsed session file. +messages+ false leaves the
|
|
@@ -341,6 +366,7 @@ module Samagotchi
|
|
|
341
366
|
when "messages" then @messages.map { |msg| scrub_utf8(stringify_message_keys(msg)) }
|
|
342
367
|
when "pending_question" then @pending_question && scrub_utf8(stringify_message_keys(@pending_question))
|
|
343
368
|
when "test_run" then !!@test_run
|
|
369
|
+
when "delegate" then @delegate
|
|
344
370
|
when "used_memory_names", "preloaded_memory_names", "muted_memory_names"
|
|
345
371
|
Array(instance_variable_get(:"@#{key}"))
|
|
346
372
|
else instance_variable_get(:"@#{key}")
|
|
@@ -10,7 +10,8 @@ require_relative "session"
|
|
|
10
10
|
module Samagotchi
|
|
11
11
|
# A session directory's file inbox and outbox (see SessionManager for the
|
|
12
12
|
# layout): input/ holds one JSON file per queued turn, notes/ the context
|
|
13
|
-
# notes a worker adds between turns, output/ one text file per reply
|
|
13
|
+
# notes a worker adds between turns, output/ one text file per reply,
|
|
14
|
+
# children/ the rings of delegate children that have news (ChildReports).
|
|
14
15
|
# Writers are any process (the web, chi send, chi note, send_note); the
|
|
15
16
|
# worker claims input and notes by renaming them to *.processing.
|
|
16
17
|
module SessionInbox
|
|
@@ -20,6 +21,8 @@ module Samagotchi
|
|
|
20
21
|
NOTES_DIR = "notes"
|
|
21
22
|
NOTE_MAX_BYTES = 16 * 1024
|
|
22
23
|
OUTPUT_DIR = "output"
|
|
24
|
+
# A delegate child's doorbell: "look at me", not the report itself.
|
|
25
|
+
CHILDREN_DIR = "children"
|
|
23
26
|
# Input-file format this worker reads (JSON with the sender's ids and
|
|
24
27
|
# images: refs), advertised in the Bridge sidecar so a chi from before
|
|
25
28
|
# it (one that wrote plain .txt to a worker not advertising 2, or held
|
|
@@ -117,6 +120,36 @@ module Samagotchi
|
|
|
117
120
|
nil
|
|
118
121
|
end
|
|
119
122
|
|
|
123
|
+
# Ring a parent: a file naming the child and why. The caller makes sure
|
|
124
|
+
# the parent's session exists (no orphan dir for a deleted one).
|
|
125
|
+
# @param why [String] "turn_end", "question" or "crash"
|
|
126
|
+
# @return [String] the ring file's path
|
|
127
|
+
def self.write_ring(session_dir, child_id:, why:)
|
|
128
|
+
dir = File.join(session_dir, CHILDREN_DIR)
|
|
129
|
+
FileUtils.mkdir_p(dir)
|
|
130
|
+
path = File.join(dir, "#{Time.now.strftime("%Y%m%d%H%M%S%9N")}-#{child_id.to_s[0, 8]}.json")
|
|
131
|
+
AtomicFile.write(path, JSON.generate({ "child_id" => child_id, "why" => why, "at" => Time.now.iso8601(3) }))
|
|
132
|
+
path
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The rings waiting, oldest first.
|
|
136
|
+
def self.find_ring_files(session_dir)
|
|
137
|
+
dir = File.join(session_dir, CHILDREN_DIR)
|
|
138
|
+
return [] unless Dir.exist?(dir)
|
|
139
|
+
|
|
140
|
+
Dir.glob(File.join(dir, "*.json")).sort_by { |p| File.basename(p) }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @return [Hash, nil] {child_id:, why:, at:}, nil for a file that holds none
|
|
144
|
+
def self.read_ring(ring_file)
|
|
145
|
+
data = JSON.parse(File.read(ring_file))
|
|
146
|
+
return nil unless data.is_a?(Hash) && !data["child_id"].to_s.empty?
|
|
147
|
+
|
|
148
|
+
{ child_id: data["child_id"].to_s, why: data["why"].to_s, at: data["at"] }
|
|
149
|
+
rescue JSON::ParserError, SystemCallError
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
|
|
120
153
|
def self.write_output(session_dir, response)
|
|
121
154
|
output_dir = File.join(session_dir, OUTPUT_DIR)
|
|
122
155
|
FileUtils.mkdir_p(output_dir)
|
|
@@ -130,14 +130,20 @@ module Samagotchi
|
|
|
130
130
|
# @param images_from [String, nil] the session dir the seed's images are in
|
|
131
131
|
# @param title [String, nil] what the lists show before the first turn
|
|
132
132
|
# (the prompt's preview by default, else the seed's first user message)
|
|
133
|
-
# @return [Session] with #seed_images_dropped: refs whose file was gone
|
|
133
|
+
# @return [Session] with #seed_images_dropped: refs whose file was gone,
|
|
134
|
+
# and #model_warning: an id its host's saved list doesn't have
|
|
134
135
|
def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
|
|
135
136
|
memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
|
|
136
|
-
title: nil)
|
|
137
|
+
title: nil, delegate: false)
|
|
137
138
|
sd = state_dir || Session.default_state_dir
|
|
138
139
|
# The resolved ref is stored (a resumed session keeps its model when an
|
|
139
|
-
# alias is retargeted), with the name as typed beside it.
|
|
140
|
+
# alias is retargeted), with the name as typed beside it. A wrong host
|
|
141
|
+
# refuses here, before anything is saved or spawned; a model id the
|
|
142
|
+
# host's saved list doesn't have (ModelListStore) only warns
|
|
143
|
+
# (#model_warning on the session), as some hosts serve ids they don't
|
|
144
|
+
# list.
|
|
140
145
|
typed = Samagotchi::ModelProfile.check_host!(Samagotchi::ModelProfile.required_model_name(model_name))
|
|
146
|
+
model_warning = Samagotchi::ModelProfile.model_warning(typed)
|
|
141
147
|
ref = ConfigFile.model_ref(typed).ref
|
|
142
148
|
session = Session.new_session(
|
|
143
149
|
mode: mode,
|
|
@@ -147,8 +153,10 @@ module Samagotchi
|
|
|
147
153
|
preloaded_memory_names: memories,
|
|
148
154
|
muted_memory_names: muted_memories,
|
|
149
155
|
parent_id: parent_id,
|
|
150
|
-
messages: messages
|
|
156
|
+
messages: messages,
|
|
157
|
+
delegate: delegate
|
|
151
158
|
)
|
|
159
|
+
session.model_warning = model_warning
|
|
152
160
|
# With no prompt there is no first turn to run (an attaching UI sends
|
|
153
161
|
# the prompts), so the session starts idle.
|
|
154
162
|
session.status = prompt.to_s.strip.empty? ? Session::STATUS_IDLE : Session::STATUS_RUNNING
|
|
@@ -455,6 +463,32 @@ module Samagotchi
|
|
|
455
463
|
session
|
|
456
464
|
end
|
|
457
465
|
|
|
466
|
+
# A delegate child rang a parent that has no worker (ChildRing): start
|
|
467
|
+
# one, which runs a turn for the report. Not for a parent that is gone,
|
|
468
|
+
# stopped, archived, a scratch session, or open in a chi REPL: the ring
|
|
469
|
+
# waits on disk for whoever runs it next (#resume_session itself would
|
|
470
|
+
# clear the stop). A stop landing between the check and the resume is
|
|
471
|
+
# a small window, left as is.
|
|
472
|
+
# @return [Symbol] :woken, or why not (:gone, :stopped, :archived,
|
|
473
|
+
# :scratch, :owned)
|
|
474
|
+
def self.wake_for_report(session_id, state_dir: nil)
|
|
475
|
+
sd = state_dir || Session.default_state_dir
|
|
476
|
+
return :gone unless Session.exist?(session_id, state_dir: sd)
|
|
477
|
+
return :owned if session_owner(session_id, state_dir: sd)
|
|
478
|
+
return :stopped if Session.stopped_marker?(session_id, state_dir: sd)
|
|
479
|
+
return :archived if ArchiveStore.archived?(Session.session_dir(session_id, state_dir: sd))
|
|
480
|
+
|
|
481
|
+
session = Session.load(session_id, state_dir: sd)
|
|
482
|
+
return :stopped if session.status == Session::STATUS_STOPPED
|
|
483
|
+
return :scratch if session.scratch
|
|
484
|
+
|
|
485
|
+
resume_session(session_id, state_dir: sd)
|
|
486
|
+
Log.info(:worker, "delegate_woke_parent", parent: session_id[0, 8])
|
|
487
|
+
:woken
|
|
488
|
+
rescue OwnedByTUI
|
|
489
|
+
:owned
|
|
490
|
+
end
|
|
491
|
+
|
|
458
492
|
# How long after a spawn a resume trusts that worker to take the
|
|
459
493
|
# session, while it runs (and the session wasn't stopped since).
|
|
460
494
|
WORKER_START_GRACE = 30.0
|
|
@@ -825,8 +859,10 @@ module Samagotchi
|
|
|
825
859
|
# owner after its write and wakes one, or this finds its input.
|
|
826
860
|
# A restart starts its successor here, on the newest chi installed
|
|
827
861
|
# (worker_command), once this one's lock is free.
|
|
862
|
+
# Delegate reports that rang while it left wake one too.
|
|
828
863
|
if result == :restart ||
|
|
829
|
-
(%i[idle_exit exit_requested].include?(result) &&
|
|
864
|
+
(%i[idle_exit exit_requested].include?(result) &&
|
|
865
|
+
(!SessionInbox.find_new_input_files(session_dir).empty? || worker.wake_due?))
|
|
830
866
|
resume_session(session_id, state_dir: sd)
|
|
831
867
|
elsif worker.discard?
|
|
832
868
|
discard_left_session(session_id, state_dir: sd, default_model: worker.default_model)
|
|
@@ -481,9 +481,12 @@ module Samagotchi
|
|
|
481
481
|
# A turn that failed mid-stream never saw its generation end:
|
|
482
482
|
# what it got to still counts.
|
|
483
483
|
close_generation if @turn.gen_open
|
|
484
|
-
# A call still running when the turn ends
|
|
485
|
-
#
|
|
486
|
-
|
|
484
|
+
# A call still running when the turn ends never completes: its
|
|
485
|
+
# record says so, as the count already has it. A cancel stopped it
|
|
486
|
+
# ("stopped", as ToolActivity names a call the Stop cut); a
|
|
487
|
+
# failure left it "canceled".
|
|
488
|
+
cut = status == "canceled" ? "stopped" : "canceled"
|
|
489
|
+
@turn.tool_calls_by_id.each_value { |active| finish_tool_record(active, cut) }
|
|
487
490
|
@turn.tool_calls_by_id.clear
|
|
488
491
|
finished_at = now
|
|
489
492
|
record = {
|
data/lib/samagotchi/steer.rb
CHANGED
|
@@ -19,14 +19,22 @@ module Samagotchi
|
|
|
19
19
|
INPUT_KIND = "input"
|
|
20
20
|
|
|
21
21
|
# A drain item for an input line with its sender (source nil = the user).
|
|
22
|
-
# A plain String is still a valid item: the user's line.
|
|
23
|
-
|
|
22
|
+
# A plain String is still a valid item: the user's line. +mark+: fields
|
|
23
|
+
# its input message carries ({turn_start: true, turn_id:} for the
|
|
24
|
+
# first message of a turn with no prompt, a delegate report's wake
|
|
25
|
+
# turn), nil for none.
|
|
26
|
+
Line = Data.define(:text, :source, :mark) do
|
|
27
|
+
def initialize(text:, source:, mark: nil) = super
|
|
28
|
+
end
|
|
24
29
|
|
|
25
30
|
# The client ids whose input is not the user's own words. Literals, not
|
|
26
31
|
# SendCommand::CLIENT_ID / Tools::Delegate::CLIENT_PREFIX, so this file
|
|
27
32
|
# needs no requires (a spec pins them equal).
|
|
28
33
|
CHI_SEND_CLIENT = "cli:send"
|
|
29
34
|
DELEGATE_CLIENT_PREFIX = "delegate:"
|
|
35
|
+
# A delegate child's report (ChildReports::CLIENT_PREFIX).
|
|
36
|
+
CHILD_CLIENT_PREFIX = "child:"
|
|
37
|
+
DELEGATE_REPORT = "delegate_report"
|
|
30
38
|
PLUGIN_CLIENT = "plugin"
|
|
31
39
|
|
|
32
40
|
# What one drain brought: the merged user text (nil when none) and how
|
|
@@ -43,16 +51,22 @@ module Samagotchi
|
|
|
43
51
|
list = inputs.map do |input|
|
|
44
52
|
message = { role: "user", kind: INPUT_KIND }
|
|
45
53
|
message[:source] = input[:source] if input[:source]
|
|
54
|
+
message.merge!(input[:mark]) if input[:mark]
|
|
46
55
|
message.merge(content: input[:content])
|
|
47
56
|
end
|
|
48
57
|
list + steers.map { |steer| Steer.message(**steer) }
|
|
49
58
|
end
|
|
50
59
|
|
|
51
60
|
# The :pending_input_merged fields beside iteration and answer;
|
|
52
|
-
# steers: only when there are some, so plain merges stay as they were
|
|
61
|
+
# steers: only when there are some, so plain merges stay as they were;
|
|
62
|
+
# reports: the delegate reports' text, one per report (a UI shows
|
|
63
|
+
# each as the child's, not as a merged message).
|
|
53
64
|
def event_fields
|
|
54
65
|
fields = { count: count, content: content }
|
|
55
66
|
fields[:steers] = steers unless steers.empty?
|
|
67
|
+
reports = inputs.select { |input| input[:source] == DELEGATE_REPORT }
|
|
68
|
+
.flat_map { |input| input[:content].split(/\n\n(?=session: )/) }
|
|
69
|
+
fields[:reports] = reports unless reports.empty?
|
|
56
70
|
fields
|
|
57
71
|
end
|
|
58
72
|
end
|
|
@@ -68,6 +82,19 @@ module Samagotchi
|
|
|
68
82
|
"plugin_send" => "sent by a plugin"
|
|
69
83
|
}.freeze
|
|
70
84
|
USER_HEADER = "[Steer #{SENDERS["user"]}, #{HEADER_TAIL}".freeze
|
|
85
|
+
# A source whose message is not a steer at all gets its own whole
|
|
86
|
+
# header line here instead of "[Steer <sender>, …]". A delegate
|
|
87
|
+
# child's report (ChildReports) is chi's news, not the user's: its
|
|
88
|
+
# wording was picked on small models (it keeps them on their own task
|
|
89
|
+
# mid-turn and off delegate_result; "Follow it" made one apply the
|
|
90
|
+
# child's fix unasked).
|
|
91
|
+
HEADERS = {
|
|
92
|
+
"delegate_report" => "[Delegate report, delivered by chi when your delegate session ended its turn; " \
|
|
93
|
+
"not your user's message. Tell your user what it found or what went wrong, in your own " \
|
|
94
|
+
"words; if you are in the middle of a task, include it in your reply and finish the task. " \
|
|
95
|
+
"Don't call delegate_result for it; to retry or redirect that child, use delegate with " \
|
|
96
|
+
"its session:.]"
|
|
97
|
+
}.freeze
|
|
71
98
|
|
|
72
99
|
module_function
|
|
73
100
|
|
|
@@ -77,6 +104,7 @@ module Samagotchi
|
|
|
77
104
|
id = client_id.to_s
|
|
78
105
|
if id == CHI_SEND_CLIENT then "chi_send"
|
|
79
106
|
elsif id.start_with?(DELEGATE_CLIENT_PREFIX) then "parent_agent"
|
|
107
|
+
elsif id.start_with?(CHILD_CLIENT_PREFIX) then DELEGATE_REPORT
|
|
80
108
|
elsif id == PLUGIN_CLIENT then "plugin_send"
|
|
81
109
|
end
|
|
82
110
|
end
|
|
@@ -89,6 +117,8 @@ module Samagotchi
|
|
|
89
117
|
return unless [KIND, INPUT_KIND].include?(kind)
|
|
90
118
|
|
|
91
119
|
source = (message[:source] || message["source"]).to_s
|
|
120
|
+
return HEADERS[source] if HEADERS.key?(source)
|
|
121
|
+
|
|
92
122
|
source = "user" if source.empty?
|
|
93
123
|
sender = SENDERS[source] || (kind == KIND ? "from the #{source} plugin" : SENDERS["user"])
|
|
94
124
|
"[Steer #{sender}, #{HEADER_TAIL}"
|
|
@@ -139,9 +169,11 @@ module Samagotchi
|
|
|
139
169
|
end
|
|
140
170
|
|
|
141
171
|
# A prompt that started a turn: not a steer, not input merged into a
|
|
142
|
-
# running turn
|
|
172
|
+
# running turn, except the input a turn with no prompt starts with (a
|
|
173
|
+
# wake turn's delegate report, marked turn_start). What the turns a
|
|
174
|
+
# page shows count.
|
|
143
175
|
def turn_prompt?(message)
|
|
144
|
-
prompt?(message) && !input?(message)
|
|
176
|
+
prompt?(message) && (!input?(message) || (message[:turn_start] || message["turn_start"]) == true)
|
|
145
177
|
end
|
|
146
178
|
|
|
147
179
|
# Call a loop's drain: +at_answer+ goes only to a drain that takes it
|
|
@@ -197,10 +229,10 @@ module Samagotchi
|
|
|
197
229
|
def input_groups(lines)
|
|
198
230
|
pairs = lines.filter_map do |line|
|
|
199
231
|
text = (line.is_a?(Line) ? line.text : line).to_s.strip
|
|
200
|
-
[text, line.is_a?(Line) ? line.source : nil] unless text.empty?
|
|
232
|
+
[text, line.is_a?(Line) ? line.source : nil, line.is_a?(Line) ? line.mark : nil] unless text.empty?
|
|
201
233
|
end
|
|
202
234
|
pairs.chunk_while { |a, b| a[1] == b[1] }.map do |run|
|
|
203
|
-
{ content: run.map(&:first).join("\n\n"), source: run.first[1] }
|
|
235
|
+
{ content: run.map(&:first).join("\n\n"), source: run.first[1], mark: run.first[2] }.compact
|
|
204
236
|
end
|
|
205
237
|
end
|
|
206
238
|
end
|
|
@@ -1041,7 +1041,7 @@ module Samagotchi
|
|
|
1041
1041
|
render_join_notes(messages)
|
|
1042
1042
|
return unless last_user
|
|
1043
1043
|
|
|
1044
|
-
@screen.commit(
|
|
1044
|
+
@screen.commit(join_prompt_line(exchange[last_user]))
|
|
1045
1045
|
Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
|
|
1046
1046
|
from = messages.index { |m| m.equal?(exchange[last_user]) }
|
|
1047
1047
|
render_join_steps(messages, from, durations: join_tool_records(exchange[last_user]))
|
|
@@ -1224,7 +1224,8 @@ module Samagotchi
|
|
|
1224
1224
|
if event[:prompt].nil?
|
|
1225
1225
|
# A continue turn (after the offer's yes) has no prompt to show; a
|
|
1226
1226
|
# reminder turn shows its reminders (:reminder_injected).
|
|
1227
|
-
|
|
1227
|
+
# A turn run for delegate reports shows them as they merge.
|
|
1228
|
+
@screen.commit(prompt_line(origin[:client_id], "(continuing)")) unless reminder_origin?(origin) || report_origin?(origin)
|
|
1228
1229
|
elsif !(own?(origin[:client_id]) || @shown_enqueued.include?(origin[:enqueued_id]))
|
|
1229
1230
|
@screen.commit(prompt_line(origin[:client_id], event[:prompt]))
|
|
1230
1231
|
end
|
|
@@ -1267,6 +1268,7 @@ module Samagotchi
|
|
|
1267
1268
|
end
|
|
1268
1269
|
|
|
1269
1270
|
def reminder_origin?(origin) = origin[:client_id].to_s.start_with?("system:")
|
|
1271
|
+
def report_origin?(origin) = origin[:client_id].to_s.start_with?(Steer::CHILD_CLIENT_PREFIX)
|
|
1270
1272
|
end
|
|
1271
1273
|
end
|
|
1272
1274
|
end
|
|
@@ -186,7 +186,9 @@ module Samagotchi
|
|
|
186
186
|
def render_merge(event)
|
|
187
187
|
@view.finish_thinking_spinner
|
|
188
188
|
@view.print_line(event[:answer]) unless event[:answer].to_s.strip.empty?
|
|
189
|
-
|
|
189
|
+
reports = Array(event[:reports])
|
|
190
|
+
reports.each { |report| @view.print_line(@view.report_line(report)) }
|
|
191
|
+
count = event[:count].to_i - reports.size
|
|
190
192
|
@view.print_line("(#{count} message#{"s" unless count == 1} merged into the running turn)") if count.positive?
|
|
191
193
|
Array(event[:steers]).each { |steer| @view.print_line(@view.format_steer_line(source: steer[:source], text: steer[:text])) }
|
|
192
194
|
end
|