samagotchi 0.63.0 → 0.65.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 +4 -4
- data/CHANGELOG.md +86 -1
- data/docs/architecture.md +32 -5
- data/docs/cli.md +41 -9
- data/docs/configuration.md +143 -11
- data/docs/internals/engine.md +25 -6
- data/docs/plugins.md +7 -0
- data/docs/sessions.md +14 -10
- data/docs/sub-agent.md +21 -4
- data/docs/testing.md +6 -0
- data/lib/samagotchi/answer_command.rb +5 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +29 -5
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/child_reports.rb +7 -2
- data/lib/samagotchi/cli/parent_wait.rb +9 -2
- data/lib/samagotchi/client.rb +4 -2
- data/lib/samagotchi/composer_route.rb +90 -0
- data/lib/samagotchi/config.rb +11 -7
- data/lib/samagotchi/continue_offer.rb +17 -3
- data/lib/samagotchi/delivery.rb +13 -8
- data/lib/samagotchi/engine.rb +41 -7
- data/lib/samagotchi/generation_phase.rb +17 -0
- data/lib/samagotchi/host_config.rb +33 -12
- data/lib/samagotchi/host_model.rb +2 -2
- data/lib/samagotchi/host_registry.rb +34 -11
- data/lib/samagotchi/idle_client.rb +62 -25
- data/lib/samagotchi/idle_recap.rb +5 -2
- data/lib/samagotchi/idle_target.rb +30 -7
- data/lib/samagotchi/interrupted_turn.rb +7 -3
- data/lib/samagotchi/last_turn.rb +5 -2
- data/lib/samagotchi/llm/adapters.rb +41 -0
- data/lib/samagotchi/llm/anthropic_assembly.rb +210 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
- data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
- data/lib/samagotchi/llm/api_key.rb +26 -9
- data/lib/samagotchi/llm/chat_loop.rb +113 -14
- data/lib/samagotchi/llm/dialect.rb +57 -0
- data/lib/samagotchi/llm/errors.rb +81 -12
- data/lib/samagotchi/llm/model_listing.rb +111 -0
- data/lib/samagotchi/llm/model_result.rb +9 -2
- data/lib/samagotchi/llm/openai_chat.rb +46 -93
- data/lib/samagotchi/model_profile.rb +1 -1
- data/lib/samagotchi/pending_input_queue.rb +10 -3
- data/lib/samagotchi/plugin/sessions.rb +27 -5
- data/lib/samagotchi/reasoning_history.rb +14 -4
- data/lib/samagotchi/recap_setup.rb +2 -1
- data/lib/samagotchi/reply_wait.rb +171 -6
- data/lib/samagotchi/self_report.rb +18 -28
- data/lib/samagotchi/send_command.rb +63 -19
- data/lib/samagotchi/session.rb +3 -2
- data/lib/samagotchi/session_commands.rb +1 -1
- data/lib/samagotchi/session_inbox.rb +63 -7
- data/lib/samagotchi/session_manager.rb +49 -20
- data/lib/samagotchi/session_metrics.rb +6 -11
- data/lib/samagotchi/steer_cut.rb +4 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +55 -28
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -22
- data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
- data/lib/samagotchi/terminal_ui.rb +72 -57
- data/lib/samagotchi/tool_activity.rb +5 -5
- data/lib/samagotchi/tools/delegate.rb +28 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
- data/lib/samagotchi/tools/delegate_wait.rb +17 -5
- data/lib/samagotchi/tools/execute.rb +11 -4
- data/lib/samagotchi/turn_checkpoint.rb +9 -3
- data/lib/samagotchi/turn_note.rb +22 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -1
- data/lib/samagotchi/web/public/activity.js +3 -0
- data/lib/samagotchi/web/public/app.js +96 -13
- data/lib/samagotchi/web/public/commands.js +8 -8
- data/lib/samagotchi/web/public/composer.js +3 -3
- data/lib/samagotchi/web/public/delivery_line.js +228 -0
- data/lib/samagotchi/web/public/index.html +29 -0
- data/lib/samagotchi/web/public/live_turn.js +8 -0
- data/lib/samagotchi/web/public/stage_model.js +27 -7
- data/lib/samagotchi/web/public/stage_view.js +32 -10
- data/lib/samagotchi/web/public/stream_handlers.js +8 -2
- data/lib/samagotchi/web/public/timing.js +2 -1
- data/lib/samagotchi/web/public/turn_events.js +22 -8
- data/lib/samagotchi/web/public/turn_html.js +20 -4
- data/lib/samagotchi/web/public/turn_model.js +29 -12
- data/lib/samagotchi/web/public/turn_view.js +6 -4
- data/lib/samagotchi/worker.rb +7 -5
- metadata +9 -1
|
@@ -15,17 +15,24 @@ module Samagotchi
|
|
|
15
15
|
# conversation) and .empty_retries (the retries spent): the Engine saves
|
|
16
16
|
# them on the turn's note for the UIs (TurnNote.empty).
|
|
17
17
|
#
|
|
18
|
+
# .refusal is set when the provider refused the answer (Messages'
|
|
19
|
+
# stop_reason refusal, OpenAI's content_filter): {category:} as the
|
|
20
|
+
# provider names it, {} without one; nil otherwise. Such a turn is an
|
|
21
|
+
# empty answer the loop never asked again.
|
|
22
|
+
#
|
|
18
23
|
# .run_start is where the turn's run started in the conversation (the
|
|
19
24
|
# length of the messages the loop got), set by the Engine after the loop
|
|
20
25
|
# returns; nil from a loop called directly. A step-limit offer keeps it
|
|
21
26
|
# (TurnFlow), so a Continue resumes that run (Engine#run_turn resume_from:).
|
|
22
27
|
class ModelResult
|
|
23
28
|
attr_reader :text, :conversation, :canceled, :cancellation_reason, :exhausted,
|
|
24
|
-
:tool_activity, :context_status, :pending_tool_calls, :empty_steps, :empty_retries
|
|
29
|
+
:tool_activity, :context_status, :pending_tool_calls, :empty_steps, :empty_retries, :refusal
|
|
25
30
|
attr_accessor :run_start
|
|
26
31
|
|
|
27
32
|
def initialize(text:, conversation: nil, canceled: false, cancellation_reason: nil, exhausted: false,
|
|
28
|
-
tool_activity: [], context_status: nil, pending_tool_calls: false, empty_steps: [], empty_retries: 0
|
|
33
|
+
tool_activity: [], context_status: nil, pending_tool_calls: false, empty_steps: [], empty_retries: 0,
|
|
34
|
+
refusal: nil)
|
|
35
|
+
@refusal = refusal
|
|
29
36
|
@empty_steps = Array(empty_steps)
|
|
30
37
|
@empty_retries = empty_retries.to_i
|
|
31
38
|
@text = text
|
|
@@ -4,30 +4,16 @@ require "json"
|
|
|
4
4
|
require "uri"
|
|
5
5
|
require_relative "../config"
|
|
6
6
|
require_relative "../sampling_settings"
|
|
7
|
+
require_relative "../thinking"
|
|
7
8
|
require_relative "errors"
|
|
8
9
|
require_relative "http"
|
|
10
|
+
require_relative "model_listing"
|
|
9
11
|
require_relative "prompt_cache"
|
|
10
12
|
require_relative "usage"
|
|
11
13
|
require_relative "utf8_scrub"
|
|
12
14
|
|
|
13
15
|
module Samagotchi
|
|
14
16
|
module LLM
|
|
15
|
-
# A model a host lists. Unknown fields are nil.
|
|
16
|
-
ModelInfo = Data.define(:id, :context_window, :supports_tools, :raw) do
|
|
17
|
-
# true when the list says the model takes images (OpenRouter's
|
|
18
|
-
# architecture.input_modalities, llama.cpp's "multimodal" capability),
|
|
19
|
-
# false when it lists input modalities without "image", else nil.
|
|
20
|
-
def image_input
|
|
21
|
-
return nil unless raw.is_a?(Hash)
|
|
22
|
-
|
|
23
|
-
modalities = raw.dig("architecture", "input_modalities")
|
|
24
|
-
return modalities.include?("image") if modalities.is_a?(Array)
|
|
25
|
-
return true if Array(raw["capabilities"]).include?("multimodal")
|
|
26
|
-
|
|
27
|
-
nil
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
|
|
31
17
|
# A tool call the model made. arguments: the parsed Hash, or the raw
|
|
32
18
|
# String when it isn't valid JSON (the tool then reports the error).
|
|
33
19
|
ToolCall = Data.define(:id, :name, :arguments)
|
|
@@ -43,9 +29,17 @@ module Samagotchi
|
|
|
43
29
|
# +top_logprobs+: the first answer token's likeliest alternatives
|
|
44
30
|
# (TokenLogprob) when the request asked for logprobs and the server
|
|
45
31
|
# sent them (a non-streamed answer only); [] otherwise.
|
|
32
|
+
# +provider_content+: the response's content blocks in order, as the
|
|
33
|
+
# server sent them (string keys), for a wire that sends them back
|
|
34
|
+
# verbatim (AnthropicMessages: thinking blocks with their signatures);
|
|
35
|
+
# nil on the OpenAI wire.
|
|
36
|
+
# +stop_details+: why the provider stopped, when it says more than the
|
|
37
|
+
# finish reason (Messages' stop_details on a refusal: type, category,
|
|
38
|
+
# explanation; string keys); nil otherwise.
|
|
46
39
|
ChatResponse = Data.define(:text, :reasoning, :tool_calls, :usage, :finish_reason, :model, :provider, :cut,
|
|
47
|
-
:top_logprobs) do
|
|
48
|
-
def initialize(model: nil, provider: nil, cut: nil, top_logprobs: [].freeze,
|
|
40
|
+
:top_logprobs, :provider_content, :stop_details) do
|
|
41
|
+
def initialize(model: nil, provider: nil, cut: nil, top_logprobs: [].freeze, provider_content: nil, stop_details: nil,
|
|
42
|
+
**fields) = super
|
|
49
43
|
end
|
|
50
44
|
|
|
51
45
|
# One alternative for an answer token: its text and log probability.
|
|
@@ -75,6 +69,8 @@ module Samagotchi
|
|
|
75
69
|
# default, with errors mapped to ProviderError. It speaks the wire format
|
|
76
70
|
# only; the chat loop owns the conversation and the tools.
|
|
77
71
|
class OpenAIChat
|
|
72
|
+
include ModelListing
|
|
73
|
+
|
|
78
74
|
MAX_MODEL_PAGES = 20
|
|
79
75
|
# How much of a 200's non-stream body is read as a possible JSON error.
|
|
80
76
|
PLAIN_ERROR_LIMIT = 64 * 1024
|
|
@@ -90,14 +86,11 @@ module Samagotchi
|
|
|
90
86
|
|
|
91
87
|
# @param entry [HostRegistry::HostEntry]
|
|
92
88
|
def self.for(entry, **)
|
|
93
|
-
new(base_url: entry.
|
|
94
|
-
remote: entry.remote?, default_max_tokens: (OPENROUTER_MAX_TOKENS if openrouter?(entry.
|
|
89
|
+
new(base_url: entry.api_base_url, host_name: entry.name, api_key_env: entry.api_key_env, auth: entry.auth,
|
|
90
|
+
remote: entry.remote?, default_max_tokens: (OPENROUTER_MAX_TOKENS if openrouter?(entry.api_base_url)),
|
|
95
91
|
**)
|
|
96
92
|
end
|
|
97
93
|
|
|
98
|
-
# true when a streamed +payload+ carries a tool-call delta.
|
|
99
|
-
def self.tool_call_delta?(payload) = Assembly.tool_call_delta?(payload)
|
|
100
|
-
|
|
101
94
|
# true when +url+ is OpenRouter's API (openrouter.ai or a subdomain).
|
|
102
95
|
def self.openrouter?(url)
|
|
103
96
|
host = URI(url.to_s).host.to_s.downcase
|
|
@@ -134,19 +127,23 @@ module Samagotchi
|
|
|
134
127
|
# @param host_name [String] names the host in errors
|
|
135
128
|
# @param api_key_env [String, nil] the variable holding the API key;
|
|
136
129
|
# nil sends no Authorization header (a local server)
|
|
130
|
+
# @param auth [Symbol, nil] the header the key goes in
|
|
131
|
+
# (ApiKey::SCHEMES); nil: Authorization: Bearer
|
|
137
132
|
# @param stream [Boolean] stream the reply (false: one JSON body)
|
|
138
133
|
# @param retries [Boolean] false: one attempt
|
|
139
134
|
# @param timeout [Numeric, nil] read timeout in seconds (default
|
|
140
135
|
# server.read_timeout); the connect timeout is capped by it
|
|
136
|
+
# @param open_timeout [Numeric, nil] connect timeout in seconds, when
|
|
137
|
+
# shorter than that (a reachability probe's)
|
|
141
138
|
# @param models_ttl [Numeric] how long #context_window reuses the list
|
|
142
139
|
# @param remote [Boolean] a provider on the network, not a local server
|
|
143
140
|
# @param first_token_timeout [Numeric, nil] seconds a streamed answer
|
|
144
141
|
# may take to show something (LLM::HTTP); nil: no limit
|
|
145
142
|
# @param default_max_tokens [Integer, nil] max_tokens for a request
|
|
146
143
|
# whose options name none (OPENROUTER_MAX_TOKENS); nil sends none
|
|
147
|
-
def initialize(base_url:, host_name:, api_key_env: nil, stream: true, retries: true, timeout: nil,
|
|
144
|
+
def initialize(base_url:, host_name:, api_key_env: nil, auth: nil, stream: true, retries: true, timeout: nil,
|
|
148
145
|
env: ENV, sleeper: nil, retry_policy: nil, models_ttl: DEFAULT_MODELS_TTL, remote: false,
|
|
149
|
-
first_token_timeout: nil, purpose: "chat", default_max_tokens: nil)
|
|
146
|
+
first_token_timeout: nil, purpose: "chat", default_max_tokens: nil, open_timeout: nil)
|
|
150
147
|
@remote = remote
|
|
151
148
|
@default_max_tokens = default_max_tokens
|
|
152
149
|
# What its requests are for, in the log (LLM::HTTP::LOGGED_PURPOSES).
|
|
@@ -156,13 +153,12 @@ module Samagotchi
|
|
|
156
153
|
@host_name = host_name.to_s
|
|
157
154
|
@api_key_env = api_key_env
|
|
158
155
|
@stream = stream
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
open_timeout, read_timeout = timeouts(timeout)
|
|
156
|
+
init_model_listing(models_ttl)
|
|
157
|
+
open_timeout, read_timeout = timeouts(timeout, open_timeout)
|
|
162
158
|
policy = retry_policy || (retries ? nil : HTTP::RetryPolicy.none)
|
|
163
159
|
@http = HTTP.new(label: @host_name, open_timeout: open_timeout, read_timeout: read_timeout,
|
|
164
160
|
retry_policy: policy, sleeper: sleeper, first_token_timeout: first_token_timeout,
|
|
165
|
-
api_key: ApiKey.for(api_key_env, host: @host_name, env: env))
|
|
161
|
+
api_key: ApiKey.for(api_key_env, host: @host_name, env: env, scheme: auth))
|
|
166
162
|
end
|
|
167
163
|
|
|
168
164
|
# @param messages [Array<Hash>] wire messages (role, content, tool_calls,
|
|
@@ -216,6 +212,25 @@ module Samagotchi
|
|
|
216
212
|
# A provider on the network: no llama.cpp /props to ask for the window.
|
|
217
213
|
def remote? = @remote
|
|
218
214
|
|
|
215
|
+
# Model entries go back as text, tool_calls and reasoning_content,
|
|
216
|
+
# never as saved provider blocks (AnthropicMessages does).
|
|
217
|
+
def replays_content? = false
|
|
218
|
+
|
|
219
|
+
# true when a streamed chunk's +payload+ (as #chat's on_delta gets it)
|
|
220
|
+
# carries part of a tool call (ChatLoop marks the chunk's event).
|
|
221
|
+
def tool_call_delta?(payload) = Assembly.tool_call_delta?(payload)
|
|
222
|
+
|
|
223
|
+
# The request fields a thinking +level+ (Thinking::LEVELS) adds on
|
|
224
|
+
# this wire: Thinking.chat_fields's OpenAI-style switches.
|
|
225
|
+
# @return [Hash] frozen
|
|
226
|
+
def thinking_fields(level) = Thinking.chat_fields(level)
|
|
227
|
+
|
|
228
|
+
# The thinking fields for a short side answer from a model that
|
|
229
|
+
# refused thinking off (gpt-oss: "Reasoning is mandatory"): none, the
|
|
230
|
+
# model's own default.
|
|
231
|
+
# @return [Hash] frozen
|
|
232
|
+
def thinking_off_refused_fields = {}.freeze
|
|
233
|
+
|
|
219
234
|
# @return [Array<ModelInfo>] the host's models (every page)
|
|
220
235
|
def list_models
|
|
221
236
|
models = []
|
|
@@ -231,26 +246,6 @@ module Samagotchi
|
|
|
231
246
|
models
|
|
232
247
|
end
|
|
233
248
|
|
|
234
|
-
# The window (tokens) the host lists for +model+, or nil. The list is
|
|
235
|
-
# read once per models_ttl; a failed listing answers nil.
|
|
236
|
-
def context_window(model:)
|
|
237
|
-
info = cached_models.find { |m| m.id == model.to_s } ||
|
|
238
|
-
cached_models.find { |m| m.id.casecmp?(model.to_s) }
|
|
239
|
-
info&.context_window
|
|
240
|
-
rescue StandardError
|
|
241
|
-
nil
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
# Whether the host's list says +model+ takes images (ModelInfo#image_input),
|
|
245
|
-
# or nil when it doesn't say or the listing fails.
|
|
246
|
-
def image_input(model:)
|
|
247
|
-
info = cached_models.find { |m| m.id == model.to_s } ||
|
|
248
|
-
cached_models.find { |m| m.id.casecmp?(model.to_s) }
|
|
249
|
-
info&.image_input
|
|
250
|
-
rescue StandardError
|
|
251
|
-
nil
|
|
252
|
-
end
|
|
253
|
-
|
|
254
249
|
def self.served_model(payload)
|
|
255
250
|
model = payload.is_a?(Hash) ? payload["model"] : nil
|
|
256
251
|
model.is_a?(String) && !model.strip.empty? ? model : nil
|
|
@@ -265,9 +260,10 @@ module Samagotchi
|
|
|
265
260
|
|
|
266
261
|
private
|
|
267
262
|
|
|
268
|
-
def timeouts(timeout)
|
|
263
|
+
def timeouts(timeout, open_limit = nil)
|
|
269
264
|
open_timeout = Samagotchi::Config.positive_seconds("server.open_timeout")
|
|
270
265
|
read_timeout = Samagotchi::Config.positive_seconds("server.read_timeout")
|
|
266
|
+
open_timeout = [open_timeout, open_limit].min if open_limit
|
|
271
267
|
return [open_timeout, read_timeout] unless timeout
|
|
272
268
|
|
|
273
269
|
[[open_timeout, timeout].min, timeout]
|
|
@@ -411,49 +407,6 @@ module Samagotchi
|
|
|
411
407
|
|
|
412
408
|
def get_request(uri) = Net::HTTP::Get.new(uri)
|
|
413
409
|
|
|
414
|
-
def model_entries(body)
|
|
415
|
-
return [] unless body.is_a?(Hash)
|
|
416
|
-
|
|
417
|
-
entries = body["data"].is_a?(Array) ? body["data"] : body["models"]
|
|
418
|
-
Array(entries).select { |entry| entry.is_a?(Hash) }
|
|
419
|
-
end
|
|
420
|
-
|
|
421
|
-
def model_info(raw)
|
|
422
|
-
id = (raw["id"] || raw["model"] || raw["name"]).to_s
|
|
423
|
-
ModelInfo.new(id: id, context_window: window_of(raw), supports_tools: tools_support(raw), raw: raw)
|
|
424
|
-
end
|
|
425
|
-
|
|
426
|
-
# The running window when the list says: context_length, context_window,
|
|
427
|
-
# max_model_len, or llama.cpp's meta.n_ctx (not n_ctx_train).
|
|
428
|
-
def window_of(raw)
|
|
429
|
-
[raw["context_length"], raw["context_window"], raw["max_model_len"], raw.dig("meta", "n_ctx")].each do |value|
|
|
430
|
-
return value if value.is_a?(Integer) && value.positive?
|
|
431
|
-
end
|
|
432
|
-
nil
|
|
433
|
-
end
|
|
434
|
-
|
|
435
|
-
def tools_support(raw)
|
|
436
|
-
listed = raw["supported_parameters"] || raw["capabilities"]
|
|
437
|
-
return nil unless listed.is_a?(Array)
|
|
438
|
-
|
|
439
|
-
listed.include?("tools") || nil
|
|
440
|
-
end
|
|
441
|
-
|
|
442
|
-
def cached_models
|
|
443
|
-
@models_mutex.synchronize do
|
|
444
|
-
fresh = @models_at && (monotonic - @models_at) < @models_ttl
|
|
445
|
-
return @models if fresh
|
|
446
|
-
end
|
|
447
|
-
models = list_models
|
|
448
|
-
@models_mutex.synchronize do
|
|
449
|
-
@models = models
|
|
450
|
-
@models_at = monotonic
|
|
451
|
-
end
|
|
452
|
-
models
|
|
453
|
-
end
|
|
454
|
-
|
|
455
|
-
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
456
|
-
|
|
457
410
|
# Streamed deltas put together: text, reasoning, tool calls by index,
|
|
458
411
|
# the finish reason, the usage chunk, the served model and provider.
|
|
459
412
|
class Assembly
|
|
@@ -484,7 +484,7 @@ module Samagotchi
|
|
|
484
484
|
private_class_method :configured
|
|
485
485
|
|
|
486
486
|
# Only a native llama.cpp host has /props with a chat template; a chat
|
|
487
|
-
# host (api: openai) barely uses a profile, and mlx/oMLX expose none.
|
|
487
|
+
# host (api: openai or anthropic) barely uses a profile, and mlx/oMLX expose none.
|
|
488
488
|
def self.probe?(entry, client)
|
|
489
489
|
return false if client.nil? || !client.respond_to?(:server_props)
|
|
490
490
|
return false if entry&.chat?
|
|
@@ -19,17 +19,24 @@ module Samagotchi
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
# Append a message to the queue. Nil/empty strings are ignored.
|
|
22
|
-
|
|
22
|
+
# +seq+ is the sender's own order number for it (the REPL numbers every
|
|
23
|
+
# line it takes during a turn, so its leftovers keep send order across
|
|
24
|
+
# this queue and its after-turn list: #drain_entries).
|
|
25
|
+
def push(text, seq: nil)
|
|
23
26
|
text = text.to_s
|
|
24
27
|
return if text.empty?
|
|
25
28
|
|
|
26
|
-
@mutex.synchronize { @messages << text }
|
|
29
|
+
@mutex.synchronize { @messages << [seq, text] }
|
|
27
30
|
nil
|
|
28
31
|
end
|
|
29
32
|
|
|
30
33
|
# Remove and return all queued messages in FIFO order. Non-blocking.
|
|
31
34
|
# @return [Array<String>]
|
|
32
|
-
def drain
|
|
35
|
+
def drain = drain_entries.map(&:last)
|
|
36
|
+
|
|
37
|
+
# #drain with each message's +seq+ (nil when pushed without one).
|
|
38
|
+
# @return [Array(Integer|nil, String)]
|
|
39
|
+
def drain_entries
|
|
33
40
|
@mutex.synchronize do
|
|
34
41
|
drained = @messages
|
|
35
42
|
@messages = []
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../client_id"
|
|
4
|
+
require_relative "../delivery"
|
|
4
5
|
require_relative "../session"
|
|
5
6
|
require_relative "../bridge_client"
|
|
6
7
|
require_relative "../bridge/turn_accumulator"
|
|
@@ -62,18 +63,28 @@ module Samagotchi
|
|
|
62
63
|
# Send +text+ to session +id+ as a user message (it runs as a turn;
|
|
63
64
|
# a stopped session is woken). Waits up to 5 s for its worker, so call
|
|
64
65
|
# it from an anytime command or your own thread, never a tool or hook
|
|
65
|
-
# of a running turn.
|
|
66
|
+
# of a running turn. A running turn takes it at its next step; with
|
|
67
|
+
# +delivery: :queue+ it runs as a turn of its own after that one
|
|
68
|
+
# instead. A plugin never cuts the running turn's thinking.
|
|
69
|
+
# @param delivery [Symbol, String, nil] nil or :next_step (default),
|
|
70
|
+
# :queue
|
|
66
71
|
# @return [String] the id of the session it went to
|
|
67
|
-
# @raise [Error] no such session, a REPL owns it,
|
|
68
|
-
|
|
72
|
+
# @raise [Error] no such session, a REPL owns it, it wasn't taken
|
|
73
|
+
# (a worker older than queueing refuses a queue), or delivery is
|
|
74
|
+
# :cut or unknown
|
|
75
|
+
def send(id, text, delivery: nil)
|
|
69
76
|
raise Error, "nothing to send" if text.to_s.strip.empty?
|
|
70
77
|
|
|
78
|
+
wire = plugin_delivery(delivery)
|
|
71
79
|
state_dir = self.state_dir
|
|
72
80
|
sid = resolve(id, state_dir)
|
|
73
|
-
delivered = SessionManager.deliver_turn(sid, prompt: text.to_s, client_id: ClientId::PLUGIN, state_dir: state_dir
|
|
81
|
+
delivered = SessionManager.deliver_turn(sid, prompt: text.to_s, client_id: ClientId::PLUGIN, state_dir: state_dir,
|
|
82
|
+
delivery: wire)
|
|
74
83
|
case delivered[:status]
|
|
75
84
|
when :accepted then sid
|
|
76
|
-
when :refused
|
|
85
|
+
when :refused
|
|
86
|
+
ack = delivered[:ack] || {}
|
|
87
|
+
raise Error, "session #{sid[0, 8]} refused the message (#{ack["error"]}#{": #{ack["detail"]}" if ack["detail"]})"
|
|
77
88
|
when :timeout then raise Error, "session #{sid[0, 8]}'s worker did not answer in time; the message was not sent"
|
|
78
89
|
else raise Error, "the message to session #{sid[0, 8]} could not be written"
|
|
79
90
|
end
|
|
@@ -81,6 +92,17 @@ module Samagotchi
|
|
|
81
92
|
raise Error, "session #{sid[0, 8]} is open in a chi REPL, which takes no messages from others"
|
|
82
93
|
end
|
|
83
94
|
|
|
95
|
+
# The wire value for send's delivery: (nil for the default).
|
|
96
|
+
def plugin_delivery(delivery)
|
|
97
|
+
case delivery&.to_s
|
|
98
|
+
when nil, "", Delivery::NEXT_STEP then nil
|
|
99
|
+
when Delivery::QUEUE then Delivery::QUEUE
|
|
100
|
+
when Delivery::CUT then raise Error, "plugins never cut: send it plain (the next step) or delivery: :queue"
|
|
101
|
+
else raise Error, "delivery takes :next_step or :queue, not #{delivery.inspect}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
private :plugin_delivery
|
|
105
|
+
|
|
84
106
|
# Session +id+ now: from its worker when one runs (with a running turn
|
|
85
107
|
# so far), else as saved.
|
|
86
108
|
# @return [Hash] {id:, title:, status:, parent_id:, running:, messages:}
|
|
@@ -16,12 +16,17 @@ module Samagotchi
|
|
|
16
16
|
#
|
|
17
17
|
# Order (#resolve): the session's own level (/reasoning-history), then
|
|
18
18
|
# SAMAGOTCHI_REASONING_HISTORY, models.<key>.reasoning_history,
|
|
19
|
-
# hosts.<name>.reasoning_history, then
|
|
19
|
+
# hosts.<name>.reasoning_history, then the host's api default: all for
|
|
20
|
+
# api: anthropic (its thinking blocks go back verbatim, and removing
|
|
21
|
+
# earlier turns' would rewrite the cached prefix every turn; ChatLoop
|
|
22
|
+
# floors drop at turn there), turn elsewhere. Native hosts ignore it:
|
|
20
23
|
# KernelLoop keeps the running turn's reasoning on its own, from the
|
|
21
24
|
# run's start (.run_start).
|
|
22
25
|
module ReasoningHistory
|
|
23
26
|
LEVELS = %i[drop turn all].freeze
|
|
24
27
|
DEFAULT = :turn
|
|
28
|
+
# The default for a host's api when it isn't DEFAULT.
|
|
29
|
+
API_DEFAULTS = { anthropic: :all }.freeze
|
|
25
30
|
KEY = "reasoning_history"
|
|
26
31
|
ENV_NAME = "SAMAGOTCHI_REASONING_HISTORY"
|
|
27
32
|
# The source a session's own level reads as.
|
|
@@ -82,7 +87,7 @@ module Samagotchi
|
|
|
82
87
|
host = target.entry&.reasoning_history
|
|
83
88
|
return [host, "hosts.#{target.entry.name}"] if host
|
|
84
89
|
|
|
85
|
-
[DEFAULT, nil]
|
|
90
|
+
[API_DEFAULTS.fetch(target.entry&.api&.to_sym, DEFAULT), nil]
|
|
86
91
|
end
|
|
87
92
|
|
|
88
93
|
# #resolve, as a SettingExplained with the session's own level beside it.
|
|
@@ -112,10 +117,15 @@ module Samagotchi
|
|
|
112
117
|
end
|
|
113
118
|
|
|
114
119
|
# Whether +conversation+ has a model entry with thinking (what all
|
|
115
|
-
# sends back as reasoning_content; LLM::ChatLoop#thought?)
|
|
120
|
+
# sends back as reasoning_content; LLM::ChatLoop#thought?), or with a
|
|
121
|
+
# saved thinking block (a Messages host's, which may carry no text:
|
|
122
|
+
# Claude's omitted display).
|
|
116
123
|
def earlier_thought?(conversation)
|
|
117
124
|
Array(conversation).any? do |entry|
|
|
118
|
-
entry[:role].to_s == "model"
|
|
125
|
+
next false unless entry[:role].to_s == "model"
|
|
126
|
+
|
|
127
|
+
(entry[:thinking].is_a?(String) && !entry[:thinking].strip.empty?) ||
|
|
128
|
+
Array(entry[:anthropic_content]).any? { |block| block.is_a?(Hash) && %w[thinking redacted_thinking].include?(block["type"].to_s) }
|
|
119
129
|
end
|
|
120
130
|
end
|
|
121
131
|
|
|
@@ -65,7 +65,8 @@ module Samagotchi
|
|
|
65
65
|
min_user_turns: recap_number_setting(kwarg_config, :min_user_turns, "recap.min_user_turns", IdleRecap::DEFAULT_MIN_USER_TURNS, :int),
|
|
66
66
|
timeout: recap_number_setting(kwarg_config, :timeout, "recap.timeout", IdleRecap::DEFAULT_TIMEOUT_SECONDS, :float),
|
|
67
67
|
sentences: recap_sentences(string_config(kwarg_config, :sentences) || registry_string("recap.sentences")),
|
|
68
|
-
store: RecapStore.new(session_id_lookup: -> { session_id.call }, state_dir_lookup: -> { state_dir.call })
|
|
68
|
+
store: RecapStore.new(session_id_lookup: -> { session_id.call }, state_dir_lookup: -> { state_dir.call }),
|
|
69
|
+
session_id: -> { session_id.call }
|
|
69
70
|
)
|
|
70
71
|
end
|
|
71
72
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "session"
|
|
4
4
|
require_relative "turn_note"
|
|
5
5
|
require_relative "session_inbox"
|
|
6
|
+
require_relative "worker_sidecar"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
# Loaded on first use: session_manager requires terminal_ui, which
|
|
@@ -14,9 +15,15 @@ module Samagotchi
|
|
|
14
15
|
# output/<timestamp>.txt file (one per turn that ended with visible text,
|
|
15
16
|
# written just before its idle save), so "next" means a filename past the
|
|
16
17
|
# cursor the caller holds: the newest one it already has, or nil for any.
|
|
18
|
+
# A wait for one input's turn (turn:, a queued message) looks for that
|
|
19
|
+
# turn's own file instead (output/<timestamp>-<enqueued_id>.txt).
|
|
17
20
|
# The delegate tools and `chi send --wait` wrap it in their own words.
|
|
18
21
|
module ReplyWait
|
|
19
22
|
POLL_INTERVAL = 0.5
|
|
23
|
+
# A worker's sidecar feature (Bridge::FEATURES): its running turn's
|
|
24
|
+
# checkpoint mark (InterruptedTurn#enqueued_id), a continue turn and a
|
|
25
|
+
# Stop's last_turn carry the turn's enqueued_id (#own_turn).
|
|
26
|
+
TURN_IDS_FEATURE = "turn_ids"
|
|
20
27
|
|
|
21
28
|
# @!attribute status [Symbol] :done, :waiting_for_answer, :no_reply,
|
|
22
29
|
# :error, :stopped, :canceled, :timeout or :worker_gone
|
|
@@ -38,7 +45,9 @@ module Samagotchi
|
|
|
38
45
|
# @!attribute retryable [Boolean, nil] whether that error may pass on a
|
|
39
46
|
# retry
|
|
40
47
|
# @!attribute cancel_reason [String, nil] a "canceled" turn's reason
|
|
41
|
-
# (user, hook, ctrl_c, manual
|
|
48
|
+
# (user, hook, ctrl_c, manual, session_stopped: the session was
|
|
49
|
+
# stopped mid-turn), passed through as the worker saved it; any
|
|
50
|
+
# reason is a "canceled" outcome
|
|
42
51
|
# @!attribute stopped_by [String, nil] the hook that canceled it
|
|
43
52
|
# (loop-guard)
|
|
44
53
|
# @!attribute session [Session, nil] the session as the look that
|
|
@@ -70,18 +79,64 @@ module Samagotchi
|
|
|
70
79
|
# @param interject [#call, nil] called once a poll, before the sleep
|
|
71
80
|
# (the delegate wait relays other children's approvals there); the
|
|
72
81
|
# time it takes doesn't count against +timeout+
|
|
82
|
+
# @param turn [String, nil] the enqueued_id of the input whose turn is
|
|
83
|
+
# waited for (a queued message, which runs after the turn running
|
|
84
|
+
# now). With it only that turn ends the wait: its reply file
|
|
85
|
+
# (SessionInbox.reply_for), or an idle session whose last_turn has
|
|
86
|
+
# that enqueued_id (failed, canceled, empty: #no_reply). Other turns'
|
|
87
|
+
# replies and ends are not its, and a pending question counts only
|
|
88
|
+
# when it is that turn's (#own_question?). A last_turn that ended
|
|
89
|
+
# before the baseline's (the turn whose continue offer was just
|
|
90
|
+
# answered) is not the end. An input a running turn merged as a
|
|
91
|
+
# steer (taken, never run as its own) is waited for as without turn:
|
|
92
|
+
# (that turn's reply or question). A turn seen running, then past
|
|
93
|
+
# (another turn runs or ended, last_turn not it): :no_reply, outcome
|
|
94
|
+
# unknown.
|
|
95
|
+
# @param queued [Boolean] the turn: input was sent as a queue (or with
|
|
96
|
+
# images), which a running turn never merges: no fallback, even when
|
|
97
|
+
# its file was taken before the first look
|
|
73
98
|
# @return [Result]
|
|
74
99
|
# @raise [ArgumentError] no such session
|
|
75
100
|
def call(id, state_dir:, cursor:, timeout: nil, poll_interval: POLL_INTERVAL, cancelled: -> { false },
|
|
76
|
-
baseline: nil, owner_grace: nil, interject: nil)
|
|
101
|
+
baseline: nil, owner_grace: nil, interject: nil, turn: nil, queued: false)
|
|
77
102
|
deadline = timeout && (monotonic + timeout.to_f)
|
|
78
103
|
seen_running = false
|
|
79
104
|
gone_since = nil
|
|
105
|
+
turn = turn.to_s.empty? ? nil : turn.to_s
|
|
106
|
+
session_dir = Session.session_dir(id, state_dir: state_dir) if turn
|
|
107
|
+
# Whether the turn's input file has been taken (latched): looked at
|
|
108
|
+
# before each load, so a running session seen after it is that turn
|
|
109
|
+
# or a later one, never the turn that ran when the message was sent.
|
|
110
|
+
claimed = false
|
|
111
|
+
# Its input runs a turn of its own (a queue, images: +queued+, or seen
|
|
112
|
+
# waiting as one): never merged into another turn.
|
|
113
|
+
own_turn_input = queued
|
|
114
|
+
# The turn was seen running (its mark's id), and last_turn's ended_at
|
|
115
|
+
# then: a later end that isn't its is a turn after it.
|
|
116
|
+
seen_own = false
|
|
117
|
+
own_base = nil
|
|
80
118
|
|
|
81
119
|
loop do
|
|
120
|
+
if turn && !claimed
|
|
121
|
+
waiting = SessionInbox.waiting_input(session_dir, turn)
|
|
122
|
+
own_turn_input ||= waiting == :turn_end
|
|
123
|
+
claimed = waiting.nil?
|
|
124
|
+
end
|
|
82
125
|
session = Session.load(id, state_dir: state_dir)
|
|
126
|
+
if turn && !seen_own && session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == turn
|
|
127
|
+
seen_own = true
|
|
128
|
+
own_base = session.last_turn&.ended_at
|
|
129
|
+
end
|
|
130
|
+
# Taken but never run as a turn of its own: a running turn's drain
|
|
131
|
+
# merged it as a steer, so that turn's reply (or question) is its
|
|
132
|
+
# answer. Waited for as the next reply past the cursor, as without
|
|
133
|
+
# turn:, never as a turn that will not come.
|
|
134
|
+
if turn && claimed && !seen_own && !own_turn_input && merged_elsewhere?(session, turn, baseline, session_dir)
|
|
135
|
+
turn = nil
|
|
136
|
+
seen_running = session.status == Session::STATUS_RUNNING
|
|
137
|
+
end
|
|
83
138
|
|
|
84
|
-
if (reply = reply_past(id, state_dir: state_dir, cursor: cursor))
|
|
139
|
+
if (reply = turn ? reply_for_turn(id, state_dir: state_dir, turn: turn) : reply_past(id, state_dir: state_dir, cursor: cursor))
|
|
85
140
|
reply.session = settled(session, id, state_dir: state_dir)
|
|
86
141
|
return reply
|
|
87
142
|
end
|
|
@@ -98,13 +153,31 @@ module Samagotchi
|
|
|
98
153
|
# REPL's is answered there.
|
|
99
154
|
pending = session.pending_question
|
|
100
155
|
if pending && !(baseline && pending[:id] == baseline[:question_id]) &&
|
|
156
|
+
(turn.nil? || own_question?(session, turn, claimed)) &&
|
|
101
157
|
session.waiting_question(live: SessionManager.worker_live?(id, state_dir: state_dir))
|
|
102
158
|
return Result.new(status: :waiting_for_answer, question: pending, session: session)
|
|
103
159
|
end
|
|
104
160
|
|
|
105
161
|
return Result.new(status: :canceled) if cancelled.call
|
|
106
162
|
|
|
107
|
-
if
|
|
163
|
+
if turn
|
|
164
|
+
# Only the turn's own end: the session runs another turn when the
|
|
165
|
+
# wait starts, and that one's end is not the answer. A last_turn
|
|
166
|
+
# with its id is its end, even with a next queued turn running
|
|
167
|
+
# by now (the look between the two missed the idle).
|
|
168
|
+
# Only an end after the baseline: a continue offer answered from
|
|
169
|
+
# idle leaves the exhausted turn's last_turn, with the same id,
|
|
170
|
+
# until the continue turn (or a Stop) records its own.
|
|
171
|
+
if session.last_turn&.enqueued_id == turn && ended_since_baseline?(session, baseline)
|
|
172
|
+
return reply_for_turn(id, state_dir: state_dir, turn: turn)&.tap { |r| r.session = session } ||
|
|
173
|
+
no_reply(session, baseline, fresh: true)
|
|
174
|
+
end
|
|
175
|
+
# Seen running, and now another turn runs or a later turn ended
|
|
176
|
+
# while last_turn (looked at first) isn't its: lost (its worker
|
|
177
|
+
# died), or it ended with no reply and a next turn ran and ended
|
|
178
|
+
# between two looks. Waiting on would sit through those turns.
|
|
179
|
+
return Result.new(status: :no_reply, session: session) if seen_own && past_own?(session, turn, own_base)
|
|
180
|
+
elsif session.status == Session::STATUS_RUNNING
|
|
108
181
|
seen_running = true
|
|
109
182
|
elsif (seen_running && !baseline&.key?(:last_turn)) || turn_ended_since?(session, baseline)
|
|
110
183
|
# It ran and is idle again with no new reply: canceled, failed or
|
|
@@ -164,11 +237,103 @@ module Samagotchi
|
|
|
164
237
|
!!(baseline[:messages] && session.messages.size > baseline[:messages])
|
|
165
238
|
end
|
|
166
239
|
|
|
240
|
+
# The turn a caller that sent nothing means (chi answer, chi send --wait
|
|
241
|
+
# with no message): the one running now (its checkpoint mark's
|
|
242
|
+
# enqueued_id), or, idle, the turn whose continue offer stands (its
|
|
243
|
+
# last_turn's), when the worker's sidecar says it ties a continue turn
|
|
244
|
+
# to that id (TURN_IDS_FEATURE). nil otherwise (no id known, a worker
|
|
245
|
+
# from before): the caller waits for the next reply as before.
|
|
246
|
+
# @param session [Session] the session as loaded now
|
|
247
|
+
# @return [String, nil] an enqueued_id for call's turn:
|
|
248
|
+
def own_turn(session, session_dir:)
|
|
249
|
+
if session.status == Session::STATUS_RUNNING
|
|
250
|
+
# Only a worker that writes it has one: no feature check.
|
|
251
|
+
return session.interrupted_turn&.enqueued_id
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
pending = session.pending_question
|
|
255
|
+
return nil unless pending && pending[:kind].to_s == "continue"
|
|
256
|
+
return nil unless turn_ids?(session_dir)
|
|
257
|
+
|
|
258
|
+
session.last_turn&.enqueued_id
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Whether the session's worker sidecar names TURN_IDS_FEATURE.
|
|
262
|
+
def turn_ids?(session_dir)
|
|
263
|
+
WorkerSidecar.read(session_dir)&.features&.include?(TURN_IDS_FEATURE) || false
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# A pending question is a turn: wait's (its enqueued_id) only when that
|
|
267
|
+
# turn asked it. While a turn runs its checkpoint mark says which turn
|
|
268
|
+
# that is (a continue turn carries its offer turn's id); a mark with no
|
|
269
|
+
# id (a worker from before) falls back to the claimed rule (its input
|
|
270
|
+
# claimed, the session running, last_turn not already it). Idle: only
|
|
271
|
+
# its own continue offer (last_turn has the id). Any other is another
|
|
272
|
+
# turn's, whose questions are the user's: the wait goes on.
|
|
273
|
+
def own_question?(session, turn, claimed)
|
|
274
|
+
ended = session.last_turn&.enqueued_id == turn
|
|
275
|
+
return ended unless session.status == Session::STATUS_RUNNING
|
|
276
|
+
|
|
277
|
+
running_id = session.interrupted_turn&.enqueued_id
|
|
278
|
+
return running_id == turn if running_id
|
|
279
|
+
|
|
280
|
+
# Ended, running again: a next turn.
|
|
281
|
+
!ended && claimed
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# The turn's end, after the baseline was taken (with no baseline, or
|
|
285
|
+
# one without last_turn, any).
|
|
286
|
+
def ended_since_baseline?(session, baseline)
|
|
287
|
+
return true unless baseline&.key?(:last_turn)
|
|
288
|
+
|
|
289
|
+
session.last_turn&.ended_at != baseline[:last_turn]
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# A turn: wait's taken input ran in another turn (a steer its drain
|
|
293
|
+
# merged): that turn runs (its mark has another id, or none from a
|
|
294
|
+
# worker that writes ids), or, idle, a turn not its ended since the
|
|
295
|
+
# baseline. last_turn or a reply file being its own is never that.
|
|
296
|
+
def merged_elsewhere?(session, turn, baseline, session_dir)
|
|
297
|
+
return false if session.last_turn&.enqueued_id == turn || SessionInbox.reply_for(session_dir, turn)
|
|
298
|
+
|
|
299
|
+
if session.status == Session::STATUS_RUNNING
|
|
300
|
+
running_id = session.interrupted_turn&.enqueued_id
|
|
301
|
+
return running_id != turn if running_id
|
|
302
|
+
|
|
303
|
+
return turn_ids?(session_dir)
|
|
304
|
+
end
|
|
305
|
+
baseline&.key?(:last_turn) ? turn_ended_since?(session, baseline) : false
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# A turn seen running (+own_base+: last_turn's ended_at then) is past:
|
|
309
|
+
# last_turn isn't its, and another turn runs (a mark with another id)
|
|
310
|
+
# or a turn ended since.
|
|
311
|
+
def past_own?(session, turn, own_base)
|
|
312
|
+
return false if session.last_turn&.enqueued_id == turn
|
|
313
|
+
|
|
314
|
+
running_id = session.status == Session::STATUS_RUNNING ? session.interrupted_turn&.enqueued_id : nil
|
|
315
|
+
return true if running_id && running_id != turn
|
|
316
|
+
|
|
317
|
+
ended = session.last_turn&.ended_at
|
|
318
|
+
!ended.nil? && ended != own_base
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# @return [Result, nil] :done with the reply of the turn that ran the
|
|
322
|
+
# input +turn+ (its enqueued_id)
|
|
323
|
+
def reply_for_turn(id, state_dir:, turn:)
|
|
324
|
+
file = SessionInbox.reply_for(Session.session_dir(id, state_dir: state_dir), turn)
|
|
325
|
+
return nil unless file
|
|
326
|
+
|
|
327
|
+
Result.new(status: :done, text: File.read(File.join(reply_dir(id, state_dir: state_dir), file)), file: file)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# @param fresh [Boolean, nil] the last_turn is known to be the turn
|
|
331
|
+
# waited for (a turn: wait); nil decides it from the baseline
|
|
167
332
|
# @return [Result] :no_reply with the outcome the session's last_turn
|
|
168
333
|
# records, when it is the turn waited for
|
|
169
|
-
def no_reply(session, baseline)
|
|
334
|
+
def no_reply(session, baseline, fresh: nil)
|
|
170
335
|
last = session.last_turn || LastTurn.new
|
|
171
|
-
fresh = baseline.nil? || !baseline.key?(:last_turn) || (last.ended_at && last.ended_at != baseline[:last_turn])
|
|
336
|
+
fresh = baseline.nil? || !baseline.key?(:last_turn) || (last.ended_at && last.ended_at != baseline[:last_turn]) if fresh.nil?
|
|
172
337
|
outcome = fresh ? last.outcome : nil
|
|
173
338
|
return Result.new(status: :no_reply, outcome: "exhausted", limit: last.limit, session: session) if fresh && last.exhausted
|
|
174
339
|
|