samagotchi 0.30.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 +46 -1
- data/docs/configuration.md +4 -0
- data/docs/internals/engine.md +1 -1
- data/docs/sessions.md +9 -2
- data/lib/samagotchi/bridge/card_store.rb +1 -1
- data/lib/samagotchi/bridge/turn_notice.rb +1 -1
- 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/config.rb +5 -0
- data/lib/samagotchi/engine.rb +2 -0
- data/lib/samagotchi/idle_recap.rb +4 -0
- data/lib/samagotchi/kernel_loop.rb +44 -5
- data/lib/samagotchi/llm/chat_loop.rb +2 -1
- data/lib/samagotchi/llm/errors.rb +9 -0
- data/lib/samagotchi/log_subscriber.rb +1 -1
- data/lib/samagotchi/prompt_warmup.rb +30 -5
- data/lib/samagotchi/reply_wait.rb +39 -8
- data/lib/samagotchi/session.rb +26 -5
- data/lib/samagotchi/session_inbox.rb +34 -1
- data/lib/samagotchi/session_manager.rb +32 -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 +31 -4
- data/lib/samagotchi/terminal_ui.rb +9 -0
- data/lib/samagotchi/tool_call_parser.rb +40 -0
- data/lib/samagotchi/tool_declarations.rb +7 -7
- data/lib/samagotchi/tools/delegate.rb +13 -4
- data/lib/samagotchi/tools/delegate_cursor.rb +91 -0
- data/lib/samagotchi/tools/delegate_wait.rb +50 -31
- 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/app.js +12 -4
- data/lib/samagotchi/web/public/format.js +8 -1
- data/lib/samagotchi/web/public/notify.js +4 -2
- data/lib/samagotchi/web/public/turn_events.js +26 -8
- data/lib/samagotchi/web/public/turn_view.js +23 -4
- data/lib/samagotchi/worker.rb +217 -8
- metadata +3 -1
|
@@ -213,7 +213,7 @@ module Samagotchi
|
|
|
213
213
|
# Array), plus a model turn's tool_calls and thinking (the host's
|
|
214
214
|
# reasoning, never sent back), a result's tool_call_id, the image
|
|
215
215
|
# refs of a user message or a tool result, a prompt's turn_id (Engine,
|
|
216
|
-
# never sent), and a plugin tool result's tool_params and tool_labels
|
|
216
|
+
# never sent) and a wake turn's report's turn_start (Steer, never sent), and a plugin tool result's tool_params and tool_labels
|
|
217
217
|
# (the live row's params line and label, never sent back), an
|
|
218
218
|
# edit/write result's tool_diffs (never sent), and an empty-answer
|
|
219
219
|
# note's marker (TurnNote.empty, never sent).
|
|
@@ -225,6 +225,7 @@ module Samagotchi
|
|
|
225
225
|
message[:tool_call_id] = entry[:tool_call_id] if entry[:tool_call_id]
|
|
226
226
|
message[:images] = entry[:images] if entry[:images].is_a?(Array) && !entry[:images].empty?
|
|
227
227
|
message[:turn_id] = entry[:turn_id] if entry[:turn_id]
|
|
228
|
+
message[:turn_start] = true if entry[:turn_start] == true
|
|
228
229
|
message[:thinking] = entry[:thinking] if entry[:thinking].is_a?(String) && !entry[:thinking].empty?
|
|
229
230
|
ToolResponse::SAVED_KEYS.each { |key| message[key] = entry[key] if entry[key] }
|
|
230
231
|
message[TurnNote::RETRY_NUDGE] = true if TurnNote.retry_nudge?(entry)
|
|
@@ -207,6 +207,15 @@ module Samagotchi
|
|
|
207
207
|
def summary = "unexpected response from host #{host}: #{detail}"
|
|
208
208
|
end
|
|
209
209
|
|
|
210
|
+
# A native generation that came out corrupt twice (KernelLoop: a tool
|
|
211
|
+
# call never closed, or a fresh thought header after the answer), the
|
|
212
|
+
# second time without the server's prompt cache. A poisoned llama.cpp
|
|
213
|
+
# prompt cache does this (ggml-org/llama.cpp#27148); the text is not
|
|
214
|
+
# an answer.
|
|
215
|
+
class MalformedGeneration < ProtocolError
|
|
216
|
+
def summary = "malformed generation from host #{host}: #{detail}"
|
|
217
|
+
end
|
|
218
|
+
|
|
210
219
|
# A request that kept failing on network errors until the retry budget
|
|
211
220
|
# ran out (or failed mid-stream, where a retry would repeat output).
|
|
212
221
|
class RetryExhausted < ConnectionError
|
|
@@ -29,7 +29,7 @@ module Samagotchi
|
|
|
29
29
|
question_cancelled: %i[id reason],
|
|
30
30
|
question_relay: %i[id reason],
|
|
31
31
|
generation_cancelled: %i[iteration],
|
|
32
|
-
empty_answer_retry: %i[iteration attempt of finish_reason thinking_chars stopped_by]
|
|
32
|
+
empty_answer_retry: %i[iteration attempt of finish_reason thinking_chars stopped_by malformed]
|
|
33
33
|
}.freeze
|
|
34
34
|
|
|
35
35
|
# @param session_id [#call] the session the events are about (the
|
|
@@ -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
|
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
|
|
@@ -89,7 +96,7 @@ module Samagotchi
|
|
|
89
96
|
first_preview: "", test_run: false, pending_question: nil,
|
|
90
97
|
used_memory_names: [], project_root: nil,
|
|
91
98
|
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
|
|
92
|
-
last_turn: nil, model_typed: nil)
|
|
99
|
+
last_turn: nil, model_typed: nil, delegate: false)
|
|
93
100
|
@id = id
|
|
94
101
|
@metadata_version = metadata_version
|
|
95
102
|
@mode = mode
|
|
@@ -111,9 +118,20 @@ module Samagotchi
|
|
|
111
118
|
@parent_id = parent_id&.to_s
|
|
112
119
|
@scratch = !!scratch
|
|
113
120
|
@last_turn = last_turn
|
|
121
|
+
@delegate = !!delegate
|
|
114
122
|
@archived = false
|
|
115
123
|
end
|
|
116
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
|
+
|
|
117
135
|
# The question this session waits on, as the lists show it: {id:, kind:}
|
|
118
136
|
# with kind "question" (the model's), "approval", "hook" or "continue"
|
|
119
137
|
# (the step-limit question, asked between turns); nil with
|
|
@@ -151,7 +169,7 @@ module Samagotchi
|
|
|
151
169
|
# seed); [] by default
|
|
152
170
|
def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
|
|
153
171
|
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [],
|
|
154
|
-
scratch: false, model_typed: nil)
|
|
172
|
+
scratch: false, model_typed: nil, delegate: false)
|
|
155
173
|
now = Time.now.iso8601(3)
|
|
156
174
|
resolved_test = if test_run.nil?
|
|
157
175
|
test_session_env?
|
|
@@ -174,7 +192,8 @@ module Samagotchi
|
|
|
174
192
|
preloaded_memory_names: preloaded_memory_names,
|
|
175
193
|
muted_memory_names: muted_memory_names,
|
|
176
194
|
parent_id: parent_id,
|
|
177
|
-
scratch: scratch
|
|
195
|
+
scratch: scratch,
|
|
196
|
+
delegate: delegate
|
|
178
197
|
)
|
|
179
198
|
end
|
|
180
199
|
|
|
@@ -228,7 +247,8 @@ module Samagotchi
|
|
|
228
247
|
"muted_memory_names" => [],
|
|
229
248
|
"parent_id" => nil,
|
|
230
249
|
"scratch" => false,
|
|
231
|
-
"last_turn" => nil
|
|
250
|
+
"last_turn" => nil,
|
|
251
|
+
"delegate" => false
|
|
232
252
|
}.freeze
|
|
233
253
|
|
|
234
254
|
# A session from a parsed session file. +messages+ false leaves the
|
|
@@ -346,6 +366,7 @@ module Samagotchi
|
|
|
346
366
|
when "messages" then @messages.map { |msg| scrub_utf8(stringify_message_keys(msg)) }
|
|
347
367
|
when "pending_question" then @pending_question && scrub_utf8(stringify_message_keys(@pending_question))
|
|
348
368
|
when "test_run" then !!@test_run
|
|
369
|
+
when "delegate" then @delegate
|
|
349
370
|
when "used_memory_names", "preloaded_memory_names", "muted_memory_names"
|
|
350
371
|
Array(instance_variable_get(:"@#{key}"))
|
|
351
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)
|
|
@@ -134,7 +134,7 @@ module Samagotchi
|
|
|
134
134
|
# and #model_warning: an id its host's saved list doesn't have
|
|
135
135
|
def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
|
|
136
136
|
memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
|
|
137
|
-
title: nil)
|
|
137
|
+
title: nil, delegate: false)
|
|
138
138
|
sd = state_dir || Session.default_state_dir
|
|
139
139
|
# The resolved ref is stored (a resumed session keeps its model when an
|
|
140
140
|
# alias is retargeted), with the name as typed beside it. A wrong host
|
|
@@ -153,7 +153,8 @@ module Samagotchi
|
|
|
153
153
|
preloaded_memory_names: memories,
|
|
154
154
|
muted_memory_names: muted_memories,
|
|
155
155
|
parent_id: parent_id,
|
|
156
|
-
messages: messages
|
|
156
|
+
messages: messages,
|
|
157
|
+
delegate: delegate
|
|
157
158
|
)
|
|
158
159
|
session.model_warning = model_warning
|
|
159
160
|
# With no prompt there is no first turn to run (an attaching UI sends
|
|
@@ -462,6 +463,32 @@ module Samagotchi
|
|
|
462
463
|
session
|
|
463
464
|
end
|
|
464
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
|
+
|
|
465
492
|
# How long after a spawn a resume trusts that worker to take the
|
|
466
493
|
# session, while it runs (and the session wasn't stopped since).
|
|
467
494
|
WORKER_START_GRACE = 30.0
|
|
@@ -832,8 +859,10 @@ module Samagotchi
|
|
|
832
859
|
# owner after its write and wakes one, or this finds its input.
|
|
833
860
|
# A restart starts its successor here, on the newest chi installed
|
|
834
861
|
# (worker_command), once this one's lock is free.
|
|
862
|
+
# Delegate reports that rang while it left wake one too.
|
|
835
863
|
if result == :restart ||
|
|
836
|
-
(%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?))
|
|
837
866
|
resume_session(session_id, state_dir: sd)
|
|
838
867
|
elsif worker.discard?
|
|
839
868
|
discard_left_session(session_id, state_dir: sd, default_model: worker.default_model)
|
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
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "../served_model"
|
|
4
4
|
require_relative "../image_store"
|
|
5
5
|
require_relative "../turn_note"
|
|
6
|
+
require_relative "../steer"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
class TerminalUI
|
|
@@ -43,7 +44,8 @@ module Samagotchi
|
|
|
43
44
|
# Who a steer names as its sender: a plugin's own label, chi's sender
|
|
44
45
|
# ids in words (the web's format.js steerSender agrees;
|
|
45
46
|
# spec/shared/labels_matrix.json). "": no one named.
|
|
46
|
-
STEER_SENDERS = { "parent_agent" => "parent agent", "chi_send" => "chi send", "plugin_send" => "plugin"
|
|
47
|
+
STEER_SENDERS = { "parent_agent" => "parent agent", "chi_send" => "chi send", "plugin_send" => "plugin",
|
|
48
|
+
"delegate_report" => "delegate report" }.freeze
|
|
47
49
|
|
|
48
50
|
def steer_sender(source)
|
|
49
51
|
STEER_SENDERS.fetch(source.to_s, source.to_s)
|
|
@@ -59,9 +61,13 @@ module Samagotchi
|
|
|
59
61
|
end
|
|
60
62
|
|
|
61
63
|
# "↻ empty answer, asking again (1/1)": the loop retries an empty answer;
|
|
62
|
-
# "↻ cut by loop-guard, asking again (1/1)" after a plugin cut it
|
|
64
|
+
# "↻ cut by loop-guard, asking again (1/1)" after a plugin cut it;
|
|
65
|
+
# "↻ malformed answer, …" after a corrupt native generation.
|
|
63
66
|
def format_empty_retry_line(event)
|
|
64
|
-
what = event[:stopped_by]
|
|
67
|
+
what = if event[:stopped_by] then "cut by #{event[:stopped_by]}"
|
|
68
|
+
elsif event[:malformed] then "malformed answer"
|
|
69
|
+
else "empty answer"
|
|
70
|
+
end
|
|
65
71
|
paint("↻ #{what}, asking again (#{event[:attempt]}/#{event[:of]})", 90)
|
|
66
72
|
end
|
|
67
73
|
|
|
@@ -109,7 +115,8 @@ module Samagotchi
|
|
|
109
115
|
|
|
110
116
|
# Prompt labels by the sender's client_id prefix (turn_events.js
|
|
111
117
|
# CLIENT_LABELS; spec/shared/labels_matrix.json).
|
|
112
|
-
CLIENT_LABELS = { "web" => "web", "tui" => "tui", "system" => "reminder", "delegate" => "delegate"
|
|
118
|
+
CLIENT_LABELS = { "web" => "web", "tui" => "tui", "system" => "reminder", "delegate" => "delegate",
|
|
119
|
+
"child" => "delegate report" }.freeze
|
|
113
120
|
|
|
114
121
|
# "web> <prompt>": a prompt, labelled by who sent it.
|
|
115
122
|
def prompt_line(client_id, prompt)
|
|
@@ -117,6 +124,26 @@ module Samagotchi
|
|
|
117
124
|
"#{paint("#{label}>", 35)} #{prompt}"
|
|
118
125
|
end
|
|
119
126
|
|
|
127
|
+
# "delegate report> 3f2a1c9e answered: <the reply's first line>": a
|
|
128
|
+
# delegate child's report (ChildReports) merged into the turn.
|
|
129
|
+
def report_line(report)
|
|
130
|
+
id = report[/\Asession: (\S+)/, 1].to_s[0, 8]
|
|
131
|
+
status = report[/^status: (.*)$/, 1]
|
|
132
|
+
rest = report.split("\n").drop(2).reject { |line| line.strip.empty? || line == "---" }.first.to_s
|
|
133
|
+
rest = "#{rest[0, STEER_PREVIEW - 1]}…" if rest.length > STEER_PREVIEW
|
|
134
|
+
"#{paint("#{CLIENT_LABELS["child"]}>", 35)} #{[id, status].compact.join(" ")}#{": #{rest}" unless rest.empty?}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The last prompt as a join shows it: a delegate report (a wake
|
|
138
|
+
# turn's) as the child's lines, anything else as the user's.
|
|
139
|
+
def join_prompt_line(message)
|
|
140
|
+
source = (message[:source] || message["source"]).to_s
|
|
141
|
+
content = (message[:content] || message["content"]).to_s
|
|
142
|
+
return prompt_line(nil, content) unless source == Steer::DELEGATE_REPORT
|
|
143
|
+
|
|
144
|
+
content.split(/\n\n(?=session: )/).map { |report| report_line(report) }.join("\n")
|
|
145
|
+
end
|
|
146
|
+
|
|
120
147
|
# "reminder: a, b": the reminders a turn runs for (names or hashes).
|
|
121
148
|
def reminder_line(reminders)
|
|
122
149
|
names = Array(reminders).filter_map { |r| r.is_a?(Hash) ? r[:name] : r }
|