samagotchi 0.78.0 → 0.80.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 +44 -1
- data/README.md +7 -5
- data/docs/cli.md +24 -7
- data/docs/configuration.md +11 -6
- data/docs/internals/anthropic.md +12 -5
- data/docs/internals/bundles.md +4 -0
- data/docs/internals/engine.md +15 -5
- data/docs/plugins.md +144 -3
- data/docs/releasing.md +2 -2
- data/docs/sessions.md +1 -1
- data/docs/testing.md +32 -2
- data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
- data/lib/samagotchi/bootstrap/probe.rb +153 -31
- data/lib/samagotchi/bootstrap_command.rb +92 -15
- data/lib/samagotchi/bridge.rb +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/continue_offer.rb +5 -5
- data/lib/samagotchi/engine.rb +40 -37
- data/lib/samagotchi/guardrails/approval.rb +6 -4
- data/lib/samagotchi/guardrails/gate.rb +7 -3
- data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
- data/lib/samagotchi/hooks/registry.rb +44 -3
- data/lib/samagotchi/host_registry.rb +12 -4
- data/lib/samagotchi/llm/adapter_common.rb +8 -4
- data/lib/samagotchi/llm/anthropic_binding_recovery.rb +198 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +39 -178
- data/lib/samagotchi/llm/anthropic_wire.rb +7 -1
- data/lib/samagotchi/llm/chat_loop.rb +21 -12
- data/lib/samagotchi/llm/http.rb +12 -7
- data/lib/samagotchi/llm/model_listing.rb +29 -1
- data/lib/samagotchi/llm/refusal_memo.rb +3 -2
- data/lib/samagotchi/plugin/card.rb +44 -0
- data/lib/samagotchi/plugin/context.rb +53 -8
- data/lib/samagotchi/plugin/http.rb +154 -0
- data/lib/samagotchi/plugin/http_transport.rb +77 -0
- data/lib/samagotchi/plugin/loader.rb +29 -10
- data/lib/samagotchi/question_answer.rb +29 -0
- data/lib/samagotchi/question_desk.rb +30 -22
- data/lib/samagotchi/reasoning_history.rb +2 -1
- data/lib/samagotchi/relay_verifier.rb +1 -1
- data/lib/samagotchi/testing/boundary.rb +156 -0
- data/lib/samagotchi/testing/bundle.rb +349 -0
- data/lib/samagotchi/testing/fake_executable.rb +42 -0
- data/lib/samagotchi/testing/fake_http.rb +137 -0
- data/lib/samagotchi/testing/helpers.rb +56 -0
- data/lib/samagotchi/testing/host.rb +76 -0
- data/lib/samagotchi/testing/minitest.rb +31 -0
- data/lib/samagotchi/testing/recorder.rb +138 -0
- data/lib/samagotchi/testing/recording_api.rb +27 -0
- data/lib/samagotchi/testing/rspec.rb +31 -0
- data/lib/samagotchi/testing/sandbox.rb +163 -0
- data/lib/samagotchi/testing/sessions.rb +77 -0
- data/lib/samagotchi/testing/verdict.rb +28 -0
- data/lib/samagotchi/testing/waiting.rb +36 -0
- data/lib/samagotchi/testing.rb +26 -0
- data/lib/samagotchi/tools/delegate_relay.rb +7 -6
- data/lib/samagotchi/tools/tool_index.rb +3 -0
- data/lib/samagotchi/turn_state.rb +28 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +79 -245
- data/lib/samagotchi/web/public/composer_grip.js +231 -0
- data/lib/samagotchi/web/public/images.js +33 -0
- data/lib/samagotchi/web/public/index.html +73 -40
- data/lib/samagotchi/web/public/level_chip.js +11 -11
- data/lib/samagotchi/web/public/lists_view.js +19 -14
- data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
- data/lib/samagotchi/web/public/model_picker.js +5 -6
- data/lib/samagotchi/web/public/scope.js +3 -3
- data/lib/samagotchi/web/public/stage_view.js +3 -2
- data/lib/samagotchi/web/public/storage.js +23 -0
- data/lib/samagotchi/web/public/strip.js +35 -9
- data/lib/samagotchi/web/public/strip_grip.js +76 -0
- data/lib/samagotchi/web/public/thinking_chip.js +5 -5
- data/lib/samagotchi/web/public/toasts.js +5 -4
- metadata +24 -1
|
@@ -32,8 +32,10 @@ module Samagotchi
|
|
|
32
32
|
TOOL_ID_INVALID = /[^a-zA-Z0-9_-]/
|
|
33
33
|
# What Messages takes as a tool name.
|
|
34
34
|
TOOL_NAME = /\A[a-zA-Z0-9_-]{1,128}\z/
|
|
35
|
+
# The content block types a model's thinking is replayed in.
|
|
36
|
+
THINKING_TYPES = %w[thinking redacted_thinking].freeze
|
|
35
37
|
# Blocks that can't carry a cache_control breakpoint.
|
|
36
|
-
UNMARKABLE =
|
|
38
|
+
UNMARKABLE = THINKING_TYPES
|
|
37
39
|
|
|
38
40
|
# A request's system blocks and messages.
|
|
39
41
|
Translated = Data.define(:system, :messages)
|
|
@@ -105,6 +107,10 @@ module Samagotchi
|
|
|
105
107
|
end
|
|
106
108
|
end
|
|
107
109
|
|
|
110
|
+
# Whether +block+ is a thinking or redacted_thinking block (a Hash,
|
|
111
|
+
# string or symbol keys).
|
|
112
|
+
def thinking_block?(block) = block.is_a?(Hash) && THINKING_TYPES.include?(type_of(block))
|
|
113
|
+
|
|
108
114
|
# +id+ as Messages takes it (^[a-zA-Z0-9_-]+$), the same in tool_use
|
|
109
115
|
# and tool_result: a valid id as it is, another with its invalid
|
|
110
116
|
# characters replaced and a short hash of it appended (so "a.b" and
|
|
@@ -342,15 +342,6 @@ module Samagotchi
|
|
|
342
342
|
nil
|
|
343
343
|
end
|
|
344
344
|
|
|
345
|
-
# The shape the engine persists: role and content (parts stay an
|
|
346
|
-
# Array), plus a model turn's tool_calls and thinking (the host's
|
|
347
|
-
# reasoning, sent back as #wire_messages says), a result's tool_call_id, the image
|
|
348
|
-
# refs of a user message or a tool result, a prompt's turn_id (Engine,
|
|
349
|
-
# never sent) and a wake turn's report's turn_start (Steer, never sent), and a plugin tool result's tool_params and tool_labels
|
|
350
|
-
# (the live row's params line and label, never sent back), an
|
|
351
|
-
# edit/write result's tool_diffs (never sent), an empty-answer
|
|
352
|
-
# note's marker (TurnNote.empty, never sent), and an answer's display
|
|
353
|
-
# and display_refs (AnswerDisplay, AnswerRefs; never sent).
|
|
354
345
|
# Removes the replayed thinking blocks a response's ThinkingDrop
|
|
355
346
|
# names (AnthropicWire.block_identity) from the model entries'
|
|
356
347
|
# anthropic_content: the edit the provider made for that request, so
|
|
@@ -375,6 +366,15 @@ module Samagotchi
|
|
|
375
366
|
removed
|
|
376
367
|
end
|
|
377
368
|
|
|
369
|
+
# The shape the engine persists: role and content (parts stay an
|
|
370
|
+
# Array), plus a model turn's tool_calls and thinking (the host's
|
|
371
|
+
# reasoning, sent back as #wire_messages says), a result's tool_call_id, the image
|
|
372
|
+
# refs of a user message or a tool result, a prompt's turn_id (Engine,
|
|
373
|
+
# never sent) and a wake turn's report's turn_start (Steer, never sent), and a plugin tool result's tool_params and tool_labels
|
|
374
|
+
# (the live row's params line and label, never sent back), an
|
|
375
|
+
# edit/write result's tool_diffs (never sent), an empty-answer
|
|
376
|
+
# note's marker (TurnNote.empty, never sent), and an answer's display
|
|
377
|
+
# and display_refs (AnswerDisplay, AnswerRefs; never sent).
|
|
378
378
|
def plain(conversation)
|
|
379
379
|
conversation.map do |entry|
|
|
380
380
|
content = entry[:content].is_a?(Array) ? entry[:content] : entry[:content].to_s
|
|
@@ -471,8 +471,6 @@ module Samagotchi
|
|
|
471
471
|
|
|
472
472
|
def thought?(entry) = entry[:thinking].is_a?(String) && !entry[:thinking].strip.empty?
|
|
473
473
|
|
|
474
|
-
THINKING_BLOCKS = %w[thinking redacted_thinking].freeze
|
|
475
|
-
|
|
476
474
|
# A model entry's saved blocks (anthropic_content) to send back
|
|
477
475
|
# verbatim, for an adapter that replays them: nil when there are none,
|
|
478
476
|
# for an interrupted entry (its partial blocks have no signature), and
|
|
@@ -485,7 +483,7 @@ module Samagotchi
|
|
|
485
483
|
return nil unless replays_content? && blocks.is_a?(Array) && !blocks.empty? && !entry[:interrupted]
|
|
486
484
|
return nil unless Array(entry[:tool_calls]).all? { |call| paired.include?(call[:id]) }
|
|
487
485
|
|
|
488
|
-
blocks = blocks.reject { |block|
|
|
486
|
+
blocks = blocks.reject { |block| AnthropicWire.thinking_block?(block) } unless keep_thinking
|
|
489
487
|
blocks.empty? ? nil : blocks
|
|
490
488
|
end
|
|
491
489
|
|
|
@@ -519,6 +517,17 @@ module Samagotchi
|
|
|
519
517
|
# The empty generations, never saved: the UIs draw the turn's
|
|
520
518
|
# steps from them (ModelResult#empty_steps).
|
|
521
519
|
@empty_steps = []
|
|
520
|
+
# Set as the run goes: the last response's refusal, the context
|
|
521
|
+
# window the last request was sized for, that request's text and
|
|
522
|
+
# image estimates, whether a refused reasoning is being asked
|
|
523
|
+
# again without it, and whether a preserved-thinking recovery
|
|
524
|
+
# settled nothing once already.
|
|
525
|
+
@refusal = nil
|
|
526
|
+
@window = nil
|
|
527
|
+
@request_chars = nil
|
|
528
|
+
@request_image_tokens = nil
|
|
529
|
+
@without_reasoning = false
|
|
530
|
+
@recovery_repeated = false
|
|
522
531
|
@context = ContextStatus.new(conversation: conversation, llm_context: loop.llm_context)
|
|
523
532
|
loop.llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: @context,
|
|
524
533
|
emit: method(:emit))
|
data/lib/samagotchi/llm/http.rb
CHANGED
|
@@ -43,8 +43,9 @@ module Samagotchi
|
|
|
43
43
|
|
|
44
44
|
# Requests of these purposes are the ones a turn waits on (or, for the
|
|
45
45
|
# turn-end warm-up, prepares the next one): INFO lines (and ERROR when
|
|
46
|
-
# they fail)
|
|
47
|
-
|
|
46
|
+
# they fail); so are a plugin's ctx.http calls ("plugin"). Probes and
|
|
47
|
+
# model lists are DEBUG.
|
|
48
|
+
LOGGED_PURPOSES = %w[chat recap warmup plugin].freeze
|
|
48
49
|
|
|
49
50
|
# Exponential backoff: base_delay * 2^(attempt-1), capped at max_delay,
|
|
50
51
|
# for up to +max+ retries (max + 1 attempts).
|
|
@@ -103,10 +104,13 @@ module Samagotchi
|
|
|
103
104
|
# show something (see #stream_lines); nil: no limit
|
|
104
105
|
# @param api_key [ApiKey, nil] sent on every request; nil: no
|
|
105
106
|
# Authorization header (a local server)
|
|
107
|
+
# @param diagnosis_header [String, nil] a response header the adapter
|
|
108
|
+
# wants kept on a ProviderError as its diagnosis; nil: none
|
|
106
109
|
def initialize(label:, open_timeout:, read_timeout:, retry_policy: nil, sleeper: nil, first_token_timeout: nil,
|
|
107
|
-
api_key: nil)
|
|
110
|
+
api_key: nil, diagnosis_header: nil)
|
|
108
111
|
@label = label
|
|
109
112
|
@api_key = api_key
|
|
113
|
+
@diagnosis_header = diagnosis_header
|
|
110
114
|
@open_timeout = open_timeout
|
|
111
115
|
@read_timeout = read_timeout
|
|
112
116
|
@first_token_timeout = first_token_timeout&.positive? ? first_token_timeout : nil
|
|
@@ -187,11 +191,11 @@ module Samagotchi
|
|
|
187
191
|
# @raise [RequestCancelled] when +cancel_controller+ cancels (with or
|
|
188
192
|
# without retries)
|
|
189
193
|
def fetch(uri, request, retries: true, check_status: true, open_timeout: nil, read_timeout: nil,
|
|
190
|
-
cancel_controller: nil, log_fields: {})
|
|
194
|
+
write_timeout: nil, cancel_controller: nil, log_fields: {})
|
|
191
195
|
identify(request)
|
|
192
196
|
current = new_attempt_state(uri, request, log_fields, stream: false)
|
|
193
197
|
attempt = lambda do |state|
|
|
194
|
-
start(uri, open_timeout: open_timeout, read_timeout: read_timeout, max_retries: 0) do |http|
|
|
198
|
+
start(uri, open_timeout: open_timeout, read_timeout: read_timeout, write_timeout: write_timeout, max_retries: 0) do |http|
|
|
195
199
|
state[:http] = http
|
|
196
200
|
http.request(request).tap do |response|
|
|
197
201
|
state[:status] = response.code.to_i
|
|
@@ -221,8 +225,9 @@ module Samagotchi
|
|
|
221
225
|
@api_key&.authorize(request)
|
|
222
226
|
end
|
|
223
227
|
|
|
224
|
-
def start(uri, open_timeout: nil, read_timeout: nil, max_retries: nil, &)
|
|
228
|
+
def start(uri, open_timeout: nil, read_timeout: nil, write_timeout: nil, max_retries: nil, &)
|
|
225
229
|
options = { open_timeout: open_timeout || @open_timeout, read_timeout: read_timeout || @read_timeout }
|
|
230
|
+
options[:write_timeout] = write_timeout if write_timeout
|
|
226
231
|
# Net::HTTP retries an idempotent request once on its own (max_retries
|
|
227
232
|
# defaults to 1) when the connection drops before an answer: chi's
|
|
228
233
|
# RetryPolicy owns retries (and the log line says how many were sent),
|
|
@@ -418,7 +423,7 @@ module Samagotchi
|
|
|
418
423
|
|
|
419
424
|
error = ProviderErrors.from_response(status: status, body: response.body.to_s, host: @label,
|
|
420
425
|
retry_after: response["Retry-After"],
|
|
421
|
-
diagnosis: response[
|
|
426
|
+
diagnosis: @diagnosis_header && response[@diagnosis_header])
|
|
422
427
|
error.hint ||= @api_key ? @api_key.hint : ApiKey.missing_hint(@label) if error.is_a?(AuthError)
|
|
423
428
|
raise error
|
|
424
429
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../log"
|
|
4
|
+
|
|
3
5
|
module Samagotchi
|
|
4
6
|
module LLM
|
|
5
7
|
# A model a host lists. Unknown fields are nil.
|
|
@@ -51,7 +53,10 @@ module Samagotchi
|
|
|
51
53
|
|
|
52
54
|
def init_model_listing(models_ttl)
|
|
53
55
|
@models_ttl = models_ttl
|
|
56
|
+
@models_failure_ttl = [models_ttl, 60].min
|
|
54
57
|
@models_mutex = Mutex.new
|
|
58
|
+
@models_failure_at = nil
|
|
59
|
+
@models_failure_logged_at = nil
|
|
55
60
|
end
|
|
56
61
|
|
|
57
62
|
# +model+'s entry in the cached list: its exact id, else any case.
|
|
@@ -93,16 +98,39 @@ module Samagotchi
|
|
|
93
98
|
end
|
|
94
99
|
|
|
95
100
|
def cached_models
|
|
101
|
+
now = monotonic
|
|
96
102
|
@models_mutex.synchronize do
|
|
97
|
-
fresh = @models_at && (
|
|
103
|
+
fresh = @models_at && (now - @models_at) < @models_ttl
|
|
98
104
|
return @models if fresh
|
|
105
|
+
|
|
106
|
+
failed_recently = @models_failure_at && (now - @models_failure_at) < @models_failure_ttl
|
|
107
|
+
return [] if failed_recently
|
|
99
108
|
end
|
|
100
109
|
models = list_models
|
|
101
110
|
@models_mutex.synchronize do
|
|
102
111
|
@models = models
|
|
103
112
|
@models_at = monotonic
|
|
113
|
+
@models_failure_at = nil
|
|
104
114
|
end
|
|
105
115
|
models
|
|
116
|
+
rescue StandardError => e
|
|
117
|
+
@models_mutex.synchronize { @models_failure_at = monotonic }
|
|
118
|
+
log_model_listing_failure(e)
|
|
119
|
+
raise
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def log_model_listing_failure(error)
|
|
123
|
+
now = monotonic
|
|
124
|
+
should_log = @models_mutex.synchronize do
|
|
125
|
+
next false if @models_failure_logged_at && (now - @models_failure_logged_at) < @models_ttl
|
|
126
|
+
|
|
127
|
+
@models_failure_logged_at = now
|
|
128
|
+
true
|
|
129
|
+
end
|
|
130
|
+
return unless should_log
|
|
131
|
+
|
|
132
|
+
host = @host_name.to_s.empty? ? @base_url : @host_name
|
|
133
|
+
Log.warn(:model, "list_failed", host: host, error: error.class.name, msg: error.message.to_s[0, 500])
|
|
106
134
|
end
|
|
107
135
|
|
|
108
136
|
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
@@ -6,8 +6,9 @@ module Samagotchi
|
|
|
6
6
|
# host's, HostRegistry keeps one per worker; ChatLoop: the worker's): a
|
|
7
7
|
# +kind+ (a Symbol) for one model, or for the whole host when the model
|
|
8
8
|
# is nil, with a value (true, or what the refusal named: an output cap).
|
|
9
|
-
# The turn
|
|
10
|
-
# write takes the
|
|
9
|
+
# The turn thread and the model-list reads share HostRegistry's adapter
|
|
10
|
+
# (an IdleClient's adapter is its own), so every read and write takes the
|
|
11
|
+
# lock.
|
|
11
12
|
class RefusalMemo
|
|
12
13
|
def initialize
|
|
13
14
|
@mutex = Mutex.new
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
module Plugin
|
|
7
|
+
# A card's checked event (docs/plugins.md, Cards): what Engine#show_card
|
|
8
|
+
# routes and the bundle test kit records.
|
|
9
|
+
module Card
|
|
10
|
+
LEVELS = %i[info warn].freeze
|
|
11
|
+
MAX_ACTIONS = 6
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# @return [Hash] {type: :card, id:, source:, title:, body:, level:,
|
|
16
|
+
# actions: [{label:, command:}]}; a new id when none is given
|
|
17
|
+
# @raise [ArgumentError] a card without a title, a bad level or action
|
|
18
|
+
def build(source:, title:, body:, actions:, level:, id:)
|
|
19
|
+
title = title.to_s.strip
|
|
20
|
+
raise ArgumentError, "a card needs a title" if title.empty?
|
|
21
|
+
|
|
22
|
+
level = level.to_s.to_sym
|
|
23
|
+
raise ArgumentError, "card level must be one of #{LEVELS.join(", ")}" unless LEVELS.include?(level)
|
|
24
|
+
|
|
25
|
+
actions = Array(actions)
|
|
26
|
+
raise ArgumentError, "a card has at most #{MAX_ACTIONS} actions" if actions.size > MAX_ACTIONS
|
|
27
|
+
|
|
28
|
+
actions = actions.map do |action|
|
|
29
|
+
raise ArgumentError, "a card action is a Hash {label:, command:}" unless action.is_a?(Hash)
|
|
30
|
+
|
|
31
|
+
action = action.transform_keys(&:to_sym)
|
|
32
|
+
command = action[:command].to_s.strip
|
|
33
|
+
raise ArgumentError, "a card action needs a command" if command.empty? || command.include?("\n")
|
|
34
|
+
|
|
35
|
+
label = action[:label].to_s.strip
|
|
36
|
+
{ label: label.empty? ? command : label, command: command }
|
|
37
|
+
end
|
|
38
|
+
id = id.to_s.strip
|
|
39
|
+
{ type: :card, id: id.empty? ? SecureRandom.hex(4) : id, source: source.to_s, title: title, body: body.to_s,
|
|
40
|
+
level: level, actions: actions }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -6,18 +6,32 @@ require_relative "../paths"
|
|
|
6
6
|
require_relative "../guardrails/context"
|
|
7
7
|
require_relative "../idle_client"
|
|
8
8
|
require_relative "side_question"
|
|
9
|
-
require_relative "sessions"
|
|
10
|
-
require_relative "attached_context"
|
|
11
9
|
|
|
12
10
|
module Samagotchi
|
|
11
|
+
# Context#saved_session reads it; Sessions and AttachedContext load it too.
|
|
12
|
+
autoload :Session, File.expand_path("../session", __dir__)
|
|
13
|
+
|
|
13
14
|
module Plugin
|
|
15
|
+
# Loaded on first ctx.sessions / ctx.context (or a bundle naming
|
|
16
|
+
# Sessions::Error): they pull in the session, delivery and bridge
|
|
17
|
+
# client files (~150), which a plugin that never calls them, and the
|
|
18
|
+
# bundle test kit, don't need.
|
|
19
|
+
autoload :Sessions, File.expand_path("sessions", __dir__)
|
|
20
|
+
autoload :AttachedContext, File.expand_path("attached_context", __dir__)
|
|
21
|
+
# ctx.http's file needs only json and uri (net/http loads with the first
|
|
22
|
+
# real request); autoloaded so a bundle can name HttpError at load time.
|
|
23
|
+
autoload :Http, File.expand_path("http", __dir__)
|
|
24
|
+
autoload :HttpError, File.expand_path("http", __dir__)
|
|
25
|
+
autoload :HttpCancelled, File.expand_path("http", __dir__)
|
|
26
|
+
|
|
14
27
|
# The Engine's side of a Context: callables, so the Engine itself is
|
|
15
28
|
# never handed out. +messages+ returns the conversation, +notify+ takes
|
|
16
29
|
# (text, level, label, fallback_for:), +ask_user+ takes (question:, options:, header:,
|
|
17
30
|
# allow_freeform:, hook:) like the hook runtime's, +card+ takes
|
|
18
31
|
# Engine#show_card's keywords and returns the id, +ask_model+ takes
|
|
19
|
-
# (chat messages, timeout:, max_tokens:, cancel_controller
|
|
20
|
-
# the answer
|
|
32
|
+
# (chat messages, timeout:, max_tokens:, cancel_controller:, model:: a
|
|
33
|
+
# configured model ref, nil for the session's) and returns the answer
|
|
34
|
+
# text.
|
|
21
35
|
# +messages_partial+ says whether +messages+ leaves out a running turn;
|
|
22
36
|
# +prompt_files+ returns the files whose text the built system prompt
|
|
23
37
|
# holds (SystemPrompt#prompt_files, [] before a build); +model_name+
|
|
@@ -32,10 +46,14 @@ module Samagotchi
|
|
|
32
46
|
# +toolkit+ returns the session's toolkit name, +declared_tools+ the
|
|
33
47
|
# tool names its next turn declares, +use_toolkit+ takes a name and
|
|
34
48
|
# returns whether it staged it (Engine#use_toolkit).
|
|
49
|
+
# +cancel_controller+ takes (handler:) and returns the controller a
|
|
50
|
+
# ctx.http call without cancel: uses, nil for none: an init task's own,
|
|
51
|
+
# else the running turn's for work done for it (the turn's thread, or a
|
|
52
|
+
# plugin handler: +handler+) until the turn has ended.
|
|
35
53
|
Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
|
|
36
54
|
:ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
|
|
37
55
|
:steer, :stop_turn, :stop_generation, :frontend, :toolkit, :declared_tools, :use_toolkit,
|
|
38
|
-
keyword_init: true)
|
|
56
|
+
:cancel_controller, keyword_init: true)
|
|
39
57
|
|
|
40
58
|
# ctx.ask_model failed: the model couldn't be reached, timed out, or
|
|
41
59
|
# sent nothing usable. The message says why, for the user.
|
|
@@ -85,7 +103,12 @@ module Samagotchi
|
|
|
85
103
|
# @param label [String] "<file> (bundle <name>)", what notices and
|
|
86
104
|
# questions are labelled by
|
|
87
105
|
# @param host [Host]
|
|
88
|
-
|
|
106
|
+
# @param sessions [Object, nil] what #sessions returns instead of a
|
|
107
|
+
# Sessions over +host+ (the test kit's recording one)
|
|
108
|
+
# @param http_transport [#call, nil] what #http sends through instead
|
|
109
|
+
# of the network (the test kit's FakeHttp); the default cancel
|
|
110
|
+
# lookup stays the Context's own
|
|
111
|
+
def initialize(bundle:, label:, settings:, host:, env: ENV, sessions: nil, http_transport: nil)
|
|
89
112
|
@bundle = bundle.to_s
|
|
90
113
|
@label = label
|
|
91
114
|
@settings = deep_freeze(settings.is_a?(Hash) ? settings : {})
|
|
@@ -94,6 +117,9 @@ module Samagotchi
|
|
|
94
117
|
@log = Logger.new(@bundle)
|
|
95
118
|
@git = Guardrails::GitInfo.new
|
|
96
119
|
@data_dir = nil
|
|
120
|
+
@sessions = sessions
|
|
121
|
+
@http_transport = http_transport
|
|
122
|
+
@http = nil
|
|
97
123
|
end
|
|
98
124
|
|
|
99
125
|
# The bundle, never the environment it holds (every API key in it).
|
|
@@ -254,15 +280,21 @@ module Samagotchi
|
|
|
254
280
|
# ASK_MAX_TOKENS); an answer cut off ends with "…"
|
|
255
281
|
# @param cancel [CancellationController, nil] cancelling it aborts
|
|
256
282
|
# the request
|
|
283
|
+
# @param model [String, nil] a configured model ("host:id", an alias,
|
|
284
|
+
# or a model id one of the hosts lists) instead of the session's:
|
|
285
|
+
# keys stay chi's config, a plugin never names a URL or a key
|
|
257
286
|
# @return [String] the answer ("" when the model said nothing)
|
|
287
|
+
# @raise [ArgumentError] no prompt, or a +model+ that names nothing configured
|
|
258
288
|
# @raise [ModelError] the request failed or timed out
|
|
259
289
|
# @raise [ModelCancelled] +cancel+ was cancelled
|
|
260
|
-
def ask_model(messages:, prompt:, system: nil, timeout: 120, max_tokens: nil, cancel: nil)
|
|
290
|
+
def ask_model(messages:, prompt:, system: nil, timeout: 120, max_tokens: nil, cancel: nil, model: nil)
|
|
261
291
|
raise ArgumentError, "ask_model needs a prompt" if prompt.to_s.strip.empty?
|
|
292
|
+
raise ArgumentError, "ask_model's model: is a String like \"host:id\"" unless model.nil? || (model.is_a?(String) && !model.strip.empty?)
|
|
262
293
|
|
|
263
294
|
request = SideQuestion.request(messages: messages, prompt: prompt, system: system)
|
|
264
295
|
limit = max_tokens.nil? ? ASK_MAX_TOKENS : Integer(max_tokens)
|
|
265
|
-
@host.ask_model.call(request, timeout: Float(timeout), max_tokens: limit, cancel_controller: cancel
|
|
296
|
+
@host.ask_model.call(request, timeout: Float(timeout), max_tokens: limit, cancel_controller: cancel,
|
|
297
|
+
model: model&.strip).to_s
|
|
266
298
|
rescue IdleClient::SummarizeError => e
|
|
267
299
|
raise ModelError, e.message
|
|
268
300
|
rescue LLM::RequestCancelled
|
|
@@ -279,6 +311,16 @@ module Samagotchi
|
|
|
279
311
|
@sessions ||= Sessions.new(@host)
|
|
280
312
|
end
|
|
281
313
|
|
|
314
|
+
# An HTTP client for calling services (Http, docs/plugins.md). A call
|
|
315
|
+
# made for the turn (a tool, a handler) stops when the turn is stopped;
|
|
316
|
+
# one from a command, a service or your own thread has only its
|
|
317
|
+
# timeout unless it passes cancel:.
|
|
318
|
+
# @return [Http]
|
|
319
|
+
def http
|
|
320
|
+
@http ||= Http.new(bundle: @bundle, transport: @http_transport,
|
|
321
|
+
cancel_lookup: -> { @host.cancel_controller&.call(handler: in_handler?) })
|
|
322
|
+
end
|
|
323
|
+
|
|
282
324
|
# This session's attached context: attach a URL or a command, list
|
|
283
325
|
# (AttachedContext).
|
|
284
326
|
# @return [AttachedContext]
|
|
@@ -342,6 +384,9 @@ module Samagotchi
|
|
|
342
384
|
|
|
343
385
|
private
|
|
344
386
|
|
|
387
|
+
# Whether this plugin's handler runs on this thread (#with_event).
|
|
388
|
+
def in_handler? = !!Thread.current[CURRENT_EVENTS]&.key?(self)
|
|
389
|
+
|
|
345
390
|
# The event[:x] helper of the event this plugin's handler runs for on
|
|
346
391
|
# this thread, or nil (not in a handler).
|
|
347
392
|
def event_helper(name)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module Plugin
|
|
8
|
+
# ctx.http failed: the request didn't complete (refused, timed out, DNS,
|
|
9
|
+
# TLS, reset, a malformed response, a bad gzip body), or Response#json
|
|
10
|
+
# was called on a body that isn't JSON. The message says what and where,
|
|
11
|
+
# never headers or a body; the original error is the #cause.
|
|
12
|
+
class HttpError < StandardError; end
|
|
13
|
+
|
|
14
|
+
# A ctx.http request was cancelled (its cancel: controller, or the turn's
|
|
15
|
+
# Stop).
|
|
16
|
+
class HttpCancelled < HttpError; end
|
|
17
|
+
|
|
18
|
+
# An HTTP client for a plugin (ctx.http, docs/plugins.md): a few methods,
|
|
19
|
+
# a small Response, one error type (HttpError). It checks its arguments
|
|
20
|
+
# and builds the request; a transport (Http::NetTransport by default,
|
|
21
|
+
# the test kit's FakeHttp) puts it on the wire.
|
|
22
|
+
#
|
|
23
|
+
# Stateless apart from its bundle's name and its transport: any number of
|
|
24
|
+
# threads may call it.
|
|
25
|
+
#
|
|
26
|
+
# This file needs only json and uri: net/http and chi's HTTP layer load
|
|
27
|
+
# with the first real request (NetTransport).
|
|
28
|
+
class Http
|
|
29
|
+
# What a request is given as the timeout (open, and per read) when none is.
|
|
30
|
+
DEFAULT_TIMEOUT = 10
|
|
31
|
+
METHODS = %w[GET POST PUT PATCH DELETE HEAD].freeze
|
|
32
|
+
|
|
33
|
+
# One answer. +headers+ is frozen, with lower-case names and a repeated
|
|
34
|
+
# header's values joined with ", "; +body+ is never nil (HEAD and empty
|
|
35
|
+
# bodies are ""), tagged UTF-8 when its bytes are valid UTF-8, else
|
|
36
|
+
# left binary (scrub it before handing unknown text to the model).
|
|
37
|
+
Response = Data.define(:status, :headers, :body) do
|
|
38
|
+
# @return [Boolean] a 2xx status
|
|
39
|
+
def ok? = status.between?(200, 299)
|
|
40
|
+
|
|
41
|
+
# The body parsed as JSON, on each call (a Data keeps nothing).
|
|
42
|
+
# @raise [HttpError] the body isn't JSON
|
|
43
|
+
def json
|
|
44
|
+
JSON.parse(body)
|
|
45
|
+
rescue JSON::ParserError, EncodingError
|
|
46
|
+
raise HttpError, "not JSON: status #{status}, #{body.bytesize} bytes"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param bundle [String] the bundle's name (the log line's bundle=)
|
|
51
|
+
# @param cancel_lookup [#call] → the CancellationController a call
|
|
52
|
+
# without cancel: uses (nil: none), read at call time
|
|
53
|
+
# @param transport [#call, nil] call(method:, uri:, headers:, body:,
|
|
54
|
+
# timeout:, cancel:) → Response, or raises HttpError / HttpCancelled
|
|
55
|
+
def initialize(bundle:, cancel_lookup: -> {}, transport: nil)
|
|
56
|
+
@bundle = bundle.to_s
|
|
57
|
+
@cancel_lookup = cancel_lookup
|
|
58
|
+
@transport = transport
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param url [String, URI] http or https
|
|
62
|
+
# @param headers [Hash] header name => value
|
|
63
|
+
# @param timeout [Numeric] seconds: open, and each read
|
|
64
|
+
# @param cancel [CancellationController, nil] overrides the default
|
|
65
|
+
# (the turn's, for a call made for the turn)
|
|
66
|
+
# @return [Response] any status: a 4xx/5xx is returned, not raised
|
|
67
|
+
# @raise [ArgumentError] a bad URL, scheme, header or timeout
|
|
68
|
+
# @raise [HttpError] the request didn't complete
|
|
69
|
+
# @raise [HttpCancelled] it was cancelled
|
|
70
|
+
def get(url, headers: {}, timeout: DEFAULT_TIMEOUT, cancel: nil)
|
|
71
|
+
request("GET", url, headers: headers, timeout: timeout, cancel: cancel)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# POST +body+ encoded as JSON (Content-Type and Accept: application/json
|
|
75
|
+
# unless +headers+ name them).
|
|
76
|
+
# @param body [Object] anything JSON can encode
|
|
77
|
+
# @raise [ArgumentError] +body+ can't be encoded as JSON, and as #get
|
|
78
|
+
def post_json(url, body, headers: {}, timeout: DEFAULT_TIMEOUT, cancel: nil)
|
|
79
|
+
encoded = begin
|
|
80
|
+
JSON.generate(body)
|
|
81
|
+
rescue JSON::JSONError, EncodingError => e
|
|
82
|
+
raise ArgumentError, "can't encode the body as JSON: #{e.class}"
|
|
83
|
+
end
|
|
84
|
+
headers = { "Content-Type" => "application/json", "Accept" => "application/json" }
|
|
85
|
+
.reject { |name, _| named?(headers, name) }.merge(headers)
|
|
86
|
+
request("POST", url, body: encoded, headers: headers, timeout: timeout, cancel: cancel)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @param method [String, Symbol] GET POST PUT PATCH DELETE HEAD
|
|
90
|
+
# @param body [String, nil] sent as is (set your own Content-Type)
|
|
91
|
+
# @raise [ArgumentError] an unknown method, a body that isn't a String, and as #get
|
|
92
|
+
def request(method, url, body: nil, headers: {}, timeout: DEFAULT_TIMEOUT, cancel: nil)
|
|
93
|
+
verb = method.to_s.upcase
|
|
94
|
+
raise ArgumentError, "unknown HTTP method #{method.inspect} (#{METHODS.join(", ")})" unless METHODS.include?(verb)
|
|
95
|
+
raise ArgumentError, "the body must be a String (or nil), got #{body.class}" unless body.nil? || body.is_a?(String)
|
|
96
|
+
|
|
97
|
+
transport.call(method: verb, uri: parse_url(url), headers: header_hash(headers), body: body,
|
|
98
|
+
timeout: timeout_seconds(timeout), cancel: cancel || @cancel_lookup.call)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def transport
|
|
104
|
+
@transport ||= begin
|
|
105
|
+
require_relative "http_transport"
|
|
106
|
+
NetTransport.new(bundle: @bundle)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def parse_url(url)
|
|
111
|
+
uri = url.is_a?(URI::Generic) ? url : URI.parse(url.to_s)
|
|
112
|
+
unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
|
|
113
|
+
raise ArgumentError, "the URL must be http:// or https:// with a host: #{quotable(url)}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
uri
|
|
117
|
+
rescue URI::InvalidURIError
|
|
118
|
+
raise ArgumentError, "not a URL: #{quotable(url)}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# +url+ as an error message may quote it: its first 100 characters
|
|
122
|
+
# without the query, fragment and user info (a key in them would reach
|
|
123
|
+
# the logs with the message).
|
|
124
|
+
def quotable(url)
|
|
125
|
+
text = url.to_s.sub(/[?#].*\z/m, "").sub(%r{\A([a-z][a-z0-9+.-]*://)[^/@]*@}i, "\\1")
|
|
126
|
+
text[0, 100].inspect
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def header_hash(headers)
|
|
130
|
+
raise ArgumentError, "headers must be a Hash" unless headers.is_a?(Hash)
|
|
131
|
+
|
|
132
|
+
headers.to_h do |name, value|
|
|
133
|
+
name = name.to_s
|
|
134
|
+
value = value.to_s
|
|
135
|
+
unless name.match?(/\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/) && !value.match?(/[\r\n\0]/)
|
|
136
|
+
raise ArgumentError, "bad header #{name[0, 40].inspect}: a name of token characters, a value on one line"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
[name, value]
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def named?(headers, name)
|
|
144
|
+
headers.keys.any? { |key| key.to_s.casecmp?(name) }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def timeout_seconds(timeout)
|
|
148
|
+
raise ArgumentError, "timeout must be a positive number of seconds" unless timeout.is_a?(Numeric) && timeout.positive?
|
|
149
|
+
|
|
150
|
+
timeout.to_f
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "zlib"
|
|
6
|
+
require_relative "http"
|
|
7
|
+
require_relative "../llm/http"
|
|
8
|
+
require_relative "../llm/errors"
|
|
9
|
+
|
|
10
|
+
module Samagotchi
|
|
11
|
+
module Plugin
|
|
12
|
+
class Http
|
|
13
|
+
# Plugin::Http's wire: one LLM::HTTP request per call (chi's TLS,
|
|
14
|
+
# proxies, User-Agent, cancel by closing the socket, and one log line
|
|
15
|
+
# without query, headers or body), with chi's retries off. Every error
|
|
16
|
+
# but a cancel becomes an HttpError, so a plugin rescues one type.
|
|
17
|
+
#
|
|
18
|
+
# Loaded with the first real request (it pulls in net/http and the
|
|
19
|
+
# LLM layer). Stateless.
|
|
20
|
+
class NetTransport
|
|
21
|
+
# What went wrong, in words (a message never carries a response).
|
|
22
|
+
REASONS = {
|
|
23
|
+
Errno::ECONNREFUSED => "connection refused", Errno::ECONNRESET => "connection reset",
|
|
24
|
+
Errno::EHOSTUNREACH => "host unreachable", Errno::ENETUNREACH => "network unreachable",
|
|
25
|
+
Errno::ETIMEDOUT => "timed out", Errno::EPIPE => "connection closed",
|
|
26
|
+
Timeout::Error => "timed out", IO::TimeoutError => "timed out", EOFError => "connection closed",
|
|
27
|
+
SocketError => "can't resolve the host", OpenSSL::SSL::SSLError => "TLS error",
|
|
28
|
+
Net::HTTPBadResponse => "malformed response", Net::HTTPHeaderSyntaxError => "malformed response",
|
|
29
|
+
Net::ProtocolError => "protocol error", Zlib::Error => "bad compressed body"
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
def initialize(bundle:)
|
|
33
|
+
@bundle = bundle.to_s
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Response]
|
|
37
|
+
# @raise [HttpError, HttpCancelled]
|
|
38
|
+
def call(method:, uri:, headers:, body:, timeout:, cancel:)
|
|
39
|
+
request = Net::HTTP.const_get(method.capitalize).new(uri, headers)
|
|
40
|
+
request.body = body if body
|
|
41
|
+
client = LLM::HTTP.new(label: "plugin:#{@bundle}", open_timeout: timeout, read_timeout: timeout,
|
|
42
|
+
retry_policy: LLM::HTTP::RetryPolicy.none)
|
|
43
|
+
response = client.fetch(uri, request, retries: false, check_status: false, write_timeout: timeout, cancel_controller: cancel,
|
|
44
|
+
log_fields: { purpose: "plugin", bundle: @bundle })
|
|
45
|
+
build_response(response)
|
|
46
|
+
rescue LLM::RequestCancelled
|
|
47
|
+
raise HttpCancelled, "#{method} #{where(uri)}: cancelled"
|
|
48
|
+
rescue HttpError
|
|
49
|
+
raise
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
raise HttpError, "#{method} #{where(uri)}: #{reason(e)}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def build_response(response)
|
|
57
|
+
headers = {}
|
|
58
|
+
response.each_header { |name, value| headers[name.downcase] = text(value) }
|
|
59
|
+
Response.new(status: response.code.to_i, headers: headers.freeze, body: text(response.body))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The bytes as UTF-8 when they are, else as they came.
|
|
63
|
+
def text(body)
|
|
64
|
+
text = body.to_s.dup
|
|
65
|
+
utf8 = text.dup.force_encoding(Encoding::UTF_8)
|
|
66
|
+
utf8.valid_encoding? ? utf8 : text.force_encoding(Encoding::BINARY)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def where(uri) = "#{uri.host}:#{uri.port}"
|
|
70
|
+
|
|
71
|
+
def reason(error)
|
|
72
|
+
REASONS.find { |klass, _| error.is_a?(klass) }&.last || "failed (#{error.class.name})"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|