samagotchi 0.63.0 → 0.65.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 +86 -1
- data/docs/architecture.md +32 -5
- data/docs/cli.md +41 -9
- data/docs/configuration.md +143 -11
- data/docs/internals/engine.md +25 -6
- data/docs/plugins.md +7 -0
- data/docs/sessions.md +14 -10
- data/docs/sub-agent.md +21 -4
- data/docs/testing.md +6 -0
- data/lib/samagotchi/answer_command.rb +5 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +29 -5
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/child_reports.rb +7 -2
- data/lib/samagotchi/cli/parent_wait.rb +9 -2
- data/lib/samagotchi/client.rb +4 -2
- data/lib/samagotchi/composer_route.rb +90 -0
- data/lib/samagotchi/config.rb +11 -7
- data/lib/samagotchi/continue_offer.rb +17 -3
- data/lib/samagotchi/delivery.rb +13 -8
- data/lib/samagotchi/engine.rb +41 -7
- data/lib/samagotchi/generation_phase.rb +17 -0
- data/lib/samagotchi/host_config.rb +33 -12
- data/lib/samagotchi/host_model.rb +2 -2
- data/lib/samagotchi/host_registry.rb +34 -11
- data/lib/samagotchi/idle_client.rb +62 -25
- data/lib/samagotchi/idle_recap.rb +5 -2
- data/lib/samagotchi/idle_target.rb +30 -7
- data/lib/samagotchi/interrupted_turn.rb +7 -3
- data/lib/samagotchi/last_turn.rb +5 -2
- data/lib/samagotchi/llm/adapters.rb +41 -0
- data/lib/samagotchi/llm/anthropic_assembly.rb +210 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
- data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
- data/lib/samagotchi/llm/api_key.rb +26 -9
- data/lib/samagotchi/llm/chat_loop.rb +113 -14
- data/lib/samagotchi/llm/dialect.rb +57 -0
- data/lib/samagotchi/llm/errors.rb +81 -12
- data/lib/samagotchi/llm/model_listing.rb +111 -0
- data/lib/samagotchi/llm/model_result.rb +9 -2
- data/lib/samagotchi/llm/openai_chat.rb +46 -93
- data/lib/samagotchi/model_profile.rb +1 -1
- data/lib/samagotchi/pending_input_queue.rb +10 -3
- data/lib/samagotchi/plugin/sessions.rb +27 -5
- data/lib/samagotchi/reasoning_history.rb +14 -4
- data/lib/samagotchi/recap_setup.rb +2 -1
- data/lib/samagotchi/reply_wait.rb +171 -6
- data/lib/samagotchi/self_report.rb +18 -28
- data/lib/samagotchi/send_command.rb +63 -19
- data/lib/samagotchi/session.rb +3 -2
- data/lib/samagotchi/session_commands.rb +1 -1
- data/lib/samagotchi/session_inbox.rb +63 -7
- data/lib/samagotchi/session_manager.rb +49 -20
- data/lib/samagotchi/session_metrics.rb +6 -11
- data/lib/samagotchi/steer_cut.rb +4 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +55 -28
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -22
- data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
- data/lib/samagotchi/terminal_ui.rb +72 -57
- data/lib/samagotchi/tool_activity.rb +5 -5
- data/lib/samagotchi/tools/delegate.rb +28 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
- data/lib/samagotchi/tools/delegate_wait.rb +17 -5
- data/lib/samagotchi/tools/execute.rb +11 -4
- data/lib/samagotchi/turn_checkpoint.rb +9 -3
- data/lib/samagotchi/turn_note.rb +22 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -1
- data/lib/samagotchi/web/public/activity.js +3 -0
- data/lib/samagotchi/web/public/app.js +96 -13
- data/lib/samagotchi/web/public/commands.js +8 -8
- data/lib/samagotchi/web/public/composer.js +3 -3
- data/lib/samagotchi/web/public/delivery_line.js +228 -0
- data/lib/samagotchi/web/public/index.html +29 -0
- data/lib/samagotchi/web/public/live_turn.js +8 -0
- data/lib/samagotchi/web/public/stage_model.js +27 -7
- data/lib/samagotchi/web/public/stage_view.js +32 -10
- data/lib/samagotchi/web/public/stream_handlers.js +8 -2
- data/lib/samagotchi/web/public/timing.js +2 -1
- data/lib/samagotchi/web/public/turn_events.js +22 -8
- data/lib/samagotchi/web/public/turn_html.js +20 -4
- data/lib/samagotchi/web/public/turn_model.js +29 -12
- data/lib/samagotchi/web/public/turn_view.js +6 -4
- data/lib/samagotchi/worker.rb +7 -5
- metadata +9 -1
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
|
-
require "net/http"
|
|
5
|
-
require "uri"
|
|
6
3
|
require_relative "version"
|
|
7
4
|
require_relative "config"
|
|
8
5
|
require_relative "context_window"
|
|
@@ -16,8 +13,7 @@ require_relative "thinking"
|
|
|
16
13
|
require_relative "reasoning_history"
|
|
17
14
|
require_relative "served_model"
|
|
18
15
|
require_relative "host_registry"
|
|
19
|
-
require_relative "llm/
|
|
20
|
-
require_relative "llm/api_key"
|
|
16
|
+
require_relative "llm/adapters"
|
|
21
17
|
require_relative "tools/memory"
|
|
22
18
|
require_relative "hooks/loader"
|
|
23
19
|
require_relative "memory_bundle/provenance"
|
|
@@ -273,7 +269,9 @@ module Samagotchi
|
|
|
273
269
|
entry = HostRegistry.new(env: env).resolve(model).entry
|
|
274
270
|
return "-" unless entry
|
|
275
271
|
|
|
276
|
-
|
|
272
|
+
return "native (raw prompt)" unless entry.chat?
|
|
273
|
+
|
|
274
|
+
entry.dialect ? "chat (api: #{entry.api}, dialect: #{entry.dialect})" : "chat (api: #{entry.api})"
|
|
277
275
|
end
|
|
278
276
|
|
|
279
277
|
NO_PROPS = "reported per turn (the server has no /props)"
|
|
@@ -281,7 +279,7 @@ module Samagotchi
|
|
|
281
279
|
# chi self's one server call: a single-model llama.cpp answers any name
|
|
282
280
|
# with the model it loaded, and its /props names it (model_alias). One
|
|
283
281
|
# GET with the probe's short timeouts; a remote host isn't asked. A local
|
|
284
|
-
# chat host (api: openai) has no /props, so it gets
|
|
282
|
+
# chat host (api: openai or anthropic) has no /props, so it gets its model list
|
|
285
283
|
# with the same timeouts: up/down and the ids it serves.
|
|
286
284
|
def served_model_for(model, env)
|
|
287
285
|
registry = HostRegistry.new(env: env)
|
|
@@ -306,36 +304,28 @@ module Samagotchi
|
|
|
306
304
|
"unknown (#{e.class})"
|
|
307
305
|
end
|
|
308
306
|
|
|
309
|
-
# A local chat host's reachability:
|
|
310
|
-
#
|
|
311
|
-
#
|
|
307
|
+
# A local chat host's reachability: its model list through its own
|
|
308
|
+
# adapter (LLM::Adapters, not the registry's cached one) with the native
|
|
309
|
+
# probe's short timeouts (Client's /props probe), no retry. Up with the
|
|
310
|
+
# ids it serves, down when it doesn't answer.
|
|
312
311
|
def chat_served_model_for(entry, bare, env)
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
read_timeout: Client::CONTEXT_WINDOW_PROBE_READ_TIMEOUT,
|
|
317
|
-
retry_policy: LLM::HTTP::RetryPolicy.none,
|
|
318
|
-
api_key: LLM::ApiKey.for(entry.api_key_env, host: entry.host, env: env))
|
|
319
|
-
response = http.fetch(uri, request, retries: false, check_status: false, log_fields: { purpose: "models" })
|
|
320
|
-
return "down (HTTP #{response.code})" unless response.code.to_s == "200"
|
|
321
|
-
|
|
322
|
-
ids = chat_model_ids(response.body)
|
|
312
|
+
adapter = LLM::Adapters.for(entry, open_timeout: Client::CONTEXT_WINDOW_PROBE_OPEN_TIMEOUT,
|
|
313
|
+
timeout: Client::CONTEXT_WINDOW_PROBE_READ_TIMEOUT, retries: false, env: env)
|
|
314
|
+
ids = adapter.list_models.map(&:id).reject(&:empty?)
|
|
323
315
|
return "up (no models listed)" if ids.empty?
|
|
324
316
|
|
|
325
317
|
note = ids.any? { |id| id.casecmp?(bare.to_s) } ? "" : " (not #{bare})"
|
|
326
318
|
"up: #{ids.join(", ")}#{note}"
|
|
319
|
+
rescue LLM::ProviderError => e
|
|
320
|
+
# A status (an error, or a 3xx such as a proxy's login page) is down;
|
|
321
|
+
# a 200 whose body isn't a model list (ProtocolError) is up.
|
|
322
|
+
return "down (HTTP #{e.status})" if e.status
|
|
323
|
+
|
|
324
|
+
e.is_a?(LLM::ProtocolError) ? "up (no models listed)" : "down (the server didn't answer; is it running?)"
|
|
327
325
|
rescue StandardError
|
|
328
326
|
"down (the server didn't answer; is it running?)"
|
|
329
327
|
end
|
|
330
328
|
|
|
331
|
-
def chat_model_ids(body)
|
|
332
|
-
parsed = JSON.parse(body.to_s)
|
|
333
|
-
data = parsed.is_a?(Hash) ? parsed["data"] : parsed
|
|
334
|
-
Array(data).filter_map { |raw| raw.is_a?(Hash) ? (raw["id"] || raw[:id]).to_s : raw.to_s }.reject(&:empty?)
|
|
335
|
-
rescue JSON::ParserError
|
|
336
|
-
[]
|
|
337
|
-
end
|
|
338
|
-
|
|
339
329
|
# Offline, so no /props probe: where nothing is configured, a native
|
|
340
330
|
# llama.cpp host's chat template decides at runtime.
|
|
341
331
|
def profile_for(model, env)
|
|
@@ -38,10 +38,11 @@ module Samagotchi
|
|
|
38
38
|
CUT_OUTCOMES = %w[now waits off].freeze
|
|
39
39
|
|
|
40
40
|
USAGE = <<~TEXT
|
|
41
|
-
Usage: chi send [-m TEXT] [--image PATH]... [--cut] (ID|PREFIX)...
|
|
41
|
+
Usage: chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...
|
|
42
42
|
chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [-m TEXT] [--image PATH]...
|
|
43
43
|
chi send --new --continues (ID|PREFIX|last:ID) [-m TEXT] [--image PATH]...
|
|
44
44
|
chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
|
|
45
|
+
chi send --queue --wait [--timeout S] [--format json] [-m TEXT] ID
|
|
45
46
|
chi send --wait [--timeout S] [--format json] ID
|
|
46
47
|
Sends a message to each session, as if typed in it: a turn starts,
|
|
47
48
|
or a running one takes it at its next step. A stopped session's
|
|
@@ -92,6 +93,12 @@ module Samagotchi
|
|
|
92
93
|
next step and cuts nothing. To an idle session it
|
|
93
94
|
sends normally. Refused with --wait into a running
|
|
94
95
|
turn (the steer's turn is the running one)
|
|
96
|
+
--queue run the message as a turn of its own after the running
|
|
97
|
+
turn ends, instead of merging it at its next step. To
|
|
98
|
+
an idle session it runs at once. With --wait it
|
|
99
|
+
waits for that turn's own answer, so a running turn
|
|
100
|
+
is fine. Not with --cut. Refused for a worker older
|
|
101
|
+
than queueing (it would steer; restart it)
|
|
95
102
|
--timeout S (--wait) give up after S seconds, exit 4 (the turn
|
|
96
103
|
goes on); default no limit
|
|
97
104
|
--format json
|
|
@@ -99,8 +106,9 @@ module Samagotchi
|
|
|
99
106
|
status answered (text), question (question,
|
|
100
107
|
answer_with), running, or failed, canceled,
|
|
101
108
|
limit, no_answer, error, worker_gone, stopped,
|
|
102
|
-
command (detail); with --cut
|
|
103
|
-
the worker applied
|
|
109
|
+
command (detail); with --cut or --queue also
|
|
110
|
+
delivery (what the worker applied: next_step, cut,
|
|
111
|
+
queue), with --cut also cut (now, waits, off)
|
|
104
112
|
Exit with --wait: 0 answered, 1 failed or gone, 2 usage, 3 a
|
|
105
113
|
question waits, 4 still running (--timeout), 130 Ctrl-C.
|
|
106
114
|
Only sessions on this machine. Answers show in the attached TUI
|
|
@@ -121,6 +129,7 @@ module Samagotchi
|
|
|
121
129
|
f.value "--image", key: :images, repeat: true
|
|
122
130
|
f.switch "--wait"
|
|
123
131
|
f.switch "--cut"
|
|
132
|
+
f.switch "--queue"
|
|
124
133
|
f.value "--timeout"
|
|
125
134
|
f.value "--format"
|
|
126
135
|
# Starting a turn in every live session at once is too easy to do
|
|
@@ -181,6 +190,7 @@ module Samagotchi
|
|
|
181
190
|
# With --wait stdout is the answer alone.
|
|
182
191
|
@info = options[:wait] ? @stderr : @stdout
|
|
183
192
|
@cut = options[:cut] == true
|
|
193
|
+
@queue = options[:queue] == true
|
|
184
194
|
prompt = compose(utf8(read_stdin), utf8(options[:message]))
|
|
185
195
|
# Before the wait-only case, which would drop the images.
|
|
186
196
|
return usage_error("--image needs a message: pass -m TEXT or pipe it in") if prompt.nil? && !options[:images].empty?
|
|
@@ -236,6 +246,8 @@ module Samagotchi
|
|
|
236
246
|
return parsed if parsed.is_a?(Integer)
|
|
237
247
|
|
|
238
248
|
options = parsed.options.merge(ids: parsed.args)
|
|
249
|
+
return usage_error("--cut and --queue: choose one") if options[:cut] && options[:queue]
|
|
250
|
+
|
|
239
251
|
if options[:format]
|
|
240
252
|
return usage_error("--format needs --wait") unless options[:wait]
|
|
241
253
|
return usage_error("--format takes text or json") unless FORMATS.include?(options[:format])
|
|
@@ -385,12 +397,15 @@ module Samagotchi
|
|
|
385
397
|
# are taken before the message goes in, so neither an older reply nor a
|
|
386
398
|
# turn that ends before the first look is mistaken for the answer. A
|
|
387
399
|
# running turn is refused: a message that misses it runs next, and the
|
|
388
|
-
# running one's reply would come back as the answer.
|
|
400
|
+
# running one's reply would come back as the answer. Not with --queue:
|
|
401
|
+
# the message's own turn is waited for (ReplyWait's turn:, its
|
|
402
|
+
# enqueued_id), whatever runs before it.
|
|
389
403
|
# @return [Integer] the exit status
|
|
390
404
|
def run_wait(prompt, options)
|
|
391
405
|
id = resolve(options[:ids].first) or return 1
|
|
392
406
|
session = Session.load(id, state_dir: @state_dir)
|
|
393
|
-
if session.status == Session::STATUS_RUNNING && (owner = SessionManager.session_owner(id, state_dir: @state_dir))
|
|
407
|
+
if session.status == Session::STATUS_RUNNING && (owner = SessionManager.session_owner(id, state_dir: @state_dir)) &&
|
|
408
|
+
(!@queue || session.waiting_question(live: owner.worker?))
|
|
394
409
|
# The turn waits on a question: the message would only queue
|
|
395
410
|
# behind it, and the answer goes in with chi answer.
|
|
396
411
|
if (waiting = session.waiting_question(live: owner.worker?))
|
|
@@ -412,7 +427,11 @@ module Samagotchi
|
|
|
412
427
|
return 1 unless deliver(id, prompt)
|
|
413
428
|
return report_command(id) if @sent_command
|
|
414
429
|
|
|
415
|
-
|
|
430
|
+
# The ack's id names the queued message's turn, also when the turn
|
|
431
|
+
# running at the check ended before it went in (an idle worker's ack
|
|
432
|
+
# says next_step, and the file still runs as that turn).
|
|
433
|
+
turn = @queue ? @sent_enqueued_id : nil
|
|
434
|
+
wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], turn: turn, queued: !turn.nil?)
|
|
416
435
|
end
|
|
417
436
|
|
|
418
437
|
# --wait with no message: the session's next reply, sending nothing,
|
|
@@ -421,15 +440,18 @@ module Samagotchi
|
|
|
421
440
|
# was already reported. No message count: a note landing between turns
|
|
422
441
|
# grows the messages without a turn. An idle session with no worker
|
|
423
442
|
# waits for whatever wakes one (the web, chi send) rather than calling
|
|
424
|
-
# it gone.
|
|
443
|
+
# it gone. The turn running now, or the one whose continue offer
|
|
444
|
+
# stands, is the one waited for (ReplyWait.own_turn): its reply, not a
|
|
445
|
+
# turn's queued behind it; with none known, the next reply.
|
|
425
446
|
# @return [Integer] the exit status
|
|
426
447
|
def run_wait_only(options)
|
|
427
448
|
id = resolve(options[:ids].first) or return 1
|
|
428
449
|
session = Session.load(id, state_dir: @state_dir)
|
|
429
450
|
live = session.status == Session::STATUS_RUNNING || SessionManager.session_owner(id, state_dir: @state_dir)
|
|
451
|
+
turn = ReplyWait.own_turn(session, session_dir: Session.session_dir(id, state_dir: @state_dir))
|
|
430
452
|
wait_for_reply(id, cursor: ReplyWait.newest_reply(id, state_dir: @state_dir),
|
|
431
453
|
baseline: baseline_of(session).merge(messages: nil),
|
|
432
|
-
timeout: options[:timeout], owner_grace: live ? self.class::WORKER_GONE_AFTER : nil)
|
|
454
|
+
timeout: options[:timeout], owner_grace: live ? self.class::WORKER_GONE_AFTER : nil, turn: turn)
|
|
433
455
|
end
|
|
434
456
|
|
|
435
457
|
# The session as it was before the message went in (ReplyWait's
|
|
@@ -535,13 +557,21 @@ module Samagotchi
|
|
|
535
557
|
owner = SessionManager.session_owner(id, state_dir: @state_dir)
|
|
536
558
|
running = owner && session.status == Session::STATUS_RUNNING
|
|
537
559
|
refs = copy_images(Session.session_dir(id, state_dir: @state_dir))
|
|
538
|
-
# --cut asks the running turn for a cut
|
|
539
|
-
# ordinary message
|
|
540
|
-
|
|
560
|
+
# --cut asks the running turn for a cut (to an idle session an
|
|
561
|
+
# ordinary message: nothing to cut). --queue always goes as a queue,
|
|
562
|
+
# idle or not: the Bridge acks next_step to an idle worker and the
|
|
563
|
+
# file still carries it, so a turn another client starts first never
|
|
564
|
+
# merges it, and its turn and reply carry its enqueued_id (the
|
|
565
|
+
# --wait's turn:); a worker older than queueing is refused.
|
|
566
|
+
delivery = if @queue then Delivery::QUEUE
|
|
567
|
+
elsif @cut && running then Delivery::CUT
|
|
568
|
+
end
|
|
541
569
|
result = SessionManager.deliver_turn(id, prompt: prompt, client_id: CLIENT_ID, images: refs, state_dir: @state_dir,
|
|
542
570
|
delivery: delivery)
|
|
543
571
|
unless result[:status] == :accepted
|
|
544
|
-
|
|
572
|
+
# A worker older than queueing would steer it: refused, not sent.
|
|
573
|
+
word = result.dig(:ack, "error") == Delivery::QUEUE_UNAVAILABLE["error"] ? "refused" : "failed"
|
|
574
|
+
@info.puts("#{short} #{word}: #{result.dig(:ack, "detail") || "could not queue it"}")
|
|
545
575
|
return false
|
|
546
576
|
end
|
|
547
577
|
|
|
@@ -556,6 +586,7 @@ module Samagotchi
|
|
|
556
586
|
# (next_step for an idle session) and a cut's outcome.
|
|
557
587
|
@sent_delivery = ack["delivery"] || delivery
|
|
558
588
|
@sent_cut = ack["cut"]
|
|
589
|
+
@sent_enqueued_id = (ack["enqueued_id"] || ack[:enqueued_id])&.to_s
|
|
559
590
|
@info.puts("#{short} sent#{with_images}#{delivery_note(ack, delivery: delivery, running: running, owner: owner)}")
|
|
560
591
|
true
|
|
561
592
|
rescue SessionManager::OwnedByTUI
|
|
@@ -568,12 +599,14 @@ module Samagotchi
|
|
|
568
599
|
|
|
569
600
|
# What the line says became of the message: a plain one goes in at the
|
|
570
601
|
# running turn's next step (nothing cuts by default); a --cut one names
|
|
571
|
-
# the outcome from the ack (now, waits, off);
|
|
572
|
-
#
|
|
602
|
+
# the outcome from the ack (now, waits, off); a --queue one runs after
|
|
603
|
+
# the turn (or now, when the turn ended before it went in); images keep
|
|
604
|
+
# their own wording; an idle session's turn simply runs.
|
|
573
605
|
# @param ack [Hash] the Bridge's 202 body ({} for the input file)
|
|
574
606
|
# @return [String] "" or " (...)"
|
|
575
607
|
def delivery_note(ack, delivery:, running:, owner:)
|
|
576
608
|
return " (started its worker)" if owner.nil?
|
|
609
|
+
return queue_note(ack, running: running) if Delivery.queue?(delivery)
|
|
577
610
|
return "" unless running
|
|
578
611
|
|
|
579
612
|
# A busy worker runs a message with images as its own next turn
|
|
@@ -589,12 +622,23 @@ module Samagotchi
|
|
|
589
622
|
" #{TerminalUI::Formatting.steer_cut_note(outcome.to_sym, ack["cut_after"])}"
|
|
590
623
|
end
|
|
591
624
|
|
|
592
|
-
#
|
|
593
|
-
#
|
|
594
|
-
#
|
|
595
|
-
#
|
|
625
|
+
# A --queue, worded from the Bridge's ack: queue when a turn ran as it
|
|
626
|
+
# went in (one another client started too), next_step when none did
|
|
627
|
+
# (it runs now). The input file (no Bridge) carries the queue to
|
|
628
|
+
# whichever turn runs: worded from the session as chi read it.
|
|
629
|
+
def queue_note(ack, running:)
|
|
630
|
+
queued = ack.key?("delivery") ? ack["delivery"] == Delivery::QUEUE : running
|
|
631
|
+
return " (queued: runs after the current turn)" if queued
|
|
632
|
+
|
|
633
|
+
running ? " (the running turn had ended: it runs now)" : " (nothing running: it runs now)"
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
# More keys for --format json, with --cut or --queue only (the line is
|
|
637
|
+
# unchanged otherwise): the delivery the worker applied (next_step to
|
|
638
|
+
# an idle session) and a cut's outcome (now, waits, off). --cut --wait
|
|
639
|
+
# is refused for a running turn, so its json line says next_step.
|
|
596
640
|
def report_extra
|
|
597
|
-
return {} unless @cut
|
|
641
|
+
return {} unless @cut || @queue
|
|
598
642
|
|
|
599
643
|
extra = { delivery: @sent_delivery || Delivery::NEXT_STEP }
|
|
600
644
|
extra[:cut] = @sent_cut if @sent_cut
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -722,14 +722,15 @@ module Samagotchi
|
|
|
722
722
|
symbolize_empty_answer(message)
|
|
723
723
|
end
|
|
724
724
|
|
|
725
|
-
# An empty-answer note's marker (TurnNote.empty)
|
|
726
|
-
# they were saved.
|
|
725
|
+
# An empty-answer note's marker (TurnNote.empty), its steps and its
|
|
726
|
+
# refusal, as they were saved.
|
|
727
727
|
def symbolize_empty_answer(message)
|
|
728
728
|
marker = message[:empty_answer]
|
|
729
729
|
return message unless marker.is_a?(Hash)
|
|
730
730
|
|
|
731
731
|
marker = marker.transform_keys(&:to_sym)
|
|
732
732
|
marker[:steps] = marker[:steps].map { |step| step.is_a?(Hash) ? step.transform_keys(&:to_sym) : step } if marker[:steps].is_a?(Array)
|
|
733
|
+
marker[:refusal] = marker[:refusal].transform_keys(&:to_sym) if marker[:refusal].is_a?(Hash)
|
|
733
734
|
message.merge(empty_answer: marker)
|
|
734
735
|
end
|
|
735
736
|
|
|
@@ -116,7 +116,7 @@ module Samagotchi
|
|
|
116
116
|
registry.register("/detach", "leave and keep the worker running", local: true, uis: [:attached],
|
|
117
117
|
match: ->(text) { text.casecmp?("/detach") })
|
|
118
118
|
# /cut TEXT and /queue TEXT while a turn runs: a delivery the UIs parse
|
|
119
|
-
# themselves (
|
|
119
|
+
# themselves (ComposerRoute.steer_choice) before any command check; the
|
|
120
120
|
# entries exist for Tab completion and /help. The web composer sends
|
|
121
121
|
# /cut too. /queue is a real after-the-turn list only in the local
|
|
122
122
|
# REPL (the attached TUI's worker doesn't skip queued files yet), so
|
|
@@ -158,11 +158,39 @@ module Samagotchi
|
|
|
158
158
|
nil
|
|
159
159
|
end
|
|
160
160
|
|
|
161
|
-
|
|
161
|
+
# A turn's reply: output/<timestamp>.txt, or
|
|
162
|
+
# output/<timestamp>-<enqueued_id>.txt for a turn that ran an input
|
|
163
|
+
# with an id, so a wait for that input's turn (ReplyWait's turn:) finds
|
|
164
|
+
# its own reply. The names still sort by time ("-" sorts before ".",
|
|
165
|
+
# so an id'd name and a bare one of the same timestamp keep order);
|
|
166
|
+
# every reader globs *.txt.
|
|
167
|
+
# @param enqueued_id [String, nil] the turn's input id (LastTurn#enqueued_id)
|
|
168
|
+
def self.write_output(session_dir, response, enqueued_id: nil)
|
|
162
169
|
output_dir = File.join(session_dir, OUTPUT_DIR)
|
|
163
170
|
FileUtils.mkdir_p(output_dir)
|
|
164
171
|
timestamp = Time.now.strftime("%Y%m%d%H%M%S%9N")
|
|
165
|
-
AtomicFile.write(File.join(output_dir, "#{timestamp}.txt"), response.to_s)
|
|
172
|
+
AtomicFile.write(File.join(output_dir, "#{output_name(timestamp, enqueued_id)}.txt"), response.to_s)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# An id that is safe in a file name (a Bridge's or deliver_turn's
|
|
176
|
+
# uuid); anything else leaves the name bare.
|
|
177
|
+
ENQUEUED_ID_NAME = /\A[A-Za-z0-9_-]{1,64}\z/
|
|
178
|
+
|
|
179
|
+
def self.output_name(timestamp, enqueued_id)
|
|
180
|
+
id = enqueued_id.to_s
|
|
181
|
+
ENQUEUED_ID_NAME.match?(id) ? "#{timestamp}-#{id}" : timestamp
|
|
182
|
+
end
|
|
183
|
+
private_class_method :output_name
|
|
184
|
+
|
|
185
|
+
# @return [String, nil] the reply filename of the turn that ran the
|
|
186
|
+
# input +enqueued_id+, or nil while it has none
|
|
187
|
+
def self.reply_for(session_dir, enqueued_id)
|
|
188
|
+
dir = File.join(session_dir, OUTPUT_DIR)
|
|
189
|
+
id = enqueued_id.to_s
|
|
190
|
+
return nil if id.empty? || !Dir.exist?(dir)
|
|
191
|
+
|
|
192
|
+
suffix = "-#{id}.txt"
|
|
193
|
+
Dir.children(dir).select { |f| f.end_with?(suffix) }.max
|
|
166
194
|
end
|
|
167
195
|
|
|
168
196
|
# The output files' text, oldest first; with +since_time+ only files
|
|
@@ -218,12 +246,40 @@ module Samagotchi
|
|
|
218
246
|
end
|
|
219
247
|
|
|
220
248
|
# Whether a mid-turn drain leaves this unclaimed input file for a turn
|
|
221
|
-
# of its own instead of merging it at a step boundary
|
|
222
|
-
# image message (steering merges text only)
|
|
223
|
-
#
|
|
224
|
-
#
|
|
249
|
+
# of its own instead of merging it at a step boundary: a queued message
|
|
250
|
+
# (`delivery: queue`) or an image message (steering merges text only).
|
|
251
|
+
# The drain skips such a file without stopping at it, so a later text
|
|
252
|
+
# line still merges; the drain's file filter reads this one predicate,
|
|
253
|
+
# so what a boundary leaves changes in one place.
|
|
225
254
|
def self.waits_for_turn_end?(input_file)
|
|
226
|
-
|
|
255
|
+
data = JSON.parse(File.read(input_file))
|
|
256
|
+
data.is_a?(Hash) && (Array(data["images"]).any? || Delivery.queue?(data["delivery"]))
|
|
257
|
+
rescue JSON::ParserError, SystemCallError
|
|
258
|
+
false
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Whether an input file carrying +enqueued_id+ still waits unclaimed in
|
|
262
|
+
# input/ (no worker has taken it for a turn or a step yet).
|
|
263
|
+
def self.input_waiting?(session_dir, enqueued_id) = !waiting_input(session_dir, enqueued_id).nil?
|
|
264
|
+
|
|
265
|
+
# How the unclaimed input file carrying +enqueued_id+ waits: :turn_end
|
|
266
|
+
# (a queue or an image message: it runs as a turn of its own, never
|
|
267
|
+
# merged, #waits_for_turn_end?), :merges (a running turn's drain may
|
|
268
|
+
# take it as a steer), or nil when none waits.
|
|
269
|
+
# @return [Symbol, nil]
|
|
270
|
+
def self.waiting_input(session_dir, enqueued_id)
|
|
271
|
+
id = enqueued_id.to_s
|
|
272
|
+
return nil if id.empty?
|
|
273
|
+
|
|
274
|
+
find_new_input_files(session_dir).each do |file|
|
|
275
|
+
data = JSON.parse(File.read(file))
|
|
276
|
+
next unless data.is_a?(Hash) && data["enqueued_id"] == id
|
|
277
|
+
|
|
278
|
+
return Array(data["images"]).any? || Delivery.queue?(data["delivery"]) ? :turn_end : :merges
|
|
279
|
+
rescue JSON::ParserError, SystemCallError
|
|
280
|
+
next
|
|
281
|
+
end
|
|
282
|
+
nil
|
|
227
283
|
end
|
|
228
284
|
|
|
229
285
|
def self.claim_input_file(input_file)
|
|
@@ -205,8 +205,9 @@ module Samagotchi
|
|
|
205
205
|
# child inherits this process's ENV and reads config.yml itself (as it
|
|
206
206
|
# is when the worker starts); opts[:env] adds to it (merged, not
|
|
207
207
|
# replaced) what it can't read there: this `chi`'s CLI settings
|
|
208
|
-
# (Config.cli_env)
|
|
209
|
-
# SAMAGOTCHI_GUARDRAILS_
|
|
208
|
+
# (Config.cli_env) and the absolute log file. It unsets
|
|
209
|
+
# SAMAGOTCHI_GUARDRAILS_*, the session markers (SESSION_MARKER_ENV) and
|
|
210
|
+
# SAMAGOTCHI_HOSTS_JSON (HOSTS_JSON_ENV).
|
|
210
211
|
# XDG_CONFIG_HOME passes: whoever starts or
|
|
211
212
|
# wakes the worker picks which config.yml it reads (docs/guardrails.md).
|
|
212
213
|
private_class_method def self.spawn_options(session)
|
|
@@ -223,14 +224,12 @@ module Samagotchi
|
|
|
223
224
|
Log.warn(:worker, "cwd_gone", sid: session.id, dir: dir, cwd: Dir.pwd)
|
|
224
225
|
end
|
|
225
226
|
child_env = {}
|
|
226
|
-
#
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
nil
|
|
233
|
-
end
|
|
227
|
+
# The worker reads config.yml's hosts: as it starts, like every other
|
|
228
|
+
# setting: a host added or changed after this process (a parent
|
|
229
|
+
# worker, `chi web`) started reaches it. Unset, not just left out: a
|
|
230
|
+
# worker an older chi started still has one, and its delegates would
|
|
231
|
+
# inherit its start-time hosts.
|
|
232
|
+
child_env[HOSTS_JSON_ENV] = nil
|
|
234
233
|
# A worker gets no CLI args: --model, --thinking, --log-level and the
|
|
235
234
|
# other flags this chi was started with.
|
|
236
235
|
child_env.merge!(Config.cli_env)
|
|
@@ -694,7 +693,7 @@ module Samagotchi
|
|
|
694
693
|
# so a resume right after spawns a fresh worker instead of finding the
|
|
695
694
|
# dying one.
|
|
696
695
|
# A turn still running is canceled first (STOP_CANCEL_WAIT), as the
|
|
697
|
-
# web's Cancel does: its worker saves it. The TERM stops (and saves)
|
|
696
|
+
# web's Cancel does, with reason STOP_CANCEL_REASON: its worker saves it. The TERM stops (and saves)
|
|
698
697
|
# what still runs (WorkerSignals), with a short grace after the marker.
|
|
699
698
|
# @return [Boolean, nil] with +wait+: whether the owner was gone in time
|
|
700
699
|
# @raise [OwnedByTUI] when the interactive TUI owns the session
|
|
@@ -714,7 +713,14 @@ module Samagotchi
|
|
|
714
713
|
wait_for_owner_release(session_id, timeout: wait, state_dir: sd) if wait
|
|
715
714
|
end
|
|
716
715
|
|
|
716
|
+
# ConfigFile.hosts_config's override of config.yml's hosts:, which no
|
|
717
|
+
# spawned worker inherits (spawn_options).
|
|
718
|
+
HOSTS_JSON_ENV = "SAMAGOTCHI_HOSTS_JSON"
|
|
719
|
+
|
|
717
720
|
STOP_CANCEL_WAIT = 3.0
|
|
721
|
+
# The cancel reason of a turn a session stop cancels (Bridge::CANCEL_REASONS):
|
|
722
|
+
# not the user's Stop, which only ends the turn ("user").
|
|
723
|
+
STOP_CANCEL_REASON = "session_stopped"
|
|
718
724
|
|
|
719
725
|
# Cancel the session's running turn over its Bridge and wait (up to
|
|
720
726
|
# STOP_CANCEL_WAIT) for the worker to save it. Best effort: without a
|
|
@@ -723,7 +729,7 @@ module Samagotchi
|
|
|
723
729
|
return unless Session.load(session_id, state_dir: state_dir).status == Session::STATUS_RUNNING
|
|
724
730
|
|
|
725
731
|
client = BridgeClient.discover(session_id, session_dir: Session.session_dir(session_id, state_dir: state_dir))
|
|
726
|
-
return unless client && client.cancel(reason:
|
|
732
|
+
return unless client && client.cancel(reason: STOP_CANCEL_REASON).status == 202
|
|
727
733
|
|
|
728
734
|
BridgeClient.poll(STOP_CANCEL_WAIT) do
|
|
729
735
|
Session.load(session_id, state_dir: state_dir).status != Session::STATUS_RUNNING
|
|
@@ -1167,9 +1173,12 @@ module Samagotchi
|
|
|
1167
1173
|
# the session's sidecar, waiting TURN_BRIDGE_WAIT for a new worker's
|
|
1168
1174
|
# @return [Hash] {status: :accepted, ack: Hash} (the Bridge's reply, or
|
|
1169
1175
|
# {status:, enqueued_id:, session_id:} for a file), {status: :refused,
|
|
1170
|
-
# code:, ack:} when the Bridge refused the
|
|
1171
|
-
#
|
|
1172
|
-
#
|
|
1176
|
+
# code:, ack:} when the Bridge refused the request (any 4xx with an
|
|
1177
|
+
# error: bad images, a delivery it doesn't run) or a queue would reach
|
|
1178
|
+
# a worker that doesn't queue (no file then: it would steer),
|
|
1179
|
+
# {status: :timeout, ack:} when it took the request but never answered
|
|
1180
|
+
# (no file then), or {status: :failed} when the input file couldn't be
|
|
1181
|
+
# written
|
|
1173
1182
|
# @raise [OwnedByTUI] a chi REPL owns the session
|
|
1174
1183
|
# @raise [ArgumentError] no such session
|
|
1175
1184
|
def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], delivery: nil, state_dir: nil, manager: self,
|
|
@@ -1178,20 +1187,28 @@ module Samagotchi
|
|
|
1178
1187
|
bridge ||= -> { BridgeClient.wait_for(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT) }
|
|
1179
1188
|
manager.resume_session(session_id, state_dir: state_dir)
|
|
1180
1189
|
if (client = bridge.call)
|
|
1190
|
+
# A worker from before queueing (no "delivery" feature) ignores the
|
|
1191
|
+
# field or refuses it; either way a file would steer the running turn.
|
|
1192
|
+
return queue_unavailable if Delivery.queue?(delivery) && !worker_queues?(session_dir)
|
|
1193
|
+
|
|
1181
1194
|
begin
|
|
1182
1195
|
options = { prompt: prompt, client_id: client_id }
|
|
1183
1196
|
options[:images] = images unless images.empty?
|
|
1184
|
-
# An old worker ignores the field: it cuts anyway (its own default)
|
|
1185
|
-
# and its file never carries a queue, so a queue would steer there.
|
|
1197
|
+
# An old worker ignores the field: it cuts anyway (its own default).
|
|
1186
1198
|
options[:delivery] = delivery unless Delivery.next_step?(delivery)
|
|
1187
1199
|
reply = client.post_turn(**options)
|
|
1188
1200
|
ack = reply.json
|
|
1189
1201
|
return { status: :accepted, ack: ack } if reply.status == 202 && ack.is_a?(Hash)
|
|
1190
|
-
if ack.is_a?(Hash) && ack["error"] == "bad_images"
|
|
1191
|
-
return { status: :refused, code: reply.status, ack: ack }
|
|
1192
|
-
end
|
|
1193
1202
|
# Read after its deadline and dropped: a file would run it after all.
|
|
1194
1203
|
return turn_timeout if ack.is_a?(Hash) && ack["error"] == "deadline_passed"
|
|
1204
|
+
# Any other 4xx with an error is a refusal (bad images, a delivery
|
|
1205
|
+
# it doesn't run, a client bug): a file would bypass it, and a
|
|
1206
|
+
# refused queue would steer. A 413 (a body over the Bridge's
|
|
1207
|
+
# limit, a big piped chi send) and a 5xx keep the file fallback
|
|
1208
|
+
# below, which carries the delivery (a queue too).
|
|
1209
|
+
if (400..499).cover?(reply.status) && reply.status != 413 && ack.is_a?(Hash) && ack["error"]
|
|
1210
|
+
return { status: :refused, code: reply.status, ack: ack }
|
|
1211
|
+
end
|
|
1195
1212
|
rescue Errno::ETIMEDOUT
|
|
1196
1213
|
# The Bridge drops a turn it reads after the request's deadline
|
|
1197
1214
|
# (BridgeClient#post_turn), so a worker that wakes later won't run
|
|
@@ -1222,6 +1239,18 @@ module Samagotchi
|
|
|
1222
1239
|
{ status: :accepted, ack: { status: "accepted", enqueued_id: enqueued_id, session_id: session_id } }
|
|
1223
1240
|
end
|
|
1224
1241
|
|
|
1242
|
+
# Whether the session's live worker runs delivery: queue (its sidecar
|
|
1243
|
+
# names the feature). No sidecar to read (the client came from a stand-in,
|
|
1244
|
+
# or it went between the wait and here): true, the post finds out.
|
|
1245
|
+
private_class_method def self.worker_queues?(session_dir)
|
|
1246
|
+
sidecar = WorkerSidecar.read(session_dir)
|
|
1247
|
+
sidecar.nil? || sidecar.features.include?(Delivery::QUEUE_FEATURE)
|
|
1248
|
+
end
|
|
1249
|
+
|
|
1250
|
+
private_class_method def self.queue_unavailable
|
|
1251
|
+
{ status: :refused, code: 409, ack: Delivery::QUEUE_UNAVAILABLE }
|
|
1252
|
+
end
|
|
1253
|
+
|
|
1225
1254
|
private_class_method def self.turn_timeout
|
|
1226
1255
|
{ status: :timeout, ack: { "error" => "worker_timeout",
|
|
1227
1256
|
"detail" => "the session's worker did not answer, so the message was not sent" } }
|
|
@@ -883,7 +883,7 @@ module Samagotchi
|
|
|
883
883
|
payload = event[:payload]
|
|
884
884
|
payload = {} unless payload.is_a?(Hash)
|
|
885
885
|
|
|
886
|
-
@mutex.synchronize { note_first_chunk(
|
|
886
|
+
@mutex.synchronize { note_first_chunk(event) }
|
|
887
887
|
usage = TokenUsage.from_payload(payload)
|
|
888
888
|
if usage
|
|
889
889
|
@mutex.synchronize { note_server_usage(usage) if @turn }
|
|
@@ -915,21 +915,16 @@ module Samagotchi
|
|
|
915
915
|
turn.gen_had_server = true
|
|
916
916
|
end
|
|
917
917
|
|
|
918
|
-
# The first chunk with content, reasoning or a tool call
|
|
919
|
-
#
|
|
920
|
-
# holds the mutex.
|
|
921
|
-
def note_first_chunk(
|
|
918
|
+
# The first chunk with content, reasoning or a tool call (the loop marks
|
|
919
|
+
# its event tool_call: true, whatever the wire) starts the generation's
|
|
920
|
+
# decode clock (an estimated speed runs from it). Caller holds the mutex.
|
|
921
|
+
def note_first_chunk(event)
|
|
922
922
|
return unless @turn && @turn.gen_open && @turn.gen_first_chunk_at.nil?
|
|
923
|
-
return unless !event[:content].to_s.empty? ||
|
|
923
|
+
return unless !event[:content].to_s.empty? || event[:tool_call]
|
|
924
924
|
|
|
925
925
|
@turn.gen_first_chunk_at = monotonic_time
|
|
926
926
|
end
|
|
927
927
|
|
|
928
|
-
def tool_call_delta?(payload)
|
|
929
|
-
choice = payload["choices"].is_a?(Array) ? payload["choices"].first : nil
|
|
930
|
-
choice.is_a?(Hash) && choice["delta"].is_a?(Hash) && !Array(choice["delta"]["tool_calls"]).empty?
|
|
931
|
-
end
|
|
932
|
-
|
|
933
928
|
def event_content_chars(payload, event)
|
|
934
929
|
len = payload_content(payload, event).length
|
|
935
930
|
len.positive? ? len : nil
|
data/lib/samagotchi/steer_cut.rb
CHANGED
|
@@ -82,6 +82,10 @@ module Samagotchi
|
|
|
82
82
|
@waiting.epoch
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
+
# The running generation's phase (GenerationPhase::Snapshot), nil with
|
|
86
|
+
# none running.
|
|
87
|
+
def generation_snapshot = @phase.snapshot
|
|
88
|
+
|
|
85
89
|
# The turn's stream: the generation's phase follows it, and a thinking
|
|
86
90
|
# chunk rechecks a waiting message. +progress+: a chunk's lanes
|
|
87
91
|
# (.lanes), read from it when not given.
|