samagotchi 0.79.0 → 0.81.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 +59 -1
- data/README.md +7 -5
- data/docs/architecture.md +9 -1
- data/docs/cli.md +32 -7
- data/docs/configuration.md +10 -4
- data/docs/guardrails.md +26 -3
- data/docs/internals/anthropic.md +3 -1
- data/docs/internals/bundles.md +4 -0
- data/docs/internals/engine.md +13 -3
- data/docs/plugins.md +149 -3
- data/docs/releasing.md +2 -2
- data/docs/sessions.md +9 -1
- data/docs/testing.md +51 -2
- data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
- data/lib/samagotchi/bootstrap/probe.rb +153 -31
- data/lib/samagotchi/bootstrap_command.rb +93 -16
- data/lib/samagotchi/bridge.rb +1 -1
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -4
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +2 -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 +68 -39
- data/lib/samagotchi/guardrails/approval.rb +6 -4
- data/lib/samagotchi/guardrails/data_readers.rb +214 -0
- data/lib/samagotchi/guardrails/gate.rb +7 -3
- data/lib/samagotchi/guardrails/rules.rb +4 -2
- data/lib/samagotchi/guardrails/shell_lex.rb +166 -28
- data/lib/samagotchi/guardrails/targets.rb +14 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
- data/lib/samagotchi/hooks/registry.rb +44 -3
- data/lib/samagotchi/host_config.rb +8 -8
- data/lib/samagotchi/host_registry.rb +12 -4
- data/lib/samagotchi/idle_client.rb +41 -11
- data/lib/samagotchi/idle_recap.rb +6 -1
- data/lib/samagotchi/idle_target.rb +12 -4
- data/lib/samagotchi/llm/adapter_common.rb +4 -2
- data/lib/samagotchi/llm/anthropic_binding_recovery.rb +3 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +2 -1
- data/lib/samagotchi/llm/http.rb +12 -7
- data/lib/samagotchi/llm/usage.rb +9 -3
- data/lib/samagotchi/model_price.rb +16 -0
- data/lib/samagotchi/plugin/card.rb +44 -0
- data/lib/samagotchi/plugin/context.rb +61 -9
- 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/plugin/service.rb +24 -1
- data/lib/samagotchi/question_answer.rb +29 -0
- data/lib/samagotchi/question_desk.rb +20 -19
- data/lib/samagotchi/recap_setup.rb +2 -1
- data/lib/samagotchi/relay_verifier.rb +1 -1
- data/lib/samagotchi/session_metrics/side_totals.rb +110 -0
- data/lib/samagotchi/session_metrics.rb +46 -12
- data/lib/samagotchi/session_observer.rb +3 -0
- data/lib/samagotchi/side_usage.rb +38 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
- data/lib/samagotchi/terminal_ui/model_segments_text.rb +27 -3
- 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 +141 -0
- data/lib/samagotchi/testing/recording_api.rb +27 -0
- data/lib/samagotchi/testing/rspec.rb +31 -0
- data/lib/samagotchi/testing/sandbox.rb +165 -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/app.rb +4 -1
- data/lib/samagotchi/web/public/app.js +47 -23
- data/lib/samagotchi/web/public/ctx.js +32 -6
- data/lib/samagotchi/web/public/images.js +33 -0
- data/lib/samagotchi/web/public/index.html +71 -38
- data/lib/samagotchi/web/public/info_bar_model.js +1 -1
- data/lib/samagotchi/web/public/level_chip.js +11 -11
- data/lib/samagotchi/web/public/lists_view.js +20 -15
- data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
- data/lib/samagotchi/web/public/model_picker.js +1 -1
- data/lib/samagotchi/web/public/scope.js +3 -3
- data/lib/samagotchi/web/public/stream_handlers.js +7 -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/timing.js +6 -1
- data/lib/samagotchi/web/session_summary.rb +2 -0
- metadata +24 -1
|
@@ -5,19 +5,34 @@ require_relative "../log"
|
|
|
5
5
|
require_relative "../paths"
|
|
6
6
|
require_relative "../guardrails/context"
|
|
7
7
|
require_relative "../idle_client"
|
|
8
|
+
require_relative "service"
|
|
8
9
|
require_relative "side_question"
|
|
9
|
-
require_relative "sessions"
|
|
10
|
-
require_relative "attached_context"
|
|
11
10
|
|
|
12
11
|
module Samagotchi
|
|
12
|
+
# Context#saved_session reads it; Sessions and AttachedContext load it too.
|
|
13
|
+
autoload :Session, File.expand_path("../session", __dir__)
|
|
14
|
+
|
|
13
15
|
module Plugin
|
|
16
|
+
# Loaded on first ctx.sessions / ctx.context (or a bundle naming
|
|
17
|
+
# Sessions::Error): they pull in the session, delivery and bridge
|
|
18
|
+
# client files (~150), which a plugin that never calls them, and the
|
|
19
|
+
# bundle test kit, don't need.
|
|
20
|
+
autoload :Sessions, File.expand_path("sessions", __dir__)
|
|
21
|
+
autoload :AttachedContext, File.expand_path("attached_context", __dir__)
|
|
22
|
+
# ctx.http's file needs only json and uri (net/http loads with the first
|
|
23
|
+
# real request); autoloaded so a bundle can name HttpError at load time.
|
|
24
|
+
autoload :Http, File.expand_path("http", __dir__)
|
|
25
|
+
autoload :HttpError, File.expand_path("http", __dir__)
|
|
26
|
+
autoload :HttpCancelled, File.expand_path("http", __dir__)
|
|
27
|
+
|
|
14
28
|
# The Engine's side of a Context: callables, so the Engine itself is
|
|
15
29
|
# never handed out. +messages+ returns the conversation, +notify+ takes
|
|
16
30
|
# (text, level, label, fallback_for:), +ask_user+ takes (question:, options:, header:,
|
|
17
31
|
# allow_freeform:, hook:) like the hook runtime's, +card+ takes
|
|
18
32
|
# Engine#show_card's keywords and returns the id, +ask_model+ takes
|
|
19
|
-
# (chat messages, timeout:, max_tokens:, cancel_controller
|
|
20
|
-
# the answer
|
|
33
|
+
# (chat messages, timeout:, max_tokens:, cancel_controller:, model:: a
|
|
34
|
+
# configured model ref, nil for the session's) and returns the answer
|
|
35
|
+
# text.
|
|
21
36
|
# +messages_partial+ says whether +messages+ leaves out a running turn;
|
|
22
37
|
# +prompt_files+ returns the files whose text the built system prompt
|
|
23
38
|
# holds (SystemPrompt#prompt_files, [] before a build); +model_name+
|
|
@@ -32,10 +47,14 @@ module Samagotchi
|
|
|
32
47
|
# +toolkit+ returns the session's toolkit name, +declared_tools+ the
|
|
33
48
|
# tool names its next turn declares, +use_toolkit+ takes a name and
|
|
34
49
|
# returns whether it staged it (Engine#use_toolkit).
|
|
50
|
+
# +cancel_controller+ takes (handler:) and returns the controller a
|
|
51
|
+
# ctx.http call without cancel: uses, nil for none: an init task's own,
|
|
52
|
+
# else the running turn's for work done for it (the turn's thread, or a
|
|
53
|
+
# plugin handler: +handler+) until the turn has ended.
|
|
35
54
|
Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
|
|
36
55
|
:ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
|
|
37
56
|
:steer, :stop_turn, :stop_generation, :frontend, :toolkit, :declared_tools, :use_toolkit,
|
|
38
|
-
keyword_init: true)
|
|
57
|
+
:cancel_controller, keyword_init: true)
|
|
39
58
|
|
|
40
59
|
# ctx.ask_model failed: the model couldn't be reached, timed out, or
|
|
41
60
|
# sent nothing usable. The message says why, for the user.
|
|
@@ -58,7 +77,7 @@ module Samagotchi
|
|
|
58
77
|
# handler started) they act on the session now, through the Host.
|
|
59
78
|
class Context
|
|
60
79
|
# Thread.current key: {Context => the event its handler runs for}.
|
|
61
|
-
CURRENT_EVENTS =
|
|
80
|
+
CURRENT_EVENTS = Service::CURRENT_EVENTS
|
|
62
81
|
# Debug-log records tagged plugins, with bundle=<bundle> (tags are a
|
|
63
82
|
# closed list, LogLine::TAGS).
|
|
64
83
|
class Logger
|
|
@@ -85,7 +104,12 @@ module Samagotchi
|
|
|
85
104
|
# @param label [String] "<file> (bundle <name>)", what notices and
|
|
86
105
|
# questions are labelled by
|
|
87
106
|
# @param host [Host]
|
|
88
|
-
|
|
107
|
+
# @param sessions [Object, nil] what #sessions returns instead of a
|
|
108
|
+
# Sessions over +host+ (the test kit's recording one)
|
|
109
|
+
# @param http_transport [#call, nil] what #http sends through instead
|
|
110
|
+
# of the network (the test kit's FakeHttp); the default cancel
|
|
111
|
+
# lookup stays the Context's own
|
|
112
|
+
def initialize(bundle:, label:, settings:, host:, env: ENV, sessions: nil, http_transport: nil)
|
|
89
113
|
@bundle = bundle.to_s
|
|
90
114
|
@label = label
|
|
91
115
|
@settings = deep_freeze(settings.is_a?(Hash) ? settings : {})
|
|
@@ -94,6 +118,9 @@ module Samagotchi
|
|
|
94
118
|
@log = Logger.new(@bundle)
|
|
95
119
|
@git = Guardrails::GitInfo.new
|
|
96
120
|
@data_dir = nil
|
|
121
|
+
@sessions = sessions
|
|
122
|
+
@http_transport = http_transport
|
|
123
|
+
@http = nil
|
|
97
124
|
end
|
|
98
125
|
|
|
99
126
|
# The bundle, never the environment it holds (every API key in it).
|
|
@@ -254,15 +281,21 @@ module Samagotchi
|
|
|
254
281
|
# ASK_MAX_TOKENS); an answer cut off ends with "…"
|
|
255
282
|
# @param cancel [CancellationController, nil] cancelling it aborts
|
|
256
283
|
# the request
|
|
284
|
+
# @param model [String, nil] a configured model ("host:id", an alias,
|
|
285
|
+
# or a model id one of the hosts lists) instead of the session's:
|
|
286
|
+
# keys stay chi's config, a plugin never names a URL or a key
|
|
257
287
|
# @return [String] the answer ("" when the model said nothing)
|
|
288
|
+
# @raise [ArgumentError] no prompt, or a +model+ that names nothing configured
|
|
258
289
|
# @raise [ModelError] the request failed or timed out
|
|
259
290
|
# @raise [ModelCancelled] +cancel+ was cancelled
|
|
260
|
-
def ask_model(messages:, prompt:, system: nil, timeout: 120, max_tokens: nil, cancel: nil)
|
|
291
|
+
def ask_model(messages:, prompt:, system: nil, timeout: 120, max_tokens: nil, cancel: nil, model: nil)
|
|
261
292
|
raise ArgumentError, "ask_model needs a prompt" if prompt.to_s.strip.empty?
|
|
293
|
+
raise ArgumentError, "ask_model's model: is a String like \"host:id\"" unless model.nil? || (model.is_a?(String) && !model.strip.empty?)
|
|
262
294
|
|
|
263
295
|
request = SideQuestion.request(messages: messages, prompt: prompt, system: system)
|
|
264
296
|
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
|
|
297
|
+
@host.ask_model.call(request, timeout: Float(timeout), max_tokens: limit, cancel_controller: cancel,
|
|
298
|
+
model: model&.strip, source: @bundle).to_s
|
|
266
299
|
rescue IdleClient::SummarizeError => e
|
|
267
300
|
raise ModelError, e.message
|
|
268
301
|
rescue LLM::RequestCancelled
|
|
@@ -279,6 +312,16 @@ module Samagotchi
|
|
|
279
312
|
@sessions ||= Sessions.new(@host)
|
|
280
313
|
end
|
|
281
314
|
|
|
315
|
+
# An HTTP client for calling services (Http, docs/plugins.md). A call
|
|
316
|
+
# made for the turn (a tool, a handler) stops when the turn is stopped;
|
|
317
|
+
# one from a command, a service or your own thread has only its
|
|
318
|
+
# timeout unless it passes cancel:.
|
|
319
|
+
# @return [Http]
|
|
320
|
+
def http
|
|
321
|
+
@http ||= Http.new(bundle: @bundle, transport: @http_transport,
|
|
322
|
+
cancel_lookup: -> { http_cancel_controller })
|
|
323
|
+
end
|
|
324
|
+
|
|
282
325
|
# This session's attached context: attach a URL or a command, list
|
|
283
326
|
# (AttachedContext).
|
|
284
327
|
# @return [AttachedContext]
|
|
@@ -342,6 +385,15 @@ module Samagotchi
|
|
|
342
385
|
|
|
343
386
|
private
|
|
344
387
|
|
|
388
|
+
# Whether this plugin's handler runs on this thread (#with_event).
|
|
389
|
+
def in_handler? = !!Thread.current[CURRENT_EVENTS]&.key?(self)
|
|
390
|
+
|
|
391
|
+
# The controller a ctx.http call without cancel: stops with: nil inside
|
|
392
|
+
# a service's start block, even on the turn's thread.
|
|
393
|
+
def http_cancel_controller
|
|
394
|
+
Service.starting? ? nil : @host.cancel_controller&.call(handler: in_handler?)
|
|
395
|
+
end
|
|
396
|
+
|
|
345
397
|
# The event[:x] helper of the event this plugin's handler runs for on
|
|
346
398
|
# this thread, or nil (not in a handler).
|
|
347
399
|
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
|
|
@@ -65,22 +65,41 @@ module Samagotchi
|
|
|
65
65
|
reason = bundle.error || unloadable_reason(file, bundle)
|
|
66
66
|
return failed(bundle_name, basename, reason, failures) if reason
|
|
67
67
|
|
|
68
|
-
api =
|
|
69
|
-
plugin = instantiate(bundle_name, file, settings)
|
|
70
|
-
plugin_label = label(bundle_name, basename)
|
|
71
|
-
api = Api.new(bundle: bundle_name, label: plugin_label, registries: registries,
|
|
72
|
-
context: registries.context_for&.call(bundle_name, settings, plugin_label))
|
|
73
|
-
plugin.register(api)
|
|
74
|
-
api.commit!
|
|
68
|
+
api = register_file(bundle_name, file, registries, settings: settings)
|
|
75
69
|
Log.info(TAG, "plugin_loaded", bundle: bundle_name, file: basename, **api.counts)
|
|
76
70
|
true
|
|
77
71
|
rescue Exception => e # rubocop:disable Lint/RescueException -- a plugin's SyntaxError or exit must not stop chi
|
|
78
|
-
api&.abort!
|
|
79
72
|
raise if e.is_a?(SignalException) # Ctrl-C and kill signals are the user's
|
|
80
73
|
|
|
81
74
|
failed(bundle_name, basename, "#{e.class}: #{e.message}", failures)
|
|
82
75
|
end
|
|
83
76
|
|
|
77
|
+
# Build +file+'s plugin with +settings+ and commit what its #register
|
|
78
|
+
# staged into +registries+ (no sha or requires_chi check: #load_bundle
|
|
79
|
+
# makes them; the test kit loads a bundle's source this way).
|
|
80
|
+
# @param namespace [Module, nil] the module the file is evaluated in
|
|
81
|
+
# (default: a fresh one, #fresh_namespace)
|
|
82
|
+
# @param api_class [Class] what #register gets (Api; the test kit's
|
|
83
|
+
# recording subclass)
|
|
84
|
+
# @yieldparam plugin [Object] the built plugin, before #register
|
|
85
|
+
# @return [Api] the committed Api (#counts)
|
|
86
|
+
# @raise [Exception] whatever the file, #register or #commit! raised,
|
|
87
|
+
# after stopping the services it started; nothing is registered
|
|
88
|
+
def register_file(bundle_name, file, registries, settings: {}, namespace: nil, api_class: Api)
|
|
89
|
+
api = nil
|
|
90
|
+
plugin = instantiate(bundle_name, file, settings, namespace: namespace)
|
|
91
|
+
yield plugin if block_given?
|
|
92
|
+
plugin_label = label(bundle_name, File.basename(file))
|
|
93
|
+
api = api_class.new(bundle: bundle_name, label: plugin_label, registries: registries,
|
|
94
|
+
context: registries.context_for&.call(bundle_name, settings, plugin_label))
|
|
95
|
+
plugin.register(api)
|
|
96
|
+
api.commit!
|
|
97
|
+
api
|
|
98
|
+
rescue Exception # rubocop:disable Lint/RescueException -- stop its services whatever it raised
|
|
99
|
+
api&.abort!
|
|
100
|
+
raise
|
|
101
|
+
end
|
|
102
|
+
|
|
84
103
|
# What a plugin's hooks, notices and failures are named by.
|
|
85
104
|
def label(bundle_name, basename) = "#{basename} (bundle #{bundle_name})"
|
|
86
105
|
|
|
@@ -98,8 +117,8 @@ module Samagotchi
|
|
|
98
117
|
# classes, not the last load's reopened) and build its class
|
|
99
118
|
# (plugin.rb → Plugin; my_plugin.rb → MyPlugin) as hooks are built:
|
|
100
119
|
# an initialize that takes an argument gets the settings.
|
|
101
|
-
def instantiate(bundle_name, file, settings)
|
|
102
|
-
namespace
|
|
120
|
+
def instantiate(bundle_name, file, settings, namespace: nil)
|
|
121
|
+
namespace ||= fresh_namespace(bundle_name)
|
|
103
122
|
namespace.module_eval(File.read(file), file, 1)
|
|
104
123
|
klass = Hooks.class_for(file, namespace)
|
|
105
124
|
plugin = Hooks.build_plugin(klass, settings)
|
|
@@ -15,6 +15,16 @@ module Samagotchi
|
|
|
15
15
|
# again.
|
|
16
16
|
class Stopped < StandardError; end
|
|
17
17
|
|
|
18
|
+
# The thread-local Context#with_event keeps its handler events in;
|
|
19
|
+
# the start block runs with it cleared.
|
|
20
|
+
CURRENT_EVENTS = :samagotchi_plugin_current_events
|
|
21
|
+
# Thread.current key: true while a start block runs (the turn's own
|
|
22
|
+
# thread too: a ctx.http call there isn't the turn's).
|
|
23
|
+
STARTING = :samagotchi_plugin_service_starting
|
|
24
|
+
|
|
25
|
+
# Whether a service's start block runs on this thread.
|
|
26
|
+
def self.starting? = !!Thread.current[STARTING]
|
|
27
|
+
|
|
18
28
|
# @return [String] "<bundle>:<name>"
|
|
19
29
|
attr_reader :name
|
|
20
30
|
|
|
@@ -37,7 +47,7 @@ module Samagotchi
|
|
|
37
47
|
return @value if @state == :running
|
|
38
48
|
|
|
39
49
|
begin
|
|
40
|
-
@value =
|
|
50
|
+
@value = start_outside_handler
|
|
41
51
|
@state = :running
|
|
42
52
|
Log.info(:plugins, "service_started", service: @name)
|
|
43
53
|
@value
|
|
@@ -81,6 +91,19 @@ module Samagotchi
|
|
|
81
91
|
|
|
82
92
|
private
|
|
83
93
|
|
|
94
|
+
# The start block is the service's, not the handler's whose first
|
|
95
|
+
# #value started it: no event helper or ctx.http turn cancel inside.
|
|
96
|
+
def start_outside_handler
|
|
97
|
+
events = Thread.current[CURRENT_EVENTS]
|
|
98
|
+
starting = Thread.current[STARTING]
|
|
99
|
+
Thread.current[CURRENT_EVENTS] = nil
|
|
100
|
+
Thread.current[STARTING] = true
|
|
101
|
+
@start.call(self)
|
|
102
|
+
ensure
|
|
103
|
+
Thread.current[CURRENT_EVENTS] = events
|
|
104
|
+
Thread.current[STARTING] = starting
|
|
105
|
+
end
|
|
106
|
+
|
|
84
107
|
def run_on_stop
|
|
85
108
|
callbacks = @on_stop.reverse
|
|
86
109
|
@on_stop = []
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# An answer to the pending question (QuestionDesk#answer's value, what
|
|
5
|
+
# Engine#answer_question returns): id: the question's id; selected: the
|
|
6
|
+
# chosen option labels; freeform: the typed text or nil; selected_indices:
|
|
7
|
+
# the chosen options' positions; by: "parent_agent" for a parent agent's
|
|
8
|
+
# answer, else nil.
|
|
9
|
+
#
|
|
10
|
+
# Crosses a process or a bundle as a Hash (#to_h, symbol keys): the
|
|
11
|
+
# :question_answered event, the Bridge's and RelayVerifier's JSON
|
|
12
|
+
# responses, the ask_user_question tool result and the hook/plugin
|
|
13
|
+
# ask_user result (built field by field in QuestionDesk#ask_for_hook).
|
|
14
|
+
# JSON.generate of a Data value writes its inspect string, so every JSON
|
|
15
|
+
# boundary calls #to_h first.
|
|
16
|
+
QuestionAnswer = Data.define(:id, :selected, :freeform, :selected_indices, :by) do
|
|
17
|
+
def initialize(id:, selected:, freeform: nil, selected_indices: [], by: nil)
|
|
18
|
+
super
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Whether a parent agent gave it (not the user).
|
|
22
|
+
def parent_agent? = by == "parent_agent"
|
|
23
|
+
|
|
24
|
+
# The event / JSON shape: symbol keys in member order; +by+ left out
|
|
25
|
+
# when nil, every other key kept even when nil.
|
|
26
|
+
# @return [Hash{Symbol => Object}]
|
|
27
|
+
def to_h = super.tap { |h| h.delete(:by) if h[:by].nil? }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -8,6 +8,7 @@ require "time"
|
|
|
8
8
|
require_relative "client_id"
|
|
9
9
|
require_relative "log"
|
|
10
10
|
require_relative "pending_question"
|
|
11
|
+
require_relative "question_answer"
|
|
11
12
|
require_relative "tools/ask_user_question"
|
|
12
13
|
require_relative "guardrails/parent_approvals"
|
|
13
14
|
require_relative "guardrails/parent_continue"
|
|
@@ -124,7 +125,7 @@ module Samagotchi
|
|
|
124
125
|
result = { dismissed: true, id: result[:id], note: note }
|
|
125
126
|
end
|
|
126
127
|
# Who answered is for the relay, not the model's tool result.
|
|
127
|
-
result = result.except(:by) if result.is_a?(
|
|
128
|
+
result = result.to_h.except(:by) if result.is_a?(QuestionAnswer)
|
|
128
129
|
result.is_a?(String) ? result : JSON.generate(result)
|
|
129
130
|
end
|
|
130
131
|
|
|
@@ -139,8 +140,8 @@ module Samagotchi
|
|
|
139
140
|
# question waits (outside the lock): a String closes the question with
|
|
140
141
|
# that reason, announced and returned as a cancel; nil keeps waiting.
|
|
141
142
|
# An answer recorded first wins. Not on the sync-handler path.
|
|
142
|
-
# @return [Hash, String] the answer {
|
|
143
|
-
#
|
|
143
|
+
# @return [QuestionAnswer, Hash, String] the answer, or {error:, …}; a
|
|
144
|
+
# String when a sync handler returned text itself
|
|
144
145
|
def open_question(fields, watch: nil)
|
|
145
146
|
supersede_standing
|
|
146
147
|
ask(fields, watch)
|
|
@@ -156,7 +157,7 @@ module Samagotchi
|
|
|
156
157
|
# close (as a superseded one does). A standing one already up is
|
|
157
158
|
# replaced (withdrawn, reason "replaced").
|
|
158
159
|
# @param fields [Hash] as for #open_question
|
|
159
|
-
# @param on_answer [#call] (answer, client_id:)
|
|
160
|
+
# @param on_answer [#call] (answer [QuestionAnswer], client_id:)
|
|
160
161
|
# @param on_superseded_close [#call, nil] called when a question that
|
|
161
162
|
# superseded it closes (its poster posts it again if it still
|
|
162
163
|
# stands); nil: posted again as it was
|
|
@@ -220,14 +221,16 @@ module Samagotchi
|
|
|
220
221
|
@answer
|
|
221
222
|
elsif sync_res.is_a?(Hash) && sync_res[:selected]
|
|
222
223
|
# Treat returned hash as answer (handler rendered and parsed)
|
|
223
|
-
@answer = sync_res
|
|
224
|
+
@answer = QuestionAnswer.new(id: id.to_s, selected: Array(sync_res[:selected]),
|
|
225
|
+
freeform: sync_res[:freeform],
|
|
226
|
+
selected_indices: Array(sync_res[:selected_indices]))
|
|
224
227
|
@pending = nil
|
|
225
|
-
|
|
228
|
+
@answer
|
|
226
229
|
end
|
|
227
230
|
end
|
|
228
231
|
if ans
|
|
229
232
|
clear_saved_question
|
|
230
|
-
emit({ type: :question_answered, id: id, answer: ans })
|
|
233
|
+
emit({ type: :question_answered, id: id, answer: ans.to_h })
|
|
231
234
|
return ans
|
|
232
235
|
end
|
|
233
236
|
if sync_res.is_a?(String) && !sync_res.strip.empty?
|
|
@@ -274,7 +277,7 @@ module Samagotchi
|
|
|
274
277
|
end
|
|
275
278
|
|
|
276
279
|
if answer
|
|
277
|
-
emit({ type: :question_answered, id: id, answer: answer })
|
|
280
|
+
emit({ type: :question_answered, id: id, answer: answer.to_h })
|
|
278
281
|
answer
|
|
279
282
|
else
|
|
280
283
|
{ error: "no answer", id: id }
|
|
@@ -289,7 +292,7 @@ module Samagotchi
|
|
|
289
292
|
# @param parent_agent [Boolean, nil] the answer is a parent agent's, so
|
|
290
293
|
# it is held to guardrails.parent_approvals on an approval and doesn't
|
|
291
294
|
# bring the session back to the lists; nil: chi answer's client id says so
|
|
292
|
-
# @return [
|
|
295
|
+
# @return [QuestionAnswer] the answer; a parent agent's carries by: "parent_agent"
|
|
293
296
|
# @raise [NotPending, ArgumentError, Refused]
|
|
294
297
|
def answer(id:, selected:, freeform: nil, client_id: nil, parent_agent: nil)
|
|
295
298
|
sel = Array(selected).map { |v| v.to_s.strip }.reject(&:empty?)
|
|
@@ -398,7 +401,8 @@ module Samagotchi
|
|
|
398
401
|
# web), single-select, kind "hook". A --non-interactive run has no one
|
|
399
402
|
# to ask: nil at once. Anything but an answer (a sync handler's text, no
|
|
400
403
|
# answer, cancelled) is nil too.
|
|
401
|
-
# @return [Hash, nil] {selected:, freeform:, selected_indices:}
|
|
404
|
+
# @return [Hash, nil] {selected:, freeform:, selected_indices:} (a plain
|
|
405
|
+
# Hash: the bundle boundary)
|
|
402
406
|
def ask_for_hook(question, options, header, allow_freeform, hook)
|
|
403
407
|
return nil if @interface_lookup.call == :non_interactive
|
|
404
408
|
|
|
@@ -411,11 +415,9 @@ module Samagotchi
|
|
|
411
415
|
fields = { question: question.to_s, options: opts, header: header, multi_select: false,
|
|
412
416
|
allow_freeform: !!allow_freeform, kind: "hook", hook: hook.to_s }.compact
|
|
413
417
|
answer = open_question(fields)
|
|
414
|
-
return nil unless answer.is_a?(
|
|
418
|
+
return nil unless answer.is_a?(QuestionAnswer)
|
|
415
419
|
|
|
416
|
-
|
|
417
|
-
result[:selected_indices] = answer[:selected_indices] if answer.key?(:selected_indices)
|
|
418
|
-
result
|
|
420
|
+
{ selected: answer.selected, freeform: answer.freeform, selected_indices: answer.selected_indices }
|
|
419
421
|
end
|
|
420
422
|
|
|
421
423
|
private
|
|
@@ -483,7 +485,7 @@ module Samagotchi
|
|
|
483
485
|
# handed to its poster with who answered.
|
|
484
486
|
def settle_standing(standing, answer, client_id)
|
|
485
487
|
clear_saved_question
|
|
486
|
-
emit({ type: :question_answered, id: answer
|
|
488
|
+
emit({ type: :question_answered, id: answer.id, answer: answer.to_h })
|
|
487
489
|
standing[:on_answer].call(answer, client_id: client_id)
|
|
488
490
|
rescue StandardError => e
|
|
489
491
|
Log.warn(:turn, "standing_answer_failed", error: e.class.name, message: e.message)
|
|
@@ -493,7 +495,7 @@ module Samagotchi
|
|
|
493
495
|
# == label in v1), one for a single-select, one or a text when required,
|
|
494
496
|
# and a parent agent's within guardrails.parent_approvals. Under the
|
|
495
497
|
# question lock: checked against the question pending now.
|
|
496
|
-
# @return [
|
|
498
|
+
# @return [QuestionAnswer]
|
|
497
499
|
# @raise [ArgumentError, Refused]
|
|
498
500
|
def validated_answer(pending, sel, fm, parent_agent:, parent_setting:)
|
|
499
501
|
opts = Array(pending.options)
|
|
@@ -508,9 +510,8 @@ module Samagotchi
|
|
|
508
510
|
reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: parent_setting)
|
|
509
511
|
raise Refused, reason if reason
|
|
510
512
|
end
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
answer
|
|
513
|
+
QuestionAnswer.new(id: pending.id.to_s, selected: sel, freeform: fm, selected_indices: indices.compact,
|
|
514
|
+
by: parent_agent ? "parent_agent" : nil)
|
|
514
515
|
end
|
|
515
516
|
|
|
516
517
|
# Wait until the question is answered, cancelled or closed by +watch+.
|
|
@@ -66,7 +66,8 @@ module Samagotchi
|
|
|
66
66
|
timeout: recap_number_setting(kwarg_config, :timeout, "recap.timeout", IdleRecap::DEFAULT_TIMEOUT_SECONDS, :float),
|
|
67
67
|
sentences: recap_sentences(string_config(kwarg_config, :sentences) || registry_string("recap.sentences")),
|
|
68
68
|
store: RecapStore.new(session_id_lookup: -> { session_id.call }, state_dir_lookup: -> { state_dir.call }),
|
|
69
|
-
session_id: -> { session_id.call }
|
|
69
|
+
session_id: -> { session_id.call },
|
|
70
|
+
on_usage: ->(usage) { engine.note_side_usage(usage) }
|
|
70
71
|
)
|
|
71
72
|
end
|
|
72
73
|
|
|
@@ -54,7 +54,7 @@ module Samagotchi
|
|
|
54
54
|
result = @engine.answer_question(id: question_id, selected: selected, freeform: answer["freeform"],
|
|
55
55
|
client_id: client_id, parent_agent: parent_agent)
|
|
56
56
|
log("relay_answered", relay_id, id: question_id, by: relay["by"])
|
|
57
|
-
[{}, 200, { status: "answered", question_id: question_id, answer: result }]
|
|
57
|
+
[{}, 200, { status: "answered", question_id: question_id, answer: result.to_h }]
|
|
58
58
|
rescue QuestionDesk::NotPending
|
|
59
59
|
not_pending(question_id)
|
|
60
60
|
rescue QuestionDesk::Refused => e
|