samagotchi 0.49.0 → 0.51.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 +84 -1
- data/bin/chi +2 -2
- data/docs/cli.md +33 -8
- data/docs/configuration.md +11 -9
- data/docs/hooks.md +12 -3
- data/docs/internals/engine.md +4 -2
- data/docs/sessions.md +8 -4
- data/docs/testing.md +2 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
- data/lib/samagotchi/bridge.rb +24 -9
- data/lib/samagotchi/bridge_client.rb +3 -1
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +13 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/delivery.rb +33 -0
- data/lib/samagotchi/engine.rb +1 -1
- data/lib/samagotchi/hooks/stream_watch.rb +9 -2
- data/lib/samagotchi/host_registry.rb +17 -4
- data/lib/samagotchi/kernel_loop.rb +28 -2
- data/lib/samagotchi/llm/native_tool_normalizer.rb +2 -2
- data/lib/samagotchi/send_command.rb +124 -10
- data/lib/samagotchi/session_commands.rb +10 -2
- data/lib/samagotchi/session_inbox.rb +27 -7
- data/lib/samagotchi/session_manager.rb +13 -3
- data/lib/samagotchi/session_metrics.rb +32 -6
- data/lib/samagotchi/sessions_command.rb +84 -7
- data/lib/samagotchi/steer_cut.rb +12 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +50 -3
- data/lib/samagotchi/terminal_ui/event_renderer.rb +4 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +68 -6
- data/lib/samagotchi/terminal_ui.rb +48 -5
- data/lib/samagotchi/tool_activity.rb +3 -0
- data/lib/samagotchi/tool_runner.rb +2 -0
- data/lib/samagotchi/tools/builtin_calls.rb +33 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +6 -2
- data/lib/samagotchi/web/message_parts.rb +3 -0
- data/lib/samagotchi/web/public/activity.js +6 -0
- data/lib/samagotchi/web/public/app.js +71 -29
- data/lib/samagotchi/web/public/index.html +17 -15
- data/lib/samagotchi/web/public/live_timing.js +32 -5
- data/lib/samagotchi/web/public/session_title.js +26 -0
- data/lib/samagotchi/web/public/stage_model.js +15 -0
- data/lib/samagotchi/web/public/stage_view.js +10 -4
- data/lib/samagotchi/web/public/strip.js +4 -6
- data/lib/samagotchi/web/public/timing.js +11 -0
- data/lib/samagotchi/web/public/turn_events.js +14 -2
- data/lib/samagotchi/web/public/turn_html.js +24 -1
- data/lib/samagotchi/web/public/turn_view.js +9 -3
- data/lib/samagotchi/web/session_hub.rb +11 -3
- data/lib/samagotchi/worker.rb +1 -1
- data/lib/samagotchi/worker_inbound.rb +8 -5
- data/lib/samagotchi.rb +1 -0
- metadata +3 -1
|
@@ -32,16 +32,20 @@ module Samagotchi
|
|
|
32
32
|
@clock = clock
|
|
33
33
|
@slow_logged = false
|
|
34
34
|
@iteration = nil
|
|
35
|
+
@restarted = false
|
|
35
36
|
end
|
|
36
37
|
|
|
37
|
-
# A new generation (:generation_started)
|
|
38
|
-
|
|
38
|
+
# A new generation (:generation_started), or a dropped stream's step
|
|
39
|
+
# asked again (restarted: true: the first fire after it carries
|
|
40
|
+
# restarted: true — what came before is void).
|
|
41
|
+
def started(iteration, restarted: false)
|
|
39
42
|
@iteration = iteration
|
|
40
43
|
@started_at = @last_fire_at = @clock.call
|
|
41
44
|
@thinking = +""
|
|
42
45
|
@text = +""
|
|
43
46
|
@thinking_chars = 0
|
|
44
47
|
@text_chars = 0
|
|
48
|
+
@restarted = restarted
|
|
45
49
|
end
|
|
46
50
|
|
|
47
51
|
# One chunk's new thinking and visible text.
|
|
@@ -70,6 +74,9 @@ module Samagotchi
|
|
|
70
74
|
elapsed_ms: ((now - @started_at) * 1000).round,
|
|
71
75
|
# A question would hold the HTTP read for minutes.
|
|
72
76
|
ask_user: ->(**) {} }
|
|
77
|
+
# The first fire after a restarted retry says so, once.
|
|
78
|
+
event[:restarted] = true if @restarted
|
|
79
|
+
@restarted = false
|
|
73
80
|
@thinking = +""
|
|
74
81
|
@text = +""
|
|
75
82
|
@last_fire_at = now
|
|
@@ -200,9 +200,11 @@ module Samagotchi
|
|
|
200
200
|
# id it is sent as (ModelRef: one alias pass). A ref that names a host
|
|
201
201
|
# goes there. A bare id goes to the hosts whose cached list has it or
|
|
202
202
|
# that declare it (hosts.<name>.models, known before any /models): the
|
|
203
|
-
# default host when it is one of them, else the first in hosts: order
|
|
204
|
-
#
|
|
205
|
-
#
|
|
203
|
+
# default host when it is one of them, else the first in hosts: order.
|
|
204
|
+
# When none does, the hosts whose saved list (ModelListStore, another
|
|
205
|
+
# process's listing: a worker spawned with --model never lists) has
|
|
206
|
+
# it, the same way; else the default host (no discovery here, the
|
|
207
|
+
# latency budget). Never by a substring.
|
|
206
208
|
def host_for_model(raw_model)
|
|
207
209
|
ref = model_ref(raw_model)
|
|
208
210
|
# ModelRef names a host only when it is one of ours
|
|
@@ -211,6 +213,7 @@ module Samagotchi
|
|
|
211
213
|
down = ref.id.to_s.strip.downcase
|
|
212
214
|
listed = @mutex.synchronize { @model_index }&.fetch(down, nil) || []
|
|
213
215
|
candidates = @entries.values.select { |e| listed.include?(e.name) || e.models&.key?(down) }.map(&:name)
|
|
216
|
+
candidates = saved_hosts_for(down) if candidates.empty?
|
|
214
217
|
default = default_entry
|
|
215
218
|
name = candidates.include?(default.name) ? default.name : candidates.first
|
|
216
219
|
[(name && find_entry(name)) || default, ref.id]
|
|
@@ -383,7 +386,17 @@ module Samagotchi
|
|
|
383
386
|
# a worker without listing (`chi send --new --model`, delegate):
|
|
384
387
|
# ModelListStore is what they check an id against.
|
|
385
388
|
def save_model_list(name, models)
|
|
386
|
-
ModelListStore.save(name, Array(models).map(&:id))
|
|
389
|
+
ModelListStore.save(name, Array(models).map(&:id), env: @env)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# The configured hosts, in hosts: order, whose fresh saved list
|
|
393
|
+
# (ModelListStore) has +down+ (a downcased id). The store never raises.
|
|
394
|
+
def saved_hosts_for(down)
|
|
395
|
+
saved = ModelListStore.read(env: @env)
|
|
396
|
+
@entries.each_key.select do |name|
|
|
397
|
+
list = saved[name]
|
|
398
|
+
list && !list.stale? && list.known?(down)
|
|
399
|
+
end
|
|
387
400
|
end
|
|
388
401
|
|
|
389
402
|
def fresh_list(name, entry)
|
|
@@ -952,11 +952,30 @@ module Samagotchi
|
|
|
952
952
|
|
|
953
953
|
private
|
|
954
954
|
|
|
955
|
+
# Shell-like names (the harness's is `execute`). The unknown-tool error
|
|
956
|
+
# never echoes the name the model used — a model that sees its own wrong
|
|
957
|
+
# name again calls it again (a steer that named `bash` raised one model's
|
|
958
|
+
# bash rate from 9% to 30%); the activity event and the logs keep it.
|
|
959
|
+
SHELL_LIKE_TOOL_NAMES = %w[
|
|
960
|
+
bash sh zsh shell terminal cmd command run run_command run_shell exec execute_command
|
|
961
|
+
].freeze
|
|
962
|
+
|
|
963
|
+
# The error an unknown tool name gets back. A shell-like name with
|
|
964
|
+
# `execute` offered this turn points at `execute`; every other unknown
|
|
965
|
+
# name lists what is offered. Neither echoes the wrong name.
|
|
966
|
+
def unknown_tool_error(name, layers)
|
|
967
|
+
names = @tools.names(layers: layers)
|
|
968
|
+
if SHELL_LIKE_TOOL_NAMES.include?(name.to_s.downcase) && names.include?("execute")
|
|
969
|
+
"Error: no such tool. Shell commands run with `execute` (same arguments)."
|
|
970
|
+
else
|
|
971
|
+
"Error: no such tool. Available: #{names.join(", ")}"
|
|
972
|
+
end
|
|
973
|
+
end
|
|
974
|
+
|
|
955
975
|
def dispatch(call)
|
|
956
976
|
entry = @tools.offered(call[:name], layers: llm_context_layers)
|
|
957
977
|
unless entry
|
|
958
|
-
|
|
959
|
-
result = "Error: unknown tool '#{call[:name]}'. Available: #{available}"
|
|
978
|
+
result = unknown_tool_error(call[:name], llm_context_layers)
|
|
960
979
|
return {
|
|
961
980
|
output: result,
|
|
962
981
|
activity: ToolActivity.tool_activity_event(call[:name], call, result, registry: @tools)
|
|
@@ -965,6 +984,13 @@ module Samagotchi
|
|
|
965
984
|
|
|
966
985
|
dump_log("tool_call", call[:content], tool: call[:name], path: call[:path], scope: call[:scope])
|
|
967
986
|
|
|
987
|
+
if call[:called_as]
|
|
988
|
+
# The model's spelling for a tool an alias ran (bash → execute); its
|
|
989
|
+
# rate is what the alias exists for, so it goes in the log. Tag :turn
|
|
990
|
+
# like the other per-call tool logs (there is no :tool tag).
|
|
991
|
+
Log.info(:turn, "tool_alias", name: call[:called_as], as: call[:name])
|
|
992
|
+
end
|
|
993
|
+
|
|
968
994
|
result = entry.handler.call(call, tool_context)
|
|
969
995
|
|
|
970
996
|
dump_log("tool_result", result, tool: call[:name])
|
|
@@ -11,8 +11,8 @@ module Samagotchi
|
|
|
11
11
|
# the call, as it does for the Gemma and Qwen parsers.
|
|
12
12
|
#
|
|
13
13
|
# Unknown tool names pass through with their arguments on args:, so
|
|
14
|
-
# `dispatch` renders its standard "
|
|
15
|
-
# the loop feeds that back like any tool result.
|
|
14
|
+
# `dispatch` renders its standard "no such tool …" error (which never
|
|
15
|
+
# echoes the name) — the loop feeds that back like any tool result.
|
|
16
16
|
class NativeToolNormalizer
|
|
17
17
|
class << self
|
|
18
18
|
# Map a single native tool call (LLM::ToolCall) to the internal call hash.
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "client_id"
|
|
4
|
+
require_relative "delivery"
|
|
4
5
|
require_relative "session"
|
|
6
|
+
require_relative "session_commands"
|
|
5
7
|
require_relative "session_inbox"
|
|
6
8
|
require_relative "session_manager"
|
|
7
9
|
require_relative "session_chain"
|
|
@@ -33,13 +35,14 @@ module Samagotchi
|
|
|
33
35
|
MAX_IMAGES = ImageStore::MAX_TURN_REFS
|
|
34
36
|
|
|
35
37
|
USAGE = <<~TEXT
|
|
36
|
-
Usage: chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...
|
|
38
|
+
Usage: chi send [-m TEXT] [--image PATH]... [--cut] (ID|PREFIX)...
|
|
37
39
|
chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [-m TEXT] [--image PATH]...
|
|
38
40
|
chi send --new --continues (ID|PREFIX|last:ID) [-m TEXT] [--image PATH]...
|
|
39
41
|
chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
|
|
40
42
|
chi send --wait [--timeout S] [--format json] ID
|
|
41
43
|
Sends a message to each session, as if typed in it: a turn starts,
|
|
42
|
-
or a running one
|
|
44
|
+
or a running one takes it at its next step. A stopped session's
|
|
45
|
+
worker starts.
|
|
43
46
|
A session command (/model x, !cmd) runs as the command; --wait
|
|
44
47
|
then has no reply to wait for (exit 0, status command).
|
|
45
48
|
-m TEXT the message; stdin, when piped too, goes above it as a
|
|
@@ -74,6 +77,14 @@ module Samagotchi
|
|
|
74
77
|
nothing is sent: it waits for the session's next
|
|
75
78
|
reply, a running turn's too (after exit 3, 4 or 130,
|
|
76
79
|
wait again this way)
|
|
80
|
+
--cut ask the running turn to cut the generation it is
|
|
81
|
+
streaming and take the message now (a generation that
|
|
82
|
+
has streamed only thinking for steer.cut_after; else
|
|
83
|
+
the message waits for the thinking to pass it). A
|
|
84
|
+
message without --cut goes in at the running turn's
|
|
85
|
+
next step and cuts nothing. To an idle session it
|
|
86
|
+
sends normally. Refused with --wait into a running
|
|
87
|
+
turn (the steer's turn is the running one)
|
|
77
88
|
--timeout S (--wait) give up after S seconds, exit 4 (the turn
|
|
78
89
|
goes on); default no limit
|
|
79
90
|
--format json
|
|
@@ -81,7 +92,8 @@ module Samagotchi
|
|
|
81
92
|
status answered (text), question (question,
|
|
82
93
|
answer_with), running, or failed, canceled,
|
|
83
94
|
limit, no_answer, error, worker_gone, stopped,
|
|
84
|
-
command (detail)
|
|
95
|
+
command (detail); with --cut also delivery (what
|
|
96
|
+
the worker applied) and cut (now, waits, off)
|
|
85
97
|
Exit with --wait: 0 answered, 1 failed or gone, 2 usage, 3 a
|
|
86
98
|
question waits, 4 still running (--timeout), 130 Ctrl-C.
|
|
87
99
|
Only sessions on this machine. Answers show in the attached TUI
|
|
@@ -100,6 +112,7 @@ module Samagotchi
|
|
|
100
112
|
f.value "--llm-context-budget", key: :llm_budget
|
|
101
113
|
f.value "--image", key: :images, repeat: true
|
|
102
114
|
f.switch "--wait"
|
|
115
|
+
f.switch "--cut"
|
|
103
116
|
f.value "--timeout"
|
|
104
117
|
f.value "--format"
|
|
105
118
|
# Starting a turn in every live session at once is too easy to do
|
|
@@ -159,6 +172,7 @@ module Samagotchi
|
|
|
159
172
|
def run_parsed(options)
|
|
160
173
|
# With --wait stdout is the answer alone.
|
|
161
174
|
@info = options[:wait] ? @stderr : @stdout
|
|
175
|
+
@cut = options[:cut] == true
|
|
162
176
|
prompt = compose(utf8(read_stdin), utf8(options[:message]))
|
|
163
177
|
# Before the wait-only case, which would drop the images.
|
|
164
178
|
return usage_error("--image needs a message: pass -m TEXT or pipe it in") if prompt.nil? && !options[:images].empty?
|
|
@@ -175,6 +189,14 @@ module Samagotchi
|
|
|
175
189
|
@stderr.puts("chi send: #{e.message}")
|
|
176
190
|
return 1
|
|
177
191
|
end
|
|
192
|
+
# A UI-only command (/stats, /exit, …) runs in the terminal and the web,
|
|
193
|
+
# not in the worker: sent through, the worker doesn't run it and the
|
|
194
|
+
# line reaches the model as a prompt, costing a whole turn. Refused
|
|
195
|
+
# before anything is sent (before a --new session is created too); the
|
|
196
|
+
# session's plugin commands are never local.
|
|
197
|
+
if prompt && (local = local_command(prompt))
|
|
198
|
+
return refuse_local_command(local, options)
|
|
199
|
+
end
|
|
178
200
|
# A file that is not there or not an image: a run-time failure (1),
|
|
179
201
|
# reported in the --format json line too; --image itself was fine.
|
|
180
202
|
return CLI::Exit::FAILED unless ingest_images(options[:images])
|
|
@@ -440,6 +462,58 @@ module Samagotchi
|
|
|
440
462
|
# (a bundle's too).
|
|
441
463
|
def command_like?(text) = text.to_s.lstrip.start_with?("/", "!")
|
|
442
464
|
|
|
465
|
+
# The UI-only built-in command +text+ (stripped) is a line of (/stats,
|
|
466
|
+
# /exit, …), or nil: the worker never runs it, so chi send must not pass
|
|
467
|
+
# it on. What the UIs run: the line as the entry's match takes it — bare
|
|
468
|
+
# "exit" too (/exit's match), but not with arguments ("/exit --delete" is
|
|
469
|
+
# a UI line; "/stats please" is a prompt), as in a terminal.
|
|
470
|
+
# @return [Commands::Registry::Entry, nil]
|
|
471
|
+
def local_command(text)
|
|
472
|
+
line = text.to_s.strip
|
|
473
|
+
return nil unless command_like?(line) || line.match?(/\Aexit\z/i)
|
|
474
|
+
|
|
475
|
+
entry = SessionCommands.builtin_registry.lookup_local(line)
|
|
476
|
+
# Only the exact line the UI runs: "/stats please" is a prompt, and
|
|
477
|
+
# "/exit --delete" is left to the worker (the TUI runs it as /exit, the
|
|
478
|
+
# worker answers its unknown-command way). Bare "exit" is /exit's line
|
|
479
|
+
# as its match takes it; the reply names the command, /exit.
|
|
480
|
+
return nil unless entry
|
|
481
|
+
return entry if line == entry.name
|
|
482
|
+
|
|
483
|
+
line.match?(/\Aexit\z/i) ? entry : nil
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# A UI-only command: nothing is sent (with --new, no session is created
|
|
487
|
+
# either). /stats points at its model-free reader and names the session;
|
|
488
|
+
# the others just say the worker doesn't run them. Exit 1, as other
|
|
489
|
+
# refusals; with --wait --format json the line is the usual error object
|
|
490
|
+
# (ParentWait's end-of-run line when the reply never comes).
|
|
491
|
+
# @return [Integer] 1
|
|
492
|
+
def refuse_local_command(entry, options)
|
|
493
|
+
if options[:new]
|
|
494
|
+
error_line("chi send: refused: #{local_refusal(entry.name, nil)}")
|
|
495
|
+
elsif options[:wait]
|
|
496
|
+
# --wait: the JSON line goes to stdout, @info is stderr; an unknown
|
|
497
|
+
# or ambiguous id gets resolve's own error line.
|
|
498
|
+
id = resolve(options[:ids].first) or return CLI::Exit::FAILED
|
|
499
|
+
@stderr.puts("#{id[0, 8]} refused: #{local_refusal(entry.name, id)}")
|
|
500
|
+
else
|
|
501
|
+
options[:ids].uniq.each do |given|
|
|
502
|
+
id = resolve(given) or next
|
|
503
|
+
@info.puts("#{id[0, 8]} refused: #{local_refusal(entry.name, id)}")
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
CLI::Exit::FAILED
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Why a UI-only command isn't sent: /stats points at its model-free
|
|
510
|
+
# reader (naming the session when there is one).
|
|
511
|
+
def local_refusal(name, id)
|
|
512
|
+
return "#{name} is a terminal and web command; chi send doesn't run it" unless name == "/stats"
|
|
513
|
+
|
|
514
|
+
"#{name} is a terminal and web command; use chi sessions stats #{id ? id[0, 8] : "ID"}"
|
|
515
|
+
end
|
|
516
|
+
|
|
443
517
|
# --wait after a message that ran as a session command: no reply comes
|
|
444
518
|
# for it. Its output shows where the session is open.
|
|
445
519
|
# @return [Integer] 0
|
|
@@ -466,7 +540,11 @@ module Samagotchi
|
|
|
466
540
|
owner = SessionManager.session_owner(id, state_dir: @state_dir)
|
|
467
541
|
running = owner && session.status == Session::STATUS_RUNNING
|
|
468
542
|
refs = copy_images(Session.session_dir(id, state_dir: @state_dir))
|
|
469
|
-
|
|
543
|
+
# --cut asks the running turn for a cut; to an idle session it is an
|
|
544
|
+
# ordinary message (there is nothing to cut).
|
|
545
|
+
delivery = @cut && running ? Delivery::CUT : nil
|
|
546
|
+
result = SessionManager.deliver_turn(id, prompt: prompt, client_id: CLIENT_ID, images: refs, state_dir: @state_dir,
|
|
547
|
+
delivery: delivery)
|
|
470
548
|
unless result[:status] == :accepted
|
|
471
549
|
@info.puts("#{short} failed: #{result.dig(:ack, "detail") || "could not queue it"}")
|
|
472
550
|
return false
|
|
@@ -478,12 +556,12 @@ module Samagotchi
|
|
|
478
556
|
return true
|
|
479
557
|
end
|
|
480
558
|
|
|
481
|
-
|
|
482
|
-
#
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
@info.puts("#{short} sent#{with_images}#{
|
|
559
|
+
ack = result[:ack].is_a?(Hash) ? result[:ack] : {}
|
|
560
|
+
# What the worker applied, for --format json: its ack's delivery
|
|
561
|
+
# (next_step for an idle session) and a cut's outcome.
|
|
562
|
+
@sent_delivery = ack["delivery"] || delivery
|
|
563
|
+
@sent_cut = ack["cut"]
|
|
564
|
+
@info.puts("#{short} sent#{with_images}#{delivery_note(ack, delivery: delivery, running: running, owner: owner)}")
|
|
487
565
|
true
|
|
488
566
|
rescue SessionManager::OwnedByTUI
|
|
489
567
|
@info.puts("#{short} refused: it is open in a chi REPL; messages need attached mode")
|
|
@@ -493,6 +571,42 @@ module Samagotchi
|
|
|
493
571
|
false
|
|
494
572
|
end
|
|
495
573
|
|
|
574
|
+
# What the line says became of the message: a plain one goes in at the
|
|
575
|
+
# running turn's next step (nothing cuts by default); a --cut one names
|
|
576
|
+
# the outcome from the ack (now, waits, off); images keep their own
|
|
577
|
+
# wording; an idle session's turn simply runs.
|
|
578
|
+
# @param ack [Hash] the Bridge's 202 body ({} for the input file)
|
|
579
|
+
# @return [String] "" or " (...)"
|
|
580
|
+
def delivery_note(ack, delivery:, running:, owner:)
|
|
581
|
+
return " (started its worker)" if owner.nil?
|
|
582
|
+
return "" unless running
|
|
583
|
+
|
|
584
|
+
# A busy worker runs a message with images as its own next turn
|
|
585
|
+
# rather than merging it into the running one.
|
|
586
|
+
return " (runs after the current turn)" unless @images.empty?
|
|
587
|
+
|
|
588
|
+
case (Delivery.cut?(delivery) ? ack["cut"].to_s : "")
|
|
589
|
+
when "now" then " (cut in now)"
|
|
590
|
+
when "waits" then " (cuts in once the thinking passes #{Config.get("steer.cut_after").to_i} s)"
|
|
591
|
+
when "off" then " (cutting is off; goes in at the next step)"
|
|
592
|
+
# No outcome: a plain message, or the input file (the Bridge went
|
|
593
|
+
# away), which the next step's drain takes.
|
|
594
|
+
else " (goes in at the running turn's next step)"
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
# More keys for --format json, with --cut only (the line is unchanged
|
|
599
|
+
# otherwise): the delivery the worker applied (next_step to an idle
|
|
600
|
+
# session) and a cut's outcome (now, waits, off). In P1 --cut --wait is
|
|
601
|
+
# refused for a running turn, so the json line says next_step.
|
|
602
|
+
def report_extra
|
|
603
|
+
return {} unless @cut
|
|
604
|
+
|
|
605
|
+
extra = { delivery: @sent_delivery || Delivery::NEXT_STEP }
|
|
606
|
+
extra[:cut] = @sent_cut if @sent_cut
|
|
607
|
+
extra
|
|
608
|
+
end
|
|
609
|
+
|
|
496
610
|
# What to say when +model_name+ is known not to take images, or nil
|
|
497
611
|
# (it does, or it is unknown: sent as before). Asked once per model.
|
|
498
612
|
def vision_refusal(model_name, typed: nil)
|
|
@@ -71,8 +71,7 @@ module Samagotchi
|
|
|
71
71
|
continue_answer(answer.empty? ? CONTINUE_COMMAND : answer)
|
|
72
72
|
end
|
|
73
73
|
# It reads the hosts only (the registry is mutex-guarded): mid-turn too.
|
|
74
|
-
registry.register(MODELS_COMMAND, "list the hosts' models (/models <text> filters)",
|
|
75
|
-
mid_turn: ->(_text) { :anytime }) do |text|
|
|
74
|
+
registry.register(MODELS_COMMAND, "list the hosts' models (/models <text> filters)", anytime: true) do |text|
|
|
76
75
|
reply(models_listing(text.delete_prefix(MODELS_COMMAND).strip))
|
|
77
76
|
end
|
|
78
77
|
registry.register(GUARDRAILS_COMMAND, "list the guardrail rules and approvals (/guardrails revoke N)",
|
|
@@ -101,6 +100,15 @@ module Samagotchi
|
|
|
101
100
|
# The REPL owns its session: it answers /detach with a note, and doesn't offer it.
|
|
102
101
|
registry.register("/detach", "leave and keep the worker running", local: true, uis: [:attached],
|
|
103
102
|
match: ->(text) { text.casecmp?("/detach") })
|
|
103
|
+
# /cut TEXT and /queue TEXT while a turn runs: a delivery the UIs parse
|
|
104
|
+
# themselves (Formatting#steer_choice) before any command check; the
|
|
105
|
+
# entries exist for Tab completion and /help. /queue is a real
|
|
106
|
+
# after-the-turn list only in the local REPL (the attached TUI's
|
|
107
|
+
# worker doesn't skip queued files yet), so it is offered there.
|
|
108
|
+
registry.register("/cut", "cut the running generation and go in now (/cut TEXT)",
|
|
109
|
+
local: true, match: ->(text) { text.casecmp?("/cut") || text.match?(%r{\A/cut\s+\S}) })
|
|
110
|
+
registry.register("/queue", "run as the next turn after this one (/queue TEXT)",
|
|
111
|
+
local: true, uis: [:repl], match: ->(text) { text.casecmp?("/queue") || text.match?(%r{\A/queue\s+\S}) })
|
|
104
112
|
registry
|
|
105
113
|
end
|
|
106
114
|
|
|
@@ -5,6 +5,7 @@ require "json"
|
|
|
5
5
|
require "time"
|
|
6
6
|
require "securerandom"
|
|
7
7
|
require_relative "atomic_file"
|
|
8
|
+
require_relative "delivery"
|
|
8
9
|
require_relative "session"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
@@ -34,12 +35,18 @@ module Samagotchi
|
|
|
34
35
|
class NoteRejected < ArgumentError; end
|
|
35
36
|
|
|
36
37
|
# Queue a turn's input file: JSON with the prompt and the sender's ids.
|
|
38
|
+
# +delivery+ is only written when it is "queue": a step-boundary
|
|
39
|
+
# message is the default an older worker assumes, and a cut is done by
|
|
40
|
+
# the sender (the Bridge) at once, not by the file.
|
|
37
41
|
# @param client_id [String, nil] the sending UI
|
|
38
42
|
# @param enqueued_id [String, nil] the id its ACK / :turn_enqueued carry
|
|
43
|
+
# @param delivery [String, nil] the wire value: "next_step" (default),
|
|
44
|
+
# "cut" or "queue"
|
|
39
45
|
# @param images [Array<Hash>] image refs ({file:, name:}) in the
|
|
40
46
|
# session's images/
|
|
41
47
|
# @return [String, false] the input file's path, or false
|
|
42
|
-
def self.write_input(session_dir, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: []
|
|
48
|
+
def self.write_input(session_dir, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [],
|
|
49
|
+
delivery: nil)
|
|
43
50
|
images = Array(images)
|
|
44
51
|
input_dir = File.join(session_dir, INPUT_DIR)
|
|
45
52
|
FileUtils.mkdir_p(input_dir)
|
|
@@ -47,6 +54,7 @@ module Samagotchi
|
|
|
47
54
|
path = File.join(input_dir, "#{Time.now.strftime("%Y%m%d%H%M%S%9N")}.json")
|
|
48
55
|
record = { "prompt" => prompt.to_s, "client_id" => client_id, "enqueued_id" => enqueued_id,
|
|
49
56
|
"no_interrupt" => (no_interrupt ? true : nil),
|
|
57
|
+
"delivery" => (Delivery.queue?(delivery) ? Delivery::QUEUE : nil),
|
|
50
58
|
"images" => (images.empty? ? nil : images.map { |image| image.transform_keys(&:to_s) }) }.compact
|
|
51
59
|
AtomicFile.write(path, JSON.generate(record))
|
|
52
60
|
path
|
|
@@ -175,17 +183,20 @@ module Samagotchi
|
|
|
175
183
|
Dir.glob(File.join(input_dir, "*.json"))
|
|
176
184
|
end
|
|
177
185
|
|
|
178
|
-
# @return [Array(String, Hash|nil, Boolean, Array<Hash
|
|
179
|
-
# file's prompt, origin ({client_id:, enqueued_id:},
|
|
180
|
-
# no sender), whether its turn runs with the raised
|
|
181
|
-
# (--no-interrupt),
|
|
186
|
+
# @return [Array(String, Hash|nil, Boolean, Array<Hash>, String|nil)] a
|
|
187
|
+
# claimed input file's prompt, origin ({client_id:, enqueued_id:},
|
|
188
|
+
# nil when it names no sender), whether its turn runs with the raised
|
|
189
|
+
# iteration limit (--no-interrupt), its image refs ({file:, name:}),
|
|
190
|
+
# and its delivery ("queue" or nil; a file without the key is one an
|
|
191
|
+
# older chi wrote, so nil)
|
|
182
192
|
def self.read_input(claimed_file)
|
|
183
193
|
data = JSON.parse(File.read(claimed_file).to_s)
|
|
184
194
|
origin = { client_id: data["client_id"], enqueued_id: data["enqueued_id"] }.compact
|
|
185
195
|
images = Array(data["images"]).select { |image| image.is_a?(Hash) }.map { |image| image.transform_keys(&:to_sym) }
|
|
186
|
-
[data["prompt"].to_s, origin.empty? ? nil : origin, data["no_interrupt"] == true, images
|
|
196
|
+
[data["prompt"].to_s, origin.empty? ? nil : origin, data["no_interrupt"] == true, images,
|
|
197
|
+
Delivery.queue?(data["delivery"]) ? Delivery::QUEUE : nil]
|
|
187
198
|
rescue JSON::ParserError
|
|
188
|
-
[nil, nil, false, []]
|
|
199
|
+
[nil, nil, false, [], nil]
|
|
189
200
|
end
|
|
190
201
|
|
|
191
202
|
# Whether an unclaimed input file carries images (a mid-turn drain
|
|
@@ -197,6 +208,15 @@ module Samagotchi
|
|
|
197
208
|
false
|
|
198
209
|
end
|
|
199
210
|
|
|
211
|
+
# Whether a mid-turn drain leaves this unclaimed input file for a turn
|
|
212
|
+
# of its own instead of merging it at a step boundary. Today that is an
|
|
213
|
+
# image message (steering merges text only); the drain's file filter
|
|
214
|
+
# reads this one predicate, so what a boundary refuses changes in one
|
|
215
|
+
# place.
|
|
216
|
+
def self.waits_for_turn_end?(input_file)
|
|
217
|
+
input_has_images?(input_file)
|
|
218
|
+
end
|
|
219
|
+
|
|
200
220
|
def self.claim_input_file(input_file)
|
|
201
221
|
processing_path = "#{input_file}.processing"
|
|
202
222
|
File.rename(input_file, processing_path)
|
|
@@ -8,6 +8,7 @@ require "rbconfig"
|
|
|
8
8
|
require_relative "client_id"
|
|
9
9
|
require_relative "atomic_file"
|
|
10
10
|
require_relative "config"
|
|
11
|
+
require_relative "delivery"
|
|
11
12
|
|
|
12
13
|
require_relative "session"
|
|
13
14
|
require_relative "session_inbox"
|
|
@@ -1288,14 +1289,16 @@ module Samagotchi
|
|
|
1288
1289
|
# boundary. The file is JSON carrying the sender's ids.
|
|
1289
1290
|
# @param client_id [String, nil] the sending UI
|
|
1290
1291
|
# @param enqueued_id [String, nil] the id its ACK / :turn_enqueued carry
|
|
1292
|
+
# @param delivery [String, nil] the wire value: "next_step" (default),
|
|
1293
|
+
# "cut" or "queue"
|
|
1291
1294
|
# @param images [Array<Hash>] image refs ({file:, name:}) in the
|
|
1292
1295
|
# session's images/
|
|
1293
1296
|
# @return [String, false] the input file's path, or false.
|
|
1294
1297
|
def self.write_turn_input(session_id, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, state_dir: nil,
|
|
1295
|
-
images: [])
|
|
1298
|
+
images: [], delivery: nil)
|
|
1296
1299
|
session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
|
|
1297
1300
|
SessionInbox.write_input(session_dir, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
|
|
1298
|
-
no_interrupt: no_interrupt, images: images)
|
|
1301
|
+
no_interrupt: no_interrupt, images: images, delivery: delivery)
|
|
1299
1302
|
end
|
|
1300
1303
|
|
|
1301
1304
|
# How long a turn waits for the Bridge of a worker a resume just spawned.
|
|
@@ -1308,6 +1311,8 @@ module Samagotchi
|
|
|
1308
1311
|
# exit). Race-safe against a TUI taking the session, or the worker
|
|
1309
1312
|
# exiting, between the resume and the write.
|
|
1310
1313
|
# @param images [Array<Hash>] refs ({file:, name:}) already in images/
|
|
1314
|
+
# @param delivery [String, nil] what the message asks the running turn
|
|
1315
|
+
# for: "next_step" (default), "cut" or "queue"
|
|
1311
1316
|
# @param manager [#resume_session, #write_turn_input] this class, or a
|
|
1312
1317
|
# stand-in (the web's specs); #session_owner is optional
|
|
1313
1318
|
# @param bridge [#call, nil] returns the BridgeClient or nil; defaults to
|
|
@@ -1319,7 +1324,8 @@ module Samagotchi
|
|
|
1319
1324
|
# {status: :failed} when the input file couldn't be written
|
|
1320
1325
|
# @raise [OwnedByTUI] a chi REPL owns the session
|
|
1321
1326
|
# @raise [ArgumentError] no such session
|
|
1322
|
-
def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], state_dir: nil, manager: self,
|
|
1327
|
+
def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], delivery: nil, state_dir: nil, manager: self,
|
|
1328
|
+
bridge: nil)
|
|
1323
1329
|
session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
|
|
1324
1330
|
bridge ||= -> { BridgeClient.wait_for(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT) }
|
|
1325
1331
|
manager.resume_session(session_id, state_dir: state_dir)
|
|
@@ -1327,6 +1333,9 @@ module Samagotchi
|
|
|
1327
1333
|
begin
|
|
1328
1334
|
options = { prompt: prompt, client_id: client_id }
|
|
1329
1335
|
options[:images] = images unless images.empty?
|
|
1336
|
+
# An old worker ignores the field: it cuts anyway (its own default)
|
|
1337
|
+
# and its file never carries a queue, so a queue would steer there.
|
|
1338
|
+
options[:delivery] = delivery unless Delivery.next_step?(delivery)
|
|
1330
1339
|
reply = client.post_turn(**options)
|
|
1331
1340
|
ack = reply.json
|
|
1332
1341
|
return { status: :accepted, ack: ack } if reply.status == 202 && ack.is_a?(Hash)
|
|
@@ -1347,6 +1356,7 @@ module Samagotchi
|
|
|
1347
1356
|
enqueued_id = SecureRandom.uuid
|
|
1348
1357
|
input = { prompt: prompt, client_id: client_id, enqueued_id: enqueued_id, state_dir: state_dir }
|
|
1349
1358
|
input[:images] = images unless images.empty?
|
|
1359
|
+
input[:delivery] = delivery unless Delivery.next_step?(delivery)
|
|
1350
1360
|
path = manager.write_turn_input(session_id, **input)
|
|
1351
1361
|
return { status: :failed } unless path
|
|
1352
1362
|
|
|
@@ -141,6 +141,9 @@ module Samagotchi
|
|
|
141
141
|
:cuts,
|
|
142
142
|
:capped,
|
|
143
143
|
:model,
|
|
144
|
+
# The turn the user started by answering the step-limit question with
|
|
145
|
+
# Continue (:turn_started's +continue+); its record carries it.
|
|
146
|
+
:continue,
|
|
144
147
|
:id,
|
|
145
148
|
:started_at,
|
|
146
149
|
:started_monotonic,
|
|
@@ -376,6 +379,16 @@ module Samagotchi
|
|
|
376
379
|
@mutex.synchronize { build_snapshot(:recent) }
|
|
377
380
|
end
|
|
378
381
|
|
|
382
|
+
# The snapshot of a session no worker runs, read from disk (`chi
|
|
383
|
+
# sessions stats`): #snapshot's, but its records are the newest saved
|
|
384
|
+
# turn's (and its tool calls), as a live worker's snapshot carries its
|
|
385
|
+
# newest finished turn; #snapshot would list none, this collector having
|
|
386
|
+
# finished no turn of its own.
|
|
387
|
+
# @return [Hash]
|
|
388
|
+
def saved_snapshot
|
|
389
|
+
@mutex.synchronize { build_snapshot(:last) }
|
|
390
|
+
end
|
|
391
|
+
|
|
379
392
|
# Close the open generation now, ahead of its :generation_completed
|
|
380
393
|
# (which then finds it closed), and report its speed with the session's
|
|
381
394
|
# running token totals: what the event carries to the UIs.
|
|
@@ -438,6 +451,9 @@ module Samagotchi
|
|
|
438
451
|
served_model_for: @served_model_for,
|
|
439
452
|
tool_calls_total: @tool_records.size + in_flight.size,
|
|
440
453
|
tool_calls_by_tool: tally_into(totals[:by_tool].dup, in_flight),
|
|
454
|
+
# The calls the model made under an alias's name (bash run as
|
|
455
|
+
# execute), by its own spelling; only when there are any.
|
|
456
|
+
**(totals[:aliased].empty? ? {} : { tool_calls_aliased: totals[:aliased].dup }),
|
|
441
457
|
tool_errors: totals[:tool_errors],
|
|
442
458
|
iterations_total: totals[:iterations] + (turn&.iteration_count || 0),
|
|
443
459
|
gen_latency_ms: (totals[:gen_ms] + (turn&.gen_latency_accum || 0)).round,
|
|
@@ -483,14 +499,16 @@ module Samagotchi
|
|
|
483
499
|
|
|
484
500
|
# The records a snapshot carries, copied. :all is every record; :recent
|
|
485
501
|
# the newest turn this collector finished (live before its save lands)
|
|
486
|
-
# and the unsaved ones (capped)
|
|
487
|
-
#
|
|
488
|
-
# from the tails. Caller holds
|
|
502
|
+
# and the unsaved ones (capped); :last the newest turn, loaded or not;
|
|
503
|
+
# and the tool records of those turns and of the running one. Records
|
|
504
|
+
# are appended in turn order, so both come from the tails. Caller holds
|
|
505
|
+
# the mutex.
|
|
489
506
|
def records_view(mode)
|
|
490
507
|
return { turn_records: @turn_records.map(&:dup), tool_records: @tool_records.map(&:dup) } if mode == :all
|
|
491
508
|
|
|
492
509
|
unsaved = @turn_records.size - @persisted_turns
|
|
493
|
-
|
|
510
|
+
fresh = @turn_records.size > @loaded_turns ? 1 : 0
|
|
511
|
+
count = mode == :last ? 1 : unsaved.clamp(fresh, LIVE_RECORDS_CAP)
|
|
494
512
|
turns = @turn_records.last(count)
|
|
495
513
|
ids = turns.to_set { |record| record[:id] }
|
|
496
514
|
ids << @turn.id if @turn
|
|
@@ -539,6 +557,7 @@ module Samagotchi
|
|
|
539
557
|
cuts: 0,
|
|
540
558
|
capped: 0,
|
|
541
559
|
model: nil,
|
|
560
|
+
continue: event[:continue] ? true : false,
|
|
542
561
|
id: event[:turn_id] || SecureRandom.uuid,
|
|
543
562
|
started_at: now.iso8601(3),
|
|
544
563
|
started_monotonic: monotonic_time,
|
|
@@ -575,6 +594,9 @@ module Samagotchi
|
|
|
575
594
|
record[:cancellation_reason] = reason.to_s unless reason.nil? || reason.to_s.empty?
|
|
576
595
|
record[:cancelled_by] = by.to_s unless by.nil? || by.to_s.empty?
|
|
577
596
|
record[:failure] = failure if failure
|
|
597
|
+
# Every record says, so the report can tell a file with no Continue
|
|
598
|
+
# from one written before the mark.
|
|
599
|
+
record[:continue] = @turn.continue
|
|
578
600
|
record.merge!(turn_token_fields(@turn))
|
|
579
601
|
@turn_records << record
|
|
580
602
|
count_turn(record)
|
|
@@ -800,6 +822,8 @@ module Samagotchi
|
|
|
800
822
|
active = @turn.tool_calls_by_id.delete(key)
|
|
801
823
|
return unless active
|
|
802
824
|
|
|
825
|
+
called_as = event.dig(:activity, :called_as)
|
|
826
|
+
active[:called_as] = called_as.to_s if called_as
|
|
803
827
|
finish_tool_record(active, status.empty? ? "ok" : status, waited_ms: event[:waited_ms].to_i)
|
|
804
828
|
end
|
|
805
829
|
end
|
|
@@ -807,7 +831,7 @@ module Samagotchi
|
|
|
807
831
|
# Caller holds the mutex.
|
|
808
832
|
def finish_tool_record(active, status, waited_ms: 0)
|
|
809
833
|
record = active.slice(
|
|
810
|
-
:id, :turn_id, :iteration, :call_index, :tool, :started_at
|
|
834
|
+
:id, :turn_id, :iteration, :call_index, :tool, :called_as, :started_at
|
|
811
835
|
).merge(
|
|
812
836
|
status: status,
|
|
813
837
|
completed_at: now.iso8601(3),
|
|
@@ -1013,7 +1037,7 @@ module Samagotchi
|
|
|
1013
1037
|
# (#snapshot adds the running turn). Caller holds the mutex.
|
|
1014
1038
|
def reset_totals
|
|
1015
1039
|
@totals = { cancellations: 0, prompt_sum: 0, completion_sum: 0, token_sources: [], iterations: 0,
|
|
1016
|
-
gen_ms: 0, retries: 0, cuts: 0, capped: 0, by_tool: {}, tool_errors: 0, cached_sum: 0, cache_write_sum: 0,
|
|
1040
|
+
gen_ms: 0, retries: 0, cuts: 0, capped: 0, by_tool: {}, aliased: {}, tool_errors: 0, cached_sum: 0, cache_write_sum: 0,
|
|
1017
1041
|
reprefill_sum: 0, reasoning_sum: 0,
|
|
1018
1042
|
cost_sum: 0, cost_estimate_sum: 0, decode_ms_sum: 0, decode_tokens_sum: 0, last_decode_tps: nil,
|
|
1019
1043
|
last_prefill_tps: nil, tps_source: nil }
|
|
@@ -1050,6 +1074,8 @@ module Samagotchi
|
|
|
1050
1074
|
def count_tool(record)
|
|
1051
1075
|
@totals[:tool_errors] += 1 if record[:status] == "error"
|
|
1052
1076
|
tally_into(@totals[:by_tool], [record])
|
|
1077
|
+
called_as = record[:called_as].to_s
|
|
1078
|
+
@totals[:aliased][called_as] = @totals[:aliased].fetch(called_as, 0) + 1 unless called_as.empty?
|
|
1053
1079
|
end
|
|
1054
1080
|
|
|
1055
1081
|
def tally_into(counts, tools)
|