samagotchi 0.2.0 → 0.4.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 +198 -1
- data/README.md +56 -4
- data/bin/chi +118 -50
- data/docs/cli.md +184 -9
- data/docs/configuration.md +333 -47
- data/docs/desktop.md +45 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +208 -5
- data/docs/plugins.md +68 -2
- data/docs/releasing.md +23 -13
- data/docs/sessions.md +45 -17
- data/lib/samagotchi/answer_display.rb +95 -0
- data/lib/samagotchi/archive_store.rb +90 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
- data/lib/samagotchi/bootstrap/probe.rb +262 -0
- data/lib/samagotchi/bootstrap_command.rb +347 -0
- data/lib/samagotchi/bridge/pending_card.rb +89 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
- data/lib/samagotchi/bridge.rb +13 -1
- data/lib/samagotchi/bridge_client.rb +6 -2
- data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
- data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
- data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/client.rb +81 -19
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +252 -48
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
- data/lib/samagotchi/desktop/macos/Images.swift +113 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
- data/lib/samagotchi/desktop/macos.rb +59 -8
- data/lib/samagotchi/desktop_command.rb +6 -3
- data/lib/samagotchi/edit_preview.rb +82 -0
- data/lib/samagotchi/empty_answer_retry.rb +43 -0
- data/lib/samagotchi/engine.rb +434 -140
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +35 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +24 -5
- data/lib/samagotchi/host_registry.rb +9 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_recap.rb +5 -1
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +73 -94
- data/lib/samagotchi/live_versions.rb +59 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +132 -29
- data/lib/samagotchi/llm/errors.rb +41 -9
- data/lib/samagotchi/llm/http.rb +57 -17
- data/lib/samagotchi/llm/openai_chat.rb +17 -30
- data/lib/samagotchi/log_subscriber.rb +18 -3
- data/lib/samagotchi/memory_bundle/installer.rb +65 -63
- data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
- data/lib/samagotchi/memory_bundle/status.rb +4 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
- data/lib/samagotchi/model_profile.rb +24 -1
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/reply_wait.rb +126 -0
- data/lib/samagotchi/sampling_settings.rb +58 -0
- data/lib/samagotchi/self_report.rb +18 -3
- data/lib/samagotchi/send_command.rb +252 -11
- data/lib/samagotchi/session.rb +52 -11
- data/lib/samagotchi/session_archive_command.rb +107 -0
- data/lib/samagotchi/session_commands.rb +46 -7
- data/lib/samagotchi/session_manager.rb +115 -25
- data/lib/samagotchi/session_metrics.rb +222 -106
- data/lib/samagotchi/steer.rb +72 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
- data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
- data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
- data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui.rb +134 -247
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +115 -0
- data/lib/samagotchi/tool_activity.rb +3 -1
- data/lib/samagotchi/tool_runner.rb +34 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/builtins.rb +15 -4
- data/lib/samagotchi/tools/delegate_wait.rb +26 -69
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/execute.rb +52 -14
- data/lib/samagotchi/tools/task_runtime.rb +19 -0
- data/lib/samagotchi/tools/task_wait.rb +27 -3
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/turn_note.rb +60 -6
- data/lib/samagotchi/update_command.rb +308 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +7 -9
- data/lib/samagotchi/web/app.rb +91 -7
- data/lib/samagotchi/web/message_parts.rb +8 -3
- data/lib/samagotchi/web/public/activity.js +13 -1
- data/lib/samagotchi/web/public/annotate_presets.js +26 -0
- data/lib/samagotchi/web/public/annotations.js +13 -0
- data/lib/samagotchi/web/public/app.js +472 -111
- data/lib/samagotchi/web/public/card.js +5 -3
- data/lib/samagotchi/web/public/chat_view.js +13 -1
- data/lib/samagotchi/web/public/copy.js +20 -4
- data/lib/samagotchi/web/public/ctx.js +15 -0
- data/lib/samagotchi/web/public/data.js +23 -6
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/format.js +9 -0
- data/lib/samagotchi/web/public/index.html +60 -3
- data/lib/samagotchi/web/public/notify.js +175 -0
- data/lib/samagotchi/web/public/question_card.js +5 -2
- data/lib/samagotchi/web/public/sessions_list.js +7 -0
- data/lib/samagotchi/web/public/timing.js +39 -14
- data/lib/samagotchi/web/public/turn_events.js +75 -5
- data/lib/samagotchi/web/public/turn_view.js +49 -8
- data/lib/samagotchi/web/server.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +24 -1
- data/lib/samagotchi/worker.rb +16 -4
- metadata +31 -1
|
@@ -19,7 +19,8 @@ module Samagotchi
|
|
|
19
19
|
# (#kind): the UIs print one line per kind, and #retryable? says whether
|
|
20
20
|
# asking again could help.
|
|
21
21
|
#
|
|
22
|
-
# ConnectionError no answer (refused, reset, timeout); RetryExhausted
|
|
22
|
+
# ConnectionError no answer (refused, reset, timeout); RetryExhausted,
|
|
23
|
+
# ConnectionRefused
|
|
23
24
|
# RateLimited 429, with retry_after when the server says
|
|
24
25
|
# ServerError 5xx or a server's error event mid-stream
|
|
25
26
|
# AuthError 401/403, or an API key variable that is not set
|
|
@@ -51,14 +52,14 @@ module Samagotchi
|
|
|
51
52
|
# One line for the UIs, e.g. "auth failed for host fw: set FW_KEY".
|
|
52
53
|
def summary = "error from host #{host}: #{detail}"
|
|
53
54
|
|
|
54
|
-
private
|
|
55
|
-
|
|
56
|
-
def default_retryable? = false
|
|
57
|
-
|
|
58
55
|
# The message without its leading "host: ".
|
|
59
56
|
def detail
|
|
60
57
|
host && message.start_with?("#{host}: ") ? message.delete_prefix("#{host}: ") : message
|
|
61
58
|
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def default_retryable? = false
|
|
62
63
|
end
|
|
63
64
|
|
|
64
65
|
class ConnectionError < ProviderError
|
|
@@ -90,20 +91,29 @@ module Samagotchi
|
|
|
90
91
|
end
|
|
91
92
|
|
|
92
93
|
class AuthError < ProviderError
|
|
94
|
+
# What to try (LLM::ApiKey), added to the summary.
|
|
95
|
+
attr_accessor :hint
|
|
96
|
+
|
|
93
97
|
def kind = :auth
|
|
94
98
|
|
|
95
|
-
def summary = "auth failed for host #{host}: #{detail}"
|
|
99
|
+
def summary = "auth failed for host #{host}: #{detail}#{"; #{hint}" if hint}"
|
|
96
100
|
end
|
|
97
101
|
|
|
98
102
|
class BadRequest < ProviderError
|
|
99
103
|
TOOLS_HINT = "this model can't use tools, and chi needs them: pick another model (/model) or host"
|
|
100
104
|
|
|
101
|
-
|
|
105
|
+
# @param hint [String, nil] what to try, added to the summary
|
|
106
|
+
def initialize(message = nil, context_overflow: false, tools_unsupported: false, reasoning_refused: false, hint: nil,
|
|
107
|
+
**options)
|
|
102
108
|
@context_overflow = context_overflow
|
|
103
109
|
@tools_unsupported = tools_unsupported
|
|
110
|
+
@reasoning_refused = reasoning_refused
|
|
111
|
+
@hint = hint
|
|
104
112
|
super(message, **options)
|
|
105
113
|
end
|
|
106
114
|
|
|
115
|
+
attr_reader :hint
|
|
116
|
+
|
|
107
117
|
# The prompt is larger than the model's context window.
|
|
108
118
|
def context_overflow? = @context_overflow
|
|
109
119
|
|
|
@@ -111,6 +121,10 @@ module Samagotchi
|
|
|
111
121
|
# and chi always sends them.
|
|
112
122
|
def tools_unsupported? = @tools_unsupported
|
|
113
123
|
|
|
124
|
+
# A 400 about reasoning or thinking: the request's thinking fields may
|
|
125
|
+
# be what the host refuses (gpt-oss: "Reasoning is mandatory").
|
|
126
|
+
def reasoning_refused? = @reasoning_refused
|
|
127
|
+
|
|
114
128
|
def kind = :bad_request
|
|
115
129
|
|
|
116
130
|
def summary
|
|
@@ -119,7 +133,7 @@ module Samagotchi
|
|
|
119
133
|
elsif tools_unsupported?
|
|
120
134
|
"host #{host} rejected the request: #{tools_detail}; #{TOOLS_HINT}"
|
|
121
135
|
else
|
|
122
|
-
"host #{host} rejected the request: #{detail}"
|
|
136
|
+
"host #{host} rejected the request: #{detail}#{"; #{hint}" if hint}"
|
|
123
137
|
end
|
|
124
138
|
end
|
|
125
139
|
|
|
@@ -174,6 +188,19 @@ module Samagotchi
|
|
|
174
188
|
def summary = "network error after #{attempts} attempts (host #{host}: #{last_error.class})"
|
|
175
189
|
end
|
|
176
190
|
|
|
191
|
+
# A refused connection: nothing listens at the host's address. Not
|
|
192
|
+
# retried (a server that isn't running rarely starts within the backoff).
|
|
193
|
+
class ConnectionRefused < ConnectionError
|
|
194
|
+
attr_reader :address
|
|
195
|
+
|
|
196
|
+
def initialize(host:, address: nil, attempts: 1)
|
|
197
|
+
@address = address
|
|
198
|
+
super("#{host}: connection refused#{" at #{address}" if address}", host: host, retryable: false, attempts: attempts)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def summary = "can't reach host #{host}#{" at #{address}" if address} (connection refused) — is the server running?"
|
|
202
|
+
end
|
|
203
|
+
|
|
177
204
|
# A stream that showed nothing (no text, reasoning or tool call) within
|
|
178
205
|
# the host's first-token limit. A queued request can stay open for many
|
|
179
206
|
# minutes on keep-alive comments alone, which reset the read timeout.
|
|
@@ -209,6 +236,8 @@ module Samagotchi
|
|
|
209
236
|
TOOLS_UNSUPPORTED_RE = /support tool use|(does not|doesn't) support tools|tool choice requires --enable-auto-tool-choice/i
|
|
210
237
|
# A model or server that can't take images (see VisionUnsupported).
|
|
211
238
|
VISION_UNSUPPORTED_RE = /support image input|image input is not supported|mmproj/i
|
|
239
|
+
# A 400 about the thinking fields chi sent (Thinking.chat_fields).
|
|
240
|
+
REASONING_REFUSED_RE = /reasoning|thinking/i
|
|
212
241
|
RETRYABLE_SERVER_STATUSES = [500, 502, 503, 504, 529].freeze
|
|
213
242
|
|
|
214
243
|
module_function
|
|
@@ -230,7 +259,10 @@ module Samagotchi
|
|
|
230
259
|
when 401, 403 then AuthError.new(text, **options)
|
|
231
260
|
when 429 then RateLimited.new(text, retry_after: parse_retry_after(retry_after), **options)
|
|
232
261
|
when 408 then ServerError.new(text, retryable: true, **options)
|
|
233
|
-
when 400..499
|
|
262
|
+
when 400..499
|
|
263
|
+
tools = TOOLS_UNSUPPORTED_RE.match?(message)
|
|
264
|
+
BadRequest.new(text, tools_unsupported: tools,
|
|
265
|
+
reasoning_refused: status == 400 && !tools && REASONING_REFUSED_RE.match?(message), **options)
|
|
234
266
|
when 500..599
|
|
235
267
|
ServerError.new(text, retryable: RETRYABLE_SERVER_STATUSES.include?(status),
|
|
236
268
|
retry_after: parse_retry_after(retry_after), **options)
|
data/lib/samagotchi/llm/http.rb
CHANGED
|
@@ -6,6 +6,7 @@ require_relative "../config"
|
|
|
6
6
|
require_relative "../version"
|
|
7
7
|
require_relative "../log"
|
|
8
8
|
require_relative "errors"
|
|
9
|
+
require_relative "api_key"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
11
12
|
module LLM
|
|
@@ -101,8 +102,12 @@ module Samagotchi
|
|
|
101
102
|
# @param sleeper [#call] waits the given seconds (specs pass a no-op)
|
|
102
103
|
# @param first_token_timeout [Numeric, nil] seconds a stream may take to
|
|
103
104
|
# show something (see #stream_lines); nil: no limit
|
|
104
|
-
|
|
105
|
+
# @param api_key [ApiKey, nil] sent on every request; nil: no
|
|
106
|
+
# Authorization header (a local server)
|
|
107
|
+
def initialize(label:, open_timeout:, read_timeout:, retry_policy: nil, sleeper: nil, first_token_timeout: nil,
|
|
108
|
+
api_key: nil)
|
|
105
109
|
@label = label
|
|
110
|
+
@api_key = api_key
|
|
106
111
|
@open_timeout = open_timeout
|
|
107
112
|
@read_timeout = read_timeout
|
|
108
113
|
@first_token_timeout = first_token_timeout&.positive? ? first_token_timeout : nil
|
|
@@ -176,15 +181,18 @@ module Samagotchi
|
|
|
176
181
|
private :stream_attempt
|
|
177
182
|
|
|
178
183
|
# Send +request+ and return the response with its body read.
|
|
179
|
-
# @param retries [Boolean] false: one attempt, network errors raised as
|
|
184
|
+
# @param retries [Boolean] false: one attempt, network errors raised as
|
|
185
|
+
# is (Net::HTTP's own silent retry of an idempotent GET included)
|
|
180
186
|
# @param check_status [Boolean] false: return an error response instead
|
|
181
187
|
# of raising its ProviderError
|
|
188
|
+
# @raise [RequestCancelled] when +cancel_controller+ cancels (with or
|
|
189
|
+
# without retries)
|
|
182
190
|
def fetch(uri, request, retries: true, check_status: true, open_timeout: nil, read_timeout: nil,
|
|
183
191
|
cancel_controller: nil, log_fields: {})
|
|
184
192
|
identify(request)
|
|
185
193
|
current = new_attempt_state(uri, request, log_fields, stream: false)
|
|
186
194
|
attempt = lambda do |state|
|
|
187
|
-
start(uri, open_timeout: open_timeout, read_timeout: read_timeout) do |http|
|
|
195
|
+
start(uri, open_timeout: open_timeout, read_timeout: read_timeout, max_retries: retries ? nil : 0) do |http|
|
|
188
196
|
state[:http] = http
|
|
189
197
|
http.request(request).tap do |response|
|
|
190
198
|
state[:status] = response.code.to_i
|
|
@@ -193,20 +201,30 @@ module Samagotchi
|
|
|
193
201
|
end
|
|
194
202
|
end
|
|
195
203
|
logged(current) do
|
|
196
|
-
next
|
|
204
|
+
next with_retries(cancel_controller, nil, nil, current, &attempt) if retries
|
|
197
205
|
|
|
198
|
-
|
|
206
|
+
cancellable(cancel_controller, current) do
|
|
207
|
+
attempt.call(current.merge!(attempts: 1))
|
|
208
|
+
rescue StandardError => e
|
|
209
|
+
raise RequestCancelled.new(cancel_controller.reason) if !e.is_a?(RequestCancelled) && cancel_controller&.cancelled?
|
|
210
|
+
|
|
211
|
+
raise
|
|
212
|
+
end
|
|
199
213
|
end
|
|
200
214
|
end
|
|
201
215
|
|
|
202
216
|
private
|
|
203
217
|
|
|
218
|
+
# User-Agent, and the host's key when it has one (AuthError, before
|
|
219
|
+
# any request, when its variable is not set).
|
|
204
220
|
def identify(request)
|
|
205
221
|
request["User-Agent"] = Samagotchi::USER_AGENT
|
|
222
|
+
@api_key&.authorize(request)
|
|
206
223
|
end
|
|
207
224
|
|
|
208
|
-
def start(uri, open_timeout: nil, read_timeout: nil, &block)
|
|
225
|
+
def start(uri, open_timeout: nil, read_timeout: nil, max_retries: nil, &block)
|
|
209
226
|
options = { open_timeout: open_timeout || @open_timeout, read_timeout: read_timeout || @read_timeout }
|
|
227
|
+
options[:max_retries] = max_retries if max_retries
|
|
210
228
|
options[:use_ssl] = true if uri.scheme == "https"
|
|
211
229
|
Net::HTTP.start(uri.host, uri.port, **options, &block)
|
|
212
230
|
end
|
|
@@ -215,10 +233,29 @@ module Samagotchi
|
|
|
215
233
|
# errors. The block gets a hash to put the attempt's Net::HTTP in, so a
|
|
216
234
|
# cancel can reach its socket.
|
|
217
235
|
def with_retries(cancel_controller, on_retry, on_network_error, current = { mutex: Mutex.new })
|
|
236
|
+
cancellable(cancel_controller, current) do
|
|
237
|
+
retrying(cancel_controller, on_retry, on_network_error, current) { |state| yield(state) }
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Runs the block with +cancel_controller+ able to abort it: a cancel
|
|
242
|
+
# closes the socket in current[:http] (or raises RequestCancelled in
|
|
243
|
+
# this thread while there is none).
|
|
244
|
+
def cancellable(cancel_controller, current)
|
|
218
245
|
requesting_thread = Thread.current
|
|
219
246
|
listener_id = cancel_controller&.on_cancel { |reason| abort_request(current, requesting_thread, reason) }
|
|
220
247
|
raise RequestCancelled.new(cancel_controller.reason) if cancel_controller&.cancelled?
|
|
221
248
|
|
|
249
|
+
yield
|
|
250
|
+
ensure
|
|
251
|
+
# A cancel can call the listener after remove_listener returned (the
|
|
252
|
+
# controller calls listeners outside its lock); :done keeps it from
|
|
253
|
+
# raising into this thread once the request is over.
|
|
254
|
+
current[:mutex].synchronize { current[:done] = true }
|
|
255
|
+
cancel_controller&.remove_listener(listener_id)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def retrying(cancel_controller, on_retry, on_network_error, current)
|
|
222
259
|
attempts = 0
|
|
223
260
|
loop do
|
|
224
261
|
attempts += 1
|
|
@@ -243,6 +280,12 @@ module Samagotchi
|
|
|
243
280
|
raise unless self.class.network_error?(e)
|
|
244
281
|
|
|
245
282
|
on_network_error&.call(e)
|
|
283
|
+
# Nothing is listening: asking again rarely helps, so it fails
|
|
284
|
+
# at once and says what to check.
|
|
285
|
+
if e.is_a?(Errno::ECONNREFUSED)
|
|
286
|
+
raise ConnectionRefused.new(host: @label, address: current[:address], attempts: attempts)
|
|
287
|
+
end
|
|
288
|
+
|
|
246
289
|
delay = current[:streamed] ? nil : @retry_policy.delay_for(attempts)
|
|
247
290
|
raise RetryExhausted.new(attempts: attempts, last_error: e, label: @label) if delay.nil?
|
|
248
291
|
|
|
@@ -251,12 +294,6 @@ module Samagotchi
|
|
|
251
294
|
current.delete(:http)
|
|
252
295
|
end
|
|
253
296
|
end
|
|
254
|
-
ensure
|
|
255
|
-
# A cancel can call the listener after remove_listener returned (the
|
|
256
|
-
# controller calls listeners outside its lock); :done keeps it from
|
|
257
|
-
# raising into this thread once the request is over.
|
|
258
|
-
current[:mutex].synchronize { current[:done] = true }
|
|
259
|
-
cancel_controller&.remove_listener(listener_id)
|
|
260
297
|
end
|
|
261
298
|
|
|
262
299
|
# Runs one attempt (the block) under the first-token limit: a watchdog
|
|
@@ -302,7 +339,7 @@ module Samagotchi
|
|
|
302
339
|
# closes, status, timings) and what its log line says.
|
|
303
340
|
def new_attempt_state(uri, request, log_fields, stream:)
|
|
304
341
|
purpose = log_fields[:purpose]&.to_s
|
|
305
|
-
{ mutex: Mutex.new, started_at: monotonic_now, stream: stream,
|
|
342
|
+
{ mutex: Mutex.new, started_at: monotonic_now, stream: stream, address: "#{uri.host}:#{uri.port}",
|
|
306
343
|
level: LOGGED_PURPOSES.include?(purpose) ? :info : :debug,
|
|
307
344
|
log: { host: @label, method: request.method, url: log_url(uri),
|
|
308
345
|
**log_fields.compact } }
|
|
@@ -326,7 +363,7 @@ module Samagotchi
|
|
|
326
363
|
ms: elapsed_ms(current), attempts: retried(current))
|
|
327
364
|
result
|
|
328
365
|
rescue RequestCancelled => e
|
|
329
|
-
Log.
|
|
366
|
+
Log.public_send(current[:level], :http, "cancelled", **log_base(current), ms: elapsed_ms(current), reason: e.reason&.to_s)
|
|
330
367
|
raise
|
|
331
368
|
rescue StandardError => e
|
|
332
369
|
failure_level = current[:level] == :info ? :error : :debug
|
|
@@ -359,7 +396,8 @@ module Samagotchi
|
|
|
359
396
|
status: error.is_a?(ProviderError) ? error.status : nil, error: error.class.name,
|
|
360
397
|
msg: error.message.to_s[0, 300])
|
|
361
398
|
on_retry&.call(attempt: attempts, max_retries: @retry_policy.max, next_delay: delay,
|
|
362
|
-
error_class: error.class.name, error_message: error.message
|
|
399
|
+
error_class: error.class.name, error_message: error.message,
|
|
400
|
+
status: error.is_a?(ProviderError) ? error.status : nil)
|
|
363
401
|
wait(delay, cancel_controller)
|
|
364
402
|
end
|
|
365
403
|
|
|
@@ -367,8 +405,10 @@ module Samagotchi
|
|
|
367
405
|
status = response.code.to_i
|
|
368
406
|
return if status.between?(200, 299)
|
|
369
407
|
|
|
370
|
-
|
|
371
|
-
|
|
408
|
+
error = ProviderErrors.from_response(status: status, body: response.body.to_s, host: @label,
|
|
409
|
+
retry_after: response["Retry-After"])
|
|
410
|
+
error.hint ||= @api_key ? @api_key.hint : ApiKey.missing_hint(@label) if error.is_a?(AuthError)
|
|
411
|
+
raise error
|
|
372
412
|
end
|
|
373
413
|
|
|
374
414
|
def abort_request(current, requesting_thread, reason)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "uri"
|
|
5
5
|
require_relative "../config"
|
|
6
|
+
require_relative "../sampling_settings"
|
|
6
7
|
require_relative "errors"
|
|
7
8
|
require_relative "http"
|
|
8
9
|
require_relative "usage"
|
|
@@ -89,13 +90,13 @@ module Samagotchi
|
|
|
89
90
|
@host_name = host_name.to_s
|
|
90
91
|
@api_key_env = api_key_env
|
|
91
92
|
@stream = stream
|
|
92
|
-
@env = env
|
|
93
93
|
@models_ttl = models_ttl
|
|
94
94
|
@models_mutex = Mutex.new
|
|
95
95
|
open_timeout, read_timeout = timeouts(timeout)
|
|
96
96
|
policy = retry_policy || (retries ? nil : HTTP::RetryPolicy.none)
|
|
97
97
|
@http = HTTP.new(label: @host_name, open_timeout: open_timeout, read_timeout: read_timeout,
|
|
98
|
-
retry_policy: policy, sleeper: sleeper, first_token_timeout: first_token_timeout
|
|
98
|
+
retry_policy: policy, sleeper: sleeper, first_token_timeout: first_token_timeout,
|
|
99
|
+
api_key: ApiKey.for(api_key_env, host: @host_name, env: env))
|
|
99
100
|
end
|
|
100
101
|
|
|
101
102
|
# @param messages [Array<Hash>] wire messages (role, content, tool_calls,
|
|
@@ -111,9 +112,9 @@ module Samagotchi
|
|
|
111
112
|
# @return [ChatResponse]
|
|
112
113
|
def chat(messages:, model:, tools: [], cancel_controller: nil, on_delta: nil, on_retry: nil, options: {},
|
|
113
114
|
session_id: nil)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
log_fields = { model: model, purpose: @purpose }
|
|
115
|
+
body = request_body(messages, tools, model, options)
|
|
116
|
+
request = post_request("#{@base_url}/chat/completions", body, session_id: session_id)
|
|
117
|
+
log_fields = { model: model, purpose: @purpose, sampling: sampling_summary(body) }
|
|
117
118
|
return chat_once(request, cancel_controller, log_fields) unless @stream
|
|
118
119
|
|
|
119
120
|
assembly = Assembly.new
|
|
@@ -183,20 +184,13 @@ module Samagotchi
|
|
|
183
184
|
private
|
|
184
185
|
|
|
185
186
|
def timeouts(timeout)
|
|
186
|
-
open_timeout =
|
|
187
|
-
read_timeout =
|
|
187
|
+
open_timeout = Samagotchi::Config.positive_seconds("server.open_timeout")
|
|
188
|
+
read_timeout = Samagotchi::Config.positive_seconds("server.read_timeout")
|
|
188
189
|
return [open_timeout, read_timeout] unless timeout
|
|
189
190
|
|
|
190
191
|
[[open_timeout, timeout].min, timeout]
|
|
191
192
|
end
|
|
192
193
|
|
|
193
|
-
def config_integer(key, default)
|
|
194
|
-
value = Samagotchi::Config.get(key).to_i
|
|
195
|
-
value.positive? ? value : default
|
|
196
|
-
rescue StandardError
|
|
197
|
-
default
|
|
198
|
-
end
|
|
199
|
-
|
|
200
194
|
def request_body(messages, tools, model, options)
|
|
201
195
|
body = {
|
|
202
196
|
model: model,
|
|
@@ -209,7 +203,14 @@ module Samagotchi
|
|
|
209
203
|
body[:tools] = tools
|
|
210
204
|
body[:tool_choice] = "auto"
|
|
211
205
|
end
|
|
212
|
-
|
|
206
|
+
# A configured value replaces chi's own (temperature); nil drops it.
|
|
207
|
+
body.merge(options || {}).compact
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The body's fields beyond the conversation and the stream, for the
|
|
211
|
+
# log line: "temperature=0.6 presence_penalty=1.5".
|
|
212
|
+
def sampling_summary(body)
|
|
213
|
+
SamplingSettings.log_text(body.except(:model, :messages, :stream, :stream_options, :tools, :tool_choice))
|
|
213
214
|
end
|
|
214
215
|
|
|
215
216
|
# A String stays a String; an Array of parts passes as given.
|
|
@@ -278,25 +279,11 @@ module Samagotchi
|
|
|
278
279
|
Net::HTTP::Post.new(uri).tap do |request|
|
|
279
280
|
request["Content-Type"] = "application/json"
|
|
280
281
|
request["Session-Id"] = session_id.to_s unless session_id.to_s.empty?
|
|
281
|
-
authorize(request)
|
|
282
282
|
request.body = JSON.generate(body)
|
|
283
283
|
end
|
|
284
284
|
end
|
|
285
285
|
|
|
286
|
-
def get_request(uri)
|
|
287
|
-
Net::HTTP::Get.new(uri).tap { |request| authorize(request) }
|
|
288
|
-
end
|
|
289
|
-
|
|
290
|
-
def authorize(request)
|
|
291
|
-
return unless @api_key_env
|
|
292
|
-
|
|
293
|
-
key = @env[@api_key_env].to_s
|
|
294
|
-
if key.strip.empty?
|
|
295
|
-
raise AuthError.new("#{@host_name}: set #{@api_key_env} (the API key for host #{@host_name})",
|
|
296
|
-
host: @host_name)
|
|
297
|
-
end
|
|
298
|
-
request["Authorization"] = "Bearer #{key}"
|
|
299
|
-
end
|
|
286
|
+
def get_request(uri) = Net::HTTP::Get.new(uri)
|
|
300
287
|
|
|
301
288
|
def model_entries(body)
|
|
302
289
|
return [] unless body.is_a?(Hash)
|
|
@@ -27,8 +27,8 @@ module Samagotchi
|
|
|
27
27
|
question_requested: [],
|
|
28
28
|
question_answered: %i[id],
|
|
29
29
|
question_cancelled: %i[id reason],
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
generation_cancelled: %i[iteration],
|
|
31
|
+
empty_answer_retry: %i[iteration attempt of finish_reason thinking_chars]
|
|
32
32
|
}.freeze
|
|
33
33
|
|
|
34
34
|
# @param session_id [#call] the session the events are about (the
|
|
@@ -71,6 +71,15 @@ module Samagotchi
|
|
|
71
71
|
exhausted: summary[:exhausted] || nil, **origin(event))
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
+
# A plugin's steers by count and source, never their text.
|
|
75
|
+
def on_pending_input_merged(event)
|
|
76
|
+
steers = Array(event[:steers])
|
|
77
|
+
log(:info, :pending_input_merged, **event.slice(:iteration, :count),
|
|
78
|
+
steers: steers.empty? ? nil : steers.size,
|
|
79
|
+
steer_sources: steers.empty? ? nil : steers.map { |s| s[:source] }.uniq.join(","),
|
|
80
|
+
**origin(event))
|
|
81
|
+
end
|
|
82
|
+
|
|
74
83
|
def on_turn_canceled(event)
|
|
75
84
|
log(:info, :turn_canceled, ms: since(@turn_started_at), reason: event[:cancellation_reason], **origin(event))
|
|
76
85
|
end
|
|
@@ -92,7 +101,7 @@ module Samagotchi
|
|
|
92
101
|
ms: since(@generation_started_at.delete(event[:iteration])),
|
|
93
102
|
served_model: event[:served_model], requested_model: event[:requested_model],
|
|
94
103
|
content_length: event[:content_length],
|
|
95
|
-
thinking_chars: event[:thinking_chars])
|
|
104
|
+
thinking_chars: event[:thinking_chars], finish_reason: event[:finish_reason])
|
|
96
105
|
end
|
|
97
106
|
|
|
98
107
|
def on_generation_retrying(event)
|
|
@@ -125,6 +134,12 @@ module Samagotchi
|
|
|
125
134
|
log(level, :hook_notice, hook: event[:hook], msg: event[:text].to_s[0, 300])
|
|
126
135
|
end
|
|
127
136
|
|
|
137
|
+
# after_turn hooks presented the answer (AnswerDisplay): its size only
|
|
138
|
+
# (none for the display: nil that says nothing was presented).
|
|
139
|
+
def on_answer_display(event)
|
|
140
|
+
log(:info, :answer_display, chars: event[:display].to_s.length) if event[:display]
|
|
141
|
+
end
|
|
142
|
+
|
|
128
143
|
# A card shown to the user (Engine#show_card): whose, which, and its
|
|
129
144
|
# title (the body is the plugin's text; not logged).
|
|
130
145
|
def on_card(event)
|
|
@@ -295,79 +295,81 @@ module Samagotchi
|
|
|
295
295
|
|
|
296
296
|
warn_missing_needs(manifest) if manifest
|
|
297
297
|
|
|
298
|
-
# Write provenance (only if we had a manifest and not dry_run).
|
|
298
|
+
# Write provenance (only if we had a manifest and not dry_run). An
|
|
299
|
+
# upgrade with conflicts writes it too: the new version, the plugin's
|
|
300
|
+
# and hooks' new shas, and the old base for each conflicted file,
|
|
301
|
+
# marked conflict (chi bundle status shows it). Skipping it left the
|
|
302
|
+
# replaced plugin failing its sha check.
|
|
299
303
|
if manifest && !@dry_run
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
if
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
# We still need to include it to prevent pruning, but with base content
|
|
315
|
-
provenance_files[file_key] = base_path
|
|
316
|
-
else
|
|
317
|
-
provenance_files[file_key] = target_path
|
|
318
|
-
end
|
|
304
|
+
provenance_files = {}
|
|
305
|
+
if @upgrade && existing_provenance
|
|
306
|
+
all_files_in_bundle.each do |file_key|
|
|
307
|
+
target_path = File.join(target_dir, file_key)
|
|
308
|
+
next unless File.exist?(target_path)
|
|
309
|
+
status = @results[file_key] ? @results[file_key][:status].to_s : nil
|
|
310
|
+
if %w[kept noop conflict kept_pruned].include?(status)
|
|
311
|
+
# Keep old base snapshot — do not overwrite with edited current
|
|
312
|
+
# Use base snapshot as source if it exists to preserve old checksum
|
|
313
|
+
base_path = provenance.base_path(file_key)
|
|
314
|
+
if File.exist?(base_path)
|
|
315
|
+
# Keep existing provenance entry by re-using base content
|
|
316
|
+
# We still need to include it to prevent pruning, but with base content
|
|
317
|
+
provenance_files[file_key] = base_path
|
|
319
318
|
else
|
|
320
|
-
# installed, updated, skipped — use current target (which is incoming for updated)
|
|
321
319
|
provenance_files[file_key] = target_path
|
|
322
320
|
end
|
|
321
|
+
else
|
|
322
|
+
# installed, updated, skipped — use current target (which is incoming for updated)
|
|
323
|
+
provenance_files[file_key] = target_path
|
|
323
324
|
end
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
325
|
+
end
|
|
326
|
+
# Handle pruned but kept files (bundle removed file but local kept)
|
|
327
|
+
if existing_provenance[:files]
|
|
328
|
+
existing_provenance[:files].keys.each do |old_key|
|
|
329
|
+
old_key_str = old_key.to_s
|
|
330
|
+
next if all_files_in_bundle.include?(old_key_str)
|
|
331
|
+
# If it was kept_pruned, include it with base content to prevent pruning
|
|
332
|
+
if @results[old_key_str] && @results[old_key_str][:status].to_s == "kept_pruned"
|
|
333
|
+
base_path = provenance.base_path(old_key_str)
|
|
334
|
+
target_path = File.join(target_dir, old_key_str)
|
|
335
|
+
# Use base if exists to keep old checksum, else target
|
|
336
|
+
src = File.exist?(base_path) ? base_path : target_path
|
|
337
|
+
provenance_files[old_key_str] = src if File.exist?(src)
|
|
337
338
|
end
|
|
338
339
|
end
|
|
339
|
-
else
|
|
340
|
-
all_files_in_bundle.each do |file_key|
|
|
341
|
-
target_path = File.join(target_dir, file_key)
|
|
342
|
-
provenance_files[file_key] = target_path if File.exist?(target_path)
|
|
343
|
-
end
|
|
344
340
|
end
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
341
|
+
else
|
|
342
|
+
all_files_in_bundle.each do |file_key|
|
|
343
|
+
target_path = File.join(target_dir, file_key)
|
|
344
|
+
provenance_files[file_key] = target_path if File.exist?(target_path)
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
# Build hooks metadata for provenance
|
|
348
|
+
hooks_for_provenance = {}
|
|
349
|
+
if manifest && manifest.hooks && !manifest.hooks.empty?
|
|
350
|
+
hooks_for_provenance = manifest.hooks
|
|
351
|
+
elsif hooks_files_for_provenance.any?
|
|
352
|
+
hooks_files_for_provenance.each do |basename, path|
|
|
353
|
+
next unless File.exist?(path)
|
|
354
|
+
sha = Digest::SHA256.hexdigest(File.read(path))
|
|
355
|
+
hooks_for_provenance[basename] = { "sha256" => "sha256:#{sha}", "event" => "", "on_error" => "skip", "priority" => 100 }
|
|
355
356
|
end
|
|
356
|
-
Provenance.new(name: @name).write(
|
|
357
|
-
files: provenance_files,
|
|
358
|
-
scope: target_scope,
|
|
359
|
-
version: manifest.version,
|
|
360
|
-
source_path: @source,
|
|
361
|
-
hooks: hooks_for_provenance,
|
|
362
|
-
trust_level: manifest.respond_to?(:trust_level) ? manifest.trust_level : nil,
|
|
363
|
-
source_commit: source_commit,
|
|
364
|
-
hooks_files: hooks_files_for_provenance,
|
|
365
|
-
guardrails_files: guardrail_files_for_provenance,
|
|
366
|
-
plugin_file: plugin_file_for_provenance,
|
|
367
|
-
requires_chi: manifest.requires_chi,
|
|
368
|
-
needs: manifest.needs
|
|
369
|
-
)
|
|
370
357
|
end
|
|
358
|
+
Provenance.new(name: @name).write(
|
|
359
|
+
files: provenance_files,
|
|
360
|
+
scope: target_scope,
|
|
361
|
+
version: manifest.version,
|
|
362
|
+
source_path: @source,
|
|
363
|
+
hooks: hooks_for_provenance,
|
|
364
|
+
trust_level: manifest.respond_to?(:trust_level) ? manifest.trust_level : nil,
|
|
365
|
+
source_commit: source_commit,
|
|
366
|
+
hooks_files: hooks_files_for_provenance,
|
|
367
|
+
guardrails_files: guardrail_files_for_provenance,
|
|
368
|
+
plugin_file: plugin_file_for_provenance,
|
|
369
|
+
requires_chi: manifest.requires_chi,
|
|
370
|
+
needs: manifest.needs,
|
|
371
|
+
conflicts: @conflicts.keys
|
|
372
|
+
)
|
|
371
373
|
end
|
|
372
374
|
|
|
373
375
|
# Detect placeholders.
|