samagotchi 0.66.0 → 0.67.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -1
  3. data/docs/architecture.md +50 -28
  4. data/docs/cli.md +3 -1
  5. data/docs/configuration.md +50 -9
  6. data/docs/context.md +35 -1
  7. data/docs/desktop.md +90 -22
  8. data/docs/guardrails.md +8 -5
  9. data/docs/hooks.md +2 -2
  10. data/docs/internals/anthropic.md +156 -0
  11. data/docs/internals/background-tasks.md +1 -1
  12. data/docs/internals/bundles.md +2 -0
  13. data/docs/internals/engine.md +71 -29
  14. data/docs/internals/gemma4-contract.md +1 -1
  15. data/docs/internals/llm-context-bench.md +5 -4
  16. data/docs/internals/prompt-caching.md +26 -11
  17. data/docs/plugins.md +126 -23
  18. data/docs/sessions.md +6 -6
  19. data/docs/sub-agent.md +4 -1
  20. data/docs/testing.md +17 -3
  21. data/lib/samagotchi/bootstrap/probe.rb +4 -3
  22. data/lib/samagotchi/bootstrap_command.rb +13 -3
  23. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  24. data/lib/samagotchi/bridge.rb +6 -3
  25. data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
  26. data/lib/samagotchi/bundles/claude-peer/plugin.rb +34 -313
  27. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
  28. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
  29. data/lib/samagotchi/bundles/codex-peer/manifest.yml +7 -6
  30. data/lib/samagotchi/bundles/codex-peer/plugin.rb +37 -312
  31. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +8 -3
  32. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +14 -6
  33. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
  34. data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
  36. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  37. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  38. data/lib/samagotchi/config.rb +15 -7
  39. data/lib/samagotchi/context_absorber.rb +66 -13
  40. data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
  41. data/lib/samagotchi/desktop/macos/App.swift +3 -3
  42. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
  43. data/lib/samagotchi/desktop/macos/Images.swift +2 -2
  44. data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
  45. data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
  46. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
  47. data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
  48. data/lib/samagotchi/desktop/macos.rb +84 -13
  49. data/lib/samagotchi/desktop_command.rb +14 -3
  50. data/lib/samagotchi/engine.rb +96 -191
  51. data/lib/samagotchi/engine_peers.rb +3 -0
  52. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  53. data/lib/samagotchi/guardrails/approval.rb +1 -1
  54. data/lib/samagotchi/guardrails/approvals.rb +1 -1
  55. data/lib/samagotchi/guardrails/rules.rb +1 -1
  56. data/lib/samagotchi/guardrails/targets.rb +1 -1
  57. data/lib/samagotchi/hook_routes.rb +151 -0
  58. data/lib/samagotchi/hooks/registry.rb +2 -2
  59. data/lib/samagotchi/host_keys.rb +147 -0
  60. data/lib/samagotchi/llm/adapter_common.rb +138 -0
  61. data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
  62. data/lib/samagotchi/llm/api_key.rb +23 -7
  63. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  64. data/lib/samagotchi/llm/openai_chat.rb +9 -92
  65. data/lib/samagotchi/llm/refusal_memo.rb +29 -0
  66. data/lib/samagotchi/llm_context_override.rb +1 -0
  67. data/lib/samagotchi/peer_sources.rb +58 -0
  68. data/lib/samagotchi/plugin/api.rb +64 -155
  69. data/lib/samagotchi/plugin/loader.rb +4 -2
  70. data/lib/samagotchi/plugin/peer.rb +394 -0
  71. data/lib/samagotchi/plugin/tool_spec.rb +166 -0
  72. data/lib/samagotchi/plugin_tasks.rb +2 -2
  73. data/lib/samagotchi/row_refs.rb +1 -1
  74. data/lib/samagotchi/self_report.rb +5 -3
  75. data/lib/samagotchi/send_command.rb +21 -11
  76. data/lib/samagotchi/session_continue.rb +58 -15
  77. data/lib/samagotchi/session_inbox.rb +0 -13
  78. data/lib/samagotchi/tool_call_parser.rb +1 -1
  79. data/lib/samagotchi/toolkit_binding.rb +138 -0
  80. data/lib/samagotchi/tools/context_read.rb +14 -0
  81. data/lib/samagotchi/tools/execute.rb +12 -12
  82. data/lib/samagotchi/tools/registry.rb +1 -1
  83. data/lib/samagotchi/tools/task_create.rb +6 -6
  84. data/lib/samagotchi/tools/task_get.rb +13 -13
  85. data/lib/samagotchi/tools/task_list.rb +6 -6
  86. data/lib/samagotchi/tools/task_record.rb +52 -0
  87. data/lib/samagotchi/tools/task_runtime.rb +55 -91
  88. data/lib/samagotchi/tools/task_stop.rb +6 -6
  89. data/lib/samagotchi/tools/task_wait.rb +12 -13
  90. data/lib/samagotchi/tools/tool_index.rb +454 -0
  91. data/lib/samagotchi/update_command.rb +9 -6
  92. data/lib/samagotchi/version.rb +1 -1
  93. data/lib/samagotchi/web/app.rb +16 -9
  94. data/lib/samagotchi/web/capabilities.rb +1 -1
  95. data/lib/samagotchi/web/public/activity.js +2 -2
  96. data/lib/samagotchi/web/public/annotate_actions.js +1 -1
  97. data/lib/samagotchi/web/public/app.js +31 -36
  98. data/lib/samagotchi/web/public/chains.js +1 -1
  99. data/lib/samagotchi/web/public/commands.js +2 -2
  100. data/lib/samagotchi/web/public/composer_size.js +2 -2
  101. data/lib/samagotchi/web/public/copy.js +1 -1
  102. data/lib/samagotchi/web/public/ctx.js +1 -1
  103. data/lib/samagotchi/web/public/delivery_line.js +4 -4
  104. data/lib/samagotchi/web/public/families_view.js +1 -1
  105. data/lib/samagotchi/web/public/format.js +11 -7
  106. data/lib/samagotchi/web/public/images.js +25 -2
  107. data/lib/samagotchi/web/public/llm_context_chip.js +2 -2
  108. data/lib/samagotchi/web/public/notify.js +6 -4
  109. data/lib/samagotchi/web/public/question_card.js +1 -1
  110. data/lib/samagotchi/web/public/ref_scan.js +1 -1
  111. data/lib/samagotchi/web/public/session_state.js +14 -0
  112. data/lib/samagotchi/web/public/session_title.js +2 -3
  113. data/lib/samagotchi/web/public/sessions_list.js +0 -8
  114. data/lib/samagotchi/web/public/stage_model.js +2 -2
  115. data/lib/samagotchi/web/public/strip.js +3 -3
  116. data/lib/samagotchi/web/public/task_stop.js +3 -2
  117. data/lib/samagotchi/web/public/turn_events.js +1 -1
  118. data/lib/samagotchi/web/public/turn_html.js +2 -2
  119. data/lib/samagotchi/web/public/turn_model.js +2 -2
  120. data/lib/samagotchi/web/public/turn_view.js +1 -1
  121. data/lib/samagotchi/web/server.rb +0 -4
  122. data/lib/samagotchi/worker.rb +11 -8
  123. data/lib/samagotchi/worker_inbound.rb +36 -4
  124. data/lib/samagotchi/worker_wakes.rb +67 -7
  125. metadata +14 -1
@@ -5,11 +5,13 @@ require "uri"
5
5
  require_relative "../config"
6
6
  require_relative "../sampling_settings"
7
7
  require_relative "../thinking"
8
+ require_relative "adapter_common"
8
9
  require_relative "errors"
9
10
  require_relative "http"
10
11
  require_relative "model_listing"
11
12
  require_relative "openai_chat"
12
13
  require_relative "prompt_cache"
14
+ require_relative "refusal_memo"
13
15
  require_relative "anthropic_assembly"
14
16
  require_relative "anthropic_wire"
15
17
  require_relative "dialect"
@@ -43,10 +45,9 @@ module Samagotchi
43
45
  # names, per model.
44
46
  class AnthropicMessages
45
47
  include ModelListing
48
+ include AdapterCommon
46
49
 
47
50
  API_VERSION = "2023-06-01"
48
- DEFAULT_MODELS_TTL = 60
49
- MAX_MODEL_PAGES = 20
50
51
  # The budget form's budget_tokens per level (a model that takes no
51
52
  # adaptive thinking), and Messages' smallest budget.
52
53
  BUDGETS = { low: 2048, medium: 8192, high: 24_576 }.freeze
@@ -63,8 +64,6 @@ module Samagotchi
63
64
  \bdisplay\W{0,4}(?:extra\s+inputs\s+are\s+not\s+permitted|is\s+not\s+(?:permitted|allowed|supported))/xi
64
65
  # "max_tokens: 200000 > 128000, which is the maximum allowed number of output tokens for …"
65
66
  OUTPUT_CAP_RE = /max_tokens`?:?\s*\d+\s*>\s*(\d+)/i
66
- # How much of a 200's non-stream body is read as a possible JSON error.
67
- PLAIN_ERROR_LIMIT = 64 * 1024
68
67
  # max_tokens when default.max_tokens is unset and the model list names
69
68
  # none (a listed one is capped by it too).
70
69
  DEFAULT_MAX_TOKENS = 64_000
@@ -98,15 +97,12 @@ module Samagotchi
98
97
  @api_key_env = api_key_env
99
98
  @stream = stream
100
99
  init_model_listing(models_ttl)
101
- @thinking_form_refused = Set.new
102
- @output_caps = {}
103
- @cache_control_refused = false
104
- @thinking_display_refused = false
105
- open_timeout, read_timeout = timeouts(timeout, open_timeout)
106
- policy = retry_policy || (retries ? nil : HTTP::RetryPolicy.none)
107
- @http = HTTP.new(label: @host_name, open_timeout: open_timeout, read_timeout: read_timeout,
108
- retry_policy: policy, sleeper: sleeper, first_token_timeout: first_token_timeout,
109
- api_key: ApiKey.for(api_key_env, host: @host_name, env: env, scheme: auth || @dialect.auth))
100
+ # The 400 fixes (#refusal_fix): :thinking_form and :output_cap per
101
+ # model, :thinking_display and :cache_control for the host.
102
+ @refusals = RefusalMemo.new
103
+ @http = build_http(timeout: timeout, open_timeout: open_timeout, retries: retries, retry_policy: retry_policy,
104
+ sleeper: sleeper, first_token_timeout: first_token_timeout,
105
+ api_key: ApiKey.for(api_key_env, host: @host_name, env: env, scheme: auth || @dialect.auth))
110
106
  end
111
107
 
112
108
  # The same contract as OpenAIChat#chat; +messages+ are ChatLoop's
@@ -170,22 +166,11 @@ module Samagotchi
170
166
  # (max_input_tokens, max_tokens, capabilities) and the OpenAI shape a
171
167
  # gateway or Splash serves here are both read (ModelListing).
172
168
  # Whether +model+ gets the budget form (it refused adaptive thinking).
173
- def budget_form?(model) = @thinking_form_refused.include?(model.to_s)
169
+ def budget_form?(model) = @refusals.refused?(:thinking_form, model.to_s)
174
170
 
175
171
  # @return [Array<ModelInfo>]
176
172
  def list_models
177
- models = []
178
- after = nil
179
- MAX_MODEL_PAGES.times do
180
- query = URI.encode_www_form({ limit: 1000, after_id: after }.compact)
181
- uri = URI("#{@base_url}/v1/models?#{query}")
182
- body = parse_json(@http.fetch(uri, Net::HTTP::Get.new(uri), log_fields: { purpose: "models" }).body, "model list")
183
- models.concat(model_entries(body).map { |raw| model_info(raw) })
184
- break unless body.is_a?(Hash) && body["has_more"] && body["last_id"]
185
-
186
- after = body["last_id"].to_s
187
- end
188
- models
173
+ list_model_pages { |after| URI("#{@base_url}/v1/models?#{URI.encode_www_form({ limit: 1000, after_id: after }.compact)}") }
189
174
  end
190
175
 
191
176
  private
@@ -197,15 +182,6 @@ module Samagotchi
197
182
  window.is_a?(Integer) && window.positive? ? window : super
198
183
  end
199
184
 
200
- def timeouts(timeout, open_limit = nil)
201
- open_timeout = Samagotchi::Config.positive_seconds("server.open_timeout")
202
- read_timeout = Samagotchi::Config.positive_seconds("server.read_timeout")
203
- open_timeout = [open_timeout, open_limit].min if open_limit
204
- return [open_timeout, read_timeout] unless timeout
205
-
206
- [[open_timeout, timeout].min, timeout]
207
- end
208
-
209
185
  # The Messages body: model, max_tokens, the system blocks, the
210
186
  # messages, the stream flag, the tools (tool_choice auto with them),
211
187
  # then the options as Messages takes them (chi's OpenAI-only fields
@@ -213,11 +189,12 @@ module Samagotchi
213
189
  def request_body(messages, tools, model, options)
214
190
  options = (options || {}).to_h.transform_keys(&:to_sym).except(*DROPPED_OPTIONS)
215
191
  options = budget_form(options) if budget_form?(model)
216
- options = without_display(options) if @thinking_display_refused
217
- cache = PromptCache.control if @purpose == "chat" && @dialect.cache_breakpoints && !@cache_control_refused
192
+ options = without_display(options) if @refusals.refused?(:thinking_display)
193
+ cache = PromptCache.control if @purpose == "chat" && @dialect.cache_breakpoints && !@refusals.refused?(:cache_control)
218
194
  translated = AnthropicWire.translate(messages, cache: cache)
219
195
  max_tokens = options.delete(:max_tokens) || default_max_tokens(model)
220
- max_tokens = [max_tokens, @output_caps[model.to_s]].min if @output_caps[model.to_s]
196
+ output_cap = @refusals[:output_cap, model.to_s]
197
+ max_tokens = [max_tokens, output_cap].min if output_cap
221
198
  body = { model: model, max_tokens: max_tokens }
222
199
  body[:system] = translated.system unless translated.system.empty?
223
200
  body[:messages] = translated.messages
@@ -283,21 +260,21 @@ module Samagotchi
283
260
  detail = error.detail.to_s
284
261
  # Before the adaptive check: the refused field's path
285
262
  # ("thinking.adaptive.display") names adaptive too.
286
- if !@thinking_display_refused && body[:thinking].is_a?(Hash) && body[:thinking].key?(:display) &&
263
+ if !@refusals.refused?(:thinking_display) && body[:thinking].is_a?(Hash) && body[:thinking].key?(:display) &&
287
264
  DISPLAY_RE.match?(detail)
288
- @thinking_display_refused = true
265
+ @refusals.remember(:thinking_display)
289
266
  Log.info(:http, "thinking_display_refused", host: @host_name, detail: detail[0, 200])
290
267
  :thinking_display
291
268
  elsif body.dig(:thinking, :type) == "adaptive" && ADAPTIVE_REFUSED_RE.match?(detail)
292
- @thinking_form_refused << model.to_s
269
+ @refusals.remember(:thinking_form, model.to_s)
293
270
  Log.info(:http, "thinking_form_switched", host: @host_name, model: model.to_s, to: "budget", detail: detail[0, 200])
294
271
  :thinking_form
295
- elsif !@cache_control_refused && CACHE_CONTROL_RE.match?(detail) && cache_summary(body)
296
- @cache_control_refused = true
272
+ elsif !@refusals.refused?(:cache_control) && CACHE_CONTROL_RE.match?(detail) && cache_summary(body)
273
+ @refusals.remember(:cache_control)
297
274
  Log.info(:http, "cache_control_refused", host: @host_name, detail: detail[0, 200])
298
275
  :cache_control
299
276
  elsif (cap = detail[OUTPUT_CAP_RE, 1]&.to_i) && cap.positive? && body[:max_tokens].to_i > cap
300
- @output_caps[model.to_s] = cap
277
+ @refusals.remember(:output_cap, model.to_s, cap)
301
278
  Log.info(:http, "output_cap", host: @host_name, model: model.to_s, max_tokens: cap)
302
279
  :max_tokens
303
280
  end
@@ -341,75 +318,21 @@ module Samagotchi
341
318
  # `event:` lines are not needed, so a gateway that drops them works);
342
319
  # on_delta gets every event but pings, with its text and reasoning.
343
320
  def chat_streamed(request, cancel_controller, on_delta, on_retry, log_fields)
344
- assembly = AnthropicAssembly.new
345
- events = 0
346
- other = +""
347
- plain = +""
348
- # A retry streams the answer from the start again.
349
- restart = lambda do |**event|
350
- assembly = AnthropicAssembly.new
351
- plain = +""
352
- on_retry&.call(**event)
353
- end
354
- @http.stream_lines(URI(request.uri.to_s), request, cancel_controller: cancel_controller, on_retry: restart,
355
- log_fields: log_fields) do |line, shown|
356
- events += 1 if line.start_with?("data:")
357
- other << line[0, 200] if !line.start_with?("data:") && other.length < 200
358
- raise_plain_error(plain, line) if events.zero?
359
- event = parse_line(line)
360
- next unless event
361
-
321
+ stream_assembled(request, cancel_controller, on_retry, log_fields, assembly_class: AnthropicAssembly,
322
+ what: "stream event") do |assembly, event, shown|
362
323
  content, reasoning = assembly.add(event)
363
324
  shown.call if AnthropicAssembly.shows?(event)
364
325
  on_delta&.call(content: content, reasoning: reasoning, payload: assembly.with_usage(event)) unless event["type"] == "ping"
365
326
  end
366
- raise ProtocolError.new("#{@host_name}: the response had no stream events: #{other}", host: @host_name) if events.zero?
367
-
368
- assembly.response
369
- end
370
-
371
- # A 200 whose body is a plain JSON error instead of a stream: raised
372
- # from inside the stream (so it is retried by its kind) once the lines
373
- # so far parse as an error object (OpenAIChat's rule).
374
- def raise_plain_error(plain, line)
375
- return if line.empty? || line.start_with?(":") || (plain.empty? && !line.start_with?("{"))
376
- return if plain.bytesize > PLAIN_ERROR_LIMIT
377
-
378
- plain << line << "\n"
379
- error = ProviderErrors.from_error_body(plain, host: @host_name)
380
- raise error if error
381
- end
382
-
383
- # The parsed event of a `data:` line; nil for `event:` lines, blank
384
- # lines and comments. An error event raises its ProviderError.
385
- def parse_line(line)
386
- error = HTTP.sse_error(line, host: @host_name)
387
- raise error if error
388
- return nil unless line.start_with?("data:")
389
-
390
- data = line.delete_prefix("data:").strip
391
- return nil if data.empty? || data == "[DONE]"
392
-
393
- event = parse_json(data, "stream event")
394
- event.is_a?(Hash) ? event : nil
395
- end
396
-
397
- def parse_json(text, what)
398
- JSON.parse(text.to_s)
399
- rescue JSON::ParserError => e
400
- raise ProtocolError.new("#{@host_name}: malformed #{what}: #{e.message[0, 200]}", host: @host_name)
401
327
  end
402
328
 
403
329
  def post_request(url, body, session_id: nil)
404
- Net::HTTP::Post.new(URI(url)).tap do |request|
405
- request["Content-Type"] = "application/json"
406
- request["anthropic-version"] = API_VERSION
407
- unless session_id.to_s.empty?
408
- request["Session-Id"] = session_id.to_s
409
- request[@dialect.session_header] = session_id.to_s if @dialect.session_header
410
- end
411
- request.body = JSON.generate(body)
330
+ headers = { "anthropic-version" => API_VERSION }
331
+ unless session_id.to_s.empty?
332
+ headers["Session-Id"] = session_id.to_s
333
+ headers[@dialect.session_header] = session_id.to_s if @dialect.session_header
412
334
  end
335
+ json_post(url, body, headers)
413
336
  end
414
337
  end
415
338
  end
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "errors"
4
+ require_relative "../host_keys"
4
5
 
5
6
  module Samagotchi
6
7
  module LLM
7
- # A host's API key: the environment variable its api_key_env: names,
8
+ # A host's API key: the environment variable its api_key_env: names
9
+ # (else, on macOS, the login Keychain's item for that name: HostKeys),
8
10
  # sent on every request LLM::HTTP makes for the host (llama.cpp started
9
11
  # with --api-key, or a provider) in the header its +scheme+ names:
10
12
  # :bearer `Authorization: Bearer <key>`, :x_api_key `x-api-key: <key>`
@@ -26,14 +28,15 @@ module Samagotchi
26
28
 
27
29
  # What to try after a 401/403 from a host that has no api_key_env.
28
30
  def self.missing_hint(host)
29
- "the server may want an API key: put it in an environment variable and name it with api_key_env: on host #{host}"
31
+ where = HostKeys.available? ? "an environment variable (or the Keychain)" : "an environment variable"
32
+ "the server may want an API key: put it in #{where} and name it with api_key_env: on host #{host}"
30
33
  end
31
34
 
32
35
  # Sets the scheme's header (exactly one of the two), or raises
33
- # AuthError when the variable is not set.
36
+ # AuthError when neither the variable nor the Keychain has the key.
34
37
  def authorize(request)
35
- key = env[env_name].to_s
36
- raise AuthError.new("#{host}: set #{env_name} (the API key for host #{host})", host: host) if key.strip.empty?
38
+ key = HostKeys.fetch(env_name, env: env)
39
+ raise AuthError.new(unset_message, host: host) unless key
37
40
 
38
41
  if scheme == :x_api_key
39
42
  request.delete("Authorization")
@@ -44,14 +47,27 @@ module Samagotchi
44
47
  end
45
48
  end
46
49
 
47
- # What to try after a 401/403 with the key sent.
48
- def hint = "check #{env_name} (the API key for host #{host})"
50
+ # What to try after a 401/403 with the key sent, and where it came
51
+ # from (a Keychain key is a cache hit by then).
52
+ def hint
53
+ from = HostKeys.source(env_name, env: env) == :keychain ? " (from the Keychain)" : ""
54
+ "check #{env_name}#{from} (the API key for host #{host})"
55
+ end
49
56
 
50
57
  # Without the environment: it holds this key and every other secret.
51
58
  def inspect = "#<#{self.class.name} #{env_name} host=#{host}#{" scheme=#{scheme}" unless scheme == :bearer}>"
52
59
  alias_method :to_s, :inspect
53
60
 
54
61
  def pretty_print(printer) = printer.text(inspect)
62
+
63
+ private
64
+
65
+ def unset_message
66
+ message = "#{host}: set #{env_name} (the API key for host #{host})"
67
+ return message unless HostKeys.available?
68
+
69
+ "#{message} or store it in the Keychain: #{HostKeys.store_command(env_name)}"
70
+ end
55
71
  end
56
72
  ApiKey::SCHEMES = %i[bearer x_api_key].freeze
57
73
  end
@@ -8,6 +8,7 @@ require_relative "errors"
8
8
  require_relative "usage"
9
9
  require_relative "openai_chat"
10
10
  require_relative "native_tool_normalizer"
11
+ require_relative "refusal_memo"
11
12
  require_relative "stream_drops"
12
13
  require_relative "../kernel_loop"
13
14
  require_relative "../answer_display"
@@ -70,6 +71,9 @@ module Samagotchi
70
71
  @adapter = adapter
71
72
  @stable_length = stable_length
72
73
  @sleeper = sleeper || ->(seconds) { sleep(seconds) }
74
+ # What a host refused per model, for this loop's (the worker's)
75
+ # life: :reasoning_history, :thinking, :sampling.
76
+ @refusals = RefusalMemo.new
73
77
  end
74
78
 
75
79
  def provider = :chat
@@ -242,7 +246,7 @@ module Samagotchi
242
246
  # blocks always go back (a tool loop with thinking needs the last
243
247
  # assistant turn's), whatever the level says.
244
248
  def reasoning_history(model = nil)
245
- level = model && @reasoning_history_refused&.include?(model) ? :drop : @kernel.turn_settings&.reasoning_history
249
+ level = model && @refusals.refused?(:reasoning_history, model) ? :drop : @kernel.turn_settings&.reasoning_history
246
250
  level ||= :drop
247
251
  level == :drop && replays_content? ? :turn : level
248
252
  end
@@ -254,7 +258,7 @@ module Samagotchi
254
258
  # The host refused +model+'s reasoning sent back: send none from now on
255
259
  # (this worker's lifetime, as #thinking_refused!).
256
260
  def reasoning_history_refused!(model)
257
- (@reasoning_history_refused ||= Set.new) << model
261
+ @refusals.remember(:reasoning_history, model)
258
262
  end
259
263
 
260
264
  # The turn's VisionContext (the Engine sets the kernel's
@@ -274,14 +278,14 @@ module Samagotchi
274
278
  # takes them (OpenAIChat#thinking_fields: Thinking.chat_fields); none
275
279
  # for a model whose host refused them (#thinking_refused!).
276
280
  def thinking_fields(model = nil)
277
- return {} if model && (@thinking_refused ||= Set.new).include?(model)
281
+ return {} if model && @refusals.refused?(:thinking, model)
278
282
 
279
283
  adapter.thinking_fields(thinking)
280
284
  end
281
285
 
282
286
  # The host refused +model+'s thinking fields: leave them out from now on.
283
287
  def thinking_refused!(model)
284
- (@thinking_refused ||= Set.new) << model
288
+ @refusals.remember(:thinking, model)
285
289
  end
286
290
 
287
291
  # One generation's options: default.max_tokens as max_tokens (the
@@ -296,7 +300,7 @@ module Samagotchi
296
300
  options = deep_merge(thinking_fields(model), sampling)
297
301
  options = { max_tokens: max_tokens }.merge(options) if max_tokens
298
302
  options = EmptyAnswerRetry.sampling(options) if retry_generation
299
- options = options.except(*SAMPLING_KEYS) if model && @sampling_refused&.include?(model)
303
+ options = options.except(*SAMPLING_KEYS) if model && @refusals.refused?(:sampling, model)
300
304
  deep_compact(options)
301
305
  end
302
306
 
@@ -306,7 +310,7 @@ module Samagotchi
306
310
  # The host refused a sampling field for +model+: send none of
307
311
  # SAMPLING_KEYS from now on (this worker's lifetime, as #thinking_refused!).
308
312
  def sampling_refused!(model)
309
- (@sampling_refused ||= Set.new) << model
313
+ @refusals.remember(:sampling, model)
310
314
  end
311
315
 
312
316
  def strip_model_thought(text) = @kernel.strip_model_thought(text)
@@ -5,6 +5,7 @@ require "uri"
5
5
  require_relative "../config"
6
6
  require_relative "../sampling_settings"
7
7
  require_relative "../thinking"
8
+ require_relative "adapter_common"
8
9
  require_relative "errors"
9
10
  require_relative "http"
10
11
  require_relative "model_listing"
@@ -70,11 +71,8 @@ module Samagotchi
70
71
  # only; the chat loop owns the conversation and the tools.
71
72
  class OpenAIChat
72
73
  include ModelListing
74
+ include AdapterCommon
73
75
 
74
- MAX_MODEL_PAGES = 20
75
- # How much of a 200's non-stream body is read as a possible JSON error.
76
- PLAIN_ERROR_LIMIT = 64 * 1024
77
- DEFAULT_MODELS_TTL = 60
78
76
  # OpenRouter holds a paid request's estimated cost against the balance
79
77
  # while it runs: the input plus the output max_tokens allows (a fixed
80
78
  # per-request cap when it is unset), and refuses a request with a 402
@@ -154,11 +152,9 @@ module Samagotchi
154
152
  @api_key_env = api_key_env
155
153
  @stream = stream
156
154
  init_model_listing(models_ttl)
157
- open_timeout, read_timeout = timeouts(timeout, open_timeout)
158
- policy = retry_policy || (retries ? nil : HTTP::RetryPolicy.none)
159
- @http = HTTP.new(label: @host_name, open_timeout: open_timeout, read_timeout: read_timeout,
160
- retry_policy: policy, sleeper: sleeper, first_token_timeout: first_token_timeout,
161
- api_key: ApiKey.for(api_key_env, host: @host_name, env: env, scheme: auth))
155
+ @http = build_http(timeout: timeout, open_timeout: open_timeout, retries: retries, retry_policy: retry_policy,
156
+ sleeper: sleeper, first_token_timeout: first_token_timeout,
157
+ api_key: ApiKey.for(api_key_env, host: @host_name, env: env, scheme: auth))
162
158
  end
163
159
 
164
160
  # @param messages [Array<Hash>] wire messages (role, content, tool_calls,
@@ -180,33 +176,12 @@ module Samagotchi
180
176
  cache: cache_summary(body[:messages]), cache_by: ("session" if body.key?(:prompt_cache_key)) }
181
177
  return chat_once(request, cancel_controller, log_fields) unless @stream
182
178
 
183
- assembly = Assembly.new
184
- events = 0
185
- other = +""
186
- plain = +""
187
- # A retry streams the answer from the start again.
188
- restart = lambda do |**event|
189
- assembly = Assembly.new
190
- plain = +""
191
- on_retry&.call(**event)
192
- end
193
- @http.stream_lines(URI(request.uri.to_s), request, cancel_controller: cancel_controller, on_retry: restart,
194
- log_fields: log_fields) do |line, shown|
195
- events += 1 if line.start_with?("data:")
196
- other << line[0, 200] if !line.start_with?("data:") && other.length < 200
197
- raise_plain_error(plain, line) if events.zero?
198
- payload = parse_line(line)
199
- next unless payload
200
-
179
+ stream_assembled(request, cancel_controller, on_retry, log_fields, assembly_class: Assembly,
180
+ what: "stream chunk") do |assembly, payload, shown|
201
181
  content, reasoning = assembly.add(payload)
202
182
  shown.call if !content.empty? || !reasoning.empty? || Assembly.tool_call_delta?(payload)
203
183
  on_delta&.call(content: content, reasoning: reasoning, payload: payload)
204
184
  end
205
- # A server that ignores stream: true, or answers with something else
206
- # entirely, would otherwise end the turn with no answer and no error.
207
- raise ProtocolError.new("#{@host_name}: the response had no stream events: #{other}", host: @host_name) if events.zero?
208
-
209
- assembly.response
210
185
  end
211
186
 
212
187
  # A provider on the network: no llama.cpp /props to ask for the window.
@@ -233,17 +208,7 @@ module Samagotchi
233
208
 
234
209
  # @return [Array<ModelInfo>] the host's models (every page)
235
210
  def list_models
236
- models = []
237
- after = nil
238
- MAX_MODEL_PAGES.times do
239
- uri = URI("#{@base_url}/models#{"?after=#{URI.encode_www_form_component(after)}" if after}")
240
- body = parse_json(@http.fetch(uri, get_request(uri), log_fields: { purpose: "models" }).body, "model list")
241
- models.concat(model_entries(body).map { |raw| model_info(raw) })
242
- break unless body.is_a?(Hash) && body["has_more"] && body["last_id"]
243
-
244
- after = body["last_id"].to_s
245
- end
246
- models
211
+ list_model_pages { |after| URI("#{@base_url}/models#{"?after=#{URI.encode_www_form_component(after)}" if after}") }
247
212
  end
248
213
 
249
214
  def self.served_model(payload)
@@ -260,15 +225,6 @@ module Samagotchi
260
225
 
261
226
  private
262
227
 
263
- def timeouts(timeout, open_limit = nil)
264
- open_timeout = Samagotchi::Config.positive_seconds("server.open_timeout")
265
- read_timeout = Samagotchi::Config.positive_seconds("server.read_timeout")
266
- open_timeout = [open_timeout, open_limit].min if open_limit
267
- return [open_timeout, read_timeout] unless timeout
268
-
269
- [[open_timeout, timeout].min, timeout]
270
- end
271
-
272
228
  def request_body(messages, tools, model, options, session_id)
273
229
  body = {
274
230
  model: model,
@@ -364,49 +320,10 @@ module Samagotchi
364
320
 
365
321
  def served_provider(payload) = self.class.served_provider(payload)
366
322
 
367
- # The parsed payload of a `data:` line; nil for blank lines, comments
368
- # and [DONE]. Error events raise their ProviderError.
369
- # A 200 whose body is a plain JSON error instead of a stream (OpenRouter
370
- # sends an upstream 503 so): raised, from inside the stream so it is
371
- # retried by its kind, once the lines so far parse as an error object.
372
- def raise_plain_error(plain, line)
373
- return if line.empty? || line.start_with?(":") || (plain.empty? && !line.start_with?("{"))
374
- return if plain.bytesize > PLAIN_ERROR_LIMIT
375
-
376
- plain << line << "\n"
377
- error = ProviderErrors.from_error_body(plain, host: @host_name)
378
- raise error if error
379
- end
380
-
381
- def parse_line(line)
382
- error = HTTP.sse_error(line, host: @host_name)
383
- raise error if error
384
- return nil unless line.start_with?("data:")
385
-
386
- data = line.delete_prefix("data:").strip
387
- return nil if data.empty? || data == "[DONE]"
388
-
389
- payload = parse_json(data, "stream chunk")
390
- payload.is_a?(Hash) ? payload : nil
391
- end
392
-
393
- def parse_json(text, what)
394
- JSON.parse(text.to_s)
395
- rescue JSON::ParserError => e
396
- raise ProtocolError.new("#{@host_name}: malformed #{what}: #{e.message[0, 200]}", host: @host_name)
397
- end
398
-
399
323
  def post_request(url, body, session_id: nil)
400
- uri = URI(url)
401
- Net::HTTP::Post.new(uri).tap do |request|
402
- request["Content-Type"] = "application/json"
403
- request["Session-Id"] = session_id.to_s unless session_id.to_s.empty?
404
- request.body = JSON.generate(body)
405
- end
324
+ json_post(url, body, session_id.to_s.empty? ? {} : { "Session-Id" => session_id.to_s })
406
325
  end
407
326
 
408
- def get_request(uri) = Net::HTTP::Get.new(uri)
409
-
410
327
  # Streamed deltas put together: text, reasoning, tool calls by index,
411
328
  # the finish reason, the usage chunk, the served model and provider.
412
329
  class Assembly
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module LLM
5
+ # What a host refused, remembered for its holder's life (an adapter: the
6
+ # host's, HostRegistry keeps one per worker; ChatLoop: the worker's): a
7
+ # +kind+ (a Symbol) for one model, or for the whole host when the model
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.
11
+ class RefusalMemo
12
+ def initialize
13
+ @mutex = Mutex.new
14
+ @facts = {}
15
+ end
16
+
17
+ # Remember +kind+ for +model+ (nil: the host) as +value+.
18
+ def remember(kind, model = nil, value = true)
19
+ @mutex.synchronize { @facts[[kind, model]] = value }
20
+ end
21
+
22
+ # Whether +kind+ is remembered for +model+ (nil: the host).
23
+ def refused?(kind, model = nil) = @mutex.synchronize { @facts.key?([kind, model]) }
24
+
25
+ # The value remembered for +kind+ and +model+, or nil.
26
+ def [](kind, model = nil) = @mutex.synchronize { @facts[[kind, model]] }
27
+ end
28
+ end
29
+ end
@@ -153,6 +153,7 @@ module Samagotchi
153
153
  end
154
154
 
155
155
  # "64000", "64k" or "off" (0): LLMContextStrategy.budget_tokens.
156
+ # .update reaches it (and the other parse_<field>s) by its name.
156
157
  # @return [Integer] 0 for off
157
158
  def self.parse_budget_tokens(text) = LLMContextStrategy.budget_tokens(text)
158
159
 
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "context_sources"
4
+
5
+ module Samagotchi
6
+ # The attached context sources a loaded peer bundle declared as its
7
+ # peer's answers (chi.peer_source: claude-review, codex-review), with the
8
+ # peer's name ("Claude"). In-process only: a source is a peer's because
9
+ # the bundle's code says so, never because of what a source file holds,
10
+ # which a pushing process can write. Uninstalling the bundle ends it.
11
+ #
12
+ # A declared name counts only for a pushed source in the session's own
13
+ # location (ContextAbsorber#peer_label): a command or project source of
14
+ # that name is an ordinary one. See docs/context.md (Waking).
15
+ class PeerSources
16
+ # The peer's name, shown inside chi's own framing lines.
17
+ LABEL = /\A[A-Z][A-Za-z ]{0,23}\z/
18
+
19
+ def initialize
20
+ @labels = {}
21
+ @bundles = {}
22
+ @lock = Mutex.new
23
+ end
24
+
25
+ # Checks +name+ and +label+ before a plugin's registrations commit.
26
+ # @raise [ArgumentError]
27
+ def check!(name, label:, bundle:)
28
+ name = name.to_s
29
+ begin
30
+ ContextSources.check_name!(name)
31
+ rescue ContextSources::Invalid => e
32
+ raise ArgumentError, "peer source: #{e.message}"
33
+ end
34
+ raise ArgumentError, "peer source #{name}: label must match #{LABEL.inspect}" unless label.to_s.match?(LABEL)
35
+
36
+ taken = @lock.synchronize { @bundles[name] }
37
+ raise ArgumentError, "peer source #{name} is already declared (#{taken})" if taken && taken != bundle
38
+
39
+ name
40
+ end
41
+
42
+ # Declares +name+ +label+'s answers (+bundle+: who declared it).
43
+ # @raise [ArgumentError]
44
+ def declare(name, label:, bundle:)
45
+ name = check!(name, label: label, bundle: bundle)
46
+ @lock.synchronize do
47
+ @labels[name] = label.to_s
48
+ @bundles[name] = bundle
49
+ end
50
+ nil
51
+ end
52
+
53
+ # @return [String, nil] the peer's name when +name+ is declared
54
+ def label_for(name) = @lock.synchronize { @labels[name.to_s] }
55
+
56
+ def names = @lock.synchronize { @labels.keys }
57
+ end
58
+ end