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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -1
  3. data/docs/architecture.md +1 -1
  4. data/docs/cli.md +6 -1
  5. data/docs/configuration.md +28 -6
  6. data/docs/guardrails.md +23 -1
  7. data/docs/hooks.md +4 -0
  8. data/docs/internals/bundles.md +1 -1
  9. data/docs/internals/engine.md +15 -8
  10. data/docs/internals/prompt-caching.md +9 -2
  11. data/docs/plugins.md +42 -19
  12. data/docs/releasing.md +14 -6
  13. data/docs/sessions.md +3 -3
  14. data/docs/testing.md +7 -1
  15. data/lib/samagotchi/bootstrap/probe.rb +1 -3
  16. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  18. data/lib/samagotchi/config.rb +0 -19
  19. data/lib/samagotchi/engine.rb +5 -2
  20. data/lib/samagotchi/guardrails/targets.rb +9 -2
  21. data/lib/samagotchi/host_config.rb +1 -29
  22. data/lib/samagotchi/host_model.rb +0 -7
  23. data/lib/samagotchi/kernel_loop.rb +24 -7
  24. data/lib/samagotchi/llm/errors.rb +47 -1
  25. data/lib/samagotchi/llm/http.rb +20 -2
  26. data/lib/samagotchi/llm_context_stale.rb +6 -0
  27. data/lib/samagotchi/memory_bundle/manifest.rb +3 -0
  28. data/lib/samagotchi/model_price.rb +2 -4
  29. data/lib/samagotchi/session_metrics.rb +4 -1
  30. data/lib/samagotchi/system_prompt.rb +19 -3
  31. data/lib/samagotchi/terminal_ui/formatting.rb +5 -2
  32. data/lib/samagotchi/tool_activity.rb +3 -0
  33. data/lib/samagotchi/tool_row_fields.rb +4 -2
  34. data/lib/samagotchi/tool_runner.rb +24 -3
  35. data/lib/samagotchi/toolkit.rb +27 -14
  36. data/lib/samagotchi/toolkit_binding.rb +8 -2
  37. data/lib/samagotchi/toolkit_command.rb +20 -4
  38. data/lib/samagotchi/tools/builtin_calls.rb +45 -1
  39. data/lib/samagotchi/tools/registry.rb +95 -8
  40. data/lib/samagotchi/tools/tool_index.rb +223 -72
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/web/app.rb +14 -9
  43. data/lib/samagotchi/web/message_parts.rb +5 -2
  44. data/lib/samagotchi/web/public/activity.js +3 -1
  45. data/lib/samagotchi/web/public/app.js +123 -22
  46. data/lib/samagotchi/web/public/chains.js +19 -2
  47. data/lib/samagotchi/web/public/families_view.js +15 -0
  48. data/lib/samagotchi/web/public/index.html +7 -5
  49. data/lib/samagotchi/web/public/model_picker.js +21 -6
  50. data/lib/samagotchi/web/public/session_state.js +5 -0
  51. data/lib/samagotchi/web/public/stream_handlers.js +5 -2
  52. data/lib/samagotchi/web/public/timing.js +3 -1
  53. data/lib/samagotchi/web/public/toast_place.js +16 -0
  54. data/lib/samagotchi/web/public/toolkit_chip.js +11 -4
  55. data/lib/samagotchi/web/public/turn_html.js +4 -2
  56. 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), then the other name
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.68.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:9528cc4c7d512a3fdfb103bf966a0359acad11c218666512d628a8d00378aaab
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
@@ -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))
@@ -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 then "bundle #{other.source}'s tool"
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). #to_env_h writes every field in the shape of the
14
- # SAMAGOTCHI_HOSTS_JSON override (ConfigFile.hosts_json_for_env), and
15
- # .parse reads it back to an equal HostConfig.
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 that only the user can load it.
1074
- def unknown_tool_error(name, layers)
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
- if @tools.respond_to?(:left_out) && @tools.left_out(name, layers: layers)
1077
- toolkit = @tools.toolkit&.name
1078
- "Error: #{name} isn't loaded in this session#{" (toolkit #{toolkit})" if toolkit} and can't be loaded from " \
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
- entry = @tools.offered(call[:name], layers: llm_context_layers)
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.
@@ -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::ETIMEDOUT
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: JSON can't carry them
19
- # (hosts_json_for_env would drop every host) and NaN poisons the sums.
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
- "Not loaded in this session (toolkit #{registry.toolkit&.name}): #{names.join(", ")}. " \
343
- "Only the user can load one (/toolkit add <name>)."
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
- called_as.empty? ? suffix : "#{suffix}#{paint(" (called as #{called_as})", 90)}"
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), view (ToolView: the full command)
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
- # A real tool the session's toolkit leaves out of this turn.
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