samagotchi 0.79.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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -1
  3. data/README.md +7 -5
  4. data/docs/cli.md +24 -7
  5. data/docs/configuration.md +7 -4
  6. data/docs/internals/anthropic.md +3 -1
  7. data/docs/internals/bundles.md +4 -0
  8. data/docs/internals/engine.md +13 -3
  9. data/docs/plugins.md +144 -3
  10. data/docs/releasing.md +2 -2
  11. data/docs/sessions.md +1 -1
  12. data/docs/testing.md +32 -2
  13. data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
  14. data/lib/samagotchi/bootstrap/probe.rb +153 -31
  15. data/lib/samagotchi/bootstrap_command.rb +92 -15
  16. data/lib/samagotchi/bridge.rb +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  19. data/lib/samagotchi/continue_offer.rb +5 -5
  20. data/lib/samagotchi/engine.rb +40 -37
  21. data/lib/samagotchi/guardrails/approval.rb +6 -4
  22. data/lib/samagotchi/guardrails/gate.rb +7 -3
  23. data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
  24. data/lib/samagotchi/hooks/registry.rb +44 -3
  25. data/lib/samagotchi/host_registry.rb +12 -4
  26. data/lib/samagotchi/llm/adapter_common.rb +4 -2
  27. data/lib/samagotchi/llm/anthropic_binding_recovery.rb +3 -0
  28. data/lib/samagotchi/llm/anthropic_messages.rb +2 -1
  29. data/lib/samagotchi/llm/http.rb +12 -7
  30. data/lib/samagotchi/plugin/card.rb +44 -0
  31. data/lib/samagotchi/plugin/context.rb +53 -8
  32. data/lib/samagotchi/plugin/http.rb +154 -0
  33. data/lib/samagotchi/plugin/http_transport.rb +77 -0
  34. data/lib/samagotchi/plugin/loader.rb +29 -10
  35. data/lib/samagotchi/question_answer.rb +29 -0
  36. data/lib/samagotchi/question_desk.rb +20 -19
  37. data/lib/samagotchi/relay_verifier.rb +1 -1
  38. data/lib/samagotchi/testing/boundary.rb +156 -0
  39. data/lib/samagotchi/testing/bundle.rb +349 -0
  40. data/lib/samagotchi/testing/fake_executable.rb +42 -0
  41. data/lib/samagotchi/testing/fake_http.rb +137 -0
  42. data/lib/samagotchi/testing/helpers.rb +56 -0
  43. data/lib/samagotchi/testing/host.rb +76 -0
  44. data/lib/samagotchi/testing/minitest.rb +31 -0
  45. data/lib/samagotchi/testing/recorder.rb +138 -0
  46. data/lib/samagotchi/testing/recording_api.rb +27 -0
  47. data/lib/samagotchi/testing/rspec.rb +31 -0
  48. data/lib/samagotchi/testing/sandbox.rb +163 -0
  49. data/lib/samagotchi/testing/sessions.rb +77 -0
  50. data/lib/samagotchi/testing/verdict.rb +28 -0
  51. data/lib/samagotchi/testing/waiting.rb +36 -0
  52. data/lib/samagotchi/testing.rb +26 -0
  53. data/lib/samagotchi/tools/delegate_relay.rb +7 -6
  54. data/lib/samagotchi/tools/tool_index.rb +3 -0
  55. data/lib/samagotchi/turn_state.rb +28 -1
  56. data/lib/samagotchi/version.rb +1 -1
  57. data/lib/samagotchi/web/public/app.js +47 -23
  58. data/lib/samagotchi/web/public/images.js +33 -0
  59. data/lib/samagotchi/web/public/index.html +71 -38
  60. data/lib/samagotchi/web/public/level_chip.js +11 -11
  61. data/lib/samagotchi/web/public/lists_view.js +19 -14
  62. data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
  63. data/lib/samagotchi/web/public/model_picker.js +1 -1
  64. data/lib/samagotchi/web/public/scope.js +3 -3
  65. data/lib/samagotchi/web/public/strip.js +35 -9
  66. data/lib/samagotchi/web/public/strip_grip.js +76 -0
  67. data/lib/samagotchi/web/public/thinking_chip.js +5 -5
  68. metadata +21 -1
@@ -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:) and returns
20
- # the answer text.
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
- def initialize(bundle:, label:, settings:, host:, env: ENV)
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).to_s
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
@@ -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 = nil
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 = fresh_namespace(bundle_name)
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)
@@ -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?(Hash)
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 {id:, selected:, freeform:, selected_indices:},
143
- # or {error:, …}; a String when a sync handler returned text itself
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
- sync_res
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 [Hash] normalized answer; a parent agent's carries by: "parent_agent"
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?(Hash) && answer[:selected]
418
+ return nil unless answer.is_a?(QuestionAnswer)
415
419
 
416
- result = { selected: Array(answer[:selected]), freeform: answer[:freeform] }
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[:id], answer: 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 [Hash] {id:, selected:, freeform:, selected_indices:, by:}
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
- answer = { id: pending.id.to_s, selected: sel, freeform: fm, selected_indices: indices.compact }
512
- answer[:by] = "parent_agent" if parent_agent
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+.
@@ -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