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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/bin/chi +1140 -0
- data/docs/architecture.md +299 -0
- data/docs/cli.md +490 -0
- data/docs/configuration.md +494 -0
- data/docs/desktop.md +97 -0
- data/docs/guardrails.md +218 -0
- data/docs/hooks.md +309 -0
- data/docs/internals/background-tasks.md +26 -0
- data/docs/internals/context-telemetry.md +36 -0
- data/docs/internals/gemma4-contract.md +23 -0
- data/docs/internals/tool-guardrails.md +45 -0
- data/docs/memory.md +85 -0
- data/docs/plugins.md +819 -0
- data/docs/releasing.md +135 -0
- data/docs/sessions.md +155 -0
- data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
- data/lib/samagotchi/bridge/card_store.rb +126 -0
- data/lib/samagotchi/bridge/event_id.rb +25 -0
- data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
- data/lib/samagotchi/bridge/sse_writer.rb +248 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
- data/lib/samagotchi/bridge.rb +993 -0
- data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
- data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
- data/lib/samagotchi/bridge_client.rb +330 -0
- data/lib/samagotchi/bundle_needs.rb +97 -0
- data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
- data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
- data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
- data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
- data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
- data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
- data/lib/samagotchi/bundles/system/delegated.md +10 -0
- data/lib/samagotchi/bundles/system/identity.md +7 -0
- data/lib/samagotchi/bundles/system/manifest.yml +11 -0
- data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
- data/lib/samagotchi/bundles/system/self_map.md +55 -0
- data/lib/samagotchi/cancellation_controller.rb +78 -0
- data/lib/samagotchi/client.rb +429 -0
- data/lib/samagotchi/commands/registry.rb +112 -0
- data/lib/samagotchi/config.rb +910 -0
- data/lib/samagotchi/context_note.rb +77 -0
- data/lib/samagotchi/context_quote.rb +21 -0
- data/lib/samagotchi/context_usage.rb +66 -0
- data/lib/samagotchi/context_window.rb +76 -0
- data/lib/samagotchi/debug_log.rb +110 -0
- data/lib/samagotchi/desktop/macos/App.swift +102 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
- data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
- data/lib/samagotchi/desktop/macos.rb +255 -0
- data/lib/samagotchi/desktop.rb +21 -0
- data/lib/samagotchi/desktop_command.rb +143 -0
- data/lib/samagotchi/engine.rb +2807 -0
- data/lib/samagotchi/guardrails/approval.rb +125 -0
- data/lib/samagotchi/guardrails/approvals.rb +177 -0
- data/lib/samagotchi/guardrails/context.rb +71 -0
- data/lib/samagotchi/guardrails/gate.rb +125 -0
- data/lib/samagotchi/guardrails/load_failures.rb +46 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
- data/lib/samagotchi/guardrails/rules.rb +199 -0
- data/lib/samagotchi/guardrails/targets.rb +119 -0
- data/lib/samagotchi/guardrails/verdict.rb +134 -0
- data/lib/samagotchi/guardrails.rb +18 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
- data/lib/samagotchi/hooks/loader.rb +162 -0
- data/lib/samagotchi/hooks/registry.rb +261 -0
- data/lib/samagotchi/hooks.rb +30 -0
- data/lib/samagotchi/host_registry.rb +315 -0
- data/lib/samagotchi/idle_client.rb +147 -0
- data/lib/samagotchi/idle_recap.rb +549 -0
- data/lib/samagotchi/idle_reminders.rb +101 -0
- data/lib/samagotchi/idle_scheduler.rb +76 -0
- data/lib/samagotchi/image_store.rb +393 -0
- data/lib/samagotchi/installed_gem.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +1017 -0
- data/lib/samagotchi/launch_mode.rb +34 -0
- data/lib/samagotchi/llm/backend.rb +28 -0
- data/lib/samagotchi/llm/chat_loop.rb +450 -0
- data/lib/samagotchi/llm/errors.rb +329 -0
- data/lib/samagotchi/llm/http.rb +412 -0
- data/lib/samagotchi/llm/model_result.rb +72 -0
- data/lib/samagotchi/llm/native_backend.rb +50 -0
- data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
- data/lib/samagotchi/llm/openai_chat.rb +403 -0
- data/lib/samagotchi/llm/usage.rb +79 -0
- data/lib/samagotchi/log.rb +200 -0
- data/lib/samagotchi/log_line.rb +127 -0
- data/lib/samagotchi/log_path.rb +31 -0
- data/lib/samagotchi/log_subscriber.rb +163 -0
- data/lib/samagotchi/memory_bundle/builder.rb +364 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
- data/lib/samagotchi/memory_bundle/installer.rb +528 -0
- data/lib/samagotchi/memory_bundle/listing.rb +72 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
- data/lib/samagotchi/memory_bundle/merger.rb +52 -0
- data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
- data/lib/samagotchi/memory_bundle/source.rb +153 -0
- data/lib/samagotchi/memory_bundle/status.rb +107 -0
- data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
- data/lib/samagotchi/memory_bundle.rb +17 -0
- data/lib/samagotchi/memory_paths.rb +101 -0
- data/lib/samagotchi/model_overlay.rb +53 -0
- data/lib/samagotchi/model_profile.rb +309 -0
- data/lib/samagotchi/muted_memories.rb +66 -0
- data/lib/samagotchi/note_command.rb +163 -0
- data/lib/samagotchi/output_formatter.rb +100 -0
- data/lib/samagotchi/owner_lock.rb +110 -0
- data/lib/samagotchi/pending_input_queue.rb +48 -0
- data/lib/samagotchi/plugin/api.rb +362 -0
- data/lib/samagotchi/plugin/context.rb +193 -0
- data/lib/samagotchi/plugin/loader.rb +126 -0
- data/lib/samagotchi/plugin/service.rb +117 -0
- data/lib/samagotchi/plugin/sessions.rb +150 -0
- data/lib/samagotchi/plugin/side_question.rb +60 -0
- data/lib/samagotchi/plugin/tool_result.rb +24 -0
- data/lib/samagotchi/project_scope.rb +25 -0
- data/lib/samagotchi/prompt.rb +119 -0
- data/lib/samagotchi/prompt_literal_guard.rb +70 -0
- data/lib/samagotchi/recap_store.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +165 -0
- data/lib/samagotchi/self_report.rb +195 -0
- data/lib/samagotchi/send_command.rb +170 -0
- data/lib/samagotchi/served_model.rb +32 -0
- data/lib/samagotchi/session.rb +508 -0
- data/lib/samagotchi/session_commands.rb +527 -0
- data/lib/samagotchi/session_delete_command.rb +105 -0
- data/lib/samagotchi/session_manager.rb +1049 -0
- data/lib/samagotchi/session_metrics.rb +466 -0
- data/lib/samagotchi/session_observer.rb +117 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
- data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
- data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
- data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
- data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
- data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
- data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
- data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
- data/lib/samagotchi/terminal_ui/screen.rb +316 -0
- data/lib/samagotchi/terminal_ui/surface.rb +47 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
- data/lib/samagotchi/terminal_ui.rb +1992 -0
- data/lib/samagotchi/thinking_ticker.rb +110 -0
- data/lib/samagotchi/thought_stream_splitter.rb +149 -0
- data/lib/samagotchi/token_usage.rb +88 -0
- data/lib/samagotchi/tool_activity.rb +216 -0
- data/lib/samagotchi/tool_call_parser.rb +637 -0
- data/lib/samagotchi/tool_declarations.rb +561 -0
- data/lib/samagotchi/tool_runner.rb +211 -0
- data/lib/samagotchi/tools/args.rb +259 -0
- data/lib/samagotchi/tools/ask_user_question.rb +152 -0
- data/lib/samagotchi/tools/builtins.rb +122 -0
- data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
- data/lib/samagotchi/tools/delegate.rb +167 -0
- data/lib/samagotchi/tools/delegate_result.rb +53 -0
- data/lib/samagotchi/tools/delegate_wait.rb +153 -0
- data/lib/samagotchi/tools/edit.rb +155 -0
- data/lib/samagotchi/tools/execute.rb +214 -0
- data/lib/samagotchi/tools/list_reminders.rb +20 -0
- data/lib/samagotchi/tools/list_sessions.rb +74 -0
- data/lib/samagotchi/tools/memory.rb +256 -0
- data/lib/samagotchi/tools/output_guardrails.rb +93 -0
- data/lib/samagotchi/tools/peers.rb +18 -0
- data/lib/samagotchi/tools/read.rb +182 -0
- data/lib/samagotchi/tools/register_reminder.rb +53 -0
- data/lib/samagotchi/tools/registry.rb +60 -0
- data/lib/samagotchi/tools/send_note.rb +49 -0
- data/lib/samagotchi/tools/task_create.rb +29 -0
- data/lib/samagotchi/tools/task_get.rb +39 -0
- data/lib/samagotchi/tools/task_list.rb +43 -0
- data/lib/samagotchi/tools/task_runtime.rb +311 -0
- data/lib/samagotchi/tools/task_stop.rb +29 -0
- data/lib/samagotchi/tools/task_wait.rb +104 -0
- data/lib/samagotchi/tools/tool_path.rb +18 -0
- data/lib/samagotchi/tools/web_fetch.rb +163 -0
- data/lib/samagotchi/tools/write.rb +26 -0
- data/lib/samagotchi/turn_flow.rb +242 -0
- data/lib/samagotchi/turn_note.rb +76 -0
- data/lib/samagotchi/turn_tally.rb +101 -0
- data/lib/samagotchi/version.rb +7 -0
- data/lib/samagotchi/vision_context.rb +132 -0
- data/lib/samagotchi/vision_support.rb +109 -0
- data/lib/samagotchi/web/app.rb +1349 -0
- data/lib/samagotchi/web/markdown_renderer.rb +107 -0
- data/lib/samagotchi/web/message_parts.rb +169 -0
- data/lib/samagotchi/web/public/activity.js +100 -0
- data/lib/samagotchi/web/public/annotations.js +67 -0
- data/lib/samagotchi/web/public/app.js +2382 -0
- data/lib/samagotchi/web/public/card.js +74 -0
- data/lib/samagotchi/web/public/chat_view.js +360 -0
- data/lib/samagotchi/web/public/chunk_router.js +25 -0
- data/lib/samagotchi/web/public/command_complete.js +39 -0
- data/lib/samagotchi/web/public/composer_size.js +19 -0
- data/lib/samagotchi/web/public/copy.js +142 -0
- data/lib/samagotchi/web/public/ctx.js +35 -0
- data/lib/samagotchi/web/public/data.js +256 -0
- data/lib/samagotchi/web/public/format.js +232 -0
- data/lib/samagotchi/web/public/hold.js +78 -0
- data/lib/samagotchi/web/public/images.js +77 -0
- data/lib/samagotchi/web/public/index.html +568 -0
- data/lib/samagotchi/web/public/init_row.js +60 -0
- data/lib/samagotchi/web/public/model_pick.js +23 -0
- data/lib/samagotchi/web/public/question_card.js +100 -0
- data/lib/samagotchi/web/public/route.js +17 -0
- data/lib/samagotchi/web/public/scope.js +36 -0
- data/lib/samagotchi/web/public/scroll.js +24 -0
- data/lib/samagotchi/web/public/sentences.js +88 -0
- data/lib/samagotchi/web/public/sessions_list.js +60 -0
- data/lib/samagotchi/web/public/strip.js +25 -0
- data/lib/samagotchi/web/public/tally.js +37 -0
- data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
- data/lib/samagotchi/web/public/timing.js +185 -0
- data/lib/samagotchi/web/public/turn_events.js +209 -0
- data/lib/samagotchi/web/public/turn_model.js +204 -0
- data/lib/samagotchi/web/public/turn_view.js +587 -0
- data/lib/samagotchi/web/server.rb +183 -0
- data/lib/samagotchi/web/session_hub.rb +329 -0
- data/lib/samagotchi/web/session_summary.rb +85 -0
- data/lib/samagotchi/worker.rb +635 -0
- data/lib/samagotchi/worker_idle_exit.rb +87 -0
- data/lib/samagotchi.rb +12 -0
- 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
|