samagotchi 0.50.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 +89 -1
- data/README.md +1 -1
- data/bin/chi +11 -3
- data/docs/architecture.md +1 -1
- data/docs/cli.md +40 -11
- data/docs/configuration.md +63 -21
- data/docs/hooks.md +3 -2
- data/docs/internals/engine.md +4 -2
- data/docs/memory.md +11 -0
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +10 -5
- 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/turn_accumulator.rb +3 -1
- data/lib/samagotchi/bridge.rb +29 -17
- data/lib/samagotchi/bridge_client.rb +3 -1
- 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 +56 -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 +21 -13
- data/lib/samagotchi/kernel_loop.rb +28 -2
- data/lib/samagotchi/last_turn.rb +48 -0
- data/lib/samagotchi/live_versions.rb +0 -6
- data/lib/samagotchi/llm/native_tool_normalizer.rb +2 -7
- 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 +92 -21
- data/lib/samagotchi/session.rb +45 -24
- data/lib/samagotchi/session_commands.rb +41 -3
- data/lib/samagotchi/session_inbox.rb +36 -7
- data/lib/samagotchi/session_manager.rb +21 -10
- data/lib/samagotchi/session_metrics.rb +25 -6
- data/lib/samagotchi/session_setup.rb +30 -0
- data/lib/samagotchi/sessions_command.rb +1 -1
- data/lib/samagotchi/steer_cut.rb +21 -10
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +10 -4
- data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +69 -4
- data/lib/samagotchi/terminal_ui.rb +56 -8
- data/lib/samagotchi/thinking.rb +67 -7
- data/lib/samagotchi/thinking_command.rb +72 -0
- 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/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 +59 -7
- 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 +199 -48
- 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 +25 -20
- 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/session_title.js +26 -0
- data/lib/samagotchi/web/public/strip.js +4 -6
- 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 +26 -2
- data/lib/samagotchi/web/public/turn_html.js +3 -1
- 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 +44 -43
- data/lib/samagotchi/worker_inbound.rb +10 -7
- data/lib/samagotchi/worker_wakes.rb +0 -2
- data/lib/samagotchi/yaml_lines.rb +72 -0
- data/lib/samagotchi.rb +1 -0
- metadata +13 -1
|
@@ -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
|
|
@@ -200,9 +195,11 @@ module Samagotchi
|
|
|
200
195
|
# id it is sent as (ModelRef: one alias pass). A ref that names a host
|
|
201
196
|
# goes there. A bare id goes to the hosts whose cached list has it or
|
|
202
197
|
# 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
|
-
#
|
|
198
|
+
# default host when it is one of them, else the first in hosts: order.
|
|
199
|
+
# When none does, the hosts whose saved list (ModelListStore, another
|
|
200
|
+
# process's listing: a worker spawned with --model never lists) has
|
|
201
|
+
# it, the same way; else the default host (no discovery here, the
|
|
202
|
+
# latency budget). Never by a substring.
|
|
206
203
|
def host_for_model(raw_model)
|
|
207
204
|
ref = model_ref(raw_model)
|
|
208
205
|
# ModelRef names a host only when it is one of ours
|
|
@@ -211,6 +208,7 @@ module Samagotchi
|
|
|
211
208
|
down = ref.id.to_s.strip.downcase
|
|
212
209
|
listed = @mutex.synchronize { @model_index }&.fetch(down, nil) || []
|
|
213
210
|
candidates = @entries.values.select { |e| listed.include?(e.name) || e.models&.key?(down) }.map(&:name)
|
|
211
|
+
candidates = saved_hosts_for(down) if candidates.empty?
|
|
214
212
|
default = default_entry
|
|
215
213
|
name = candidates.include?(default.name) ? default.name : candidates.first
|
|
216
214
|
[(name && find_entry(name)) || default, ref.id]
|
|
@@ -386,6 +384,16 @@ module Samagotchi
|
|
|
386
384
|
ModelListStore.save(name, Array(models).map(&:id), env: @env)
|
|
387
385
|
end
|
|
388
386
|
|
|
387
|
+
# The configured hosts, in hosts: order, whose fresh saved list
|
|
388
|
+
# (ModelListStore) has +down+ (a downcased id). The store never raises.
|
|
389
|
+
def saved_hosts_for(down)
|
|
390
|
+
saved = ModelListStore.read(env: @env)
|
|
391
|
+
@entries.each_key.select do |name|
|
|
392
|
+
list = saved[name]
|
|
393
|
+
list && !list.stale? && list.known?(down)
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
389
397
|
def fresh_list(name, entry)
|
|
390
398
|
@mutex.synchronize do
|
|
391
399
|
cached = @host_lists[name]
|
|
@@ -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])
|
|
@@ -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)
|
|
@@ -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.
|
|
@@ -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
|
|
@@ -20,15 +20,15 @@ module Samagotchi
|
|
|
20
20
|
# the answer text.
|
|
21
21
|
# +messages_partial+ says whether +messages+ leaves out a running turn;
|
|
22
22
|
# +model_name+ (the session's resolved model ref, live after /model) and
|
|
23
|
-
# +state_dir+ are what ctx.sessions forks with, and +
|
|
24
|
-
# session's own
|
|
23
|
+
# +state_dir+ are what ctx.sessions forks with, and +setup+ (the
|
|
24
|
+
# session's own SessionSetup), which a fork copies; +model_key+ is the
|
|
25
25
|
# model's memory overlay key (ctx.model, ctx.model_key).
|
|
26
26
|
# +steer+ takes (text, label), +stop_turn+ and +stop_generation+
|
|
27
27
|
# (reason, label), each true when it acted on a running turn.
|
|
28
28
|
# +frontend+ returns the Engine's interface (:repl, :worker,
|
|
29
29
|
# :non_interactive), which ctx.frontend names for plugins.
|
|
30
30
|
Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
|
|
31
|
-
:ask_model, :model_name, :model_key, :state_dir, :scratch, :
|
|
31
|
+
:ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :steer, :stop_turn, :stop_generation,
|
|
32
32
|
:frontend, keyword_init: true)
|
|
33
33
|
|
|
34
34
|
# ctx.ask_model failed: the model couldn't be reached, timed out, or
|