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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -1
  3. data/README.md +7 -5
  4. data/docs/cli.md +24 -7
  5. data/docs/configuration.md +11 -6
  6. data/docs/internals/anthropic.md +12 -5
  7. data/docs/internals/bundles.md +4 -0
  8. data/docs/internals/engine.md +15 -5
  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 +8 -4
  27. data/lib/samagotchi/llm/anthropic_binding_recovery.rb +198 -0
  28. data/lib/samagotchi/llm/anthropic_messages.rb +39 -178
  29. data/lib/samagotchi/llm/anthropic_wire.rb +7 -1
  30. data/lib/samagotchi/llm/chat_loop.rb +21 -12
  31. data/lib/samagotchi/llm/http.rb +12 -7
  32. data/lib/samagotchi/llm/model_listing.rb +29 -1
  33. data/lib/samagotchi/llm/refusal_memo.rb +3 -2
  34. data/lib/samagotchi/plugin/card.rb +44 -0
  35. data/lib/samagotchi/plugin/context.rb +53 -8
  36. data/lib/samagotchi/plugin/http.rb +154 -0
  37. data/lib/samagotchi/plugin/http_transport.rb +77 -0
  38. data/lib/samagotchi/plugin/loader.rb +29 -10
  39. data/lib/samagotchi/question_answer.rb +29 -0
  40. data/lib/samagotchi/question_desk.rb +30 -22
  41. data/lib/samagotchi/reasoning_history.rb +2 -1
  42. data/lib/samagotchi/relay_verifier.rb +1 -1
  43. data/lib/samagotchi/testing/boundary.rb +156 -0
  44. data/lib/samagotchi/testing/bundle.rb +349 -0
  45. data/lib/samagotchi/testing/fake_executable.rb +42 -0
  46. data/lib/samagotchi/testing/fake_http.rb +137 -0
  47. data/lib/samagotchi/testing/helpers.rb +56 -0
  48. data/lib/samagotchi/testing/host.rb +76 -0
  49. data/lib/samagotchi/testing/minitest.rb +31 -0
  50. data/lib/samagotchi/testing/recorder.rb +138 -0
  51. data/lib/samagotchi/testing/recording_api.rb +27 -0
  52. data/lib/samagotchi/testing/rspec.rb +31 -0
  53. data/lib/samagotchi/testing/sandbox.rb +163 -0
  54. data/lib/samagotchi/testing/sessions.rb +77 -0
  55. data/lib/samagotchi/testing/verdict.rb +28 -0
  56. data/lib/samagotchi/testing/waiting.rb +36 -0
  57. data/lib/samagotchi/testing.rb +26 -0
  58. data/lib/samagotchi/tools/delegate_relay.rb +7 -6
  59. data/lib/samagotchi/tools/tool_index.rb +3 -0
  60. data/lib/samagotchi/turn_state.rb +28 -1
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/web/public/app.js +79 -245
  63. data/lib/samagotchi/web/public/composer_grip.js +231 -0
  64. data/lib/samagotchi/web/public/images.js +33 -0
  65. data/lib/samagotchi/web/public/index.html +73 -40
  66. data/lib/samagotchi/web/public/level_chip.js +11 -11
  67. data/lib/samagotchi/web/public/lists_view.js +19 -14
  68. data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
  69. data/lib/samagotchi/web/public/model_picker.js +5 -6
  70. data/lib/samagotchi/web/public/scope.js +3 -3
  71. data/lib/samagotchi/web/public/stage_view.js +3 -2
  72. data/lib/samagotchi/web/public/storage.js +23 -0
  73. data/lib/samagotchi/web/public/strip.js +35 -9
  74. data/lib/samagotchi/web/public/strip_grip.js +76 -0
  75. data/lib/samagotchi/web/public/thinking_chip.js +5 -5
  76. data/lib/samagotchi/web/public/toasts.js +5 -4
  77. 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 = %w[thinking redacted_thinking].freeze
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| block.is_a?(Hash) && THINKING_BLOCKS.include?((block["type"] || block[:type]).to_s) } unless keep_thinking
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))
@@ -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). Probes and model lists are DEBUG.
47
- LOGGED_PURPOSES = %w[chat recap warmup].freeze
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["anthropic-thinking-prefix-mismatch"])
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 && (monotonic - @models_at) < @models_ttl
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, side-ask and recap threads share a holder, so every read and
10
- # write takes the lock.
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:) 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