samagotchi 0.2.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 (243) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +126 -0
  5. data/bin/chi +1140 -0
  6. data/docs/architecture.md +299 -0
  7. data/docs/cli.md +490 -0
  8. data/docs/configuration.md +494 -0
  9. data/docs/desktop.md +97 -0
  10. data/docs/guardrails.md +218 -0
  11. data/docs/hooks.md +309 -0
  12. data/docs/internals/background-tasks.md +26 -0
  13. data/docs/internals/context-telemetry.md +36 -0
  14. data/docs/internals/gemma4-contract.md +23 -0
  15. data/docs/internals/tool-guardrails.md +45 -0
  16. data/docs/memory.md +85 -0
  17. data/docs/plugins.md +819 -0
  18. data/docs/releasing.md +135 -0
  19. data/docs/sessions.md +155 -0
  20. data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
  21. data/lib/samagotchi/bridge/card_store.rb +126 -0
  22. data/lib/samagotchi/bridge/event_id.rb +25 -0
  23. data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
  24. data/lib/samagotchi/bridge/sse_writer.rb +248 -0
  25. data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
  26. data/lib/samagotchi/bridge.rb +993 -0
  27. data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
  28. data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
  29. data/lib/samagotchi/bridge_client.rb +330 -0
  30. data/lib/samagotchi/bundle_needs.rb +97 -0
  31. data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
  32. data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
  34. data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
  35. data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
  36. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
  37. data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
  38. data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
  39. data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
  40. data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
  44. data/lib/samagotchi/bundles/system/delegated.md +10 -0
  45. data/lib/samagotchi/bundles/system/identity.md +7 -0
  46. data/lib/samagotchi/bundles/system/manifest.yml +11 -0
  47. data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
  48. data/lib/samagotchi/bundles/system/self_map.md +55 -0
  49. data/lib/samagotchi/cancellation_controller.rb +78 -0
  50. data/lib/samagotchi/client.rb +429 -0
  51. data/lib/samagotchi/commands/registry.rb +112 -0
  52. data/lib/samagotchi/config.rb +910 -0
  53. data/lib/samagotchi/context_note.rb +77 -0
  54. data/lib/samagotchi/context_quote.rb +21 -0
  55. data/lib/samagotchi/context_usage.rb +66 -0
  56. data/lib/samagotchi/context_window.rb +76 -0
  57. data/lib/samagotchi/debug_log.rb +110 -0
  58. data/lib/samagotchi/desktop/macos/App.swift +102 -0
  59. data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
  60. data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
  61. data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
  62. data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
  63. data/lib/samagotchi/desktop/macos.rb +255 -0
  64. data/lib/samagotchi/desktop.rb +21 -0
  65. data/lib/samagotchi/desktop_command.rb +143 -0
  66. data/lib/samagotchi/engine.rb +2807 -0
  67. data/lib/samagotchi/guardrails/approval.rb +125 -0
  68. data/lib/samagotchi/guardrails/approvals.rb +177 -0
  69. data/lib/samagotchi/guardrails/context.rb +71 -0
  70. data/lib/samagotchi/guardrails/gate.rb +125 -0
  71. data/lib/samagotchi/guardrails/load_failures.rb +46 -0
  72. data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
  73. data/lib/samagotchi/guardrails/rules.rb +199 -0
  74. data/lib/samagotchi/guardrails/targets.rb +119 -0
  75. data/lib/samagotchi/guardrails/verdict.rb +134 -0
  76. data/lib/samagotchi/guardrails.rb +18 -0
  77. data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
  78. data/lib/samagotchi/hooks/loader.rb +162 -0
  79. data/lib/samagotchi/hooks/registry.rb +261 -0
  80. data/lib/samagotchi/hooks.rb +30 -0
  81. data/lib/samagotchi/host_registry.rb +315 -0
  82. data/lib/samagotchi/idle_client.rb +147 -0
  83. data/lib/samagotchi/idle_recap.rb +549 -0
  84. data/lib/samagotchi/idle_reminders.rb +101 -0
  85. data/lib/samagotchi/idle_scheduler.rb +76 -0
  86. data/lib/samagotchi/image_store.rb +393 -0
  87. data/lib/samagotchi/installed_gem.rb +38 -0
  88. data/lib/samagotchi/kernel_loop.rb +1017 -0
  89. data/lib/samagotchi/launch_mode.rb +34 -0
  90. data/lib/samagotchi/llm/backend.rb +28 -0
  91. data/lib/samagotchi/llm/chat_loop.rb +450 -0
  92. data/lib/samagotchi/llm/errors.rb +329 -0
  93. data/lib/samagotchi/llm/http.rb +412 -0
  94. data/lib/samagotchi/llm/model_result.rb +72 -0
  95. data/lib/samagotchi/llm/native_backend.rb +50 -0
  96. data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
  97. data/lib/samagotchi/llm/openai_chat.rb +403 -0
  98. data/lib/samagotchi/llm/usage.rb +79 -0
  99. data/lib/samagotchi/log.rb +200 -0
  100. data/lib/samagotchi/log_line.rb +127 -0
  101. data/lib/samagotchi/log_path.rb +31 -0
  102. data/lib/samagotchi/log_subscriber.rb +163 -0
  103. data/lib/samagotchi/memory_bundle/builder.rb +364 -0
  104. data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
  105. data/lib/samagotchi/memory_bundle/installer.rb +528 -0
  106. data/lib/samagotchi/memory_bundle/listing.rb +72 -0
  107. data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
  108. data/lib/samagotchi/memory_bundle/merger.rb +52 -0
  109. data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
  110. data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
  111. data/lib/samagotchi/memory_bundle/source.rb +153 -0
  112. data/lib/samagotchi/memory_bundle/status.rb +107 -0
  113. data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
  114. data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
  115. data/lib/samagotchi/memory_bundle.rb +17 -0
  116. data/lib/samagotchi/memory_paths.rb +101 -0
  117. data/lib/samagotchi/model_overlay.rb +53 -0
  118. data/lib/samagotchi/model_profile.rb +309 -0
  119. data/lib/samagotchi/muted_memories.rb +66 -0
  120. data/lib/samagotchi/note_command.rb +163 -0
  121. data/lib/samagotchi/output_formatter.rb +100 -0
  122. data/lib/samagotchi/owner_lock.rb +110 -0
  123. data/lib/samagotchi/pending_input_queue.rb +48 -0
  124. data/lib/samagotchi/plugin/api.rb +362 -0
  125. data/lib/samagotchi/plugin/context.rb +193 -0
  126. data/lib/samagotchi/plugin/loader.rb +126 -0
  127. data/lib/samagotchi/plugin/service.rb +117 -0
  128. data/lib/samagotchi/plugin/sessions.rb +150 -0
  129. data/lib/samagotchi/plugin/side_question.rb +60 -0
  130. data/lib/samagotchi/plugin/tool_result.rb +24 -0
  131. data/lib/samagotchi/project_scope.rb +25 -0
  132. data/lib/samagotchi/prompt.rb +119 -0
  133. data/lib/samagotchi/prompt_literal_guard.rb +70 -0
  134. data/lib/samagotchi/recap_store.rb +92 -0
  135. data/lib/samagotchi/reminder_store.rb +165 -0
  136. data/lib/samagotchi/self_report.rb +195 -0
  137. data/lib/samagotchi/send_command.rb +170 -0
  138. data/lib/samagotchi/served_model.rb +32 -0
  139. data/lib/samagotchi/session.rb +508 -0
  140. data/lib/samagotchi/session_commands.rb +527 -0
  141. data/lib/samagotchi/session_delete_command.rb +105 -0
  142. data/lib/samagotchi/session_manager.rb +1049 -0
  143. data/lib/samagotchi/session_metrics.rb +466 -0
  144. data/lib/samagotchi/session_observer.rb +117 -0
  145. data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
  146. data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
  147. data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
  148. data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
  149. data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
  150. data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
  151. data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
  152. data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
  153. data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
  154. data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
  155. data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
  156. data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
  157. data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
  158. data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
  159. data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
  160. data/lib/samagotchi/terminal_ui/screen.rb +316 -0
  161. data/lib/samagotchi/terminal_ui/surface.rb +47 -0
  162. data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
  163. data/lib/samagotchi/terminal_ui.rb +1992 -0
  164. data/lib/samagotchi/thinking_ticker.rb +110 -0
  165. data/lib/samagotchi/thought_stream_splitter.rb +149 -0
  166. data/lib/samagotchi/token_usage.rb +88 -0
  167. data/lib/samagotchi/tool_activity.rb +216 -0
  168. data/lib/samagotchi/tool_call_parser.rb +637 -0
  169. data/lib/samagotchi/tool_declarations.rb +561 -0
  170. data/lib/samagotchi/tool_runner.rb +211 -0
  171. data/lib/samagotchi/tools/args.rb +259 -0
  172. data/lib/samagotchi/tools/ask_user_question.rb +152 -0
  173. data/lib/samagotchi/tools/builtins.rb +122 -0
  174. data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
  175. data/lib/samagotchi/tools/delegate.rb +167 -0
  176. data/lib/samagotchi/tools/delegate_result.rb +53 -0
  177. data/lib/samagotchi/tools/delegate_wait.rb +153 -0
  178. data/lib/samagotchi/tools/edit.rb +155 -0
  179. data/lib/samagotchi/tools/execute.rb +214 -0
  180. data/lib/samagotchi/tools/list_reminders.rb +20 -0
  181. data/lib/samagotchi/tools/list_sessions.rb +74 -0
  182. data/lib/samagotchi/tools/memory.rb +256 -0
  183. data/lib/samagotchi/tools/output_guardrails.rb +93 -0
  184. data/lib/samagotchi/tools/peers.rb +18 -0
  185. data/lib/samagotchi/tools/read.rb +182 -0
  186. data/lib/samagotchi/tools/register_reminder.rb +53 -0
  187. data/lib/samagotchi/tools/registry.rb +60 -0
  188. data/lib/samagotchi/tools/send_note.rb +49 -0
  189. data/lib/samagotchi/tools/task_create.rb +29 -0
  190. data/lib/samagotchi/tools/task_get.rb +39 -0
  191. data/lib/samagotchi/tools/task_list.rb +43 -0
  192. data/lib/samagotchi/tools/task_runtime.rb +311 -0
  193. data/lib/samagotchi/tools/task_stop.rb +29 -0
  194. data/lib/samagotchi/tools/task_wait.rb +104 -0
  195. data/lib/samagotchi/tools/tool_path.rb +18 -0
  196. data/lib/samagotchi/tools/web_fetch.rb +163 -0
  197. data/lib/samagotchi/tools/write.rb +26 -0
  198. data/lib/samagotchi/turn_flow.rb +242 -0
  199. data/lib/samagotchi/turn_note.rb +76 -0
  200. data/lib/samagotchi/turn_tally.rb +101 -0
  201. data/lib/samagotchi/version.rb +7 -0
  202. data/lib/samagotchi/vision_context.rb +132 -0
  203. data/lib/samagotchi/vision_support.rb +109 -0
  204. data/lib/samagotchi/web/app.rb +1349 -0
  205. data/lib/samagotchi/web/markdown_renderer.rb +107 -0
  206. data/lib/samagotchi/web/message_parts.rb +169 -0
  207. data/lib/samagotchi/web/public/activity.js +100 -0
  208. data/lib/samagotchi/web/public/annotations.js +67 -0
  209. data/lib/samagotchi/web/public/app.js +2382 -0
  210. data/lib/samagotchi/web/public/card.js +74 -0
  211. data/lib/samagotchi/web/public/chat_view.js +360 -0
  212. data/lib/samagotchi/web/public/chunk_router.js +25 -0
  213. data/lib/samagotchi/web/public/command_complete.js +39 -0
  214. data/lib/samagotchi/web/public/composer_size.js +19 -0
  215. data/lib/samagotchi/web/public/copy.js +142 -0
  216. data/lib/samagotchi/web/public/ctx.js +35 -0
  217. data/lib/samagotchi/web/public/data.js +256 -0
  218. data/lib/samagotchi/web/public/format.js +232 -0
  219. data/lib/samagotchi/web/public/hold.js +78 -0
  220. data/lib/samagotchi/web/public/images.js +77 -0
  221. data/lib/samagotchi/web/public/index.html +568 -0
  222. data/lib/samagotchi/web/public/init_row.js +60 -0
  223. data/lib/samagotchi/web/public/model_pick.js +23 -0
  224. data/lib/samagotchi/web/public/question_card.js +100 -0
  225. data/lib/samagotchi/web/public/route.js +17 -0
  226. data/lib/samagotchi/web/public/scope.js +36 -0
  227. data/lib/samagotchi/web/public/scroll.js +24 -0
  228. data/lib/samagotchi/web/public/sentences.js +88 -0
  229. data/lib/samagotchi/web/public/sessions_list.js +60 -0
  230. data/lib/samagotchi/web/public/strip.js +25 -0
  231. data/lib/samagotchi/web/public/tally.js +37 -0
  232. data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
  233. data/lib/samagotchi/web/public/timing.js +185 -0
  234. data/lib/samagotchi/web/public/turn_events.js +209 -0
  235. data/lib/samagotchi/web/public/turn_model.js +204 -0
  236. data/lib/samagotchi/web/public/turn_view.js +587 -0
  237. data/lib/samagotchi/web/server.rb +183 -0
  238. data/lib/samagotchi/web/session_hub.rb +329 -0
  239. data/lib/samagotchi/web/session_summary.rb +85 -0
  240. data/lib/samagotchi/worker.rb +635 -0
  241. data/lib/samagotchi/worker_idle_exit.rb +87 -0
  242. data/lib/samagotchi.rb +12 -0
  243. metadata +374 -0
@@ -0,0 +1,412 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require_relative "../config"
6
+ require_relative "../version"
7
+ require_relative "../log"
8
+ require_relative "errors"
9
+
10
+ module Samagotchi
11
+ module LLM
12
+ # The HTTP plumbing every model client shares: timeouts, TLS for https
13
+ # URLs, a line reader for streamed (SSE) bodies, the retry loop for
14
+ # network errors with cancellable waits, and cancel.
15
+ #
16
+ # Cancel: a CancellationController listener closes the in-flight socket,
17
+ # so the reading thread fails its next read and the error becomes
18
+ # RequestCancelled; no exception is thrown into a thread at an arbitrary
19
+ # point. Before the socket exists (while connecting) or between attempts,
20
+ # the listener falls back to raising RequestCancelled in the requesting
21
+ # thread, as Client did before.
22
+ class HTTP
23
+ RETRY_MAX_ENV = "SAMAGOTCHI_RETRY_MAX"
24
+ RETRY_BASE_DELAY_ENV = "SAMAGOTCHI_RETRY_BASE_DELAY"
25
+ RETRY_MAX_DELAY_ENV = "SAMAGOTCHI_RETRY_MAX_DELAY"
26
+ DEFAULT_RETRY_MAX = 5
27
+ DEFAULT_RETRY_BASE_DELAY = 0.5
28
+ DEFAULT_RETRY_MAX_DELAY = 8.0
29
+
30
+ # How often a backoff wait checks for a cancel.
31
+ WAIT_TICK = 0.05
32
+ # A Retry-After longer than this is not waited out: the error goes to
33
+ # the user, who can try again later.
34
+ MAX_RETRY_AFTER = 60.0
35
+
36
+ NETWORK_ERRORS = [
37
+ Timeout::Error, EOFError, SocketError, IO::TimeoutError,
38
+ Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::ETIMEDOUT
39
+ ].freeze
40
+
41
+ # Requests of these purposes are the ones a turn waits on: INFO lines
42
+ # (and ERROR when they fail). Probes and model lists are DEBUG.
43
+ LOGGED_PURPOSES = %w[chat recap].freeze
44
+
45
+ # Exponential backoff: base_delay * 2^(attempt-1), capped at max_delay,
46
+ # for up to +max+ retries (max + 1 attempts).
47
+ RetryPolicy = Data.define(:max, :base_delay, :max_delay) do
48
+ # retry.* from config, then the SAMAGOTCHI_RETRY_* env, then defaults.
49
+ def self.from_config
50
+ new(
51
+ max: config_value("retry.max") { |v| v.is_a?(Integer) && v >= 0 } ||
52
+ env_integer(RETRY_MAX_ENV, DEFAULT_RETRY_MAX),
53
+ base_delay: config_value("retry.base_delay") { |v| v.is_a?(Numeric) && v.positive? }&.to_f ||
54
+ env_float(RETRY_BASE_DELAY_ENV, DEFAULT_RETRY_BASE_DELAY),
55
+ max_delay: config_value("retry.max_delay") { |v| v.is_a?(Numeric) && v.positive? }&.to_f ||
56
+ env_float(RETRY_MAX_DELAY_ENV, DEFAULT_RETRY_MAX_DELAY)
57
+ )
58
+ end
59
+
60
+ def self.none = new(max: 0, base_delay: DEFAULT_RETRY_BASE_DELAY, max_delay: DEFAULT_RETRY_MAX_DELAY)
61
+
62
+ def self.config_value(key)
63
+ value = Samagotchi::Config.get(key)
64
+ yield(value) ? value : nil
65
+ rescue StandardError
66
+ nil
67
+ end
68
+
69
+ def self.env_integer(name, default)
70
+ value = ENV.fetch(name, default.to_s).to_i
71
+ value.negative? ? default : value
72
+ end
73
+
74
+ def self.env_float(name, default)
75
+ value = ENV.fetch(name, default.to_s).to_f
76
+ value.positive? ? value : default
77
+ end
78
+
79
+ # Seconds to wait before retrying after failed attempt +attempt+
80
+ # (1-based), or nil when the budget is spent.
81
+ def delay_for(attempt)
82
+ return nil if attempt > max
83
+
84
+ [base_delay * (2**(attempt - 1)), max_delay].min
85
+ end
86
+ end
87
+
88
+ def self.network_error?(error)
89
+ NETWORK_ERRORS.any? { |klass| error.is_a?(klass) }
90
+ end
91
+
92
+ # The ProviderError an SSE line carries (llama.cpp's `error:` event, a
93
+ # `data:` line with an `error` object), or nil.
94
+ def self.sse_error(line, host:)
95
+ ProviderErrors.from_sse_line(line, host: host)
96
+ end
97
+
98
+ attr_reader :label, :retry_policy
99
+
100
+ # @param label [String] names the server in RetryExhausted messages
101
+ # @param sleeper [#call] waits the given seconds (specs pass a no-op)
102
+ # @param first_token_timeout [Numeric, nil] seconds a stream may take to
103
+ # show something (see #stream_lines); nil: no limit
104
+ def initialize(label:, open_timeout:, read_timeout:, retry_policy: nil, sleeper: nil, first_token_timeout: nil)
105
+ @label = label
106
+ @open_timeout = open_timeout
107
+ @read_timeout = read_timeout
108
+ @first_token_timeout = first_token_timeout&.positive? ? first_token_timeout : nil
109
+ @retry_policy = retry_policy || RetryPolicy.from_config
110
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
111
+ end
112
+
113
+ # Send +request+ and yield each line of the streamed body (stripped,
114
+ # blank lines included) as it arrives, with a +shown+ proc. An error
115
+ # status raises its ProviderError. Network errors and retryable errors
116
+ # (a status, or a ProviderError the block raises for an error line)
117
+ # retry the request per the retry policy (a Retry-After wins over the
118
+ # backoff), unless the block called +shown+: it passed something on
119
+ # (text, a tool call), and a retry would repeat it. Lines it only
120
+ # skipped (SSE comments, a role-only chunk, an error event) don't count,
121
+ # so an upstream failure a provider sends as the first event of a 200
122
+ # is retried like the same failure as a status. Any other error is
123
+ # raised as is.
124
+ #
125
+ # With a first_token_timeout, an attempt that hasn't called +shown+
126
+ # when the limit passes is closed and raises FirstTokenTimeout, which
127
+ # is not retried. Keep-alive comments don't count: they reset the read
128
+ # timeout, so without this a queued request can wait silently for as
129
+ # long as the provider keeps it open.
130
+ #
131
+ # @param on_retry [Proc, nil] called before each backoff wait with
132
+ # attempt:, max_retries:, next_delay:, error_class:, error_message:
133
+ # @param on_network_error [Proc, nil] called with each network error
134
+ # @raise [RequestCancelled] when +cancel_controller+ cancels
135
+ # @raise [RetryExhausted] when the retries run out
136
+ # @raise [ProviderError] for an error status
137
+ # @raise [FirstTokenTimeout] when nothing was shown in time
138
+ # @param log_fields [Hash] what the log line says about the request
139
+ # (model:, purpose: chat/recap/probe/models); never its body
140
+ def stream_lines(uri, request, cancel_controller: nil, on_retry: nil, on_network_error: nil, log_fields: {}, &on_line)
141
+ identify(request)
142
+ current = new_attempt_state(uri, request, log_fields, stream: true)
143
+ logged(current) do
144
+ with_retries(cancel_controller, on_retry, on_network_error, current) do
145
+ stream_attempt(uri, request, current, &on_line)
146
+ end
147
+ end
148
+ end
149
+
150
+ def stream_attempt(uri, request, current, &on_line)
151
+ # Time to first token is the answering attempt's, not the retries'.
152
+ current[:attempt_started_at] = monotonic_now
153
+ shown = lambda do
154
+ current[:streamed] = true
155
+ current[:first_shown_at] ||= monotonic_now
156
+ end
157
+ watch_first_token(current) do
158
+ start(uri) do |http|
159
+ current[:http] = http
160
+ http.request(request) do |response|
161
+ current[:status] = response.code.to_i
162
+ check_status!(response)
163
+ buffer = +""
164
+ response.read_body do |chunk|
165
+ buffer << chunk
166
+ while (newline_index = buffer.index("\n"))
167
+ on_line.call(buffer.slice!(0, newline_index + 1).strip, shown)
168
+ end
169
+ end
170
+ # A body that doesn't end in a newline still has a last line.
171
+ on_line.call(buffer.strip, shown) unless buffer.strip.empty?
172
+ end
173
+ end
174
+ end
175
+ end
176
+ private :stream_attempt
177
+
178
+ # Send +request+ and return the response with its body read.
179
+ # @param retries [Boolean] false: one attempt, network errors raised as is
180
+ # @param check_status [Boolean] false: return an error response instead
181
+ # of raising its ProviderError
182
+ def fetch(uri, request, retries: true, check_status: true, open_timeout: nil, read_timeout: nil,
183
+ cancel_controller: nil, log_fields: {})
184
+ identify(request)
185
+ current = new_attempt_state(uri, request, log_fields, stream: false)
186
+ attempt = lambda do |state|
187
+ start(uri, open_timeout: open_timeout, read_timeout: read_timeout) do |http|
188
+ state[:http] = http
189
+ http.request(request).tap do |response|
190
+ state[:status] = response.code.to_i
191
+ check_status!(response) if check_status
192
+ end
193
+ end
194
+ end
195
+ logged(current) do
196
+ next attempt.call(current.merge!(attempts: 1)) unless retries
197
+
198
+ with_retries(cancel_controller, nil, nil, current, &attempt)
199
+ end
200
+ end
201
+
202
+ private
203
+
204
+ def identify(request)
205
+ request["User-Agent"] = Samagotchi::USER_AGENT
206
+ end
207
+
208
+ def start(uri, open_timeout: nil, read_timeout: nil, &block)
209
+ options = { open_timeout: open_timeout || @open_timeout, read_timeout: read_timeout || @read_timeout }
210
+ options[:use_ssl] = true if uri.scheme == "https"
211
+ Net::HTTP.start(uri.host, uri.port, **options, &block)
212
+ end
213
+
214
+ # Runs the block (one attempt) until it returns, retrying network
215
+ # errors. The block gets a hash to put the attempt's Net::HTTP in, so a
216
+ # cancel can reach its socket.
217
+ def with_retries(cancel_controller, on_retry, on_network_error, current = { mutex: Mutex.new })
218
+ requesting_thread = Thread.current
219
+ listener_id = cancel_controller&.on_cancel { |reason| abort_request(current, requesting_thread, reason) }
220
+ raise RequestCancelled.new(cancel_controller.reason) if cancel_controller&.cancelled?
221
+
222
+ attempts = 0
223
+ loop do
224
+ attempts += 1
225
+ current[:attempts] = attempts
226
+ begin
227
+ return yield(current)
228
+ rescue RequestCancelled
229
+ raise
230
+ rescue ProviderError => e
231
+ raise RequestCancelled.new(cancel_controller.reason) if cancel_controller&.cancelled?
232
+
233
+ e.attempts = attempts
234
+ raise if !e.retryable? || current[:streamed]
235
+
236
+ delay = e.retry_after || @retry_policy.delay_for(attempts)
237
+ raise if delay.nil? || attempts > @retry_policy.max || delay > MAX_RETRY_AFTER
238
+
239
+ retry_after(e, attempts, delay, on_retry, cancel_controller, current)
240
+ rescue StandardError => e
241
+ raise RequestCancelled.new(cancel_controller.reason) if cancel_controller&.cancelled?
242
+ raise first_token_timeout if current[:first_token_expired]
243
+ raise unless self.class.network_error?(e)
244
+
245
+ on_network_error&.call(e)
246
+ delay = current[:streamed] ? nil : @retry_policy.delay_for(attempts)
247
+ raise RetryExhausted.new(attempts: attempts, last_error: e, label: @label) if delay.nil?
248
+
249
+ retry_after(e, attempts, delay, on_retry, cancel_controller, current)
250
+ ensure
251
+ current.delete(:http)
252
+ end
253
+ end
254
+ ensure
255
+ # A cancel can call the listener after remove_listener returned (the
256
+ # controller calls listeners outside its lock); :done keeps it from
257
+ # raising into this thread once the request is over.
258
+ current[:mutex].synchronize { current[:done] = true }
259
+ cancel_controller&.remove_listener(listener_id)
260
+ end
261
+
262
+ # Runs one attempt (the block) under the first-token limit: a watchdog
263
+ # thread closes the attempt's socket when the limit passes before
264
+ # anything was shown, so the read fails and #with_retries raises
265
+ # FirstTokenTimeout; while still connecting, it raises that in the
266
+ # requesting thread, as a cancel does.
267
+ def watch_first_token(current)
268
+ return yield unless @first_token_timeout
269
+
270
+ requesting_thread = Thread.current
271
+ current[:mutex].synchronize { current[:attempt_over] = false }
272
+ watchdog = Thread.new do
273
+ sleep(@first_token_timeout)
274
+ current[:mutex].synchronize do
275
+ next if current[:streamed] || current[:attempt_over] || current[:done]
276
+
277
+ current[:first_token_expired] = true
278
+ # No Net::HTTP yet: still connecting. One whose socket is closed
279
+ # has finished, and the attempt returns on its own.
280
+ if current[:http]
281
+ socket_io(current[:http])&.close
282
+ else
283
+ requesting_thread.raise(first_token_timeout)
284
+ end
285
+ end
286
+ rescue IOError
287
+ nil
288
+ end
289
+ yield
290
+ ensure
291
+ if watchdog
292
+ current[:mutex].synchronize { current[:attempt_over] = true }
293
+ watchdog.kill
294
+ end
295
+ end
296
+
297
+ def first_token_timeout = FirstTokenTimeout.new(limit: @first_token_timeout, host: @label)
298
+
299
+ def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
300
+
301
+ # The per-request state the attempts share (the Net::HTTP a cancel
302
+ # closes, status, timings) and what its log line says.
303
+ def new_attempt_state(uri, request, log_fields, stream:)
304
+ purpose = log_fields[:purpose]&.to_s
305
+ { mutex: Mutex.new, started_at: monotonic_now, stream: stream,
306
+ level: LOGGED_PURPOSES.include?(purpose) ? :info : :debug,
307
+ log: { host: @label, method: request.method, url: log_url(uri),
308
+ **log_fields.compact } }
309
+ end
310
+
311
+ def log_base(current) = current[:log] || { host: @label }
312
+
313
+ # scheme://host[:port]/path: no user info, query or default port.
314
+ def log_url(uri)
315
+ port = uri.port == uri.default_port ? "" : ":#{uri.port}"
316
+ "#{uri.scheme}://#{uri.host}#{port}#{uri.path}"
317
+ end
318
+
319
+ # One line per request, whatever its end: done (status, time to the
320
+ # first token of a stream, total), failed, timed out or cancelled.
321
+ # Retries have their own WARN lines (#retry_after).
322
+ def logged(current)
323
+ result = yield
324
+ Log.public_send(current[:level], :http, current[:stream] ? "stream" : "fetch", **log_base(current),
325
+ status: current[:status], ttft_ms: ttft_ms(current),
326
+ ms: elapsed_ms(current), attempts: retried(current))
327
+ result
328
+ rescue RequestCancelled => e
329
+ Log.info(:http, "cancelled", **log_base(current), ms: elapsed_ms(current), reason: e.reason&.to_s)
330
+ raise
331
+ rescue StandardError => e
332
+ failure_level = current[:level] == :info ? :error : :debug
333
+ event = case e
334
+ when FirstTokenTimeout then "first_token_timeout"
335
+ when RetryExhausted then "retry_exhausted"
336
+ else "failed"
337
+ end
338
+ Log.public_send(failure_level, :http, event, **log_base(current), status: e.is_a?(ProviderError) ? e.status : nil,
339
+ ms: elapsed_ms(current), attempts: current[:attempts], error: e.class.name,
340
+ msg: (e.respond_to?(:summary) ? e.summary : e.message).to_s[0, 300])
341
+ raise
342
+ end
343
+
344
+ def elapsed_ms(current, at = monotonic_now)
345
+ at && ((at - current[:started_at]) * 1000).round
346
+ end
347
+
348
+ def ttft_ms(current)
349
+ shown = current[:first_shown_at]
350
+ shown && ((shown - current[:attempt_started_at]) * 1000).round
351
+ end
352
+
353
+ def retried(current)
354
+ current[:attempts].to_i > 1 ? current[:attempts] : nil
355
+ end
356
+
357
+ def retry_after(error, attempts, delay, on_retry, cancel_controller, current)
358
+ Log.warn(:http, "retry", **log_base(current), attempt: attempts, max_retries: @retry_policy.max, delay_s: delay,
359
+ status: error.is_a?(ProviderError) ? error.status : nil, error: error.class.name,
360
+ msg: error.message.to_s[0, 300])
361
+ on_retry&.call(attempt: attempts, max_retries: @retry_policy.max, next_delay: delay,
362
+ error_class: error.class.name, error_message: error.message)
363
+ wait(delay, cancel_controller)
364
+ end
365
+
366
+ def check_status!(response)
367
+ status = response.code.to_i
368
+ return if status.between?(200, 299)
369
+
370
+ raise ProviderErrors.from_response(status: status, body: response.body.to_s, host: @label,
371
+ retry_after: response["Retry-After"])
372
+ end
373
+
374
+ def abort_request(current, requesting_thread, reason)
375
+ current[:mutex].synchronize do
376
+ next if current[:done]
377
+
378
+ io = socket_io(current[:http])
379
+ if io
380
+ io.close
381
+ else
382
+ requesting_thread.raise(RequestCancelled.new(reason))
383
+ end
384
+ end
385
+ rescue IOError
386
+ nil
387
+ end
388
+
389
+ # The open socket of a started Net::HTTP (its BufferedIO's io), or nil.
390
+ def socket_io(http)
391
+ return nil unless http.is_a?(Net::HTTP)
392
+
393
+ io = http.instance_variable_get(:@socket)&.io
394
+ io && !io.closed? ? io : nil
395
+ end
396
+
397
+ def wait(seconds, cancel_controller)
398
+ return if seconds <= 0
399
+ return @sleeper.call(seconds) unless cancel_controller
400
+
401
+ remaining = seconds
402
+ while remaining.positive?
403
+ raise RequestCancelled.new(cancel_controller.reason) if cancel_controller.cancelled?
404
+
405
+ slice = [remaining, WAIT_TICK].min
406
+ @sleeper.call(slice)
407
+ remaining -= slice
408
+ end
409
+ end
410
+ end
411
+ end
412
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module LLM
5
+ # Normalized result returned by every ModelBackend downstream of the agentic
6
+ # loop. The loop lives INSIDE the backend (Option A), so raw provider tool
7
+ # calls are never surfaced — `tool_calls` is always `nil` downstream.
8
+ #
9
+ # It deliberately mirrors the read surface Engine#run_turn and the renderers
10
+ # (TerminalUI / Bridge) currently consume off KernelLoop::Result, so wiring
11
+ # the seam in does not require changes to those consumers:
12
+ # .conversation, .canceled?, .cancellation_reason, .output, .exhausted?
13
+ # plus the native loop's .tool_activity, .context_status and
14
+ # .pending_tool_calls, which the interactive renderer needs.
15
+ class ModelResult
16
+ attr_reader :text, :tool_calls, :provider, :usage, :metadata,
17
+ :conversation, :canceled, :cancellation_reason, :exhausted,
18
+ :tool_activity, :context_status, :pending_tool_calls
19
+
20
+ def initialize(text:, tool_calls: nil, provider: nil, usage: nil, metadata: nil,
21
+ conversation: nil, canceled: false, cancellation_reason: nil, exhausted: false,
22
+ tool_activity: [], context_status: nil, pending_tool_calls: false, empty_answer: false)
23
+ @empty_answer = empty_answer
24
+ @text = text
25
+ @tool_calls = tool_calls
26
+ @provider = provider
27
+ @usage = usage
28
+ @metadata = metadata
29
+ @conversation = conversation
30
+ @canceled = canceled
31
+ @cancellation_reason = cancellation_reason
32
+ @exhausted = exhausted
33
+ @tool_activity = Array(tool_activity)
34
+ @context_status = context_status
35
+ @pending_tool_calls = pending_tool_calls
36
+ end
37
+
38
+ # Renderers call `result.output` (engine.rb, terminal_ui.rb). Alias to `text`.
39
+ def output
40
+ text
41
+ end
42
+
43
+ def canceled?
44
+ !!canceled
45
+ end
46
+
47
+ # The turn ended with nothing visible: the text shown is a placeholder,
48
+ # the conversation holds no reply.
49
+ def empty_answer?
50
+ !!@empty_answer
51
+ end
52
+
53
+ def exhausted?
54
+ !!exhausted
55
+ end
56
+
57
+ def pending_tool_calls?
58
+ !!pending_tool_calls
59
+ end
60
+
61
+ # KernelLoop only marks a run exhausted when it stopped on pending tool
62
+ # calls, so exhausted? alone already means "resumable".
63
+ def resumable?
64
+ exhausted?
65
+ end
66
+
67
+ def to_s
68
+ text.to_s
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "backend"
4
+ require_relative "model_result"
5
+ require_relative "usage"
6
+
7
+ module Samagotchi
8
+ module LLM
9
+ # The raw-prompt loop as a backend: KernelLoop#run (llama.cpp /completion,
10
+ # Gemma/Qwen prompt formats) with its result wrapped in a ModelResult, so
11
+ # Engine and the TUI call every backend the same way.
12
+ class NativeBackend < ModelBackend
13
+ def initialize(kernel:)
14
+ @kernel = kernel
15
+ end
16
+
17
+ def provider = :native
18
+
19
+ def complete(messages:, max_iterations: 100, on_stream_event: nil, cancel_controller: nil,
20
+ model_name: nil, max_tool_output_chars: nil, pending_input: nil)
21
+ usage = UsageCollector.new
22
+ kernel_result = @kernel.run(
23
+ messages,
24
+ max_iterations: max_iterations,
25
+ on_stream_event: lambda { |event|
26
+ usage.observe(event)
27
+ on_stream_event&.call(event)
28
+ },
29
+ cancel_controller: cancel_controller,
30
+ model_name: model_name,
31
+ max_tool_output_chars: max_tool_output_chars,
32
+ pending_input: pending_input
33
+ )
34
+ ModelResult.new(
35
+ text: kernel_result.respond_to?(:output) ? kernel_result.output.to_s : kernel_result.to_s,
36
+ tool_calls: nil,
37
+ provider: :native,
38
+ usage: usage.usage(prompt_text: Array(messages).sum("") { |message| message[:content].to_s }),
39
+ conversation: kernel_result.respond_to?(:conversation) ? kernel_result.conversation : nil,
40
+ canceled: kernel_result.respond_to?(:canceled?) && kernel_result.canceled?,
41
+ cancellation_reason: kernel_result.respond_to?(:cancellation_reason) ? kernel_result.cancellation_reason : nil,
42
+ exhausted: kernel_result.respond_to?(:exhausted) && kernel_result.exhausted,
43
+ tool_activity: kernel_result.respond_to?(:tool_activity) ? kernel_result.tool_activity : [],
44
+ context_status: kernel_result.respond_to?(:context_status) ? kernel_result.context_status : nil,
45
+ pending_tool_calls: kernel_result.respond_to?(:pending_tool_calls) && kernel_result.pending_tool_calls
46
+ )
47
+ end
48
+ end
49
+ end
50
+ end