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
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "delivery"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# Where a line typed at a terminal's prompt goes, for both terminals (the
|
|
7
|
+
# REPL's TerminalUI#steer_line / #run_input_line, the attached
|
|
8
|
+
# AttachedLoop#submit). It only classifies; each UI keeps what it does
|
|
9
|
+
# with the answer (the REPL's mid-turn command policy and image lines, the
|
|
10
|
+
# attached TUI's continue offer and question). The web's composer.js
|
|
11
|
+
# routeComposerLine decides the same; spec/fixtures/composer_routes.json
|
|
12
|
+
# holds the cases both answer (docs/testing.md).
|
|
13
|
+
#
|
|
14
|
+
# In order:
|
|
15
|
+
# 1. /cut TEXT, /queue TEXT (.steer_choice), before every command check:
|
|
16
|
+
# the bare word is :usage. While a turn runs TEXT is a :deliver with
|
|
17
|
+
# its delivery (Delivery::CUT, Delivery::QUEUE); whether the worker
|
|
18
|
+
# can queue is the UI's to check. With none running TEXT is routed
|
|
19
|
+
# on as a line (step 2), its own /cut not read again.
|
|
20
|
+
# 2. A line: :empty; a terminal's own word (:local, the registry's
|
|
21
|
+
# lookup_local id: :exit, :archive, :detach, :stats, :recap…); a
|
|
22
|
+
# session command (:command); a typo of one (:typo, the hint in
|
|
23
|
+
# +reply+); else :prompt. A line with images (+images+) is a message:
|
|
24
|
+
# no command or typo check.
|
|
25
|
+
module ComposerRoute
|
|
26
|
+
CUT_COMMAND = "/cut"
|
|
27
|
+
QUEUE_COMMAND = "/queue"
|
|
28
|
+
# The word, then any whitespace and the text (as the registry's
|
|
29
|
+
# entries match them, so a line Tab or /help knows is never a prompt).
|
|
30
|
+
STEER_CHOICE = /\A(#{CUT_COMMAND}|#{QUEUE_COMMAND})(?:\s+(.*))?\z/m
|
|
31
|
+
|
|
32
|
+
KINDS = %i[empty usage deliver local command typo prompt].freeze
|
|
33
|
+
|
|
34
|
+
# @!attribute kind [Symbol] one of KINDS
|
|
35
|
+
# @!attribute text [String, nil] the line stripped: a /cut's or
|
|
36
|
+
# /queue's own text, not the word
|
|
37
|
+
# @!attribute delivery [String, nil] a :deliver's Delivery value
|
|
38
|
+
# @!attribute reply [String, nil] a :typo's hint
|
|
39
|
+
# @!attribute local [Symbol, nil] a :local's registry id
|
|
40
|
+
# @!attribute choice [Symbol, nil] :cut or :queue when the line was
|
|
41
|
+
# typed with that word (an idle one's text routed on keeps it)
|
|
42
|
+
Route = Data.define(:kind, :text, :delivery, :reply, :local, :choice) do
|
|
43
|
+
def initialize(kind:, text: nil, delivery: nil, reply: nil, local: nil, choice: nil)
|
|
44
|
+
super
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param line [String, nil] as typed
|
|
49
|
+
# @param running [Boolean] a turn runs
|
|
50
|
+
# @param registry [Commands::Registry] the session's commands
|
|
51
|
+
# @param images [Integer] the attachments not in the text (the web's
|
|
52
|
+
# chips); a terminal's @path images are its own to handle (0)
|
|
53
|
+
# @return [Route]
|
|
54
|
+
def self.call(line, running:, registry:, images: 0)
|
|
55
|
+
text = line.to_s.strip
|
|
56
|
+
choice, message = steer_choice(text)
|
|
57
|
+
return line_route(text, registry: registry, images: images) unless choice
|
|
58
|
+
return Route.new(kind: :usage, choice: choice) if message.empty?
|
|
59
|
+
return line_route(message, registry: registry, images: images).with(choice: choice) unless running
|
|
60
|
+
|
|
61
|
+
Route.new(kind: :deliver, text: message, delivery: choice == :cut ? Delivery::CUT : Delivery::QUEUE, choice: choice)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The choice a line carries, as [:cut, "TEXT"] or [:queue, "TEXT"]; nil
|
|
65
|
+
# for any other line. The text is "" when the line named the choice
|
|
66
|
+
# without one.
|
|
67
|
+
# @param line [String, nil]
|
|
68
|
+
# @return [Array(Symbol, String), nil]
|
|
69
|
+
def self.steer_choice(line)
|
|
70
|
+
match = STEER_CHOICE.match(line.to_s.strip) or return nil
|
|
71
|
+
|
|
72
|
+
[match[1] == CUT_COMMAND ? :cut : :queue, match[2].to_s.strip]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.line_route(text, registry:, images:)
|
|
76
|
+
return Route.new(kind: :empty, text: text) if text.empty?
|
|
77
|
+
|
|
78
|
+
if images.zero?
|
|
79
|
+
local = registry.lookup_local(text)&.id
|
|
80
|
+
return Route.new(kind: :local, text: text, local: local) if local
|
|
81
|
+
return Route.new(kind: :command, text: text) if registry.command?(text)
|
|
82
|
+
|
|
83
|
+
hint = registry.unknown_command_hint(text)
|
|
84
|
+
return Route.new(kind: :typo, text: text, reply: hint) if hint
|
|
85
|
+
end
|
|
86
|
+
Route.new(kind: :prompt, text: text)
|
|
87
|
+
end
|
|
88
|
+
private_class_method :line_route
|
|
89
|
+
end
|
|
90
|
+
end
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -279,7 +279,7 @@ module Samagotchi
|
|
|
279
279
|
# Maps of named entries and the keys an entry may hold
|
|
280
280
|
# (ConfigFile.hosts_config, ConfigFile.model_settings).
|
|
281
281
|
MAP_ENTRY_KEYS = {
|
|
282
|
-
"hosts" => %w[host port url transport api api_key_env profile first_token_timeout vision sampling thinking
|
|
282
|
+
"hosts" => %w[host port url transport api api_key_env auth dialect profile first_token_timeout vision sampling thinking
|
|
283
283
|
reasoning_history enabled remote window_tokens steer_cut_after llm_context_strategy llm_context_apply llm_context_budget_tokens models].freeze,
|
|
284
284
|
"models" => %w[profile vision sampling thinking reasoning_history window_tokens steer_cut_after llm_context_strategy llm_context_apply
|
|
285
285
|
llm_context_budget_tokens].freeze
|
|
@@ -851,13 +851,14 @@ module Samagotchi
|
|
|
851
851
|
|
|
852
852
|
HOSTS_KEY = "hosts"
|
|
853
853
|
VALID_TRANSPORTS_FOR_CONFIG = %w[llama_cpp mlx omlx].freeze
|
|
854
|
-
# How chi talks to a host: a raw-prompt api (also the Client transport)
|
|
855
|
-
#
|
|
856
|
-
|
|
854
|
+
# How chi talks to a host: a raw-prompt api (also the Client transport),
|
|
855
|
+
# or a chat api the chat loop talks (LLM::Adapters): openai (Chat
|
|
856
|
+
# Completions against <host>/v1) or anthropic (Messages).
|
|
857
|
+
VALID_APIS_FOR_CONFIG = (VALID_TRANSPORTS_FOR_CONFIG + %w[openai anthropic]).freeze
|
|
857
858
|
HOST_NAME_RE = /\A[a-z0-9][a-z0-9._-]*\z/i
|
|
858
859
|
ENV_NAME_RE = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
859
860
|
|
|
860
|
-
# config.yml's hosts: (or a
|
|
861
|
+
# config.yml's hosts: (or a SAMAGOTCHI_HOSTS_JSON override) by lowercased
|
|
861
862
|
# name; an entry that is disabled or invalid (warned) is left out. No
|
|
862
863
|
# entry left: "default", from server.host/port/transport.
|
|
863
864
|
# @return [Hash{String => HostConfig}]
|
|
@@ -914,7 +915,7 @@ module Samagotchi
|
|
|
914
915
|
|
|
915
916
|
# The names (lowercased) of the hosts entries with enabled: false, which
|
|
916
917
|
# hosts_config leaves out. Read from the same source: config.yml's
|
|
917
|
-
# hosts:, or SAMAGOTCHI_HOSTS_JSON
|
|
918
|
+
# hosts:, or a SAMAGOTCHI_HOSTS_JSON override.
|
|
918
919
|
# @return [Array<String>]
|
|
919
920
|
def disabled_host_names(env: ENV, path: global_path(env: env))
|
|
920
921
|
raw = hosts_source(read_yaml(env: env, path: path), env)
|
|
@@ -926,7 +927,8 @@ module Samagotchi
|
|
|
926
927
|
end
|
|
927
928
|
|
|
928
929
|
# config.yml's hosts: as written, or the SAMAGOTCHI_HOSTS_JSON override
|
|
929
|
-
#
|
|
930
|
+
# set on this process (specs; SessionManager.spawn_options unsets it for
|
|
931
|
+
# a worker, which reads the file).
|
|
930
932
|
def hosts_source(data, env)
|
|
931
933
|
raw = data[HOSTS_KEY] if data.is_a?(Hash)
|
|
932
934
|
env_json = env["SAMAGOTCHI_HOSTS_JSON"].to_s.strip
|
|
@@ -1041,6 +1043,8 @@ module Samagotchi
|
|
|
1041
1043
|
hosts.keys.map { |k| k.to_s.downcase }.include?(prefix) ? nil : prefix
|
|
1042
1044
|
end
|
|
1043
1045
|
|
|
1046
|
+
# hosts_config as a SAMAGOTCHI_HOSTS_JSON override (workers no longer
|
|
1047
|
+
# get one: they read config.yml).
|
|
1044
1048
|
def hosts_json_for_env(env: ENV, path: global_path(env: env))
|
|
1045
1049
|
hosts = hosts_config(env: env, path: path)
|
|
1046
1050
|
# Only serialize if non-default or explicitly configured hosts:
|
|
@@ -85,12 +85,17 @@ module Samagotchi
|
|
|
85
85
|
# Called with the event log held, before the command's command_ran. A
|
|
86
86
|
# Stop runs no turn: last_turn says so (not_continued), so a wait on
|
|
87
87
|
# it ends (chi answer --option Stop).
|
|
88
|
+
# It carries the offer turn's enqueued_id, so a wait on that turn
|
|
89
|
+
# (ReplyWait's turn:) ends on it too.
|
|
88
90
|
def announce_resolved(result, command)
|
|
89
91
|
@engine.announce(type: :continue_resolved, decision: result.decision.to_s, client_id: command.client_id)
|
|
90
92
|
return if result.resume
|
|
91
93
|
|
|
92
94
|
session = @engine.session
|
|
93
|
-
|
|
95
|
+
return unless session
|
|
96
|
+
|
|
97
|
+
session.last_turn = LastTurn.new(outcome: NOT_CONTINUED, ended_at: Time.now.iso8601(3),
|
|
98
|
+
enqueued_id: offer_turn_id)
|
|
94
99
|
end
|
|
95
100
|
|
|
96
101
|
# After a command's command_ran and save: a /continue that answered the
|
|
@@ -117,9 +122,13 @@ module Samagotchi
|
|
|
117
122
|
# asks again, one before the turn began too (logged, no :turn_failed).
|
|
118
123
|
# The answer's text is carried into the turn as it begins; a turn that
|
|
119
124
|
# never began leaves it to no other.
|
|
125
|
+
# The continue turn is the offer turn's: its origin carries that turn's
|
|
126
|
+
# enqueued_id, so its last_turn, its reply file and its checkpoint mark
|
|
127
|
+
# do too, and a wait on that turn (chi answer) gets its reply.
|
|
120
128
|
def run_continue_turn(command)
|
|
121
129
|
offer = @turn_flow.offer
|
|
122
130
|
resume_from = @turn_flow.resume_from
|
|
131
|
+
enqueued_id = offer_turn_id
|
|
123
132
|
close("answered")
|
|
124
133
|
steer = @continue_steer
|
|
125
134
|
@continue_steer = nil
|
|
@@ -131,8 +140,8 @@ module Samagotchi
|
|
|
131
140
|
Log.warn(:worker, "turn_not_begun", continue: true, error: e.class.name, msg: e.message)
|
|
132
141
|
return announce_offer(offer)
|
|
133
142
|
end
|
|
134
|
-
|
|
135
|
-
|
|
143
|
+
origin = { client_id: command.client_id, enqueued_id: enqueued_id }.compact
|
|
144
|
+
@run_turn.call(nil, continue: true, origin: origin, resumed: true, resume_from: resume_from,
|
|
136
145
|
max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
|
|
137
146
|
if error
|
|
138
147
|
announce_offer(offer)
|
|
@@ -156,6 +165,11 @@ module Samagotchi
|
|
|
156
165
|
|
|
157
166
|
private
|
|
158
167
|
|
|
168
|
+
# The enqueued_id of the turn the offer stands for: the exhausted
|
|
169
|
+
# turn's last_turn (a canceled continue turn's carries the same id);
|
|
170
|
+
# nil when it ran no input (a reminder turn, a delegate's first).
|
|
171
|
+
def offer_turn_id = @engine.session&.last_turn&.enqueued_id
|
|
172
|
+
|
|
159
173
|
def announce_offer(offer = @turn_flow.offer)
|
|
160
174
|
@engine.announce(type: :continue_offered, context: offer[:context], no_interrupt: offer[:no_interrupt])
|
|
161
175
|
ask
|
data/lib/samagotchi/delivery.rb
CHANGED
|
@@ -23,25 +23,30 @@ module Samagotchi
|
|
|
23
23
|
# Why a request body's delivery field can't be taken, as {error:,
|
|
24
24
|
# detail:} for a 400, or nil when it can. The Bridge and the web's
|
|
25
25
|
# POST /turn both ask this, so they refuse the same values. A missing
|
|
26
|
-
# or unknown string is not refused: it is the default (#parse).
|
|
27
|
-
# is refused until a worker runs it (no worker reads it yet, so it
|
|
28
|
-
# would merge at the next step while the ack said queue).
|
|
26
|
+
# or unknown string is not refused: it is the default (#parse).
|
|
29
27
|
# @param raw [Object] the wire value
|
|
30
28
|
# @return [Hash, nil]
|
|
31
29
|
def self.refusal(raw)
|
|
32
30
|
return nil if raw.nil?
|
|
33
31
|
return { error: "bad_delivery", detail: "delivery must be a string" } unless raw.is_a?(String)
|
|
34
|
-
return QUEUE_UNAVAILABLE if raw == QUEUE
|
|
35
32
|
|
|
36
33
|
nil
|
|
37
34
|
end
|
|
38
35
|
|
|
36
|
+
# The worker sidecar feature (Bridge::FEATURES) of a worker that runs
|
|
37
|
+
# all three values. One without it (before 0.64) ignores QUEUE, or
|
|
38
|
+
# refuses it with a 400 delivery_unavailable, so a sender checks it
|
|
39
|
+
# before sending a queue (SessionManager.deliver_turn, the attached TUI).
|
|
40
|
+
QUEUE_FEATURE = "delivery"
|
|
41
|
+
|
|
42
|
+
# The refusal for a queue a worker without QUEUE_FEATURE would steer
|
|
43
|
+
# (SessionManager.deliver_turn answers it as a 409). P0-P2 workers
|
|
44
|
+
# answer the same error code for a queue, with their own detail.
|
|
39
45
|
QUEUE_UNAVAILABLE = {
|
|
40
|
-
error
|
|
41
|
-
detail
|
|
42
|
-
|
|
46
|
+
"error" => "delivery_unavailable",
|
|
47
|
+
"detail" => "this session's worker predates queueing, so the message would steer the running turn; " \
|
|
48
|
+
"restart it (chi sessions restart ID, or the web's update badge) and send again"
|
|
43
49
|
}.freeze
|
|
44
|
-
private_constant :QUEUE_UNAVAILABLE
|
|
45
50
|
|
|
46
51
|
# Whether it is the one every sender defaulting to a step boundary uses.
|
|
47
52
|
def self.next_step?(value) = parse(value) == NEXT_STEP
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -224,6 +224,7 @@ module Samagotchi
|
|
|
224
224
|
# and after a reminder turn), so the idle layer's clock is identical
|
|
225
225
|
# across UIs.
|
|
226
226
|
@turn_state.restart_clock!
|
|
227
|
+
@side_client_mutex = Mutex.new
|
|
227
228
|
@recap = RecapSetup.build(recap, engine: self, host_registry: @host_registry,
|
|
228
229
|
session_target: -> { session_model_recap_target },
|
|
229
230
|
session_id: -> { @session&.id }, state_dir: -> { session_state_dir })
|
|
@@ -299,7 +300,7 @@ module Samagotchi
|
|
|
299
300
|
# event[:ask_user], event[:stop_turn]): the Engine's routes to the UIs.
|
|
300
301
|
@hooks.runtime = hook_runtime
|
|
301
302
|
# The loop follows the effective model's host (its api:): the raw-prompt
|
|
302
|
-
# NativeBackend, or the chat backend for openai
|
|
303
|
+
# NativeBackend, or the chat backend for chat hosts (openai, anthropic).
|
|
303
304
|
@native_backend = LLM::NativeBackend.new(kernel: @kernel)
|
|
304
305
|
end
|
|
305
306
|
private :wire_kernel
|
|
@@ -405,6 +406,10 @@ module Samagotchi
|
|
|
405
406
|
# for #cut_for_steer.
|
|
406
407
|
def input_epoch = @steer_cut.input_epoch
|
|
407
408
|
|
|
409
|
+
# The running generation's phase (SteerCut#generation_snapshot): what
|
|
410
|
+
# Bridge#snapshot puts in current_turn.generation.
|
|
411
|
+
def generation_snapshot = @steer_cut.generation_snapshot
|
|
412
|
+
|
|
408
413
|
# Like #steer, for the turn that begins next (a continue turn answered
|
|
409
414
|
# with a text): it joins that turn at its first boundary. Callable from
|
|
410
415
|
# any thread; a turn that never begins keeps it for the next one, unless
|
|
@@ -1082,7 +1087,7 @@ module Samagotchi
|
|
|
1082
1087
|
@last_context_status = saved_context_status(@metrics.snapshot[:context]) || @last_context_status if @last_context_status
|
|
1083
1088
|
end
|
|
1084
1089
|
|
|
1085
|
-
# The effective model is on a chat host (api: openai), whose loop uses
|
|
1090
|
+
# The effective model is on a chat host (api: openai or anthropic), whose loop uses
|
|
1086
1091
|
# no prompt profile.
|
|
1087
1092
|
def chat_model? = @host_registry.resolve(@effective_model_name).entry.chat?
|
|
1088
1093
|
|
|
@@ -1433,8 +1438,10 @@ module Samagotchi
|
|
|
1433
1438
|
elsif client_id == ClientId::REMINDER then "reminder"
|
|
1434
1439
|
else "client"
|
|
1435
1440
|
end
|
|
1441
|
+
enqueued_id = origin.is_a?(Hash) ? origin[:enqueued_id] : nil
|
|
1436
1442
|
session.last_turn = LastTurn.new(outcome: outcome, ended_at: Time.now.iso8601(3), seconds: seconds.round(1),
|
|
1437
1443
|
origin: source, exhausted: limit ? true : nil, limit: limit,
|
|
1444
|
+
enqueued_id: enqueued_id.to_s.empty? ? nil : enqueued_id.to_s,
|
|
1438
1445
|
**stop_facts(event))
|
|
1439
1446
|
end
|
|
1440
1447
|
|
|
@@ -2283,7 +2290,7 @@ module Samagotchi
|
|
|
2283
2290
|
# A retry's nudge at the tail goes: this note says it all. An earlier
|
|
2284
2291
|
# turn's note stays: it says why that turn ended.
|
|
2285
2292
|
if result.empty_answer?
|
|
2286
|
-
note = TurnNote.empty(retries: result.empty_retries, steps: result.empty_steps)
|
|
2293
|
+
note = TurnNote.empty(retries: result.empty_retries, steps: result.empty_steps, refusal: result.refusal)
|
|
2287
2294
|
conversation&.replace(TurnNote.without_trailing_nudge(conversation))
|
|
2288
2295
|
saved = TurnNote.without_trailing_nudge(conversation || turn.session.messages)
|
|
2289
2296
|
conversation << note if conversation
|
|
@@ -2480,6 +2487,15 @@ module Samagotchi
|
|
|
2480
2487
|
nil
|
|
2481
2488
|
end
|
|
2482
2489
|
|
|
2490
|
+
# The host refused a sampling field (the empty-answer retry's
|
|
2491
|
+
# temperature on a model that takes none) and the chat loop asked again
|
|
2492
|
+
# without them, as it will for the model from now on: a log line.
|
|
2493
|
+
def sampling_refused(event)
|
|
2494
|
+
Log.info(:model, "sampling_refused", model: event[:model], detail: event[:detail])
|
|
2495
|
+
rescue StandardError
|
|
2496
|
+
nil
|
|
2497
|
+
end
|
|
2498
|
+
|
|
2483
2499
|
# The host refused the model's reasoning sent back (reasoning_history)
|
|
2484
2500
|
# and the chat loop asked the step again without it, as it will from
|
|
2485
2501
|
# now on: logged, and said once per session and host.
|
|
@@ -2547,7 +2563,7 @@ module Samagotchi
|
|
|
2547
2563
|
context_status: result.context_status&.dup
|
|
2548
2564
|
}
|
|
2549
2565
|
# No answer: every UI shows its notice (TurnNote.empty_answer_line).
|
|
2550
|
-
summary[:empty_answer] = { retries: result.empty_retries.to_i } if result.empty_answer?
|
|
2566
|
+
summary[:empty_answer] = { retries: result.empty_retries.to_i, refusal: result.refusal }.compact if result.empty_answer?
|
|
2551
2567
|
summary
|
|
2552
2568
|
end
|
|
2553
2569
|
|
|
@@ -2732,19 +2748,36 @@ module Samagotchi
|
|
|
2732
2748
|
|
|
2733
2749
|
# ctx.ask_model's request: the session's current model on its host, as
|
|
2734
2750
|
# a turn resolves them (a /model switch counts), through its own
|
|
2735
|
-
# IdleClient, so it shares nothing with the turn's backend.
|
|
2751
|
+
# IdleClient, so it shares nothing with the turn's backend. The client
|
|
2752
|
+
# is kept while the target and timeout stay (what it learned, e.g. a
|
|
2753
|
+
# refused thinking-off, holds for the next request); the session's id
|
|
2754
|
+
# goes with the request.
|
|
2736
2755
|
# @return [String] the answer
|
|
2737
2756
|
def ask_side_model(request, timeout:, max_tokens:, cancel_controller:)
|
|
2738
2757
|
target = session_model_recap_target
|
|
2739
|
-
client =
|
|
2758
|
+
client = side_model_client(target, timeout)
|
|
2740
2759
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
2741
|
-
answer = client.ask(request, max_tokens: max_tokens, cancel_controller: cancel_controller)
|
|
2760
|
+
answer = client.ask(request, max_tokens: max_tokens, cancel_controller: cancel_controller, session_id: @session&.id)
|
|
2742
2761
|
Log.info(:plugins, "ask_model", model: target.label, answer_model: answer.model, chars: answer.text.length,
|
|
2743
2762
|
ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round)
|
|
2744
2763
|
answer.text
|
|
2745
2764
|
end
|
|
2746
2765
|
private :ask_side_model
|
|
2747
2766
|
|
|
2767
|
+
# The IdleClient for +target+ at +timeout+: the last one when both are
|
|
2768
|
+
# the same, else a new one.
|
|
2769
|
+
def side_model_client(target, timeout)
|
|
2770
|
+
@side_client_mutex.synchronize do
|
|
2771
|
+
key = [target, timeout]
|
|
2772
|
+
unless @side_client && @side_client_key == key
|
|
2773
|
+
@side_client = IdleClient.for(target, timeout: timeout)
|
|
2774
|
+
@side_client_key = key
|
|
2775
|
+
end
|
|
2776
|
+
@side_client
|
|
2777
|
+
end
|
|
2778
|
+
end
|
|
2779
|
+
private :side_model_client
|
|
2780
|
+
|
|
2748
2781
|
# The session's current model as a recap target: its host's OpenAI API
|
|
2749
2782
|
# (native llama.cpp hosts serve /v1/chat/completions too), key variable
|
|
2750
2783
|
# and bare model name, as a turn resolves them.
|
|
@@ -2811,6 +2844,7 @@ module Samagotchi
|
|
|
2811
2844
|
# reasoning sent back: a notice, not an event of its own.
|
|
2812
2845
|
next thinking_refused(event) if event[:type] == :thinking_refused
|
|
2813
2846
|
next reasoning_history_refused(event) if event[:type] == :reasoning_history_refused
|
|
2847
|
+
next sampling_refused(event) if event[:type] == :sampling_refused
|
|
2814
2848
|
|
|
2815
2849
|
event = with_generation_report(event) if event[:type] == :generation_completed
|
|
2816
2850
|
emit_event(on_event, event)
|
|
@@ -14,6 +14,12 @@ module Samagotchi
|
|
|
14
14
|
# A thinking delta this recent means the model is still thinking.
|
|
15
15
|
FRESH_SECONDS = 2.0
|
|
16
16
|
|
|
17
|
+
# What a page joining mid-generation needs for its delivery line
|
|
18
|
+
# (Bridge#snapshot's current_turn.generation): whether it has streamed
|
|
19
|
+
# only thinking so far (thinking, no text, no tool call; freshness
|
|
20
|
+
# aside) and its thinking's #age (nil before any thinking).
|
|
21
|
+
Snapshot = Data.define(:thinking_only, :age)
|
|
22
|
+
|
|
17
23
|
# @param clock [#call] monotonic seconds
|
|
18
24
|
def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
19
25
|
@clock = clock
|
|
@@ -71,6 +77,17 @@ module Samagotchi
|
|
|
71
77
|
@mutex.synchronize { @running && @thinking_since ? @clock.call - @thinking_since : nil }
|
|
72
78
|
end
|
|
73
79
|
|
|
80
|
+
# The running generation as a Snapshot, its age rounded to a tenth;
|
|
81
|
+
# nil with none running.
|
|
82
|
+
def snapshot
|
|
83
|
+
@mutex.synchronize do
|
|
84
|
+
next nil unless @running
|
|
85
|
+
|
|
86
|
+
age = @thinking_since && (@clock.call - @thinking_since).round(1)
|
|
87
|
+
Snapshot.new(thinking_only: !@thinking_since.nil? && !@text && !@tool_call, age: age)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
74
91
|
private
|
|
75
92
|
|
|
76
93
|
def clear_seen
|
|
@@ -4,29 +4,38 @@ require "uri"
|
|
|
4
4
|
require_relative "config"
|
|
5
5
|
require_relative "host_model"
|
|
6
6
|
require_relative "llm_context_strategy"
|
|
7
|
+
require_relative "llm/dialect"
|
|
7
8
|
require_relative "reasoning_history"
|
|
8
9
|
|
|
9
10
|
module Samagotchi
|
|
10
11
|
# One config.yml hosts: entry, parsed and checked (ConfigFile.hosts_config
|
|
11
12
|
# holds them by lowercased name; HostRegistry builds its HostEntry from
|
|
12
|
-
# one).
|
|
13
|
-
# SAMAGOTCHI_HOSTS_JSON
|
|
14
|
-
# it back to an equal HostConfig.
|
|
13
|
+
# one). #to_env_h writes every field in the shape of the
|
|
14
|
+
# SAMAGOTCHI_HOSTS_JSON override (ConfigFile.hosts_json_for_env), and
|
|
15
|
+
# .parse reads it back to an equal HostConfig.
|
|
15
16
|
#
|
|
16
17
|
# name: the lowercased entry name; host, port, scheme: where it is (from
|
|
17
18
|
# url: when given); url: the configured url, nil when host/port; transport,
|
|
18
|
-
# api: symbols, nil when unset; the
|
|
19
|
-
#
|
|
20
|
-
|
|
19
|
+
# api: symbols, nil when unset; auth: the header the API key goes in
|
|
20
|
+
# (LLM::ApiKey::SCHEMES), nil when unset; dialect: an api: anthropic
|
|
21
|
+
# host's LLM::Dialect name, nil when unset; the rest as
|
|
22
|
+
# HostRegistry::HostEntry describes them, nil when unset; models:
|
|
23
|
+
# {downcased id => HostModel}.
|
|
24
|
+
HostConfig = Data.define(:name, :host, :port, :scheme, :url, :transport, :api, :api_key_env, :auth, :dialect, :profile,
|
|
21
25
|
:first_token_timeout, :vision, :sampling, :thinking, :reasoning_history, :remote,
|
|
22
26
|
:window_tokens, :steer_cut_after, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
|
|
23
27
|
:models) do
|
|
28
|
+
# An api: anthropic host without url/host talks to Anthropic's own API.
|
|
29
|
+
ANTHROPIC_URL = "https://api.anthropic.com"
|
|
30
|
+
# auth: as written => the LLM::ApiKey scheme.
|
|
31
|
+
AUTH_SCHEMES = { "bearer" => :bearer, "x-api-key" => :x_api_key }.freeze
|
|
32
|
+
|
|
24
33
|
# Every field but name may be left out: nil, models {}.
|
|
25
34
|
def initialize(**fields)
|
|
26
35
|
super(**members.to_h { |m| [m, nil] }, models: {}, **fields)
|
|
27
36
|
end
|
|
28
37
|
|
|
29
|
-
# A hosts: entry as written (YAML, or a
|
|
38
|
+
# A hosts: entry as written (YAML, or a SAMAGOTCHI_HOSTS_JSON override),
|
|
30
39
|
# or nil when it is disabled (enabled: false) or invalid. An invalid
|
|
31
40
|
# one warns once, naming the entry, the way each reader of a setting
|
|
32
41
|
# does for a bad value (that value is then unset).
|
|
@@ -69,8 +78,17 @@ module Samagotchi
|
|
|
69
78
|
return invalid(name, "api_key_env must be an environment variable name")
|
|
70
79
|
end
|
|
71
80
|
|
|
72
|
-
|
|
81
|
+
auth = value.call(:auth).to_s.strip.downcase
|
|
82
|
+
fields[:auth] = AUTH_SCHEMES[auth] unless auth.empty?
|
|
83
|
+
return invalid(name, "auth must be one of #{AUTH_SCHEMES.keys.join(", ")}") if !auth.empty? && fields[:auth].nil?
|
|
84
|
+
|
|
85
|
+
if fields[:auth] && fields[:api_key_env].nil?
|
|
86
|
+
ConfigFile.warn_once "Warning: #{where}: auth has no effect without api_key_env (no key is sent)"
|
|
87
|
+
end
|
|
88
|
+
|
|
73
89
|
kinds = parse_kinds(name, value.call(:transport), value.call(:api)) or return nil
|
|
90
|
+
location = parse_location(name, value, api: kinds[:api]) or return nil
|
|
91
|
+
fields[:dialect] = LLM::Dialect.parse(value.call(:dialect), where, api: kinds[:api])
|
|
74
92
|
new(name: name.downcase, **location, **kinds, **fields)
|
|
75
93
|
rescue StandardError => e
|
|
76
94
|
# One entry that trips a reader is dropped on its own, and said so;
|
|
@@ -99,11 +117,13 @@ module Samagotchi
|
|
|
99
117
|
end
|
|
100
118
|
|
|
101
119
|
# host/port/scheme/url from url:, or from host: and port:; nil (warned)
|
|
102
|
-
# when they are wrong.
|
|
103
|
-
|
|
120
|
+
# when they are wrong. An api: anthropic entry with neither has
|
|
121
|
+
# ANTHROPIC_URL.
|
|
122
|
+
def self.parse_location(name, value, api: nil)
|
|
104
123
|
host = value.call(:host)
|
|
105
124
|
port = value.call(:port)
|
|
106
125
|
url = value.call(:url).to_s.strip
|
|
126
|
+
url = ANTHROPIC_URL if url.empty? && api == :anthropic && host.to_s.strip.empty? && port.to_s.strip.empty?
|
|
107
127
|
scheme = "http"
|
|
108
128
|
unless url.empty?
|
|
109
129
|
return invalid(name, "give url or host/port, not both") unless host.to_s.strip.empty? && port.to_s.strip.empty?
|
|
@@ -160,14 +180,15 @@ module Samagotchi
|
|
|
160
180
|
end
|
|
161
181
|
private_class_method :invalid
|
|
162
182
|
|
|
163
|
-
# The entry as a
|
|
183
|
+
# The entry as a SAMAGOTCHI_HOSTS_JSON override carries it (string keys,
|
|
164
184
|
# unset fields left out); .parse(name, to_env_h) gives it back. A url
|
|
165
185
|
# entry travels as its url (host/port come from it); the API key stays
|
|
166
|
-
# in the environment
|
|
186
|
+
# in the environment.
|
|
167
187
|
# @return [Hash{String => Object}]
|
|
168
188
|
def to_env_h
|
|
169
189
|
location = url ? { "url" => url } : { "host" => host, "port" => port }
|
|
170
190
|
location.merge("transport" => transport&.to_s, "api" => api&.to_s, "api_key_env" => api_key_env,
|
|
191
|
+
"auth" => (AUTH_SCHEMES.key(auth) if auth), "dialect" => dialect,
|
|
171
192
|
"profile" => profile, "first_token_timeout" => first_token_timeout,
|
|
172
193
|
"vision" => vision, "sampling" => sampling, "thinking" => thinking&.to_s,
|
|
173
194
|
"reasoning_history" => reasoning_history&.to_s, "remote" => remote, "window_tokens" => window_tokens,
|
|
@@ -104,8 +104,8 @@ module Samagotchi
|
|
|
104
104
|
end
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
-
# The entry as written back to config (
|
|
108
|
-
#
|
|
107
|
+
# The entry as written back to config (HostConfig#to_env_h): nil for a
|
|
108
|
+
# bare id.
|
|
109
109
|
def to_config
|
|
110
110
|
config = { "price" => price&.to_config, "served" => served }.compact
|
|
111
111
|
config unless config.empty?
|
|
@@ -6,7 +6,8 @@ require_relative "log"
|
|
|
6
6
|
require_relative "model_ref"
|
|
7
7
|
require_relative "client"
|
|
8
8
|
require_relative "model_list_store"
|
|
9
|
-
require_relative "llm/
|
|
9
|
+
require_relative "llm/adapters"
|
|
10
|
+
require_relative "llm/dialect"
|
|
10
11
|
|
|
11
12
|
module Samagotchi
|
|
12
13
|
# HostRegistry manages multiple model hosts (llama.cpp / mlx / oMLX) and
|
|
@@ -30,6 +31,8 @@ module Samagotchi
|
|
|
30
31
|
|
|
31
32
|
# url: the configured url, when the entry has one (host, port and scheme
|
|
32
33
|
# come from it); api_key_env: the variable holding the host's API key;
|
|
34
|
+
# auth: the header it goes in (LLM::ApiKey::SCHEMES), nil for the api's own default;
|
|
35
|
+
# dialect: an api: anthropic host's LLM::Dialect name (#dialect_profile), nil when unset;
|
|
33
36
|
# profile: the configured prompt profile name, if any;
|
|
34
37
|
# first_token_timeout: the configured first-token limit (see #first_token_limit);
|
|
35
38
|
# vision: the configured true/false (VisionSupport), nil when unset;
|
|
@@ -43,13 +46,13 @@ module Samagotchi
|
|
|
43
46
|
# llm_context_apply: the configured apply rule (LLMContextStrategy), nil when unset;
|
|
44
47
|
# llm_context_budget_tokens: the configured context budget (LLMContextStrategy), nil when unset;
|
|
45
48
|
# models: the ids the host serves whatever it lists, {downcased id => HostModel} ({} when none).
|
|
46
|
-
HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :
|
|
47
|
-
:first_token_timeout, :vision, :sampling, :thinking, :reasoning_history, :remote,
|
|
49
|
+
HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :auth, :dialect,
|
|
50
|
+
:profile, :first_token_timeout, :vision, :sampling, :thinking, :reasoning_history, :remote,
|
|
48
51
|
:window_tokens, :steer_cut_after, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
|
|
49
52
|
:models, keyword_init: true) do
|
|
50
|
-
# Talks
|
|
51
|
-
# raw-prompt loop.
|
|
52
|
-
def chat? = api
|
|
53
|
+
# Talks a chat API through an adapter (LLM::Adapters: the chat loop);
|
|
54
|
+
# nil and raw apis use the raw-prompt loop.
|
|
55
|
+
def chat? = LLM::Adapters.chat_api?(api)
|
|
53
56
|
|
|
54
57
|
# The server root, e.g. for llama.cpp's own endpoints and recap.
|
|
55
58
|
def root_url = "#{scheme || "http"}://#{host}:#{port}"
|
|
@@ -58,6 +61,25 @@ module Samagotchi
|
|
|
58
61
|
# configured, else the root's /v1.
|
|
59
62
|
def openai_base_url = url || "#{root_url}/v1"
|
|
60
63
|
|
|
64
|
+
# The API base the host's chat adapter talks to (LLM::Adapters): for
|
|
65
|
+
# api: openai the OpenAI-compatible base; for api: anthropic the API
|
|
66
|
+
# root without /v1 (as ANTHROPIC_BASE_URL; a url that ends in /v1
|
|
67
|
+
# loses it, so requests never go to /v1/v1).
|
|
68
|
+
def api_base_url
|
|
69
|
+
return openai_base_url unless api&.to_sym == :anthropic
|
|
70
|
+
|
|
71
|
+
(url || root_url).chomp("/").delete_suffix("/v1")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The header the host's key goes in: auth: when set, else the api's
|
|
75
|
+
# own (for api: anthropic its dialect's, x-api-key unless the dialect
|
|
76
|
+
# says otherwise; Bearer for every other api).
|
|
77
|
+
def auth_scheme = auth || (api&.to_sym == :anthropic ? dialect_profile.auth : :bearer)
|
|
78
|
+
|
|
79
|
+
# The LLM::Dialect an api: anthropic host's requests follow (STANDARD
|
|
80
|
+
# when it names none).
|
|
81
|
+
def dialect_profile = LLM::Dialect.for(dialect)
|
|
82
|
+
|
|
61
83
|
# A provider on the network rather than a local server: hosts.<name>.remote
|
|
62
84
|
# when set, else by its address (HostRegistry.remote_address?). An API
|
|
63
85
|
# key doesn't decide: a llama.cpp on the LAN can have one. A remote
|
|
@@ -106,7 +128,7 @@ module Samagotchi
|
|
|
106
128
|
entry = HostEntry.new(**fields)
|
|
107
129
|
entry.client = Client.new(host: cfg.host, port: cfg.port, transport: cfg.transport, scheme: cfg.scheme,
|
|
108
130
|
first_token_timeout: entry.first_token_limit, name: entry.name,
|
|
109
|
-
api_key_env: entry.api_key_env, env: env)
|
|
131
|
+
api_key_env: entry.api_key_env, auth: entry.auth_scheme, env: env)
|
|
110
132
|
@entries[entry.name] = entry
|
|
111
133
|
end
|
|
112
134
|
# Fallback single entry (should already be synthesized by hosts_config, but guard)
|
|
@@ -217,12 +239,13 @@ module Samagotchi
|
|
|
217
239
|
end
|
|
218
240
|
|
|
219
241
|
# The chat adapter for a host (one per host, so its cached model list
|
|
220
|
-
# serves the context window)
|
|
242
|
+
# serves the context window), as LLM::Adapters builds it for the host's
|
|
243
|
+
# api. Only chat hosts use it for turns.
|
|
221
244
|
# @return [LLM::OpenAIChat]
|
|
222
245
|
def adapter_for(entry)
|
|
223
246
|
@mutex.synchronize do
|
|
224
|
-
@adapters[entry.name] ||= LLM::
|
|
225
|
-
|
|
247
|
+
@adapters[entry.name] ||= LLM::Adapters.for(entry, models_ttl: entry.models_ttl,
|
|
248
|
+
first_token_timeout: entry.first_token_limit)
|
|
226
249
|
end
|
|
227
250
|
end
|
|
228
251
|
|
|
@@ -390,7 +413,7 @@ module Samagotchi
|
|
|
390
413
|
# the registry).
|
|
391
414
|
def inspect
|
|
392
415
|
hosts = @entries.values.map do |entry|
|
|
393
|
-
parts = [entry.
|
|
416
|
+
parts = [entry.api_base_url]
|
|
394
417
|
parts << "key=#{entry.api_key_env}" if entry.api_key_env
|
|
395
418
|
parts << "models=#{entry.models.values.map(&:id).join(",")}" unless entry.models.empty?
|
|
396
419
|
"#{entry.name}(#{parts.join(" ")})"
|