samagotchi 0.68.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 +42 -1
- data/docs/architecture.md +1 -1
- 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 +15 -8
- 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 +7 -1
- 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/engine.rb +5 -2
- 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/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/memory_bundle/manifest.rb +3 -0
- data/lib/samagotchi/model_price.rb +2 -4
- data/lib/samagotchi/session_metrics.rb +4 -1
- data/lib/samagotchi/system_prompt.rb +19 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +5 -2
- 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 +14 -9
- 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 +123 -22
- data/lib/samagotchi/web/public/chains.js +19 -2
- data/lib/samagotchi/web/public/families_view.js +15 -0
- data/lib/samagotchi/web/public/index.html +7 -5
- data/lib/samagotchi/web/public/model_picker.js +21 -6
- data/lib/samagotchi/web/public/session_state.js +5 -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
- metadata +2 -1
data/docs/testing.md
CHANGED
|
@@ -15,6 +15,11 @@ bug) or a broken spec: fix it, or disable that line with the reason (`# rubocop:
|
|
|
15
15
|
No spec reads your own setup. The suite points `XDG_CONFIG_HOME` and `XDG_STATE_HOME` at temp folders,
|
|
16
16
|
clears your `SAMAGOTCHI_*` environment, and keeps every spec off the network (WebMock).
|
|
17
17
|
|
|
18
|
+
Goldens (`*_golden_spec.rb`, the prompt snapshots) hold no machine state, so they pass on any runner: no absolute
|
|
19
|
+
paths (replace them with placeholders), no PATH tools (the suite pins `rg` as found, `spec_helper.rb`; an example
|
|
20
|
+
that sets PATH to test the lookup is tagged `:real_rg`), no Ruby-version-dependent `inspect` (a Hash's or an
|
|
21
|
+
exception's text changes between Ruby versions: write the string yourself).
|
|
22
|
+
|
|
18
23
|
The web specs run in plain node. One that loads lit-html, itself or through a module it imports (`turn_view.js`,
|
|
19
24
|
`turn_html.js`, `card.js`, `copy.js`, `images.js`, `scope.js`, `command_view.js`, `diff_view.js`, `read_view.js`,
|
|
20
25
|
`question_cards.js`, `stream_handlers.js`, `stage_view.js`, `bubbles.js`, `cards_place.js`, the chips, `info_bar.js`, `info_bar_model.js`, `lists_view.js`, `context_chips.js`, `families_view.js`,
|
|
@@ -49,7 +54,8 @@ script/affected-specs A..B # another range (A...B: from their merge-b
|
|
|
49
54
|
|
|
50
55
|
A builder runs these while working, not the full suite: the specs the change touched, the specs named after the
|
|
51
56
|
files it changed (`lib/samagotchi/foo/bar.rb` → `spec/foo/bar_spec.rb`, `bar_*_spec.rb`…), the specs that read
|
|
52
|
-
docs or source as text (its `RULES`: add one when a full run catches what the names missed)
|
|
57
|
+
docs or source as text (its `RULES`: add one when a full run catches what the names missed) and the golden of a changed
|
|
58
|
+
file's layer (any `terminal_ui/*.rb` → `spec/terminal_ui_golden_spec.rb`), then the other name
|
|
53
59
|
matches (a spec named after several changed files first, e.g. `engine_toolkit_spec.rb` for `engine.rb` and
|
|
54
60
|
`toolkit.rb`; then one beside the file, so `plugin/context.rb` picks `spec/samagotchi/plugin/*context*` before
|
|
55
61
|
`spec/guardrails/context_spec.rb`), then the specs that name a changed file's class. The default range is the merge-base with main (or origin/main) plus uncommitted and untracked files.
|
|
@@ -239,9 +239,7 @@ module Samagotchi
|
|
|
239
239
|
.fetch(uri, request, retries: false, check_status: false, log_fields: { purpose: "probe" })
|
|
240
240
|
end
|
|
241
241
|
|
|
242
|
-
def network_errors
|
|
243
|
-
[*LLM::HTTP::NETWORK_ERRORS, OpenSSL::SSL::SSLError, Errno::EADDRNOTAVAIL, Errno::ENETDOWN, Errno::EPIPE]
|
|
244
|
-
end
|
|
242
|
+
def network_errors = LLM::HTTP::NETWORK_ERRORS
|
|
245
243
|
|
|
246
244
|
def reason(error)
|
|
247
245
|
case error
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.69.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
|
|
6
6
|
files:
|
|
7
7
|
identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
|
|
8
|
-
self_map.md: sha256:
|
|
8
|
+
self_map.md: sha256:b5b3166c91f6f2af2563dd9db5f9959cda66747d907017acd0776fcd2fe29049
|
|
9
9
|
config_modification_protocol.md: sha256:c7decf27af999066195f83e71222a6ae294f13174ced9664a21cd1dabf8b854a
|
|
10
10
|
memory_guide.md: sha256:c48eea744db69e4f3e9bb415e9a03a8c78bbcaa1205ad3b1fb157a079f92d22e
|
|
11
11
|
delegated.md: sha256:a0d6ddcd4a8be06526d964898611775fd251cfd9d145a9d16afaccaa503597bd
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
commands (`anytime:` ones run mid-turn), model tools (they can return images I see), hooks,
|
|
51
51
|
cards, side answers (`ask_model`),
|
|
52
52
|
child sessions and services (e.g. MCP server processes). Shipped: `btw` (`/btw` side question),
|
|
53
|
-
`mcp` (MCP server tools, searched with find_tools and called with call_tool; a screenshot comes as a picture), `guardrails` (rules), `known-names` (typo guard), `source-links`
|
|
53
|
+
`mcp` (MCP server tools, searched with find_tools and called with call_tool; a screenshot comes as a picture; a toolkit's undeclared built-ins too, as chi/<tool>), `guardrails` (rules), `known-names` (typo guard), `source-links`
|
|
54
54
|
(turns source refs like JIRA-123 in an answer into links in the web, and a one-line note), `loop-guard`
|
|
55
55
|
(denies a repeated tool call with the same result, stops the turn after a few, and cuts thinking that
|
|
56
56
|
repeats itself, asking me again), `check-in` (after N tool
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -1058,25 +1058,6 @@ module Samagotchi
|
|
|
1058
1058
|
hosts.keys.map { |k| k.to_s.downcase }.include?(prefix) ? nil : prefix
|
|
1059
1059
|
end
|
|
1060
1060
|
|
|
1061
|
-
# hosts_config as a SAMAGOTCHI_HOSTS_JSON override (workers no longer
|
|
1062
|
-
# get one: they read config.yml).
|
|
1063
|
-
def hosts_json_for_env(env: ENV, path: global_path(env: env))
|
|
1064
|
-
hosts = hosts_config(env: env, path: path)
|
|
1065
|
-
# Only serialize if non-default or explicitly configured hosts:
|
|
1066
|
-
# include when hosts file exists with hosts: section or when workers need propagation
|
|
1067
|
-
return nil if hosts.nil? || hosts.empty?
|
|
1068
|
-
|
|
1069
|
-
# Every field travels (HostConfig#to_env_h); the worker parses it back.
|
|
1070
|
-
simple = hosts.transform_values(&:to_env_h)
|
|
1071
|
-
# Disabled hosts travel as just that, so a worker refuses "box:x"
|
|
1072
|
-
# the way its parent does instead of sending it to the default host.
|
|
1073
|
-
disabled_host_names(env: env, path: path).grep(HOST_NAME_RE).each { |name| simple[name] ||= { "enabled" => false } }
|
|
1074
|
-
JSON.generate(simple)
|
|
1075
|
-
rescue StandardError => e
|
|
1076
|
-
Log.error(:config, "hosts_json_failed", error: e.class.name, message: e.message)
|
|
1077
|
-
nil
|
|
1078
|
-
end
|
|
1079
|
-
|
|
1080
1061
|
# A top-level section of config.yml as written (nil when absent or the
|
|
1081
1062
|
# file doesn't parse); a reader checks its shape.
|
|
1082
1063
|
def section(name, env: ENV, path: global_path(env: env))
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -212,6 +212,10 @@ module Samagotchi
|
|
|
212
212
|
@lifecycle_mutex = Mutex.new
|
|
213
213
|
@shut_down = false
|
|
214
214
|
load_plugins if plugins
|
|
215
|
+
# Registered from what the sources published while they loaded, and
|
|
216
|
+
# with no plugins too: a toolkit's left-out built-ins are reachable
|
|
217
|
+
# either way (declared per toolkit, Tools::Registry).
|
|
218
|
+
@tool_index.declare(@tools, failures: @plugin_failures)
|
|
215
219
|
guardrail_rules
|
|
216
220
|
# The Engine owns the reminder store; its kernel (built here, or a
|
|
217
221
|
# spec's) gets it, so tool calls via KernelLoop and reminder injection
|
|
@@ -2497,8 +2501,6 @@ module Samagotchi
|
|
|
2497
2501
|
# start) waits for the first turn, beside the load warnings: no UI
|
|
2498
2502
|
# is there yet, and the Engine isn't built.
|
|
2499
2503
|
@extension_load.load_plugins(registries, failures: @plugin_failures)
|
|
2500
|
-
# Declared once, from what the sources published while they loaded.
|
|
2501
|
-
@tool_index.declare(@tools, failures: @plugin_failures)
|
|
2502
2504
|
end
|
|
2503
2505
|
|
|
2504
2506
|
# Keep a plugin's new tool set (chi.replace_tools, from any thread)
|
|
@@ -2547,6 +2549,7 @@ module Samagotchi
|
|
|
2547
2549
|
state_dir: -> { session_state_dir },
|
|
2548
2550
|
can_stage: -> { !turn_running? && !history? },
|
|
2549
2551
|
reset_prompt: -> { @prompt_builder&.reset! },
|
|
2552
|
+
refresh_index: -> { @tool_index.refresh! },
|
|
2550
2553
|
emit: ->(sink, event) { emit_event(sink, event) },
|
|
2551
2554
|
notify: ->(text, level, source, extra:) { hook_notify(text, level, source, extra: extra) }
|
|
2552
2555
|
)
|
|
@@ -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)
|
|
@@ -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
|
|
|
@@ -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).
|
|
@@ -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),
|
|
@@ -331,7 +331,9 @@ module Samagotchi
|
|
|
331
331
|
# The tools the session's toolkit leaves out, named once right after the
|
|
332
332
|
# base prompt: sessions on another toolkit already differ at the tools,
|
|
333
333
|
# and nothing later (a memory write) moves it. nil when none are (full):
|
|
334
|
-
# the prompt is what it was before toolkits.
|
|
334
|
+
# the prompt is what it was before toolkits. The ones call_tool reaches
|
|
335
|
+
# and the ones the toolkit removes (only the user brings those back)
|
|
336
|
+
# get a sentence each; with call_tool not declared, one list.
|
|
335
337
|
def not_loaded_line(layers)
|
|
336
338
|
registry = @tools_lookup.call
|
|
337
339
|
return nil unless registry.respond_to?(:left_out_names)
|
|
@@ -339,8 +341,22 @@ module Samagotchi
|
|
|
339
341
|
names = registry.left_out_names(layers: layers)
|
|
340
342
|
return nil if names.empty?
|
|
341
343
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
+
toolkit = "(toolkit #{registry.toolkit&.name})"
|
|
345
|
+
reachable = registry.reachable_names(layers: layers)
|
|
346
|
+
if reachable.empty?
|
|
347
|
+
return "Not loaded in this session #{toolkit}: #{names.join(", ")}. Only the user can load one (/toolkit add <name>)."
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
removed = names - reachable
|
|
351
|
+
# find_tools removed (call_tool still works): the route without it.
|
|
352
|
+
how = if removed.include?("find_tools") then "Call one with call_tool(tool: \"chi/<name>\", args: {…})."
|
|
353
|
+
else "Search them with find_tools; call one with call_tool."
|
|
354
|
+
end
|
|
355
|
+
line = "Not declared in this session #{toolkit}: #{reachable.join(", ")}. #{how}"
|
|
356
|
+
return line if removed.empty?
|
|
357
|
+
|
|
358
|
+
"#{line} Removed #{toolkit}: #{removed.join(", ")}. Only the user can load #{removed.size == 1 ? "it" : "one"} " \
|
|
359
|
+
"(/toolkit add <name>)."
|
|
344
360
|
end
|
|
345
361
|
|
|
346
362
|
def with_tail_declarations(prompt, chat:, layers:)
|
|
@@ -34,7 +34,8 @@ module Samagotchi
|
|
|
34
34
|
# What a tool line says the call did: the model's description of a
|
|
35
35
|
# command (": List the specs") in place of its cut params, else the
|
|
36
36
|
# params, dim; then, dim, the name the model called it by when that was
|
|
37
|
-
# an alias's (" (called as bash)")
|
|
37
|
+
# an alias's (" (called as bash)"), or the tool it was reached through
|
|
38
|
+
# (" (via call_tool)").
|
|
38
39
|
def tool_params_suffix(activity)
|
|
39
40
|
description = activity[:description].to_s.strip
|
|
40
41
|
params = activity[:params].to_s.strip
|
|
@@ -43,7 +44,9 @@ module Samagotchi
|
|
|
43
44
|
else ""
|
|
44
45
|
end
|
|
45
46
|
called_as = activity[:called_as].to_s
|
|
46
|
-
|
|
47
|
+
suffix = "#{suffix}#{paint(" (called as #{called_as})", 90)}" unless called_as.empty?
|
|
48
|
+
via = activity[:via].to_s
|
|
49
|
+
via.empty? ? suffix : "#{suffix}#{paint(" (via #{via})", 90)}"
|
|
47
50
|
end
|
|
48
51
|
|
|
49
52
|
# Green ok, yellow stopped (a wait the user's Stop ended), red the rest.
|
|
@@ -56,6 +56,9 @@ module Samagotchi
|
|
|
56
56
|
# The name the model used when an alias ran a built-in (called_as: "bash"
|
|
57
57
|
# for an execute), for a UI that shows it.
|
|
58
58
|
event[:called_as] = call[:called_as] if call.is_a?(Hash) && call[:called_as]
|
|
59
|
+
# The tool it was reached through (via: "call_tool", a built-in the
|
|
60
|
+
# toolkit doesn't declare): not an alias, the model named it.
|
|
61
|
+
event[:via] = call[:via] if call.is_a?(Hash) && call[:via]
|
|
59
62
|
# The model's own words for a command, which the TUI's tool line
|
|
60
63
|
# shows in place of the cut params.
|
|
61
64
|
description = command_description(tool_name, call)
|
|
@@ -23,7 +23,7 @@ module Samagotchi
|
|
|
23
23
|
# params and label stay where they are: their sources differ per path
|
|
24
24
|
# (the registry's preview, a plugin's label, the saved shown params).
|
|
25
25
|
module ToolRowFields
|
|
26
|
-
ACTIVITY_KEYS = %i[title called_as].freeze
|
|
26
|
+
ACTIVITY_KEYS = %i[title called_as via].freeze
|
|
27
27
|
EVENT_KEYS = %i[view ref refs].freeze
|
|
28
28
|
KEYS = (ACTIVITY_KEYS + EVENT_KEYS).freeze
|
|
29
29
|
|
|
@@ -33,13 +33,15 @@ module Samagotchi
|
|
|
33
33
|
# relative to it when inside it)
|
|
34
34
|
# @return [Hash] title (a few words: a project-relative path, a command
|
|
35
35
|
# without its "cd … &&"), called_as (the name the model called it by
|
|
36
|
-
# when that was an alias's: bash),
|
|
36
|
+
# when that was an alias's: bash), via (the tool it was reached
|
|
37
|
+
# through: call_tool), view (ToolView: the full command)
|
|
37
38
|
# and ref (FileRef: the file a file tool touched), each only when
|
|
38
39
|
# there is one
|
|
39
40
|
def for(tool_name, call, cwd:)
|
|
40
41
|
{
|
|
41
42
|
title: ToolActivity.tool_title(tool_name, call, cwd: cwd),
|
|
42
43
|
called_as: call[:called_as],
|
|
44
|
+
via: call[:via],
|
|
43
45
|
view: ToolView.for(tool_name, call)&.to_h,
|
|
44
46
|
ref: FileRef.for(tool_name, call, cwd: cwd)&.to_h
|
|
45
47
|
}.compact
|
|
@@ -47,6 +47,10 @@ module Samagotchi
|
|
|
47
47
|
# file (EditPreview.change; also on tool_call_completed, never in the
|
|
48
48
|
# model's output)
|
|
49
49
|
def run(call, iteration:, call_index:, call_count:, on_stream_event:, max_tool_output_chars:)
|
|
50
|
+
# A call_tool of one of chi's own tools runs as that tool, from here
|
|
51
|
+
# on (the gate, the row, the result): rules, approvals and hooks see
|
|
52
|
+
# the real call, with via: (Tools::ToolIndex#unwrap).
|
|
53
|
+
call = unwrapped(call)
|
|
50
54
|
params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
|
|
51
55
|
# The gate runs first, so tool_call_started shows the call that runs.
|
|
52
56
|
# A tool the session's toolkit leaves out skips it: no before_tool_call
|
|
@@ -60,7 +64,7 @@ module Samagotchi
|
|
|
60
64
|
started = { type: :tool_call_started, iteration: iteration, call_count: call_count, call_index: call_index,
|
|
61
65
|
tool: call[:name], call: call.dup, params: params }
|
|
62
66
|
started[:label] = label if label
|
|
63
|
-
# Its row fields (ToolRowFields: title, called_as, view, ref, and a
|
|
67
|
+
# Its row fields (ToolRowFields: title, called_as, via, view, ref, and a
|
|
64
68
|
# plugin tool's refs); the worker runs in its session's working
|
|
65
69
|
# directory.
|
|
66
70
|
fields = ToolRowFields.for(call[:name], call, cwd: Dir.pwd)
|
|
@@ -268,12 +272,29 @@ module Samagotchi
|
|
|
268
272
|
# The kernel's tools, for the activity line of a tool that isn't built in.
|
|
269
273
|
def tools = @kernel.tools
|
|
270
274
|
|
|
271
|
-
|
|
275
|
+
def layers = @kernel.respond_to?(:llm_context_layers) ? @kernel.llm_context_layers : []
|
|
276
|
+
|
|
277
|
+
# The call its tool's entry unwraps it to (call_tool on a built-in),
|
|
278
|
+
# else the call as it is. One that fails runs as it is: call_tool then
|
|
279
|
+
# refuses a built-in (fail closed).
|
|
280
|
+
def unwrapped(call)
|
|
281
|
+
registry = tools
|
|
282
|
+
return call unless registry.respond_to?(:offered) && !call[:name].nil?
|
|
283
|
+
|
|
284
|
+
registry.offered(call[:name], layers: layers)&.unwrap&.call(call, layers) || call
|
|
285
|
+
rescue StandardError => e
|
|
286
|
+
Log.warn(:turn, "tool_unwrap_failed", tool: call[:name].to_s, error: "#{e.class}: #{e.message}")
|
|
287
|
+
call
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# A call that can't run in this turn as it is, by its name: a real tool
|
|
291
|
+
# the session's toolkit leaves out, or one reached through call_tool
|
|
292
|
+
# (via:) that the toolkit doesn't reach that way (a hook's rewrite).
|
|
272
293
|
def left_out?(call)
|
|
273
294
|
registry = tools
|
|
274
295
|
return false unless registry.respond_to?(:left_out) && !call[:name].nil?
|
|
296
|
+
return registry.indexed(call[:name], layers: layers).nil? if call[:via]
|
|
275
297
|
|
|
276
|
-
layers = @kernel.respond_to?(:llm_context_layers) ? @kernel.llm_context_layers : []
|
|
277
298
|
!registry.left_out(call[:name], layers: layers).nil?
|
|
278
299
|
end
|
|
279
300
|
|