samagotchi 0.67.0 → 0.69.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 +62 -1
- data/docs/architecture.md +7 -4
- data/docs/cli.md +6 -1
- data/docs/configuration.md +28 -6
- data/docs/guardrails.md +23 -1
- data/docs/hooks.md +4 -0
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +73 -17
- data/docs/internals/prompt-caching.md +9 -2
- data/docs/plugins.md +42 -19
- data/docs/releasing.md +14 -6
- data/docs/sessions.md +3 -3
- data/docs/testing.md +8 -2
- data/lib/samagotchi/bootstrap/probe.rb +1 -3
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/config.rb +0 -19
- data/lib/samagotchi/effective_model.rb +283 -0
- data/lib/samagotchi/engine.rb +216 -360
- data/lib/samagotchi/guardrails/targets.rb +9 -2
- data/lib/samagotchi/host_config.rb +1 -29
- data/lib/samagotchi/host_model.rb +0 -7
- data/lib/samagotchi/kernel_loop.rb +24 -7
- data/lib/samagotchi/level_setting_command.rb +99 -0
- data/lib/samagotchi/llm/errors.rb +47 -1
- data/lib/samagotchi/llm/http.rb +20 -2
- data/lib/samagotchi/llm_context_stale.rb +6 -0
- data/lib/samagotchi/llm_context_strategy.rb +5 -10
- data/lib/samagotchi/memory_bundle/manifest.rb +3 -0
- data/lib/samagotchi/model_price.rb +2 -4
- data/lib/samagotchi/reasoning_history_command.rb +29 -90
- data/lib/samagotchi/session.rb +22 -8
- data/lib/samagotchi/session_metrics.rb +4 -1
- data/lib/samagotchi/session_setting.rb +86 -0
- data/lib/samagotchi/system_prompt.rb +19 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +5 -2
- data/lib/samagotchi/thinking_command.rb +24 -76
- data/lib/samagotchi/thinking_notices.rb +169 -0
- data/lib/samagotchi/tool_activity.rb +3 -0
- data/lib/samagotchi/tool_row_fields.rb +4 -2
- data/lib/samagotchi/tool_runner.rb +24 -3
- data/lib/samagotchi/toolkit.rb +27 -14
- data/lib/samagotchi/toolkit_binding.rb +8 -2
- data/lib/samagotchi/toolkit_command.rb +20 -4
- data/lib/samagotchi/tools/builtin_calls.rb +45 -1
- data/lib/samagotchi/tools/registry.rb +95 -8
- data/lib/samagotchi/tools/tool_index.rb +223 -72
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +69 -93
- data/lib/samagotchi/web/message_parts.rb +5 -2
- data/lib/samagotchi/web/public/activity.js +3 -1
- data/lib/samagotchi/web/public/app.js +196 -526
- data/lib/samagotchi/web/public/bubbles.js +291 -0
- data/lib/samagotchi/web/public/cards_place.js +184 -0
- data/lib/samagotchi/web/public/chains.js +19 -2
- data/lib/samagotchi/web/public/chip_popover.js +111 -0
- data/lib/samagotchi/web/public/command_list.js +12 -10
- data/lib/samagotchi/web/public/families_view.js +15 -0
- data/lib/samagotchi/web/public/index.html +21 -11
- data/lib/samagotchi/web/public/info_bar.js +3 -3
- data/lib/samagotchi/web/public/info_bar_model.js +110 -0
- data/lib/samagotchi/web/public/init_row.js +21 -10
- data/lib/samagotchi/web/public/level_chip.js +29 -80
- data/lib/samagotchi/web/public/lists_view.js +64 -9
- data/lib/samagotchi/web/public/llm_context_chip.js +36 -105
- data/lib/samagotchi/web/public/model_picker.js +47 -28
- data/lib/samagotchi/web/public/ref_scan.js +1 -7
- data/lib/samagotchi/web/public/refs.js +13 -0
- data/lib/samagotchi/web/public/session_state.js +5 -0
- data/lib/samagotchi/web/public/stage_frame.js +179 -0
- data/lib/samagotchi/web/public/stage_model.js +33 -0
- data/lib/samagotchi/web/public/stage_slots.js +130 -0
- data/lib/samagotchi/web/public/stage_view.js +56 -335
- data/lib/samagotchi/web/public/stage_watch.js +65 -0
- data/lib/samagotchi/web/public/stream_handlers.js +5 -2
- data/lib/samagotchi/web/public/timing.js +3 -1
- data/lib/samagotchi/web/public/toast_place.js +16 -0
- data/lib/samagotchi/web/public/toolkit_chip.js +11 -4
- data/lib/samagotchi/web/public/turn_html.js +4 -2
- data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
- data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
- data/lib/samagotchi/worker.rb +11 -16
- metadata +15 -1
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "../tools/tool_path"
|
|
4
4
|
require_relative "../tools/memory"
|
|
5
5
|
require_relative "../tool_declarations"
|
|
6
|
+
require_relative "../tools/tool_index"
|
|
6
7
|
require_relative "../log"
|
|
7
8
|
require_relative "model_size"
|
|
8
9
|
require_relative "outside"
|
|
@@ -147,14 +148,20 @@ module Samagotchi
|
|
|
147
148
|
|
|
148
149
|
# +name+ as the acts_as of +entry+'s calls: nil for none, and for a
|
|
149
150
|
# core tool or a tool another bundle registered (logged); the tool's
|
|
150
|
-
# own name is no alias either.
|
|
151
|
+
# own name is no alias either. chi's tool index (call_tool, source
|
|
152
|
+
# ToolIndex::SOURCE) skips the other-bundle check: its call_targets
|
|
153
|
+
# only name a tool one of its sources published (or a source-prefixed
|
|
154
|
+
# name, ToolIndex#claimed), so the registry is the wrong owner test,
|
|
155
|
+
# and a bundle tool sharing an MCP tool's name would otherwise take
|
|
156
|
+
# call_tool calls of that tool out of its mcp_* rules.
|
|
151
157
|
def self.acts_as_of(name, entry, registry)
|
|
152
158
|
name = name.to_s.strip
|
|
153
159
|
return nil if name.empty? || name == entry.name
|
|
154
160
|
|
|
155
161
|
other = registry[name]
|
|
156
162
|
whose = if CORE_TOOLS.include?(name) || other&.core? then "a core tool"
|
|
157
|
-
elsif other && other.source != entry.source
|
|
163
|
+
elsif other && other.source != entry.source && entry.source != Tools::ToolIndex::SOURCE
|
|
164
|
+
"bundle #{other.source}'s tool"
|
|
158
165
|
end
|
|
159
166
|
return name unless whose
|
|
160
167
|
|
|
@@ -10,9 +10,7 @@ require_relative "reasoning_history"
|
|
|
10
10
|
module Samagotchi
|
|
11
11
|
# One config.yml hosts: entry, parsed and checked (ConfigFile.hosts_config
|
|
12
12
|
# holds them by lowercased name; HostRegistry builds its HostEntry from
|
|
13
|
-
# one).
|
|
14
|
-
# SAMAGOTCHI_HOSTS_JSON override (ConfigFile.hosts_json_for_env), and
|
|
15
|
-
# .parse reads it back to an equal HostConfig.
|
|
13
|
+
# one).
|
|
16
14
|
#
|
|
17
15
|
# name: the lowercased entry name; host, port, scheme: where it is (from
|
|
18
16
|
# url: when given); url: the configured url, nil when host/port; transport,
|
|
@@ -179,31 +177,5 @@ module Samagotchi
|
|
|
179
177
|
nil
|
|
180
178
|
end
|
|
181
179
|
private_class_method :invalid
|
|
182
|
-
|
|
183
|
-
# The entry as a SAMAGOTCHI_HOSTS_JSON override carries it (string keys,
|
|
184
|
-
# unset fields left out); .parse(name, to_env_h) gives it back. A url
|
|
185
|
-
# entry travels as its url (host/port come from it); the API key stays
|
|
186
|
-
# in the environment.
|
|
187
|
-
# @return [Hash{String => Object}]
|
|
188
|
-
def to_env_h
|
|
189
|
-
location = url ? { "url" => url } : { "host" => host, "port" => port }
|
|
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,
|
|
192
|
-
"profile" => profile, "first_token_timeout" => first_token_timeout,
|
|
193
|
-
"vision" => vision, "sampling" => sampling, "thinking" => thinking&.to_s,
|
|
194
|
-
"reasoning_history" => reasoning_history&.to_s, "remote" => remote, "window_tokens" => window_tokens,
|
|
195
|
-
"steer_cut_after" => steer_cut_after,
|
|
196
|
-
"llm_context_strategy" => llm_context_strategy&.map(&:to_s),
|
|
197
|
-
"llm_context_apply" => llm_context_apply&.to_s,
|
|
198
|
-
"llm_context_budget_tokens" => llm_context_budget_tokens,
|
|
199
|
-
"models" => models_config).compact
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
private
|
|
203
|
-
|
|
204
|
-
# hosts.<name>.models as written back (HostModel#to_config), nil when none.
|
|
205
|
-
def models_config
|
|
206
|
-
models.values.to_h { |m| [m.id, m.to_config] } unless models.nil? || models.empty?
|
|
207
|
-
end
|
|
208
180
|
end
|
|
209
181
|
end
|
|
@@ -103,13 +103,6 @@ module Samagotchi
|
|
|
103
103
|
.map { |info| HostModel::Row.new(id: info.id.to_s, configured: false, info: info) }
|
|
104
104
|
end
|
|
105
105
|
end
|
|
106
|
-
|
|
107
|
-
# The entry as written back to config (HostConfig#to_env_h): nil for a
|
|
108
|
-
# bare id.
|
|
109
|
-
def to_config
|
|
110
|
-
config = { "price" => price&.to_config, "served" => served }.compact
|
|
111
|
-
config unless config.empty?
|
|
112
|
-
end
|
|
113
106
|
end
|
|
114
107
|
|
|
115
108
|
# A listing row (HostModel.rows): id as the host lists it, else as
|
|
@@ -21,6 +21,7 @@ require_relative "steer"
|
|
|
21
21
|
require_relative "reasoning_history"
|
|
22
22
|
require_relative "thought_stream_splitter"
|
|
23
23
|
require_relative "tools/builtins"
|
|
24
|
+
require_relative "tools/builtin_calls"
|
|
24
25
|
require_relative "muted_memories"
|
|
25
26
|
require_relative "tool_activity"
|
|
26
27
|
require_relative "tool_runner"
|
|
@@ -1070,12 +1071,22 @@ module Samagotchi
|
|
|
1070
1071
|
#
|
|
1071
1072
|
# A tool the session's toolkit leaves out is a real one, so its error
|
|
1072
1073
|
# does name it (deliberately, against the rule above): the model learns
|
|
1073
|
-
# it exists and
|
|
1074
|
-
|
|
1074
|
+
# it exists, and how to reach it (call_tool, when the toolkit reaches
|
|
1075
|
+
# it that way) or that only the user can load it. A call reached
|
|
1076
|
+
# through call_tool (+via+) of a tool the toolkit declares is told to
|
|
1077
|
+
# call it directly: the model's call_tool(tool: "chi/read") with read
|
|
1078
|
+
# declared, or a hook's rewrite (a direct call can't carry via:).
|
|
1079
|
+
def unknown_tool_error(name, layers, via: nil)
|
|
1075
1080
|
names = @tools.names(layers: layers)
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1081
|
+
toolkit = @tools.toolkit&.name
|
|
1082
|
+
in_toolkit = " in this session#{" (toolkit #{toolkit})" if toolkit}"
|
|
1083
|
+
if via && @tools.offered(name, layers: layers)
|
|
1084
|
+
"Error: #{name} is declared#{in_toolkit}; call it directly. Available: #{names.join(", ")}"
|
|
1085
|
+
elsif !via && @tools.respond_to?(:indexed) && @tools.indexed(name, layers: layers)
|
|
1086
|
+
"Error: #{name} isn't declared#{in_toolkit}; call it with #{Tools::BuiltinCalls::VIA}(tool: \"chi/#{name}\", args: {…}) " \
|
|
1087
|
+
"(find_tools gives its inputSchema). Available: #{names.join(", ")}"
|
|
1088
|
+
elsif @tools.respond_to?(:left_out) && @tools.left_out(name, layers: layers)
|
|
1089
|
+
"Error: #{name} isn't loaded#{in_toolkit} and can't be loaded from " \
|
|
1079
1090
|
"here; only the user can (/toolkit add #{name}). Available: #{names.join(", ")}"
|
|
1080
1091
|
elsif SHELL_LIKE_TOOL_NAMES.include?(name.to_s.downcase) && names.include?("execute")
|
|
1081
1092
|
"Error: no such tool. Shell commands run with `execute` (same arguments)."
|
|
@@ -1085,9 +1096,15 @@ module Samagotchi
|
|
|
1085
1096
|
end
|
|
1086
1097
|
|
|
1087
1098
|
def dispatch(call)
|
|
1088
|
-
|
|
1099
|
+
# A built-in reached through call_tool runs only while the toolkit
|
|
1100
|
+
# reaches it that way (Registry#indexed); any other call by its name.
|
|
1101
|
+
entry = if call[:via]
|
|
1102
|
+
@tools.respond_to?(:indexed) ? @tools.indexed(call[:name], layers: llm_context_layers) : nil
|
|
1103
|
+
else
|
|
1104
|
+
@tools.offered(call[:name], layers: llm_context_layers)
|
|
1105
|
+
end
|
|
1089
1106
|
unless entry
|
|
1090
|
-
result = unknown_tool_error(call[:name], llm_context_layers)
|
|
1107
|
+
result = unknown_tool_error(call[:name], llm_context_layers, via: call[:via])
|
|
1091
1108
|
return {
|
|
1092
1109
|
output: result,
|
|
1093
1110
|
activity: ToolActivity.tool_activity_event(call[:name], call, result, registry: @tools)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cache_confirm"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# A command for a session's own level of a setting (/thinking,
|
|
7
|
+
# /reasoning-history): with no argument, the level the next turn runs at
|
|
8
|
+
# and where it came from; with a level, sets the session's own (the
|
|
9
|
+
# setting's unset word unsets it). A change takes effect at the next
|
|
10
|
+
# turn's start (the REPL and the worker run it between turns), and the
|
|
11
|
+
# reply says what it costs the prompt cache. A costly change asks first
|
|
12
|
+
# (CacheConfirm); --yes (or -y) applies it.
|
|
13
|
+
#
|
|
14
|
+
# A subclass gives its Spec (#spec): what differs between the settings.
|
|
15
|
+
class LevelSettingCommand
|
|
16
|
+
# name: the command; label: the reply's ("thinking: low (session)");
|
|
17
|
+
# usage: the usage line an unknown word gets;
|
|
18
|
+
# parse: word → [known?, the session's own level (nil unsets)];
|
|
19
|
+
# own: engine → the session's own level now;
|
|
20
|
+
# explained: (engine, own) → SettingExplained, nil when it can't be resolved;
|
|
21
|
+
# set: (engine, own) → sets the session's own;
|
|
22
|
+
# cache_cost: (engine, from_level, to_level) → a cost_notes key, nil for none;
|
|
23
|
+
# cost_notes: {cost => the reply's line};
|
|
24
|
+
# follows: what the session follows with no own level;
|
|
25
|
+
# extra_line: engine → one more listing line, nil for none.
|
|
26
|
+
Spec = Data.define(:name, :label, :usage, :parse, :own, :explained, :set, :cache_cost, :cost_notes, :follows,
|
|
27
|
+
:extra_line)
|
|
28
|
+
|
|
29
|
+
# @param engine [Engine] the session's (#session, #history? and the spec's readers)
|
|
30
|
+
# @param save [#call] saves the session after a change
|
|
31
|
+
def initialize(engine:, save:)
|
|
32
|
+
@engine = engine
|
|
33
|
+
@save = save
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param args [String] what follows the command (--yes anywhere)
|
|
37
|
+
# @param confirm [Boolean] ask before a costly change (CacheConfirm);
|
|
38
|
+
# false applies it at once (chi send, a delegate, a start flag)
|
|
39
|
+
# @return [Array(String, Boolean, CacheConfirm::Confirm|nil)] the reply,
|
|
40
|
+
# whether the level changed, and the confirm it asks for (nothing
|
|
41
|
+
# changed then)
|
|
42
|
+
# @raise [ArgumentError] a word that isn't a level (with the usage)
|
|
43
|
+
def run(args, confirm: true)
|
|
44
|
+
word, yes = CacheConfirm.split(args)
|
|
45
|
+
return [listing(explained), false, nil] if word.empty?
|
|
46
|
+
|
|
47
|
+
known, own = spec.parse.call(word)
|
|
48
|
+
raise ArgumentError, "unknown #{spec.label} level #{word}\n#{spec.usage}" unless known
|
|
49
|
+
|
|
50
|
+
session = @engine.session or raise ArgumentError, "no session yet"
|
|
51
|
+
before = explained
|
|
52
|
+
ask = confirm && !yes ? ask(before, own, word) : nil
|
|
53
|
+
return ask if ask
|
|
54
|
+
|
|
55
|
+
spec.set.call(@engine, own)
|
|
56
|
+
@save.call(session)
|
|
57
|
+
after = explained
|
|
58
|
+
[[listing(after), *notes(before, after)].join("\n"), true, nil]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The level, where it came from, and what the session sets itself.
|
|
62
|
+
def listing(explained)
|
|
63
|
+
return "#{spec.label}: can't be resolved for this model" unless explained
|
|
64
|
+
|
|
65
|
+
lines = ["#{spec.label}: #{explained.label}"]
|
|
66
|
+
lines << " this session's own: none (follows #{spec.follows})" unless explained.own
|
|
67
|
+
extra = spec.extra_line&.call(@engine)
|
|
68
|
+
lines << extra if extra
|
|
69
|
+
lines.join("\n")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def spec = self.class::SPEC
|
|
75
|
+
|
|
76
|
+
def explained(own: spec.own.call(@engine)) = spec.explained.call(@engine, own)
|
|
77
|
+
|
|
78
|
+
# The reply that asks before a costly change, nil when it needn't.
|
|
79
|
+
def ask(before, own, word)
|
|
80
|
+
would = explained(own: own)
|
|
81
|
+
return nil unless before && would
|
|
82
|
+
|
|
83
|
+
cost = spec.cache_cost.call(@engine, before.level, would.level)
|
|
84
|
+
return nil unless CacheConfirm.needed?(cost: cost, history: @engine.history?, changed: would.level != before.level)
|
|
85
|
+
|
|
86
|
+
step = CacheConfirm.confirm(spec.name, word)
|
|
87
|
+
[[listing(before), "Switching to #{would.level} restarts the prompt cache: #{spec.cost_notes[cost]}", step.text].join("\n"),
|
|
88
|
+
false, step]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def notes(before, after)
|
|
92
|
+
return [] unless before && after
|
|
93
|
+
return ["(no change to what the next turn runs under)"] if before.level == after.level
|
|
94
|
+
|
|
95
|
+
cost = spec.cache_cost.call(@engine, before.level, after.level)
|
|
96
|
+
["From the next turn's start (a running turn keeps its own).", spec.cost_notes[cost]].compact
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -20,7 +20,8 @@ module Samagotchi
|
|
|
20
20
|
# asking again could help.
|
|
21
21
|
#
|
|
22
22
|
# ConnectionError no answer (refused, reset, timeout); RetryExhausted,
|
|
23
|
-
# ConnectionRefused
|
|
23
|
+
# ConnectionRefused, TLSError (a handshake that can't
|
|
24
|
+
# work: certificate, host name, http vs https)
|
|
24
25
|
# RateLimited 429, with retry_after when the server says
|
|
25
26
|
# CreditsHeld 402 "… in-flight requests": credit reserved by other
|
|
26
27
|
# requests; a RateLimited, retried after 20 s
|
|
@@ -271,6 +272,51 @@ module Samagotchi
|
|
|
271
272
|
def summary = "can't reach host #{host}#{" at #{address}" if address} (connection refused) — is the server running?"
|
|
272
273
|
end
|
|
273
274
|
|
|
275
|
+
# A TLS handshake that can't work however often it is tried: a
|
|
276
|
+
# certificate chi doesn't trust, a certificate for another name, or a
|
|
277
|
+
# server that doesn't speak TLS there (an https:// base_url on a plain
|
|
278
|
+
# HTTP port). Not retried. Any other OpenSSL::SSL::SSLError (an
|
|
279
|
+
# unexpected eof or a reset mid-handshake) is a network blip, retried.
|
|
280
|
+
class TLSError < ConnectionError
|
|
281
|
+
# problem => [pattern of the OpenSSL message, what to check]. The
|
|
282
|
+
# first match wins, so the host name comes before the general
|
|
283
|
+
# certificate check ("certificate verify failed (hostname mismatch)").
|
|
284
|
+
PROBLEMS = {
|
|
285
|
+
hostname: [/hostname mismatch|does not match the server certificate/i,
|
|
286
|
+
"the certificate is for another name: check the host's base_url"],
|
|
287
|
+
certificate: [/certificate verify failed/i,
|
|
288
|
+
"is its CA trusted? set SSL_CERT_FILE to a CA bundle that has it"],
|
|
289
|
+
scheme: [/wrong version number|packet length too long/i,
|
|
290
|
+
"the server doesn't answer TLS there: should the host's base_url start with http://?"]
|
|
291
|
+
}.freeze
|
|
292
|
+
|
|
293
|
+
attr_reader :address, :problem, :reason
|
|
294
|
+
|
|
295
|
+
# The permanent problem an OpenSSL::SSL::SSLError's +message+ names
|
|
296
|
+
# (:hostname, :certificate, :scheme), or nil for a transient one.
|
|
297
|
+
def self.problem_for(message)
|
|
298
|
+
PROBLEMS.find { |_problem, (pattern, _hint)| pattern.match?(message.to_s) }&.first
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# The OpenSSL reason without Ruby's "SSL_connect returned=1 errno=0
|
|
302
|
+
# peeraddr=… state=…: " prefix.
|
|
303
|
+
def self.reason_for(message)
|
|
304
|
+
message.to_s[/\bstate=[^:]*:\s*(.+)\z/m, 1] || message.to_s
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def initialize(host:, problem:, reason:, address: nil, attempts: 1)
|
|
308
|
+
@address = address
|
|
309
|
+
@problem = problem
|
|
310
|
+
@reason = reason
|
|
311
|
+
super("#{host}: TLS failed#{" at #{address}" if address}: #{reason}", host: host, retryable: false,
|
|
312
|
+
attempts: attempts)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def summary
|
|
316
|
+
"can't reach host #{host}#{" at #{address}" if address} over TLS (#{reason}) — #{PROBLEMS.fetch(problem).last}"
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
274
320
|
# A stream that showed nothing (no text, reasoning or tool call) within
|
|
275
321
|
# the host's first-token limit. A queued request can stay open for many
|
|
276
322
|
# minutes on keep-alive comments alone, which reset the read timeout.
|
data/lib/samagotchi/llm/http.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "net/http"
|
|
4
|
+
require "openssl"
|
|
4
5
|
require "uri"
|
|
5
6
|
require_relative "../config"
|
|
6
7
|
require_relative "../version"
|
|
@@ -31,9 +32,13 @@ module Samagotchi
|
|
|
31
32
|
# the user, who can try again later.
|
|
32
33
|
MAX_RETRY_AFTER = 60.0
|
|
33
34
|
|
|
35
|
+
# Errors that mean the request didn't get through (the probe treats
|
|
36
|
+
# them as "unreachable"). #retrying retries them, except a refused
|
|
37
|
+
# connection and a TLS error that can't work (.tls_problem).
|
|
34
38
|
NETWORK_ERRORS = [
|
|
35
|
-
Timeout::Error, EOFError, SocketError, IO::TimeoutError,
|
|
36
|
-
Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::
|
|
39
|
+
Timeout::Error, EOFError, SocketError, IO::TimeoutError, OpenSSL::SSL::SSLError,
|
|
40
|
+
Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::ENETDOWN,
|
|
41
|
+
Errno::ETIMEDOUT, Errno::EPIPE, Errno::EADDRNOTAVAIL
|
|
37
42
|
].freeze
|
|
38
43
|
|
|
39
44
|
# Requests of these purposes are the ones a turn waits on (or, for the
|
|
@@ -77,6 +82,13 @@ module Samagotchi
|
|
|
77
82
|
NETWORK_ERRORS.any? { |klass| error.is_a?(klass) }
|
|
78
83
|
end
|
|
79
84
|
|
|
85
|
+
# The permanent TLS problem +error+ is (TLSError::PROBLEMS: :certificate,
|
|
86
|
+
# :hostname, :scheme), or nil: not a TLS error, or a transient one
|
|
87
|
+
# (an unexpected eof or a reset mid-handshake) worth retrying.
|
|
88
|
+
def self.tls_problem(error)
|
|
89
|
+
error.is_a?(OpenSSL::SSL::SSLError) ? TLSError.problem_for(error.message) : nil
|
|
90
|
+
end
|
|
91
|
+
|
|
80
92
|
# The ProviderError an SSE line carries (llama.cpp's `error:` event, a
|
|
81
93
|
# `data:` line with an `error` object), or nil.
|
|
82
94
|
def self.sse_error(line, host:)
|
|
@@ -278,6 +290,12 @@ module Samagotchi
|
|
|
278
290
|
if e.is_a?(Errno::ECONNREFUSED)
|
|
279
291
|
raise ConnectionRefused.new(host: @label, address: current[:address], attempts: attempts)
|
|
280
292
|
end
|
|
293
|
+
# Nor is a handshake that can't work (an untrusted certificate,
|
|
294
|
+
# another name's, a plain HTTP server): it fails the same way.
|
|
295
|
+
if (problem = self.class.tls_problem(e))
|
|
296
|
+
raise TLSError.new(host: @label, address: current[:address], problem: problem,
|
|
297
|
+
reason: TLSError.reason_for(e.message), attempts: attempts)
|
|
298
|
+
end
|
|
281
299
|
|
|
282
300
|
delay = current[:streamed] ? nil : @retry_policy.delay_for(attempts)
|
|
283
301
|
raise RetryExhausted.new(attempts: attempts, last_error: e, label: @label) if delay.nil?
|
|
@@ -191,11 +191,17 @@ module Samagotchi
|
|
|
191
191
|
id = entry[:tool_call_id].to_s
|
|
192
192
|
matches = id.empty? ? [] : batch.select { |call_id, _| call_id.to_s == id }
|
|
193
193
|
call = matches.one? ? matches.first.last : nil
|
|
194
|
+
call &&= Tools::BuiltinCalls.unwrap_saved(call, entry[:content])
|
|
194
195
|
texts = call && refs.size == 1 ? ToolResponse.runs_named(entry[:content], [call[:name]]) : nil
|
|
195
196
|
return texts ? [[refs.first, call, texts.first.text]] : []
|
|
196
197
|
end
|
|
197
198
|
|
|
199
|
+
# A call_tool of a built-in ran as it: its run's lead names the
|
|
200
|
+
# built-in (BuiltinCalls.unwrap_saved).
|
|
198
201
|
calls = batch.map(&:last)
|
|
202
|
+
calls = calls.zip(ToolResponse.runs(entry[:content], calls.size)).map do |call, run|
|
|
203
|
+
Tools::BuiltinCalls.unwrap_saved(call, run&.text)
|
|
204
|
+
end
|
|
199
205
|
texts = ToolResponse.runs_named(entry[:content], calls.map { |call| call[:name] })
|
|
200
206
|
return [] unless texts && texts.size == refs.size
|
|
201
207
|
|
|
@@ -211,17 +211,12 @@ module Samagotchi
|
|
|
211
211
|
def for_session(session, registry: nil)
|
|
212
212
|
return nil if session.nil?
|
|
213
213
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
HostRegistry.new
|
|
219
|
-
end
|
|
220
|
-
name = session.model_name.to_s.strip.empty? ? ModelProfile.required_model_name(nil) : session.model_name
|
|
221
|
-
target = registry.resolve(name)
|
|
222
|
-
names = registry.lookup_names(session.model_typed || name, resolved: name, target: target)
|
|
214
|
+
# Here, not at the top: config.rb requires this file before ConfigFile
|
|
215
|
+
# exists, and effective_model.rb requires config.rb (via host_registry.rb).
|
|
216
|
+
require_relative "effective_model"
|
|
217
|
+
registry ||= HostRegistry.new
|
|
223
218
|
own = session.llm_context
|
|
224
|
-
|
|
219
|
+
EffectiveModel.of_session(session, registry: registry).llm_context_explained(own&.resolve_args || {})&.summary(own)
|
|
225
220
|
rescue StandardError
|
|
226
221
|
nil
|
|
227
222
|
end
|
|
@@ -24,6 +24,9 @@ module Samagotchi
|
|
|
24
24
|
# file: plugin.rb
|
|
25
25
|
# sha256: sha256:0a1b...
|
|
26
26
|
# requires_chi: ">= 0.1.28" # optional — a Gem::Requirement for chi's VERSION
|
|
27
|
+
# requires_chi_next: true # optional, shipped bundles — ignored here:
|
|
28
|
+
# # `rake release:bump` raises requires_chi to
|
|
29
|
+
# # the released chi (docs/releasing.md)
|
|
27
30
|
# needs: # optional — outside commands the memories rely on
|
|
28
31
|
# - command: gh # (checked on PATH, never installed; docs/memory.md)
|
|
29
32
|
# why: reads PRs # optional
|
|
@@ -15,8 +15,8 @@ module Samagotchi
|
|
|
15
15
|
|
|
16
16
|
values = raw.is_a?(Hash) ? raw.transform_keys(&:to_s).slice(*ModelPrice::KEYS) : nil
|
|
17
17
|
valid = values && values.key?("input") && values.key?("output") &&
|
|
18
|
-
# YAML's .inf and .nan are Floats too:
|
|
19
|
-
#
|
|
18
|
+
# YAML's .inf and .nan are Floats too: an infinite price
|
|
19
|
+
# isn't one, and NaN poisons the sums.
|
|
20
20
|
values.values.all? { |v| v.is_a?(Numeric) && v.finite? && !v.negative? }
|
|
21
21
|
unless valid
|
|
22
22
|
warn_once "Warning: #{where}.price needs input and output, each a number >= 0 (USD per 1M tokens); ignored"
|
|
@@ -45,8 +45,6 @@ module Samagotchi
|
|
|
45
45
|
((uncached * input) + (cached_tokens * cache_read) + (cache_write_tokens * cache_write) +
|
|
46
46
|
(completion_tokens * output)) / 1e6
|
|
47
47
|
end
|
|
48
|
-
|
|
49
|
-
def to_config = to_h.transform_keys(&:to_s)
|
|
50
48
|
end
|
|
51
49
|
|
|
52
50
|
# The keys a price may hold (config validation checks them too).
|
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
3
|
+
require_relative "level_setting_command"
|
|
4
4
|
require_relative "reasoning_history"
|
|
5
5
|
|
|
6
6
|
module Samagotchi
|
|
7
7
|
# /reasoning-history, the session's own reasoning_history level
|
|
8
|
-
# (Session#reasoning_history):
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# the
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
|
|
16
|
-
# --yes (or -y) applies it.
|
|
17
|
-
class ReasoningHistoryCommand
|
|
8
|
+
# (Session#reasoning_history): `/reasoning-history drop|turn|all` sets it
|
|
9
|
+
# (an explicit turn too), `/reasoning-history default` unsets it (the
|
|
10
|
+
# session follows SAMAGOTCHI_REASONING_HISTORY, the model, its host, then
|
|
11
|
+
# turn). Resolved on any host: a native one ignores the level (its loop
|
|
12
|
+
# keeps the running turn's reasoning), but the session keeps it for a
|
|
13
|
+
# chat model. A change that rewrites the earlier replies asks first. See
|
|
14
|
+
# LevelSettingCommand.
|
|
15
|
+
class ReasoningHistoryCommand < LevelSettingCommand
|
|
18
16
|
NAME = "/reasoning-history"
|
|
19
17
|
USAGE = "usage: #{NAME} [#{(ReasoningHistory::LEVELS + [ReasoningHistory::UNSET]).join("|")}]".freeze
|
|
20
18
|
NATIVE_NOTE = " (ignored on this host: the native loop keeps the running turn's reasoning)"
|
|
@@ -24,84 +22,25 @@ module Samagotchi
|
|
|
24
22
|
"from the first earlier thought."
|
|
25
23
|
}.freeze
|
|
26
24
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
level = ReasoningHistory.session_level(word)
|
|
48
|
-
raise ArgumentError, "unknown reasoning history level #{word}\n#{USAGE}" unless level || word == ReasoningHistory::UNSET
|
|
49
|
-
|
|
50
|
-
session = @engine.session or raise ArgumentError, "no session yet"
|
|
51
|
-
before = explained
|
|
52
|
-
ask = confirm && !yes ? ask(before, level, word) : nil
|
|
53
|
-
return ask if ask
|
|
54
|
-
|
|
55
|
-
@engine.reasoning_history_override = level
|
|
56
|
-
@save.call(session)
|
|
57
|
-
after = explained
|
|
58
|
-
[[listing(after), *notes(before, after)].join("\n"), true, nil]
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# The level, where it came from, and what the session sets itself.
|
|
62
|
-
def listing(explained)
|
|
63
|
-
return "reasoning history: can't be resolved for this model" unless explained
|
|
64
|
-
|
|
65
|
-
lines = ["reasoning history: #{explained.label}"]
|
|
66
|
-
unless explained.own
|
|
67
|
-
lines << " this session's own: none (follows #{ReasoningHistory::ENV_NAME}, the model, its host, then turn)"
|
|
68
|
-
end
|
|
69
|
-
lines << NATIVE_NOTE unless chat?
|
|
70
|
-
lines.join("\n")
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
private
|
|
74
|
-
|
|
75
|
-
# Resolved on any host: a native one ignores it, but /reasoning-history
|
|
76
|
-
# still shows what a chat model would get.
|
|
77
|
-
def explained(own: @engine.reasoning_history_override)
|
|
78
|
-
@engine.reasoning_history_explained(native: true, own: own)
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
# The reply that asks before a costly change, nil when it needn't.
|
|
82
|
-
def ask(before, own, word)
|
|
83
|
-
would = explained(own: own)
|
|
84
|
-
return nil unless before && would
|
|
85
|
-
|
|
86
|
-
cost = @engine.reasoning_history_cache_cost(before.level, would.level)
|
|
87
|
-
return nil unless CacheConfirm.needed?(cost: cost, history: @engine.history?, changed: would.level != before.level)
|
|
88
|
-
|
|
89
|
-
step = CacheConfirm.confirm(NAME, word)
|
|
90
|
-
[[listing(before), "Switching to #{would.level} restarts the prompt cache: #{COST_NOTES[cost]}", step.text].join("\n"), false, step]
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def chat?
|
|
94
|
-
@engine.chat_model?
|
|
95
|
-
rescue StandardError
|
|
96
|
-
false
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def notes(before, after)
|
|
100
|
-
return [] unless before && after
|
|
101
|
-
return ["(no change to what the next turn runs under)"] if before.level == after.level
|
|
102
|
-
|
|
103
|
-
cost = @engine.reasoning_history_cache_cost(before.level, after.level)
|
|
104
|
-
["From the next turn's start (a running turn keeps its own).", COST_NOTES[cost]].compact
|
|
105
|
-
end
|
|
25
|
+
SPEC = Spec.new(
|
|
26
|
+
name: NAME, label: "reasoning history", usage: USAGE, cost_notes: COST_NOTES,
|
|
27
|
+
parse: lambda { |word|
|
|
28
|
+
level = ReasoningHistory.session_level(word)
|
|
29
|
+
[!level.nil? || word == ReasoningHistory::UNSET, level]
|
|
30
|
+
},
|
|
31
|
+
own: lambda(&:reasoning_history_override),
|
|
32
|
+
explained: ->(engine, own) { engine.reasoning_history_explained(native: true, own: own) },
|
|
33
|
+
set: ->(engine, own) { engine.reasoning_history_override = own },
|
|
34
|
+
cache_cost: ->(engine, from, to) { engine.reasoning_history_cache_cost(from, to) },
|
|
35
|
+
follows: "#{ReasoningHistory::ENV_NAME}, the model, its host, then turn",
|
|
36
|
+
extra_line: lambda { |engine|
|
|
37
|
+
chat = begin
|
|
38
|
+
engine.chat_model?
|
|
39
|
+
rescue StandardError
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
chat ? nil : NATIVE_NOTE
|
|
43
|
+
}
|
|
44
|
+
)
|
|
106
45
|
end
|
|
107
46
|
end
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -259,6 +259,8 @@ module Samagotchi
|
|
|
259
259
|
self.interrupted_turn = interrupted_turn
|
|
260
260
|
@turn_checkpoint = nil
|
|
261
261
|
@archived = false
|
|
262
|
+
# #save's lock: one snapshot-and-write at a time (see #save).
|
|
263
|
+
@save_lock = Mutex.new
|
|
262
264
|
end
|
|
263
265
|
|
|
264
266
|
# A `delegate` child: its turns report to its parent (ChildReports). A
|
|
@@ -513,18 +515,30 @@ module Samagotchi
|
|
|
513
515
|
end
|
|
514
516
|
|
|
515
517
|
# Persist the session atomically. Updates +updated_at+ in place.
|
|
518
|
+
#
|
|
519
|
+
# One save at a time per Session: a worker saves its session from the
|
|
520
|
+
# loop/turn thread (TurnCheckpoint, Engine's turn end, WorkerInbound),
|
|
521
|
+
# from Bridge threads (QuestionDesk, commands run mid-turn) and from a
|
|
522
|
+
# signal's thread. AtomicFile keeps each write whole, but two
|
|
523
|
+
# unserialized saves could rename an older snapshot over a newer one (a
|
|
524
|
+
# lost update); under the lock the last rename is the latest snapshot.
|
|
525
|
+
# Never call it from a trap handler (Mutex#lock raises ThreadError
|
|
526
|
+
# there; WorkerSignals saves on a thread of its own) or from inside a
|
|
527
|
+
# save (the lock isn't re-entrant).
|
|
516
528
|
def save(state_dir: self.class.default_state_dir)
|
|
517
|
-
@
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
529
|
+
@save_lock.synchronize do
|
|
530
|
+
@updated_at = Time.now.iso8601(3)
|
|
531
|
+
# Persist with current metadata version so new flag is written
|
|
532
|
+
@metadata_version = METADATA_VERSION
|
|
533
|
+
FileUtils.mkdir_p(state_dir)
|
|
521
534
|
|
|
522
|
-
|
|
535
|
+
path = self.class.session_file(@id, state_dir: state_dir)
|
|
523
536
|
|
|
524
|
-
|
|
525
|
-
|
|
537
|
+
# Auto-compute first_preview if not yet cached and messages contain a user entry.
|
|
538
|
+
compute_first_preview!
|
|
526
539
|
|
|
527
|
-
|
|
540
|
+
AtomicFile.write(path, JSON.pretty_generate(to_h) + "\n")
|
|
541
|
+
end
|
|
528
542
|
self
|
|
529
543
|
end
|
|
530
544
|
|
|
@@ -852,6 +852,9 @@ module Samagotchi
|
|
|
852
852
|
|
|
853
853
|
called_as = event.dig(:activity, :called_as)
|
|
854
854
|
active[:called_as] = called_as.to_s if called_as
|
|
855
|
+
# Reached through call_tool: the reloaded row says so (not an alias).
|
|
856
|
+
via = event.dig(:activity, :via)
|
|
857
|
+
active[:via] = via.to_s if via
|
|
855
858
|
# A grep that found nothing (ok): the reloaded web row says "no match".
|
|
856
859
|
active[:no_match] = true if event.dig(:activity, :no_match)
|
|
857
860
|
finish_tool_record(active, status.empty? ? "ok" : status, waited_ms: event[:waited_ms].to_i)
|
|
@@ -861,7 +864,7 @@ module Samagotchi
|
|
|
861
864
|
# Caller holds the mutex.
|
|
862
865
|
def finish_tool_record(active, status, waited_ms: 0)
|
|
863
866
|
record = active.slice(
|
|
864
|
-
:id, :turn_id, :iteration, :call_index, :tool, :called_as, :no_match, :started_at
|
|
867
|
+
:id, :turn_id, :iteration, :call_index, :tool, :called_as, :via, :no_match, :started_at
|
|
865
868
|
).merge(
|
|
866
869
|
status: status,
|
|
867
870
|
completed_at: now.iso8601(3),
|