samagotchi 0.80.0 → 0.82.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 +57 -1
- data/docs/architecture.md +9 -1
- data/docs/cli.md +9 -1
- data/docs/configuration.md +4 -0
- data/docs/guardrails.md +26 -3
- data/docs/plugins.md +9 -3
- data/docs/sessions.md +9 -1
- data/docs/sub-agent.md +19 -8
- data/docs/testing.md +19 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
- data/lib/samagotchi/bootstrap_command.rb +1 -1
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -4
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/cli/parent_wait.rb +9 -3
- data/lib/samagotchi/engine.rb +31 -5
- data/lib/samagotchi/guardrails/data_readers.rb +214 -0
- data/lib/samagotchi/guardrails/rm_targets.rb +4 -2
- data/lib/samagotchi/guardrails/rules.rb +4 -2
- data/lib/samagotchi/guardrails/shell_lex.rb +166 -28
- data/lib/samagotchi/guardrails/shell_paths.rb +5 -3
- data/lib/samagotchi/guardrails/targets.rb +14 -0
- data/lib/samagotchi/host_config.rb +8 -8
- data/lib/samagotchi/idle_client.rb +36 -11
- data/lib/samagotchi/idle_recap.rb +6 -1
- data/lib/samagotchi/idle_target.rb +12 -4
- data/lib/samagotchi/llm/http.rb +7 -2
- data/lib/samagotchi/llm/usage.rb +9 -3
- data/lib/samagotchi/model_price.rb +16 -0
- data/lib/samagotchi/parent_report.rb +7 -3
- data/lib/samagotchi/plugin/context.rb +10 -3
- data/lib/samagotchi/plugin/service.rb +24 -1
- data/lib/samagotchi/recap_setup.rb +2 -1
- data/lib/samagotchi/reply_wait.rb +68 -2
- data/lib/samagotchi/send_command.rb +47 -8
- data/lib/samagotchi/session_metrics/side_totals.rb +117 -0
- data/lib/samagotchi/session_metrics.rb +49 -12
- data/lib/samagotchi/session_observer.rb +3 -0
- data/lib/samagotchi/side_usage.rb +38 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
- data/lib/samagotchi/terminal_ui/model_segments_text.rb +27 -3
- data/lib/samagotchi/testing/boundary.rb +15 -0
- data/lib/samagotchi/testing/bundle.rb +35 -9
- data/lib/samagotchi/testing/host.rb +2 -2
- data/lib/samagotchi/testing/recorder.rb +7 -4
- data/lib/samagotchi/testing/sandbox.rb +5 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +4 -1
- data/lib/samagotchi/web/public/app.js +3 -1
- data/lib/samagotchi/web/public/ctx.js +32 -6
- data/lib/samagotchi/web/public/info_bar_model.js +1 -1
- data/lib/samagotchi/web/public/lists_view.js +1 -1
- data/lib/samagotchi/web/public/question_cards.js +10 -5
- data/lib/samagotchi/web/public/stream_handlers.js +7 -0
- data/lib/samagotchi/web/public/timing.js +6 -1
- data/lib/samagotchi/web/session_summary.rb +2 -0
- metadata +4 -1
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require_relative "llm/adapters"
|
|
5
5
|
require_relative "log"
|
|
6
|
+
require_relative "side_usage"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
# Standalone summarizer for the idle session-recap feature.
|
|
@@ -54,9 +55,12 @@ module Samagotchi
|
|
|
54
55
|
# @param api [Symbol] the wire to talk (LLM::Adapters; IdleTarget#api)
|
|
55
56
|
# @param auth [Symbol, nil] the header the key goes in (IdleTarget#auth)
|
|
56
57
|
# @param dialect [String, nil] a Messages host's dialect (IdleTarget#dialect)
|
|
58
|
+
# @param price [ModelPrice, nil] the model's price on its host
|
|
59
|
+
# (IdleTarget#price), on each SideUsage the on_usage callbacks get
|
|
57
60
|
def initialize(model:, base_url: nil, api_key_env: nil, timeout: DEFAULT_TIMEOUT_SECONDS, env: ENV, purpose: "recap",
|
|
58
|
-
api: :openai, auth: nil, dialect: nil)
|
|
61
|
+
api: :openai, auth: nil, dialect: nil, price: nil)
|
|
59
62
|
@model = model
|
|
63
|
+
@price = price
|
|
60
64
|
# A recap is best-effort: one short attempt, no retries. The idle job
|
|
61
65
|
# tries again after the next activity, never on its own. Only the
|
|
62
66
|
# Messages adapter takes a dialect.
|
|
@@ -70,7 +74,7 @@ module Samagotchi
|
|
|
70
74
|
# @return [IdleClient]
|
|
71
75
|
def self.for(target, **)
|
|
72
76
|
new(model: target.model, base_url: target.base_url, api_key_env: target.api_key_env, api: target.api,
|
|
73
|
-
auth: target.auth, dialect: target.dialect, **)
|
|
77
|
+
auth: target.auth, dialect: target.dialect, price: target.price, **)
|
|
74
78
|
end
|
|
75
79
|
|
|
76
80
|
THINK_RE = /<\|think\|.*?\|think\|>/m
|
|
@@ -100,11 +104,13 @@ module Samagotchi
|
|
|
100
104
|
# @return [Summary, nil]
|
|
101
105
|
# +session_id+: the session it is about, sent as the turns send it
|
|
102
106
|
# (Session-Id, a dialect's affinity header), nil for none.
|
|
103
|
-
|
|
107
|
+
# +on_usage+: called with the request's SideUsage (kind "recap") once a
|
|
108
|
+
# reply came back, see #ask.
|
|
109
|
+
def summarize(prompt, session_id: nil, on_usage: nil)
|
|
104
110
|
messages = prompt.is_a?(Array) ? prompt : [{ role: "user", content: prompt.to_s.strip }]
|
|
105
111
|
return nil if messages.all? { |m| m[:content].to_s.strip.empty? }
|
|
106
112
|
|
|
107
|
-
response = generate(messages, session_id: session_id)
|
|
113
|
+
response = generate(messages, session_id: session_id, on_usage: on_usage)
|
|
108
114
|
cleaned = response.text.to_s.strip
|
|
109
115
|
cleaned.empty? ? nil : Summary.new(text: cleaned, model: response.model)
|
|
110
116
|
rescue SummarizeError
|
|
@@ -121,14 +127,18 @@ module Samagotchi
|
|
|
121
127
|
# @param options [Hash] more request body fields (logprobs: true,
|
|
122
128
|
# top_logprobs: 5)
|
|
123
129
|
# @param session_id [String, nil] the session it is about (#summarize)
|
|
130
|
+
# @param on_usage [#call, nil] called with the request's SideUsage
|
|
131
|
+
# (+kind+, the counts, the reported cost, the client's price) once a
|
|
132
|
+
# reply came back, a billed one with no usable text too; never for a
|
|
133
|
+
# request that got no reply. Its errors are logged, never raised.
|
|
124
134
|
# @return [Summary] the answer ("" when the model said nothing)
|
|
125
135
|
# @raise [SummarizeError] any failure but a cancel; its #cause is an
|
|
126
136
|
# LLM::BadRequest when the host refused the request (+options+ it
|
|
127
137
|
# doesn't take, say)
|
|
128
138
|
# @raise [LLM::RequestCancelled] +cancel_controller+ was cancelled
|
|
129
|
-
def ask(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, kind: "ask", options: {}, session_id: nil)
|
|
139
|
+
def ask(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, kind: "ask", options: {}, session_id: nil, on_usage: nil)
|
|
130
140
|
response = generate(messages, max_tokens: max_tokens, cancel_controller: cancel_controller, whole_sentences: false,
|
|
131
|
-
kind: kind, options: options, session_id: session_id)
|
|
141
|
+
kind: kind, options: options, session_id: session_id, on_usage: on_usage)
|
|
132
142
|
Summary.new(text: response.text.to_s.strip, model: response.model, top_logprobs: response.top_logprobs)
|
|
133
143
|
rescue SummarizeError, LLM::RequestCancelled
|
|
134
144
|
raise
|
|
@@ -146,7 +156,7 @@ module Samagotchi
|
|
|
146
156
|
# +max_tokens+, the text keeps its finished sentences (+whole_sentences+)
|
|
147
157
|
# or all of it, with "…".
|
|
148
158
|
def generate(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, whole_sentences: true, kind: "recap", options: {},
|
|
149
|
-
session_id: nil)
|
|
159
|
+
session_id: nil, on_usage: nil)
|
|
150
160
|
response = begin
|
|
151
161
|
request(messages, max_tokens, cancel_controller, options, session_id)
|
|
152
162
|
rescue LLM::BadRequest => e
|
|
@@ -156,6 +166,7 @@ module Samagotchi
|
|
|
156
166
|
retry
|
|
157
167
|
end
|
|
158
168
|
log_usage(response, kind)
|
|
169
|
+
report_usage(on_usage, response, kind)
|
|
159
170
|
content = response.text
|
|
160
171
|
reasoning = response.reasoning
|
|
161
172
|
raise SummarizeError, "server returned no parseable assistant content" if content.empty? && reasoning.empty?
|
|
@@ -176,14 +187,28 @@ module Samagotchi
|
|
|
176
187
|
raise SummarizeError, "server returned no parseable assistant content (#{e.message})"
|
|
177
188
|
end
|
|
178
189
|
|
|
179
|
-
#
|
|
180
|
-
#
|
|
190
|
+
# Log reported request usage when cache counts are present; session
|
|
191
|
+
# side-usage counting is handled separately by #report_usage.
|
|
181
192
|
def log_usage(response, kind)
|
|
182
|
-
|
|
193
|
+
usage = response.usage
|
|
194
|
+
fields = usage&.cache_fields || {}
|
|
183
195
|
return if fields.empty?
|
|
184
196
|
|
|
185
197
|
Log.info(:recap, "request_usage", kind: kind, model: @model, prompt: fields[:prompt_tokens],
|
|
186
|
-
cached: fields[:cached_tokens], cache_write: fields[:cache_write_tokens]
|
|
198
|
+
cached: fields[:cached_tokens], cache_write: fields[:cache_write_tokens],
|
|
199
|
+
# A cost as written: Log rounds Floats to 3 places.
|
|
200
|
+
completion: usage.completion_tokens, cost: usage.cost&.to_s)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# The request's SideUsage to +on_usage+ (a session counts it in its side
|
|
204
|
+
# line); a reply without counts is one request with zero tokens.
|
|
205
|
+
# Counting never fails the side answer: a callback's error is logged.
|
|
206
|
+
def report_usage(on_usage, response, kind)
|
|
207
|
+
return unless on_usage
|
|
208
|
+
|
|
209
|
+
on_usage.call(SideUsage.from_usage(response.usage, kind: kind, model: @model, price: @price))
|
|
210
|
+
rescue StandardError => e
|
|
211
|
+
Log.warn(:recap, "usage_callback_failed", kind: kind, model: @model, error: "#{e.class}: #{e.message}")
|
|
187
212
|
end
|
|
188
213
|
|
|
189
214
|
# After a host refused this step's thinking fields: the next step, kept
|
|
@@ -288,12 +288,17 @@ module Samagotchi
|
|
|
288
288
|
client: nil,
|
|
289
289
|
store: nil,
|
|
290
290
|
session_id: -> {},
|
|
291
|
+
on_usage: nil,
|
|
291
292
|
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
292
293
|
raise ArgumentError, "IdleRecap requires an engine" unless engine
|
|
293
294
|
|
|
294
295
|
@engine = engine
|
|
295
296
|
# The recapped session's id, sent with the request (IdleClient#summarize).
|
|
296
297
|
@session_id = session_id
|
|
298
|
+
# Called with each recap request's SideUsage (IdleClient#summarize),
|
|
299
|
+
# a dropped recap's too: it was billed (RecapSetup: the Engine's
|
|
300
|
+
# side line).
|
|
301
|
+
@on_usage = on_usage
|
|
297
302
|
@target = target || lambda {
|
|
298
303
|
IdleTarget.new(base_url: base_url, api_key_env: api_key_env, model: model, label: model)
|
|
299
304
|
}
|
|
@@ -547,7 +552,7 @@ module Samagotchi
|
|
|
547
552
|
|
|
548
553
|
def spawn_summarize(client, prompt)
|
|
549
554
|
Thread.new do
|
|
550
|
-
client.summarize(prompt, session_id: @session_id.call)
|
|
555
|
+
client.summarize(prompt, session_id: @session_id.call, on_usage: @on_usage)
|
|
551
556
|
rescue StandardError => e
|
|
552
557
|
# No recap this time (the next idle window tries again); say why.
|
|
553
558
|
Log.exception(:recap, "summarize_failed", e)
|
|
@@ -14,8 +14,14 @@ module Samagotchi
|
|
|
14
14
|
# auth:, LLM::ApiKey::SCHEMES); nil: the api's own default
|
|
15
15
|
# @!attribute dialect [String, nil] an api: anthropic host's dialect:
|
|
16
16
|
# (LLM::Dialect), so a side request gets its auth and fields
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
# @!attribute price [ModelPrice, nil] the host's models: price for the
|
|
18
|
+
# id asked for (as EffectiveModel#price), what a side request's cost is
|
|
19
|
+
# estimated from; nil for none, and for a bare base_url target
|
|
20
|
+
IdleTarget = Data.define(:base_url, :api_key_env, :model, :label, :api, :auth, :dialect, :price) do
|
|
21
|
+
def initialize(api: :openai, auth: nil, dialect: nil, price: nil, **) = super
|
|
22
|
+
|
|
23
|
+
# +entry+'s models: price for +id+ (the id asked for, case-insensitively).
|
|
24
|
+
def self.price_of(entry, id) = entry.models&.dig(id.to_s.strip.downcase)&.price
|
|
19
25
|
|
|
20
26
|
# The api a side request to +entry+ talks: a chat host's own, else the
|
|
21
27
|
# OpenAI API (a raw host's llama.cpp serves /v1 too).
|
|
@@ -49,7 +55,8 @@ module Samagotchi
|
|
|
49
55
|
resolved = host_registry.resolve(name)
|
|
50
56
|
entry = resolved.entry
|
|
51
57
|
new(base_url: entry.api_base_url, api_key_env: entry.api_key_env, model: resolved.bare_model,
|
|
52
|
-
label: name.to_s.strip, api: api_of(entry), auth: entry.auth, dialect: dialect_of(entry)
|
|
58
|
+
label: name.to_s.strip, api: api_of(entry), auth: entry.auth, dialect: dialect_of(entry),
|
|
59
|
+
price: price_of(entry, resolved.bare_model))
|
|
53
60
|
end
|
|
54
61
|
|
|
55
62
|
# A side model's settings (recap.*, broadcast.triage_*) as a target:
|
|
@@ -74,6 +81,7 @@ module Samagotchi
|
|
|
74
81
|
api = :openai
|
|
75
82
|
auth = nil
|
|
76
83
|
dialect = nil
|
|
84
|
+
entry = nil
|
|
77
85
|
if host_ref
|
|
78
86
|
entry = host_registry.find_entry(host_ref)
|
|
79
87
|
raise self::Unresolved.new(:host_ref_unknown, model: model, host_ref: host_ref) unless entry
|
|
@@ -95,7 +103,7 @@ module Samagotchi
|
|
|
95
103
|
raise self::Unresolved.new(:incomplete, model: model, host_ref: host_ref) if blank(base_url).nil? || blank(model).nil?
|
|
96
104
|
|
|
97
105
|
new(base_url: base_url.to_s.strip, api_key_env: api_key_env, model: model.to_s.strip, label: label.strip, api: api, auth: auth,
|
|
98
|
-
dialect: dialect)
|
|
106
|
+
dialect: dialect, price: entry && price_of(entry, model))
|
|
99
107
|
end
|
|
100
108
|
|
|
101
109
|
# Whether the settings fix a host (+base_url+, +host_ref+, or a model
|
data/lib/samagotchi/llm/http.rb
CHANGED
|
@@ -390,7 +390,7 @@ module Samagotchi
|
|
|
390
390
|
end
|
|
391
391
|
Log.public_send(failure_level, :http, event, **log_base(current), status: e.is_a?(ProviderError) ? e.status : nil,
|
|
392
392
|
ms: elapsed_ms(current), attempts: current[:attempts], error: e.class.name,
|
|
393
|
-
msg: (e.respond_to?(:summary) ? e.summary : e.message)
|
|
393
|
+
msg: log_message(e.respond_to?(:summary) ? e.summary : e.message))
|
|
394
394
|
raise
|
|
395
395
|
end
|
|
396
396
|
|
|
@@ -410,13 +410,18 @@ module Samagotchi
|
|
|
410
410
|
def retry_after(error, attempts, delay, on_retry, cancel_controller, current)
|
|
411
411
|
Log.warn(:http, "retry", **log_base(current), attempt: attempts, max_retries: @retry_policy.max, delay_s: delay,
|
|
412
412
|
status: error.is_a?(ProviderError) ? error.status : nil, error: error.class.name,
|
|
413
|
-
msg: error.message
|
|
413
|
+
msg: log_message(error.message))
|
|
414
414
|
on_retry&.call(attempt: attempts, max_retries: @retry_policy.max, next_delay: delay,
|
|
415
415
|
error_class: error.class.name, error_message: error.message,
|
|
416
416
|
status: error.is_a?(ProviderError) ? error.status : nil)
|
|
417
417
|
wait(delay, cancel_controller)
|
|
418
418
|
end
|
|
419
419
|
|
|
420
|
+
def log_message(value)
|
|
421
|
+
value.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\uFFFD")
|
|
422
|
+
.gsub(/[[:cntrl:]\u2028\u2029]+/, " ").split.join(" ")[0, 300]
|
|
423
|
+
end
|
|
424
|
+
|
|
420
425
|
def check_status!(response)
|
|
421
426
|
status = response.code.to_i
|
|
422
427
|
return if status.between?(200, 299)
|
data/lib/samagotchi/llm/usage.rb
CHANGED
|
@@ -8,7 +8,11 @@ module Samagotchi
|
|
|
8
8
|
# (:server), else zeros (:none). +cached_tokens+ is the prompt the server
|
|
9
9
|
# reused from its cache, +cache_write_tokens+ what it wrote to it
|
|
10
10
|
# (OpenRouter reports those for Anthropic models); 0 when not reported.
|
|
11
|
-
|
|
11
|
+
# +reasoning_tokens+ is the completion's reasoning part (0 when not
|
|
12
|
+
# reported), +cost+ the provider's reported cost in USD (OpenRouter's
|
|
13
|
+
# usage.cost; nil when not reported, a reported 0 stays 0).
|
|
14
|
+
Usage = Data.define(:prompt_tokens, :completion_tokens, :source, :cached_tokens, :cache_write_tokens,
|
|
15
|
+
:reasoning_tokens, :cost) do
|
|
12
16
|
# @return [Usage, nil] nil when the payload carries no counts
|
|
13
17
|
def self.from_payload(payload)
|
|
14
18
|
counts = TokenUsage.from_payload(payload)
|
|
@@ -16,12 +20,14 @@ module Samagotchi
|
|
|
16
20
|
|
|
17
21
|
new(prompt_tokens: counts.prompt_tokens.to_i, completion_tokens: counts.completion_tokens.to_i,
|
|
18
22
|
source: :server, cached_tokens: counts.cached_tokens.to_i,
|
|
19
|
-
cache_write_tokens: counts.cache_write_tokens.to_i
|
|
23
|
+
cache_write_tokens: counts.cache_write_tokens.to_i, reasoning_tokens: counts.reasoning_tokens.to_i,
|
|
24
|
+
cost: counts.cost)
|
|
20
25
|
end
|
|
21
26
|
|
|
22
27
|
def self.none = new(prompt_tokens: 0, completion_tokens: 0, source: :none)
|
|
23
28
|
|
|
24
|
-
def initialize(prompt_tokens:, completion_tokens:, source:, cached_tokens: 0, cache_write_tokens: 0
|
|
29
|
+
def initialize(prompt_tokens:, completion_tokens:, source:, cached_tokens: 0, cache_write_tokens: 0,
|
|
30
|
+
reasoning_tokens: 0, cost: nil)
|
|
25
31
|
super
|
|
26
32
|
end
|
|
27
33
|
|
|
@@ -34,6 +34,22 @@ module Samagotchi
|
|
|
34
34
|
end
|
|
35
35
|
private_class_method :warn_once
|
|
36
36
|
|
|
37
|
+
# The cost rule, one place for a turn's generation (SessionMetrics) and
|
|
38
|
+
# a side request (SessionMetrics::SideTotals): a reported cost wins; a
|
|
39
|
+
# reported 0, or none, with a price is estimated from the price (a
|
|
40
|
+
# gateway that doesn't bill per call reports 0). The reported cost
|
|
41
|
+
# itself is summed apart, a 0 included.
|
|
42
|
+
# @param price [ModelPrice, nil]
|
|
43
|
+
# @param reported_cost [Numeric, nil] the provider's cost (usage.cost)
|
|
44
|
+
# @return [Float, nil] the estimate, nil when none applies (no price, or
|
|
45
|
+
# a positive reported cost), so a sum without one stays as it was
|
|
46
|
+
def self.estimate(price, reported_cost:, prompt_tokens:, cached_tokens:, cache_write_tokens:, completion_tokens:)
|
|
47
|
+
return nil unless price && (reported_cost.nil? || reported_cost.zero?)
|
|
48
|
+
|
|
49
|
+
price.cost(prompt_tokens: prompt_tokens, cached_tokens: cached_tokens, cache_write_tokens: cache_write_tokens,
|
|
50
|
+
completion_tokens: completion_tokens)
|
|
51
|
+
end
|
|
52
|
+
|
|
37
53
|
# USD for one generation, OpenAI usage semantics: +prompt_tokens+ holds
|
|
38
54
|
# the cached reads and the cache writes (OpenRouter keeps that for
|
|
39
55
|
# Anthropic models), and reasoning is inside +completion_tokens+.
|
|
@@ -76,6 +76,7 @@ module Samagotchi
|
|
|
76
76
|
# @return [Hash] the --format json object
|
|
77
77
|
def json(result, session_id:, timeout: nil, extra: {})
|
|
78
78
|
report = { status: status(result), session_id: session_id }
|
|
79
|
+
report[:turn_id] = result.turn_id unless result.turn_id.to_s.empty?
|
|
79
80
|
case report[:status]
|
|
80
81
|
when "answered" then report[:text] = result.text.to_s
|
|
81
82
|
when "question"
|
|
@@ -122,7 +123,7 @@ module Samagotchi
|
|
|
122
123
|
text = if result.status == :waiting_for_answer
|
|
123
124
|
question_text(result.question, session_id: session_id)
|
|
124
125
|
else
|
|
125
|
-
"#{detail(result, session_id: session_id, timeout: timeout)}\n"
|
|
126
|
+
"#{detail(result, session_id: session_id, timeout: timeout, turn_id: result.turn_id)}\n"
|
|
126
127
|
end
|
|
127
128
|
stderr.print("#{command}: #{text}")
|
|
128
129
|
end
|
|
@@ -200,7 +201,7 @@ module Samagotchi
|
|
|
200
201
|
end
|
|
201
202
|
|
|
202
203
|
# What a wait that ended without an answer says, one line.
|
|
203
|
-
def detail(result, session_id:, timeout: nil)
|
|
204
|
+
def detail(result, session_id:, timeout: nil, turn_id: nil)
|
|
204
205
|
attach = "chi --attach #{session_id}"
|
|
205
206
|
case result.status
|
|
206
207
|
when :waiting_for_answer
|
|
@@ -217,7 +218,10 @@ module Samagotchi
|
|
|
217
218
|
when :error then "the worker failed: #{result.text}; #{attach} shows what happened"
|
|
218
219
|
when :worker_gone then "the worker is gone; #{attach} shows what happened"
|
|
219
220
|
when :stopped then "the session was stopped (chi sessions stop)"
|
|
220
|
-
else
|
|
221
|
+
else
|
|
222
|
+
wait_again = "chi send --wait --turn #{turn_id} #{session_id}" unless turn_id.to_s.empty?
|
|
223
|
+
hint = wait_again || attach
|
|
224
|
+
timeout ? "still running after #{format("%g", timeout)} s: #{hint}" : "still running: #{hint}"
|
|
221
225
|
end
|
|
222
226
|
end
|
|
223
227
|
|
|
@@ -5,6 +5,7 @@ require_relative "../log"
|
|
|
5
5
|
require_relative "../paths"
|
|
6
6
|
require_relative "../guardrails/context"
|
|
7
7
|
require_relative "../idle_client"
|
|
8
|
+
require_relative "service"
|
|
8
9
|
require_relative "side_question"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
@@ -76,7 +77,7 @@ module Samagotchi
|
|
|
76
77
|
# handler started) they act on the session now, through the Host.
|
|
77
78
|
class Context
|
|
78
79
|
# Thread.current key: {Context => the event its handler runs for}.
|
|
79
|
-
CURRENT_EVENTS =
|
|
80
|
+
CURRENT_EVENTS = Service::CURRENT_EVENTS
|
|
80
81
|
# Debug-log records tagged plugins, with bundle=<bundle> (tags are a
|
|
81
82
|
# closed list, LogLine::TAGS).
|
|
82
83
|
class Logger
|
|
@@ -294,7 +295,7 @@ module Samagotchi
|
|
|
294
295
|
request = SideQuestion.request(messages: messages, prompt: prompt, system: system)
|
|
295
296
|
limit = max_tokens.nil? ? ASK_MAX_TOKENS : Integer(max_tokens)
|
|
296
297
|
@host.ask_model.call(request, timeout: Float(timeout), max_tokens: limit, cancel_controller: cancel,
|
|
297
|
-
model: model&.strip).to_s
|
|
298
|
+
model: model&.strip, source: @bundle).to_s
|
|
298
299
|
rescue IdleClient::SummarizeError => e
|
|
299
300
|
raise ModelError, e.message
|
|
300
301
|
rescue LLM::RequestCancelled
|
|
@@ -318,7 +319,7 @@ module Samagotchi
|
|
|
318
319
|
# @return [Http]
|
|
319
320
|
def http
|
|
320
321
|
@http ||= Http.new(bundle: @bundle, transport: @http_transport,
|
|
321
|
-
cancel_lookup: -> {
|
|
322
|
+
cancel_lookup: -> { http_cancel_controller })
|
|
322
323
|
end
|
|
323
324
|
|
|
324
325
|
# This session's attached context: attach a URL or a command, list
|
|
@@ -387,6 +388,12 @@ module Samagotchi
|
|
|
387
388
|
# Whether this plugin's handler runs on this thread (#with_event).
|
|
388
389
|
def in_handler? = !!Thread.current[CURRENT_EVENTS]&.key?(self)
|
|
389
390
|
|
|
391
|
+
# The controller a ctx.http call without cancel: stops with: nil inside
|
|
392
|
+
# a service's start block, even on the turn's thread.
|
|
393
|
+
def http_cancel_controller
|
|
394
|
+
Service.starting? ? nil : @host.cancel_controller&.call(handler: in_handler?)
|
|
395
|
+
end
|
|
396
|
+
|
|
390
397
|
# The event[:x] helper of the event this plugin's handler runs for on
|
|
391
398
|
# this thread, or nil (not in a handler).
|
|
392
399
|
def event_helper(name)
|
|
@@ -15,6 +15,16 @@ module Samagotchi
|
|
|
15
15
|
# again.
|
|
16
16
|
class Stopped < StandardError; end
|
|
17
17
|
|
|
18
|
+
# The thread-local Context#with_event keeps its handler events in;
|
|
19
|
+
# the start block runs with it cleared.
|
|
20
|
+
CURRENT_EVENTS = :samagotchi_plugin_current_events
|
|
21
|
+
# Thread.current key: true while a start block runs (the turn's own
|
|
22
|
+
# thread too: a ctx.http call there isn't the turn's).
|
|
23
|
+
STARTING = :samagotchi_plugin_service_starting
|
|
24
|
+
|
|
25
|
+
# Whether a service's start block runs on this thread.
|
|
26
|
+
def self.starting? = !!Thread.current[STARTING]
|
|
27
|
+
|
|
18
28
|
# @return [String] "<bundle>:<name>"
|
|
19
29
|
attr_reader :name
|
|
20
30
|
|
|
@@ -37,7 +47,7 @@ module Samagotchi
|
|
|
37
47
|
return @value if @state == :running
|
|
38
48
|
|
|
39
49
|
begin
|
|
40
|
-
@value =
|
|
50
|
+
@value = start_outside_handler
|
|
41
51
|
@state = :running
|
|
42
52
|
Log.info(:plugins, "service_started", service: @name)
|
|
43
53
|
@value
|
|
@@ -81,6 +91,19 @@ module Samagotchi
|
|
|
81
91
|
|
|
82
92
|
private
|
|
83
93
|
|
|
94
|
+
# The start block is the service's, not the handler's whose first
|
|
95
|
+
# #value started it: no event helper or ctx.http turn cancel inside.
|
|
96
|
+
def start_outside_handler
|
|
97
|
+
events = Thread.current[CURRENT_EVENTS]
|
|
98
|
+
starting = Thread.current[STARTING]
|
|
99
|
+
Thread.current[CURRENT_EVENTS] = nil
|
|
100
|
+
Thread.current[STARTING] = true
|
|
101
|
+
@start.call(self)
|
|
102
|
+
ensure
|
|
103
|
+
Thread.current[CURRENT_EVENTS] = events
|
|
104
|
+
Thread.current[STARTING] = starting
|
|
105
|
+
end
|
|
106
|
+
|
|
84
107
|
def run_on_stop
|
|
85
108
|
callbacks = @on_stop.reverse
|
|
86
109
|
@on_stop = []
|
|
@@ -66,7 +66,8 @@ module Samagotchi
|
|
|
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
68
|
store: RecapStore.new(session_id_lookup: -> { session_id.call }, state_dir_lookup: -> { state_dir.call }),
|
|
69
|
-
session_id: -> { session_id.call }
|
|
69
|
+
session_id: -> { session_id.call },
|
|
70
|
+
on_usage: ->(usage) { engine.note_side_usage(usage) }
|
|
70
71
|
)
|
|
71
72
|
end
|
|
72
73
|
|
|
@@ -52,8 +52,10 @@ module Samagotchi
|
|
|
52
52
|
# @!attribute session [Session, nil] the session as the look that
|
|
53
53
|
# decided the outcome loaded it (a caller's next baseline); nil for
|
|
54
54
|
# :canceled and :timeout
|
|
55
|
+
# @!attribute turn_id [String, nil] the id of the turn this result is
|
|
56
|
+
# about, when known
|
|
55
57
|
Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, :error_kind, :retryable, :cancel_reason,
|
|
56
|
-
:stopped_by, :kept_steps, :session, keyword_init: true)
|
|
58
|
+
:stopped_by, :kept_steps, :session, :turn_id, keyword_init: true)
|
|
57
59
|
|
|
58
60
|
module_function
|
|
59
61
|
|
|
@@ -94,10 +96,18 @@ module Samagotchi
|
|
|
94
96
|
# @param queued [Boolean] the turn: input was sent as a queue (or with
|
|
95
97
|
# images), which a running turn never merges: no fallback, even when
|
|
96
98
|
# its file was taken before the first look
|
|
99
|
+
# @param ended_before [Boolean] a last_turn that ended before the
|
|
100
|
+
# baseline (chi send --wait with no message, on a session that went
|
|
101
|
+
# idle in the gap between waits) is the turn's end too: with the
|
|
102
|
+
# session idle and its last_turn still the baseline's, its end is
|
|
103
|
+
# the answer — its reply, or its no-reply outcome. Its reply file
|
|
104
|
+
# sorts at the cursor, not past it, and its end precedes the
|
|
105
|
+
# baseline, so without it the wait would time out "running" again.
|
|
106
|
+
# Not for a turn: wait (chi answer) whose own end it waits for.
|
|
97
107
|
# @return [Result]
|
|
98
108
|
# @raise [ArgumentError] no such session
|
|
99
109
|
def call(id, state_dir:, cursor:, timeout: nil, poll_interval: POLL_INTERVAL, cancelled: -> { false },
|
|
100
|
-
baseline: nil, owner_grace: nil, interject: nil, turn: nil, queued: false)
|
|
110
|
+
baseline: nil, owner_grace: nil, interject: nil, turn: nil, queued: false, ended_before: false)
|
|
101
111
|
deadline = timeout && (monotonic + timeout.to_f)
|
|
102
112
|
seen_running = false
|
|
103
113
|
gone_since = nil
|
|
@@ -159,6 +169,34 @@ module Samagotchi
|
|
|
159
169
|
|
|
160
170
|
return Result.new(status: :canceled) if cancelled.call
|
|
161
171
|
|
|
172
|
+
# The turn that ended before this wait (ended_before: chi send
|
|
173
|
+
# --wait with no message, on a session that went idle in the gap
|
|
174
|
+
# between waits): its last_turn still stands, the session is idle,
|
|
175
|
+
# so its end is the answer, not a wait that times out "running"
|
|
176
|
+
# again. Its reply file sorts at the cursor (the newest), not past
|
|
177
|
+
# it: it is the turn's when it is at least as new as the turn's
|
|
178
|
+
# end (the worker writes it after recording the ended_at), else the
|
|
179
|
+
# turn left no reply and an earlier turn's file is not its.
|
|
180
|
+
if ended_before && turn.nil? && session.status == Session::STATUS_IDLE &&
|
|
181
|
+
(ended = session.last_turn&.ended_at) && !ended.to_s.empty? &&
|
|
182
|
+
baseline && ended == baseline[:last_turn]
|
|
183
|
+
file = newest_reply(id, state_dir: state_dir)
|
|
184
|
+
if file && reply_is_turns?(file, ended)
|
|
185
|
+
reply = Result.new(status: :done, text: File.read(File.join(reply_dir(id, state_dir: state_dir), file)), file: file)
|
|
186
|
+
reply.session = settled(session, id, state_dir: state_dir)
|
|
187
|
+
return reply
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
return no_reply(session, baseline, fresh: true)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
if ended_before && turn && session.last_turn&.enqueued_id == turn &&
|
|
194
|
+
!(session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == turn) &&
|
|
195
|
+
(ended = session.last_turn&.ended_at) && !ended.to_s.empty? &&
|
|
196
|
+
baseline && ended == baseline[:last_turn]
|
|
197
|
+
return no_reply(session, baseline, fresh: true)
|
|
198
|
+
end
|
|
199
|
+
|
|
162
200
|
if turn
|
|
163
201
|
# Only the turn's own end: the session runs another turn when the
|
|
164
202
|
# wait starts, and that one's end is not the answer. A last_turn
|
|
@@ -257,6 +295,18 @@ module Samagotchi
|
|
|
257
295
|
session.last_turn&.enqueued_id
|
|
258
296
|
end
|
|
259
297
|
|
|
298
|
+
# Whether +turn+ is a turn the caller can wait for: running now, queued
|
|
299
|
+
# as its own turn, or the last one to have ended.
|
|
300
|
+
def known_turn?(session, session_dir:, turn:)
|
|
301
|
+
id = turn.to_s
|
|
302
|
+
return false if id.empty?
|
|
303
|
+
|
|
304
|
+
running = session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == id
|
|
305
|
+
queued = SessionInbox.waiting_input(session_dir, id) == :turn_end
|
|
306
|
+
ended = session.last_turn&.enqueued_id == id
|
|
307
|
+
running || queued || ended
|
|
308
|
+
end
|
|
309
|
+
|
|
260
310
|
# Whether the session's worker sidecar names TURN_IDS_FEATURE.
|
|
261
311
|
def turn_ids?(session_dir)
|
|
262
312
|
WorkerSidecar.read(session_dir)&.features&.include?(TURN_IDS_FEATURE) || false
|
|
@@ -390,6 +440,22 @@ module Samagotchi
|
|
|
390
440
|
cursor.nil? || file > cursor
|
|
391
441
|
end
|
|
392
442
|
|
|
443
|
+
# Whether the reply file +file+ is the turn that ended at +ended_at+'s,
|
|
444
|
+
# not an earlier turn's: the worker records the ended_at, then writes
|
|
445
|
+
# the reply (SessionInbox.write_output: the name's timestamp is the
|
|
446
|
+
# write time, at least the ended_at); every earlier turn's file is
|
|
447
|
+
# older than the turn's end. The wait and the session run on one
|
|
448
|
+
# machine, so the name's local-time read is the writer's. A name that
|
|
449
|
+
# doesn't read as a time isn't the turn's.
|
|
450
|
+
def reply_is_turns?(file, ended_at)
|
|
451
|
+
name = file.to_s
|
|
452
|
+
return false if name.length < 23
|
|
453
|
+
|
|
454
|
+
Time.parse(ended_at.to_s) <= Time.strptime(name[0, 14], "%Y%m%d%H%M%S") + (name[14, 9].to_f / (10**9))
|
|
455
|
+
rescue ArgumentError
|
|
456
|
+
false
|
|
457
|
+
end
|
|
458
|
+
|
|
393
459
|
def monotonic
|
|
394
460
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
395
461
|
end
|