samagotchi 0.51.0 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -1
- data/README.md +1 -1
- data/bin/chi +11 -3
- data/docs/architecture.md +1 -1
- data/docs/cli.md +23 -8
- data/docs/configuration.md +59 -19
- data/docs/memory.md +11 -0
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +5 -1
- data/docs/testing.md +6 -1
- data/lib/samagotchi/bootstrap/config_writer.rb +6 -37
- data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
- data/lib/samagotchi/bridge.rb +10 -13
- data/lib/samagotchi/broadcast/recipients.rb +1 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/children_status.rb +1 -1
- data/lib/samagotchi/commands/registry.rb +2 -1
- data/lib/samagotchi/config.rb +50 -182
- data/lib/samagotchi/config_text_edit.rb +8 -32
- data/lib/samagotchi/context_sources.rb +0 -7
- data/lib/samagotchi/context_window.rb +2 -2
- data/lib/samagotchi/continue_offer.rb +5 -4
- data/lib/samagotchi/cut_policy.rb +0 -1
- data/lib/samagotchi/delivery.rb +23 -0
- data/lib/samagotchi/engine.rb +48 -17
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/host_config.rb +183 -0
- data/lib/samagotchi/host_registry.rb +5 -10
- data/lib/samagotchi/last_turn.rb +48 -0
- data/lib/samagotchi/live_versions.rb +0 -6
- data/lib/samagotchi/llm/native_tool_normalizer.rb +0 -5
- data/lib/samagotchi/llm_context_override.rb +3 -22
- data/lib/samagotchi/llm_context_strategy.rb +50 -16
- data/lib/samagotchi/memory_bundle/placeholder.rb +0 -5
- data/lib/samagotchi/model_profile.rb +4 -4
- data/lib/samagotchi/model_settings.rb +46 -0
- data/lib/samagotchi/plugin/context.rb +3 -3
- data/lib/samagotchi/plugin/sessions.rb +2 -2
- data/lib/samagotchi/queued_command.rb +35 -0
- data/lib/samagotchi/reminder_store.rb +0 -27
- data/lib/samagotchi/reply_wait.rb +11 -13
- data/lib/samagotchi/sampling_settings.rb +4 -4
- data/lib/samagotchi/self_report.rb +8 -5
- data/lib/samagotchi/send_command.rb +37 -19
- data/lib/samagotchi/session.rb +45 -24
- data/lib/samagotchi/session_commands.rb +36 -7
- data/lib/samagotchi/session_inbox.rb +18 -9
- data/lib/samagotchi/session_manager.rb +8 -7
- data/lib/samagotchi/session_setup.rb +30 -0
- data/lib/samagotchi/steer_cut.rb +9 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +10 -4
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +21 -3
- data/lib/samagotchi/terminal_ui.rb +8 -3
- data/lib/samagotchi/thinking.rb +67 -7
- data/lib/samagotchi/thinking_command.rb +72 -0
- data/lib/samagotchi/tools/builtins.rb +7 -2
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -3
- data/lib/samagotchi/web/app.rb +54 -6
- data/lib/samagotchi/web/public/app.js +133 -20
- data/lib/samagotchi/web/public/data.js +8 -4
- data/lib/samagotchi/web/public/format.js +8 -4
- data/lib/samagotchi/web/public/index.html +8 -5
- data/lib/samagotchi/web/public/kept_commands.js +52 -0
- data/lib/samagotchi/web/public/model_pick.js +3 -1
- data/lib/samagotchi/web/public/thinking_chip.js +169 -0
- data/lib/samagotchi/web/public/timing.js +15 -0
- data/lib/samagotchi/web/public/turn_events.js +23 -0
- data/lib/samagotchi/web/public/turn_view.js +328 -310
- data/lib/samagotchi/web/public/vendor/lit-html.js +8 -0
- data/lib/samagotchi/web/session_summary.rb +1 -1
- data/lib/samagotchi/worker.rb +43 -42
- data/lib/samagotchi/worker_inbound.rb +5 -5
- data/lib/samagotchi/worker_wakes.rb +0 -2
- data/lib/samagotchi/yaml_lines.rb +72 -0
- metadata +11 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -954,7 +954,7 @@ module Samagotchi
|
|
|
954
954
|
served, served_for = served_model_for(snapshot, target: target)
|
|
955
955
|
snapshot = snapshot.merge(served_model: served, served_model_for: served_for,
|
|
956
956
|
served_expected_by: served_expected_by(served, target: target), llm_context: llm_context_summary,
|
|
957
|
-
prompt_notes: prompt_notes.map(&:to_h), price_host: target.entry.name)
|
|
957
|
+
thinking: thinking_explained&.summary, prompt_notes: prompt_notes.map(&:to_h), price_host: target.entry.name)
|
|
958
958
|
unless snapshot.dig(:context, :window_tokens)
|
|
959
959
|
window = current_context_window(target)
|
|
960
960
|
if window
|
|
@@ -1029,16 +1029,46 @@ module Samagotchi
|
|
|
1029
1029
|
nil
|
|
1030
1030
|
end
|
|
1031
1031
|
|
|
1032
|
-
# "off (models: qwen)" for /model: the effective
|
|
1033
|
-
# and where it came from, nil when none is set.
|
|
1032
|
+
# "off (models: qwen)", "low (session)" for /model: the effective
|
|
1033
|
+
# model's thinking level and where it came from, nil when none is set.
|
|
1034
1034
|
def thinking_summary
|
|
1035
|
+
explained = thinking_explained
|
|
1036
|
+
explained&.source ? explained.label : nil
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
# The effective model's thinking level with where it came from and the
|
|
1040
|
+
# session's own (Thinking::Explained), as the next turn resolves it;
|
|
1041
|
+
# nil when it can't be resolved.
|
|
1042
|
+
# @return [Thinking::Explained, nil]
|
|
1043
|
+
def thinking_explained
|
|
1035
1044
|
target = @host_registry.resolve(@effective_model_name)
|
|
1036
|
-
|
|
1037
|
-
source ? "#{level} (#{source})" : nil
|
|
1045
|
+
Thinking.explain(target, names: model_lookup_names(target), session: thinking_override)
|
|
1038
1046
|
rescue StandardError
|
|
1039
1047
|
nil
|
|
1040
1048
|
end
|
|
1041
1049
|
|
|
1050
|
+
# The session's own thinking level, nil when it has none.
|
|
1051
|
+
# @return [Symbol, nil]
|
|
1052
|
+
def thinking_override = @session&.thinking
|
|
1053
|
+
|
|
1054
|
+
# What a thinking level change costs the effective model's prompt
|
|
1055
|
+
# cache (Thinking.cache_cost): :full, :provider or :tail.
|
|
1056
|
+
# @return [Symbol]
|
|
1057
|
+
def thinking_cache_cost
|
|
1058
|
+
target = @host_registry.resolve(@effective_model_name)
|
|
1059
|
+
Thinking.cache_cost(target, profile: target.entry&.chat? ? nil : profile&.name)
|
|
1060
|
+
end
|
|
1061
|
+
|
|
1062
|
+
# Set the session's own thinking level (nil unsets it); the next turn's
|
|
1063
|
+
# start resolves it, never a running one (its TurnSettings are fixed).
|
|
1064
|
+
# The caller saves the session.
|
|
1065
|
+
# @raise [ArgumentError] with no session yet
|
|
1066
|
+
def thinking_override=(level)
|
|
1067
|
+
raise ArgumentError, "no session yet" unless @session
|
|
1068
|
+
|
|
1069
|
+
@session.thinking = level
|
|
1070
|
+
end
|
|
1071
|
+
|
|
1042
1072
|
# The model the server serves for the current model, and the name asked
|
|
1043
1073
|
# for: what the last generation of that name reported, else llama.cpp's
|
|
1044
1074
|
# model_alias (/props, one short cached probe; not with probe: false),
|
|
@@ -1105,6 +1135,7 @@ module Samagotchi
|
|
|
1105
1135
|
served_expected_by: served_expected_by(served_pair[0]),
|
|
1106
1136
|
context_status: @last_context_status&.dup || saved_context_status(metrics[:context]),
|
|
1107
1137
|
llm_context: llm_context_summary,
|
|
1138
|
+
thinking: thinking_explained&.summary,
|
|
1108
1139
|
recap_enabled: !@recap.nil?,
|
|
1109
1140
|
recap_min_user_turns: @recap&.min_user_turns,
|
|
1110
1141
|
recap_inactivity_seconds: @recap&.inactivity&.to_i
|
|
@@ -1242,22 +1273,22 @@ module Samagotchi
|
|
|
1242
1273
|
elsif client_id == ClientId::REMINDER then "reminder"
|
|
1243
1274
|
else "client"
|
|
1244
1275
|
end
|
|
1245
|
-
session.last_turn =
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
session.last_turn.merge!(stop_facts(event))
|
|
1276
|
+
session.last_turn = LastTurn.new(outcome: outcome, ended_at: Time.now.iso8601(3), seconds: seconds.round(1),
|
|
1277
|
+
origin: source, exhausted: limit ? true : nil, limit: limit,
|
|
1278
|
+
**stop_facts(event))
|
|
1249
1279
|
end
|
|
1250
1280
|
|
|
1251
|
-
# Why a turn stopped, from its end event, for a
|
|
1252
|
-
# (ReplyWait): a provider error's kind and whether
|
|
1253
|
-
# cancel's reason and the hook that stopped it.
|
|
1281
|
+
# Why a turn stopped, from its end event (LastTurn::STOP_FACTS), for a
|
|
1282
|
+
# parent agent's wait (ReplyWait): a provider error's kind and whether
|
|
1283
|
+
# a retry may help, a cancel's reason and the hook that stopped it.
|
|
1284
|
+
# Only what is known.
|
|
1254
1285
|
def stop_facts(event)
|
|
1255
1286
|
case event&.dig(:type)
|
|
1256
1287
|
# kept_steps: the turn's work stayed (a parent must not send the task again).
|
|
1257
1288
|
when :turn_failed
|
|
1258
|
-
{
|
|
1289
|
+
{ error_kind: event[:error_kind]&.to_s, retryable: event[:retryable], kept_steps: event[:kept_steps] }
|
|
1259
1290
|
when :turn_canceled
|
|
1260
|
-
{
|
|
1291
|
+
{ cancel_reason: event[:cancellation_reason]&.to_s, stopped_by: event[:cancelled_by]&.to_s }
|
|
1261
1292
|
else {}
|
|
1262
1293
|
end
|
|
1263
1294
|
end
|
|
@@ -2237,9 +2268,9 @@ module Samagotchi
|
|
|
2237
2268
|
hook_notify(text, level, "thinking")
|
|
2238
2269
|
end
|
|
2239
2270
|
|
|
2240
|
-
# +target+'s thinking level (Thinking.resolve).
|
|
2271
|
+
# +target+'s thinking level (Thinking.resolve), the session's own first.
|
|
2241
2272
|
def thinking_level(target)
|
|
2242
|
-
Thinking.resolve(target, names: model_lookup_names(target)).first
|
|
2273
|
+
Thinking.resolve(target, names: model_lookup_names(target), session: thinking_override).first
|
|
2243
2274
|
rescue StandardError
|
|
2244
2275
|
Thinking::DEFAULT
|
|
2245
2276
|
end
|
|
@@ -2470,7 +2501,7 @@ module Samagotchi
|
|
|
2470
2501
|
model_key: -> { @model_key },
|
|
2471
2502
|
state_dir: -> { session_state_dir },
|
|
2472
2503
|
scratch: -> { @scratch },
|
|
2473
|
-
|
|
2504
|
+
setup: -> { SessionSetup.of(@session) },
|
|
2474
2505
|
frontend: -> { interface }
|
|
2475
2506
|
)
|
|
2476
2507
|
end
|
|
@@ -17,6 +17,7 @@ module Samagotchi
|
|
|
17
17
|
def cancelled? = !!engine.active_cancel_controller&.cancelled?
|
|
18
18
|
def relay = engine.relay_peer
|
|
19
19
|
def model_ref = engine.effective_model_ref
|
|
20
|
+
def thinking = engine.thinking_override
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
# What the approval relay needs from the parent's Engine (Peers#relay):
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "config"
|
|
5
|
+
require_relative "host_model"
|
|
6
|
+
require_relative "llm_context_strategy"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
# One config.yml hosts: entry, parsed and checked (ConfigFile.hosts_config
|
|
10
|
+
# holds them by lowercased name; HostRegistry builds its HostEntry from
|
|
11
|
+
# one). A worker gets its parent's hosts back through
|
|
12
|
+
# SAMAGOTCHI_HOSTS_JSON: #to_env_h writes every field, and .parse reads
|
|
13
|
+
# it back to an equal HostConfig.
|
|
14
|
+
#
|
|
15
|
+
# name: the lowercased entry name; host, port, scheme: where it is (from
|
|
16
|
+
# url: when given); url: the configured url, nil when host/port; transport,
|
|
17
|
+
# api: symbols, nil when unset; the rest as HostRegistry::HostEntry
|
|
18
|
+
# describes them, nil when unset; models: {downcased id => HostModel}.
|
|
19
|
+
HostConfig = Data.define(:name, :host, :port, :scheme, :url, :transport, :api, :api_key_env, :profile,
|
|
20
|
+
:first_token_timeout, :vision, :sampling, :thinking, :remote, :window_tokens,
|
|
21
|
+
:llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens, :models) do
|
|
22
|
+
# Every field but name may be left out: nil, models {}.
|
|
23
|
+
def initialize(**fields)
|
|
24
|
+
super(**members.to_h { |m| [m, nil] }, models: {}, **fields)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A hosts: entry as written (YAML, or a worker's SAMAGOTCHI_HOSTS_JSON),
|
|
28
|
+
# or nil when it is disabled (enabled: false) or invalid. An invalid
|
|
29
|
+
# one warns once, naming the entry, the way each reader of a setting
|
|
30
|
+
# does for a bad value (that value is then unset).
|
|
31
|
+
# @param raw_name [String, Symbol]
|
|
32
|
+
# @param raw [Hash] string or symbol keys
|
|
33
|
+
# @return [HostConfig, nil]
|
|
34
|
+
def self.parse(raw_name, raw)
|
|
35
|
+
name = raw_name.to_s.strip
|
|
36
|
+
return nil if name.empty?
|
|
37
|
+
return invalid(name, "must match /[a-z0-9][a-z0-9._-]*/i") unless name.match?(ConfigFile::HOST_NAME_RE)
|
|
38
|
+
return invalid(name, "expected mapping") unless raw.is_a?(Hash)
|
|
39
|
+
return nil if disabled?(raw)
|
|
40
|
+
|
|
41
|
+
value = ->(key) { raw.key?(key.to_s) ? raw[key.to_s] : raw[key.to_sym] }
|
|
42
|
+
where = "hosts entry '#{name}'"
|
|
43
|
+
fields = {
|
|
44
|
+
api_key_env: value.call(:api_key_env).to_s.strip.then { |v| v.empty? ? nil : v },
|
|
45
|
+
# Kept as written; ModelProfile.resolve warns about an unknown one.
|
|
46
|
+
profile: value.call(:profile).to_s.strip.downcase.then { |v| v.empty? ? nil : v },
|
|
47
|
+
first_token_timeout: value.call(:first_token_timeout),
|
|
48
|
+
vision: ConfigFile.vision_flag(value.call(:vision), where),
|
|
49
|
+
remote: ConfigFile.bool_flag(value.call(:remote), where, "remote"),
|
|
50
|
+
window_tokens: ConfigFile.window_tokens(value.call(:window_tokens), where),
|
|
51
|
+
sampling: ConfigFile.sampling_map(value.call(:sampling), where),
|
|
52
|
+
thinking: Thinking.level(value.call(:thinking), where),
|
|
53
|
+
llm_context_strategy: LLMContextStrategy.parse(value.call(LLMContextStrategy::KEY), where),
|
|
54
|
+
llm_context_apply: LLMContextStrategy.parse_apply(value.call(LLMContextStrategy::APPLY_KEY), where),
|
|
55
|
+
models: HostModel.parse_map(value.call(:models), name),
|
|
56
|
+
llm_context_budget_tokens: LLMContextStrategy.parse_budget(value.call(LLMContextStrategy::BUDGET_KEY), where)
|
|
57
|
+
}
|
|
58
|
+
timeout = fields[:first_token_timeout]
|
|
59
|
+
unless timeout.nil? || (timeout.is_a?(Numeric) && !timeout.negative?)
|
|
60
|
+
ConfigFile.warn_once "Warning: #{where}: first_token_timeout must be seconds (0 = off); using the default"
|
|
61
|
+
fields[:first_token_timeout] = nil
|
|
62
|
+
end
|
|
63
|
+
api_key_env = fields[:api_key_env]
|
|
64
|
+
unless api_key_env.nil? || api_key_env.match?(ConfigFile::ENV_NAME_RE)
|
|
65
|
+
return invalid(name, "api_key_env must be an environment variable name")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
location = parse_location(name, value) or return nil
|
|
69
|
+
kinds = parse_kinds(name, value.call(:transport), value.call(:api)) or return nil
|
|
70
|
+
new(name: name.downcase, **location, **kinds, **fields)
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
# One entry that trips a reader is dropped on its own, and said so;
|
|
73
|
+
# the other hosts stay (the worker's copy too).
|
|
74
|
+
invalid(name || raw_name, "#{e.class}: #{e.message}")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# A literal Hash of fields (specs and in-memory callers building a
|
|
78
|
+
# HostRegistry) as a HostConfig, unchecked; a HostConfig as it is.
|
|
79
|
+
# @param name [String] the entry's name
|
|
80
|
+
# @param cfg [HostConfig, Hash] symbol keys, as #to_h gives them
|
|
81
|
+
def self.coerce(name, cfg)
|
|
82
|
+
return cfg if cfg.is_a?(HostConfig)
|
|
83
|
+
|
|
84
|
+
new(**cfg.to_h.slice(*members), name: name.to_s.downcase)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# enabled: false (or "false", any case) in a hosts entry as written.
|
|
88
|
+
def self.disabled?(raw)
|
|
89
|
+
enabled = if raw.key?("enabled")
|
|
90
|
+
raw["enabled"]
|
|
91
|
+
else
|
|
92
|
+
(raw.key?(:enabled) ? raw[:enabled] : true)
|
|
93
|
+
end
|
|
94
|
+
enabled == false || enabled.to_s.strip.downcase == "false"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# host/port/scheme/url from url:, or from host: and port:; nil (warned)
|
|
98
|
+
# when they are wrong.
|
|
99
|
+
def self.parse_location(name, value)
|
|
100
|
+
host = value.call(:host)
|
|
101
|
+
port = value.call(:port)
|
|
102
|
+
url = value.call(:url).to_s.strip
|
|
103
|
+
scheme = "http"
|
|
104
|
+
unless url.empty?
|
|
105
|
+
return invalid(name, "give url or host/port, not both") unless host.to_s.strip.empty? && port.to_s.strip.empty?
|
|
106
|
+
|
|
107
|
+
uri = begin
|
|
108
|
+
URI.parse(url)
|
|
109
|
+
rescue URI::InvalidURIError
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
return invalid(name, "url must be an http(s) URL") unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
|
|
113
|
+
|
|
114
|
+
host = uri.host
|
|
115
|
+
port = uri.port
|
|
116
|
+
scheme = uri.scheme
|
|
117
|
+
url = url.chomp("/")
|
|
118
|
+
end
|
|
119
|
+
host = host.to_s.strip
|
|
120
|
+
return invalid(name, "host is required") if host.empty?
|
|
121
|
+
|
|
122
|
+
port = port.to_s.strip.empty? ? 8080 : port.to_i
|
|
123
|
+
return invalid(name, "invalid port") if port <= 0 || port > 65_535
|
|
124
|
+
|
|
125
|
+
{ host: host, port: port, scheme: scheme, url: url.empty? ? nil : url }
|
|
126
|
+
end
|
|
127
|
+
private_class_method :parse_location
|
|
128
|
+
|
|
129
|
+
# transport: and api: as symbols; a raw-prompt api is also the
|
|
130
|
+
# transport. nil (warned) when unknown or contradicting each other.
|
|
131
|
+
def self.parse_kinds(name, transport, api)
|
|
132
|
+
transport = transport.to_s.strip.downcase
|
|
133
|
+
if transport.empty?
|
|
134
|
+
transport = nil
|
|
135
|
+
elsif !ConfigFile::VALID_TRANSPORTS_FOR_CONFIG.include?(transport)
|
|
136
|
+
return invalid(name, "unknown transport '#{transport}'")
|
|
137
|
+
end
|
|
138
|
+
api = api.to_s.strip.downcase
|
|
139
|
+
if api.empty?
|
|
140
|
+
api = nil
|
|
141
|
+
elsif !ConfigFile::VALID_APIS_FOR_CONFIG.include?(api)
|
|
142
|
+
return invalid(name, "unknown api '#{api}'")
|
|
143
|
+
elsif ConfigFile::VALID_TRANSPORTS_FOR_CONFIG.include?(api)
|
|
144
|
+
# A raw-prompt api is the transport; a different transport contradicts it.
|
|
145
|
+
return invalid(name, "api '#{api}' conflicts with transport '#{transport}'") if transport && transport != api
|
|
146
|
+
|
|
147
|
+
transport = api
|
|
148
|
+
end
|
|
149
|
+
{ transport: transport&.to_sym, api: api&.to_sym }
|
|
150
|
+
end
|
|
151
|
+
private_class_method :parse_kinds
|
|
152
|
+
|
|
153
|
+
def self.invalid(name, reason)
|
|
154
|
+
ConfigFile.warn_once "Warning: ignoring hosts entry '#{name}': #{reason}"
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
private_class_method :invalid
|
|
158
|
+
|
|
159
|
+
# The entry as a worker's SAMAGOTCHI_HOSTS_JSON carries it (string keys,
|
|
160
|
+
# unset fields left out); .parse(name, to_env_h) gives it back. A url
|
|
161
|
+
# entry travels as its url (host/port come from it); the API key stays
|
|
162
|
+
# in the environment, which workers inherit.
|
|
163
|
+
# @return [Hash{String => Object}]
|
|
164
|
+
def to_env_h
|
|
165
|
+
location = url ? { "url" => url } : { "host" => host, "port" => port }
|
|
166
|
+
location.merge("transport" => transport&.to_s, "api" => api&.to_s, "api_key_env" => api_key_env,
|
|
167
|
+
"profile" => profile, "first_token_timeout" => first_token_timeout,
|
|
168
|
+
"vision" => vision, "sampling" => sampling, "thinking" => thinking&.to_s,
|
|
169
|
+
"remote" => remote, "window_tokens" => window_tokens,
|
|
170
|
+
"llm_context_strategy" => llm_context_strategy&.map(&:to_s),
|
|
171
|
+
"llm_context_apply" => llm_context_apply&.to_s,
|
|
172
|
+
"llm_context_budget_tokens" => llm_context_budget_tokens,
|
|
173
|
+
"models" => models_config).compact
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
private
|
|
177
|
+
|
|
178
|
+
# hosts.<name>.models as written back (HostModel#to_config), nil when none.
|
|
179
|
+
def models_config
|
|
180
|
+
models.values.to_h { |m| [m.id, m.to_config] } unless models.nil? || models.empty?
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -98,16 +98,11 @@ module Samagotchi
|
|
|
98
98
|
raw = hosts_config || ConfigFile.hosts_config(env: env)
|
|
99
99
|
@entries = {}
|
|
100
100
|
raw.each do |key, cfg|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
vision: cfg[:vision], sampling: cfg[:sampling], thinking: cfg[:thinking],
|
|
107
|
-
remote: cfg[:remote], window_tokens: cfg[:window_tokens],
|
|
108
|
-
llm_context_strategy: cfg[:llm_context_strategy], llm_context_apply: cfg[:llm_context_apply],
|
|
109
|
-
llm_context_budget_tokens: cfg[:llm_context_budget_tokens], models: cfg[:models] || {})
|
|
110
|
-
entry.client = Client.new(host: cfg[:host], port: cfg[:port], transport: transport, scheme: cfg[:scheme],
|
|
101
|
+
cfg = HostConfig.coerce(key, cfg)
|
|
102
|
+
# Every HostConfig field is a HostEntry member (a new one must be added there too).
|
|
103
|
+
fields = cfg.to_h.merge(name: key.to_s.downcase, port: cfg.port.to_i, api: cfg.api&.to_sym, models: cfg.models || {})
|
|
104
|
+
entry = HostEntry.new(**fields)
|
|
105
|
+
entry.client = Client.new(host: cfg.host, port: cfg.port, transport: cfg.transport, scheme: cfg.scheme,
|
|
111
106
|
first_token_timeout: entry.first_token_limit, name: entry.name,
|
|
112
107
|
api_key_env: entry.api_key_env, env: env)
|
|
113
108
|
@entries[entry.name] = entry
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# How a session's last turn ended (Session#last_turn), saved as
|
|
5
|
+
# session.json's "last_turn" and sent in the web's session summary.
|
|
6
|
+
# Engine#record_last_turn writes it at every turn's end, ContinueOffer
|
|
7
|
+
# when a Stop answered the step-limit question (no turn ran).
|
|
8
|
+
#
|
|
9
|
+
# outcome: "completed", "failed", "canceled" or "not_continued";
|
|
10
|
+
# ended_at: iso8601; seconds: Float; origin: "client", "reminder",
|
|
11
|
+
# "delegate", "delegate_report" (a turn a parent ran for its delegate
|
|
12
|
+
# children's reports) or "context" (one an attached context source's
|
|
13
|
+
# change started); exhausted/limit: the turn ran out of iterations at
|
|
14
|
+
# limit. The rest are its stop facts (LastTurn::STOP_FACTS), only what is
|
|
15
|
+
# known. A field may be nil (unset; left out of the file).
|
|
16
|
+
LastTurn = Data.define(:outcome, :ended_at, :seconds, :origin, :exhausted, :limit,
|
|
17
|
+
:error_kind, :retryable, :kept_steps, :cancel_reason, :stopped_by) do
|
|
18
|
+
def initialize(**fields)
|
|
19
|
+
super(**members.to_h { |m| [m, nil] }, **fields)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A saved record (string keys; symbol keys read too, a key we don't
|
|
23
|
+
# know dropped), a LastTurn as it is; nil for anything else.
|
|
24
|
+
# @return [LastTurn, nil]
|
|
25
|
+
def self.from_file(raw)
|
|
26
|
+
return raw if raw.is_a?(LastTurn)
|
|
27
|
+
return nil unless raw.is_a?(Hash)
|
|
28
|
+
|
|
29
|
+
new(**members.to_h { |m| [m, raw.key?(m.to_s) ? raw[m.to_s] : raw[m]] })
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The session file's record: string keys, in member order, unset
|
|
33
|
+
# fields left out.
|
|
34
|
+
# @return [Hash{String => Object}]
|
|
35
|
+
def to_file = to_h.compact.transform_keys(&:to_s)
|
|
36
|
+
|
|
37
|
+
# The stop facts this turn has (LastTurn::STOP_FACTS), symbol keys.
|
|
38
|
+
# @return [Hash{Symbol => Object}]
|
|
39
|
+
def stop_facts = to_h.slice(*self.class::STOP_FACTS).compact
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Why a turn stopped, for a parent agent's wait (ReplyWait::Result takes
|
|
43
|
+
# them by these names): a failed turn's provider error kind, whether a
|
|
44
|
+
# retry may help and whether its work stayed (kept_steps: a parent must
|
|
45
|
+
# not send the task again); a canceled one's reason and the hook that
|
|
46
|
+
# stopped it.
|
|
47
|
+
LastTurn::STOP_FACTS = %i[error_kind retryable kept_steps cancel_reason stopped_by].freeze
|
|
48
|
+
end
|
|
@@ -36,12 +36,6 @@ module Samagotchi
|
|
|
36
36
|
workers(state_dir: state_dir).reject { |w| w.version == version }
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
# The version a chi web on host:port runs, or nil when nothing (or not
|
|
40
|
-
# chi web) answers.
|
|
41
|
-
def web_version(host, port, timeout: WEB_TIMEOUT)
|
|
42
|
-
web_info(host, port, timeout: timeout)&.fetch("version", nil).to_s.then { |v| v.empty? ? nil : v }
|
|
43
|
-
end
|
|
44
|
-
|
|
45
39
|
# The /api/info of a chi web on host:port, or nil when nothing (or not
|
|
46
40
|
# chi web) answers.
|
|
47
41
|
def web_info(host, port, timeout: WEB_TIMEOUT)
|
|
@@ -26,11 +26,6 @@ module Samagotchi
|
|
|
26
26
|
Tools::BuiltinCalls.build(call.name.to_s, args_for(call))
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
# Map an Array of tool calls to internal call hashes (nils dropped).
|
|
30
|
-
def normalize_all(calls)
|
|
31
|
-
Array(calls).map { |call| normalize(call) }.compact
|
|
32
|
-
end
|
|
33
|
-
|
|
34
29
|
private
|
|
35
30
|
|
|
36
31
|
# args_for: arguments is normally a Hash. Defensively handle nil /
|
|
@@ -48,7 +48,6 @@ module Samagotchi
|
|
|
48
48
|
FIELDS = %i[strategy apply budget_tokens].freeze
|
|
49
49
|
# A field's word for "unset: follow the model" (/llm-context strategy default).
|
|
50
50
|
DEFAULT_WORD = "default"
|
|
51
|
-
OFF_WORDS = %w[off 0].freeze
|
|
52
51
|
# The start flags (chi, chi send --new), by field.
|
|
53
52
|
FLAGS = { strategy: "--llm-context", apply: "--llm-context-apply", budget_tokens: "--llm-context-budget" }.freeze
|
|
54
53
|
# The session command, and its words by field.
|
|
@@ -111,7 +110,7 @@ module Samagotchi
|
|
|
111
110
|
end
|
|
112
111
|
|
|
113
112
|
def self.file_budget(raw)
|
|
114
|
-
raw.is_a?(Integer) ? check_budget(raw) : nil
|
|
113
|
+
raw.is_a?(Integer) ? LLMContextStrategy.check_budget(raw) : nil
|
|
115
114
|
end
|
|
116
115
|
|
|
117
116
|
# +current+ with +words+ (field => what the user typed) applied: each
|
|
@@ -153,27 +152,9 @@ module Samagotchi
|
|
|
153
152
|
rule || raise(ArgumentError, "unknown llm_context apply #{text} (#{LLMContextStrategy::APPLIES.join(", ")})")
|
|
154
153
|
end
|
|
155
154
|
|
|
156
|
-
#
|
|
157
|
-
MIN_BUDGET = 4_000
|
|
158
|
-
MAX_BUDGET = 10_000_000
|
|
159
|
-
|
|
160
|
-
# "64000", "64k" or "off" (0).
|
|
155
|
+
# "64000", "64k" or "off" (0): LLMContextStrategy.budget_tokens.
|
|
161
156
|
# @return [Integer] 0 for off
|
|
162
|
-
def self.parse_budget_tokens(text)
|
|
163
|
-
return 0 if OFF_WORDS.include?(text.downcase)
|
|
164
|
-
|
|
165
|
-
match = /\A(\d[\d_]*)(k)?\z/i.match(text)
|
|
166
|
-
raise ArgumentError, "a budget is a number of tokens (64000 or 64k) or off" unless match
|
|
167
|
-
|
|
168
|
-
check_budget(match[1].delete("_").to_i * (match[2] ? 1000 : 1))
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
# +tokens+, when 0 (off) or between MIN_BUDGET and MAX_BUDGET.
|
|
172
|
-
def self.check_budget(tokens)
|
|
173
|
-
return tokens if tokens.zero? || tokens.between?(MIN_BUDGET, MAX_BUDGET)
|
|
174
|
-
|
|
175
|
-
raise ArgumentError, "a budget of #{tokens} tokens is out of range: #{MIN_BUDGET} (4k) to #{MAX_BUDGET} (10000k), or off"
|
|
176
|
-
end
|
|
157
|
+
def self.parse_budget_tokens(text) = LLMContextStrategy.budget_tokens(text)
|
|
177
158
|
|
|
178
159
|
private_class_method :file_value, :file_strategy, :file_budget
|
|
179
160
|
end
|
|
@@ -23,7 +23,7 @@ module Samagotchi
|
|
|
23
23
|
# (false: edit-driven stale stubs are opt-in, experimental) are global only.
|
|
24
24
|
#
|
|
25
25
|
# A soft context budget, llm_context.budget_tokens, and per model or host
|
|
26
|
-
# the flat key llm_context_budget_tokens (
|
|
26
|
+
# the flat key llm_context_budget_tokens (tokens or k, 4k to 10M, or off),
|
|
27
27
|
# resolved as the apply rule is, each on its own: off (nil) by default.
|
|
28
28
|
# When set, ContextStatus counts its bands against it instead of the
|
|
29
29
|
# window (the smaller of the two), so the forget layer's offers come
|
|
@@ -103,21 +103,52 @@ module Samagotchi
|
|
|
103
103
|
DEFAULT_APPLY
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
-
# +raw+ as written
|
|
107
|
-
#
|
|
108
|
-
# unset.
|
|
106
|
+
# +raw+ as written in config (llm_context.budget_tokens, a models: or
|
|
107
|
+
# hosts: entry's llm_context_budget_tokens), read as /llm-context
|
|
108
|
+
# budget reads it (.budget_tokens); nil for unset, 0 or off. Anything
|
|
109
|
+
# else warns, naming +where+, and is unset.
|
|
109
110
|
# @return [Integer, nil]
|
|
110
111
|
def parse_budget(raw, where)
|
|
111
112
|
return nil if raw.nil? || raw.to_s.strip.empty?
|
|
112
113
|
|
|
113
|
-
tokens =
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
warn_once "Warning: #{where}: llm_context budget_tokens must be a positive number of tokens; ignored"
|
|
114
|
+
tokens = budget_tokens(raw)
|
|
115
|
+
tokens.zero? ? nil : tokens
|
|
116
|
+
rescue ArgumentError => e
|
|
117
|
+
warn_once "Warning: #{where}: #{e.message}; ignored"
|
|
118
118
|
nil
|
|
119
119
|
end
|
|
120
120
|
|
|
121
|
+
# The smallest and largest budget a session or the config may set.
|
|
122
|
+
MIN_BUDGET = 4_000
|
|
123
|
+
MAX_BUDGET = 10_000_000
|
|
124
|
+
BUDGET_OFF_WORDS = %w[off 0].freeze
|
|
125
|
+
|
|
126
|
+
# A budget in tokens: an Integer, "64000", "64_000", "64k", or "off"
|
|
127
|
+
# (0); the one grammar for /llm-context budget, chi
|
|
128
|
+
# --llm-context-budget and the config.
|
|
129
|
+
# @return [Integer] 0 for off
|
|
130
|
+
# @raise [ArgumentError] not a budget, or one out of MIN_BUDGET..MAX_BUDGET
|
|
131
|
+
def budget_tokens(raw)
|
|
132
|
+
return check_budget(raw) if raw.is_a?(Integer)
|
|
133
|
+
|
|
134
|
+
text = raw.to_s.strip
|
|
135
|
+
return 0 if BUDGET_OFF_WORDS.include?(text.downcase)
|
|
136
|
+
|
|
137
|
+
match = /\A(\d[\d_]*)(k)?\z/i.match(text)
|
|
138
|
+
raise ArgumentError, "a budget is a number of tokens (64000 or 64k) or off" unless match
|
|
139
|
+
|
|
140
|
+
check_budget(match[1].delete("_").to_i * (match[2] ? 1000 : 1))
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# +tokens+, when 0 (off) or between MIN_BUDGET and MAX_BUDGET.
|
|
144
|
+
# @return [Integer]
|
|
145
|
+
# @raise [ArgumentError]
|
|
146
|
+
def check_budget(tokens)
|
|
147
|
+
return tokens if tokens.zero? || tokens.between?(MIN_BUDGET, MAX_BUDGET)
|
|
148
|
+
|
|
149
|
+
raise ArgumentError, "a budget of #{tokens} tokens is out of range: #{MIN_BUDGET} (4k) to #{MAX_BUDGET} (10000k), or off"
|
|
150
|
+
end
|
|
151
|
+
|
|
121
152
|
# Where one value came from: :session, :model_setting, :host_setting
|
|
122
153
|
# or :config, and the place as a warning names it ("the session",
|
|
123
154
|
# "models: deepseek", "hosts entry 'box'", "llm_context.apply").
|
|
@@ -143,7 +174,7 @@ module Samagotchi
|
|
|
143
174
|
# The strategy for +target+'s turn.
|
|
144
175
|
# @param target [HostRegistry::ModelTarget, nil]
|
|
145
176
|
# @param names [Array<String>] HostRegistry#lookup_names
|
|
146
|
-
# @param models [Hash, nil] ConfigFile.model_settings (specs)
|
|
177
|
+
# @param models [Hash{String => ModelSettings}, nil] ConfigFile.model_settings (specs)
|
|
147
178
|
# @param session [Array<Symbol>, nil] the session's own layers ([]: none)
|
|
148
179
|
# @param session_apply [Symbol, nil] the session's own apply rule
|
|
149
180
|
# @param session_budget [Integer, nil] the session's own budget (0: off)
|
|
@@ -210,8 +241,9 @@ module Samagotchi
|
|
|
210
241
|
def budget_for(target, names, models, session_budget)
|
|
211
242
|
return [session_budget.positive? ? session_budget : nil, SESSION_ORIGIN] unless session_budget.nil?
|
|
212
243
|
|
|
213
|
-
|
|
214
|
-
|
|
244
|
+
if (setting = ConfigFile.model_setting(names, :llm_context_budget_tokens, models: models))
|
|
245
|
+
return [setting.value, Origin.new(source: :model_setting, where: "models: #{setting.key}")]
|
|
246
|
+
end
|
|
215
247
|
|
|
216
248
|
budget = target&.entry&.llm_context_budget_tokens
|
|
217
249
|
return [budget, Origin.new(source: :host_setting, where: "hosts entry '#{target.entry.name}'")] if budget
|
|
@@ -222,8 +254,9 @@ module Samagotchi
|
|
|
222
254
|
def layers_for(target, names, models, session)
|
|
223
255
|
return [session, :session, SESSION_ORIGIN.where] if session
|
|
224
256
|
|
|
225
|
-
|
|
226
|
-
|
|
257
|
+
if (setting = ConfigFile.model_setting(names, :llm_context_strategy, models: models))
|
|
258
|
+
return [setting.value, :model_setting, "models: #{setting.key}"]
|
|
259
|
+
end
|
|
227
260
|
|
|
228
261
|
layers = target&.entry&.llm_context_strategy
|
|
229
262
|
return [layers, :host_setting, "hosts entry '#{target.entry.name}'"] if layers
|
|
@@ -235,8 +268,9 @@ module Samagotchi
|
|
|
235
268
|
def apply_for(target, names, models, session_apply)
|
|
236
269
|
return [session_apply, SESSION_ORIGIN] if session_apply
|
|
237
270
|
|
|
238
|
-
|
|
239
|
-
|
|
271
|
+
if (setting = ConfigFile.model_setting(names, :llm_context_apply, models: models))
|
|
272
|
+
return [setting.value, Origin.new(source: :model_setting, where: "models: #{setting.key}")]
|
|
273
|
+
end
|
|
240
274
|
|
|
241
275
|
apply = target&.entry&.llm_context_apply
|
|
242
276
|
return [apply, Origin.new(source: :host_setting, where: "hosts entry '#{target.entry.name}'")] if apply
|
|
@@ -227,7 +227,7 @@ module Samagotchi
|
|
|
227
227
|
host = parsed.host_name
|
|
228
228
|
return nil unless host
|
|
229
229
|
# Declared under hosts.<name>.models: served whatever the host lists.
|
|
230
|
-
return nil if hosts[host.to_s.strip.downcase]&.
|
|
230
|
+
return nil if hosts[host.to_s.strip.downcase]&.models&.key?(parsed.id.to_s.strip.downcase)
|
|
231
231
|
|
|
232
232
|
list = lists.read(env: env)[host.to_s.strip.downcase]
|
|
233
233
|
return nil if list.nil? || list.stale? || list.known?(parsed.id)
|
|
@@ -433,10 +433,10 @@ module Samagotchi
|
|
|
433
433
|
end
|
|
434
434
|
|
|
435
435
|
names.map(&:downcase).uniq.each do |key|
|
|
436
|
-
|
|
437
|
-
next unless
|
|
436
|
+
configured_profile = models[key]&.profile
|
|
437
|
+
next unless configured_profile
|
|
438
438
|
|
|
439
|
-
profile = configured(
|
|
439
|
+
profile = configured(configured_profile, "models: #{key}")
|
|
440
440
|
return Resolution.new(profile: profile, source: :config, detail: "models: #{key}", retry: false) if profile
|
|
441
441
|
end
|
|
442
442
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
require_relative "llm_context_strategy"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# One config.yml models: entry (per-model settings keyed by model id or
|
|
8
|
+
# alias), parsed and checked; ConfigFile.model_settings holds them by
|
|
9
|
+
# downcased key. Each field is nil when unset: profile kept as written
|
|
10
|
+
# (downcased; ModelProfile.resolve validates it), vision true/false,
|
|
11
|
+
# sampling a request-field map, thinking a level, window_tokens, and the
|
|
12
|
+
# LLM context strategy, apply rule and budget (LLMContextStrategy). The
|
|
13
|
+
# same per-model fields a hosts: entry carries (HostConfig).
|
|
14
|
+
ModelSettings = Data.define(:profile, :vision, :sampling, :thinking, :window_tokens,
|
|
15
|
+
:llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens) do
|
|
16
|
+
# Every field may be left out (nil).
|
|
17
|
+
def initialize(**fields)
|
|
18
|
+
super(**members.to_h { |m| [m, nil] }, **fields)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# A models: entry as written, or nil when it is not a map (or the key is
|
|
22
|
+
# blank). A bad value warns, naming the entry, and is left unset.
|
|
23
|
+
# @param key [String] the downcased model id or alias
|
|
24
|
+
# @param raw [Hash] string or symbol keys
|
|
25
|
+
# @return [ModelSettings, nil]
|
|
26
|
+
def self.parse(key, raw)
|
|
27
|
+
return nil if key.to_s.empty? || !raw.is_a?(Hash)
|
|
28
|
+
|
|
29
|
+
value = ->(name) { raw.key?(name.to_s) ? raw[name.to_s] : raw[name.to_sym] }
|
|
30
|
+
where = "models: #{key}"
|
|
31
|
+
profile = value.call(:profile).to_s.strip.downcase
|
|
32
|
+
new(profile: profile.empty? ? nil : profile,
|
|
33
|
+
vision: ConfigFile.vision_flag(value.call(:vision), where),
|
|
34
|
+
sampling: ConfigFile.sampling_map(value.call(:sampling), where),
|
|
35
|
+
thinking: Thinking.level(value.call(:thinking), where),
|
|
36
|
+
window_tokens: ConfigFile.window_tokens(value.call(:window_tokens), where),
|
|
37
|
+
llm_context_strategy: LLMContextStrategy.parse(value.call(LLMContextStrategy::KEY), where),
|
|
38
|
+
llm_context_apply: LLMContextStrategy.parse_apply(value.call(LLMContextStrategy::APPLY_KEY), where),
|
|
39
|
+
llm_context_budget_tokens: LLMContextStrategy.parse_budget(value.call(LLMContextStrategy::BUDGET_KEY), where))
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# One field ConfigFile.model_setting found: the models: key that set it
|
|
44
|
+
# and its value.
|
|
45
|
+
ModelSetting = Data.define(:key, :value)
|
|
46
|
+
end
|