samagotchi 0.23.0 → 0.25.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 +93 -1
- data/docs/architecture.md +16 -9
- data/docs/cli.md +10 -4
- data/docs/configuration.md +30 -13
- data/docs/hooks.md +21 -7
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/prompt-caching.md +94 -0
- data/docs/plugins.md +17 -3
- data/docs/sub-agent.md +4 -3
- data/docs/testing.md +12 -0
- data/lib/samagotchi/bridge/card_store.rb +4 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
- data/lib/samagotchi/bridge/turn_notice.rb +1 -0
- data/lib/samagotchi/bridge.rb +7 -0
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +2 -1
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +43 -7
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/command_steps.rb +233 -0
- data/lib/samagotchi/config.rb +9 -2
- data/lib/samagotchi/empty_answer_retry.rb +2 -2
- data/lib/samagotchi/engine.rb +103 -5
- data/lib/samagotchi/generation_phase.rb +83 -0
- data/lib/samagotchi/guardrails/shell_lex.rb +158 -6
- data/lib/samagotchi/guardrails/shell_paths.rb +1 -1
- data/lib/samagotchi/idle_client.rb +14 -2
- data/lib/samagotchi/idle_recap.rb +4 -2
- data/lib/samagotchi/kernel_loop.rb +40 -12
- data/lib/samagotchi/llm/chat_loop.rb +37 -13
- data/lib/samagotchi/llm/errors.rb +26 -1
- data/lib/samagotchi/llm/openai_chat.rb +8 -4
- data/lib/samagotchi/llm/prompt_cache.rb +34 -9
- data/lib/samagotchi/llm/usage.rb +18 -3
- data/lib/samagotchi/log_subscriber.rb +5 -3
- data/lib/samagotchi/plugin/api.rb +3 -2
- data/lib/samagotchi/prompt.rb +4 -1
- data/lib/samagotchi/session_metrics.rb +38 -4
- data/lib/samagotchi/steer.rb +103 -8
- data/lib/samagotchi/system_prompt.rb +30 -9
- data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -3
- data/lib/samagotchi/terminal_ui/attached_view.rb +6 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +17 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +40 -9
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
- data/lib/samagotchi/terminal_ui.rb +5 -0
- data/lib/samagotchi/thinking_tails.rb +103 -0
- data/lib/samagotchi/thought_stream_splitter.rb +9 -3
- data/lib/samagotchi/token_usage.rb +18 -6
- data/lib/samagotchi/tool_activity.rb +37 -3
- data/lib/samagotchi/tool_declarations.rb +14 -0
- data/lib/samagotchi/tool_runner.rb +7 -0
- data/lib/samagotchi/tool_view.rb +76 -0
- data/lib/samagotchi/tools/builtins.rb +4 -1
- data/lib/samagotchi/tools/execute.rb +2 -1
- data/lib/samagotchi/tools/registry.rb +11 -4
- data/lib/samagotchi/tools/shell.rb +32 -0
- data/lib/samagotchi/tools/task_runtime.rb +3 -3
- data/lib/samagotchi/turn_tally.rb +17 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/waiting_steer.rb +64 -0
- data/lib/samagotchi/web/message_parts.rb +5 -0
- data/lib/samagotchi/web/public/activity.js +5 -0
- data/lib/samagotchi/web/public/app.js +2 -1
- data/lib/samagotchi/web/public/command_view.js +96 -0
- data/lib/samagotchi/web/public/copy.js +7 -0
- data/lib/samagotchi/web/public/format.js +11 -1
- data/lib/samagotchi/web/public/index.html +33 -0
- data/lib/samagotchi/web/public/stage_model.js +16 -5
- data/lib/samagotchi/web/public/stage_view.js +5 -2
- data/lib/samagotchi/web/public/turn_events.js +15 -1
- data/lib/samagotchi/web/public/turn_view.js +15 -6
- data/lib/samagotchi/worker.rb +4 -1
- metadata +9 -1
|
@@ -25,6 +25,9 @@ module Samagotchi
|
|
|
25
25
|
# CreditsHeld 402 "… in-flight requests": credit reserved by other
|
|
26
26
|
# requests; a RateLimited, retried after 20 s
|
|
27
27
|
# OutOfCredits any other 402; never retried
|
|
28
|
+
# OverBudget 402 metadata.reason weight_exceeds_budget: the
|
|
29
|
+
# request alone exceeds the key's budget; an
|
|
30
|
+
# OutOfCredits, never retried
|
|
28
31
|
# ServerError 5xx or a server's error event mid-stream
|
|
29
32
|
# AuthError 401/403, or an API key variable that is not set
|
|
30
33
|
# BadRequest other 4xx (a context overflow is one, whatever status;
|
|
@@ -105,6 +108,15 @@ module Samagotchi
|
|
|
105
108
|
def summary = "out of credits on host #{host}: #{detail}; add credits, then send again"
|
|
106
109
|
end
|
|
107
110
|
|
|
111
|
+
# A 402 whose error.metadata.reason is weight_exceeds_budget (OpenRouter):
|
|
112
|
+
# this one request's credit reservation is larger than the key's whole
|
|
113
|
+
# budget, so no amount of waiting lets it through. Never retried, even
|
|
114
|
+
# when the message reads like the in-flight one.
|
|
115
|
+
class OverBudget < OutOfCredits
|
|
116
|
+
def summary = "request too large for the credit budget on host #{host}: #{detail}; " \
|
|
117
|
+
"lower max_tokens (default.max_tokens) or raise the key's credit limit"
|
|
118
|
+
end
|
|
119
|
+
|
|
108
120
|
class ServerError < ProviderError
|
|
109
121
|
def kind = :server
|
|
110
122
|
|
|
@@ -261,6 +273,9 @@ module Samagotchi
|
|
|
261
273
|
REASONING_REFUSED_RE = /reasoning|thinking/i
|
|
262
274
|
# A 402 about credit held by in-flight requests (see CreditsHeld).
|
|
263
275
|
CREDITS_HELD_RE = /in-flight requests/i
|
|
276
|
+
# A 402's error.metadata.reason when the request can never fit the
|
|
277
|
+
# key's budget (see OverBudget).
|
|
278
|
+
OVER_BUDGET_REASON = "weight_exceeds_budget"
|
|
264
279
|
# Seconds to wait before retrying a CreditsHeld without a Retry-After.
|
|
265
280
|
CREDITS_SETTLE_DELAY = 20.0
|
|
266
281
|
RETRYABLE_SERVER_STATUSES = [500, 502, 503, 504, 529].freeze
|
|
@@ -285,7 +300,9 @@ module Samagotchi
|
|
|
285
300
|
when 429 then RateLimited.new(text, retry_after: parse_retry_after(retry_after), **options)
|
|
286
301
|
when 408 then ServerError.new(text, retryable: true, **options)
|
|
287
302
|
when 402
|
|
288
|
-
if
|
|
303
|
+
if error_reason(body) == OVER_BUDGET_REASON
|
|
304
|
+
OverBudget.new(text, **options)
|
|
305
|
+
elsif CREDITS_HELD_RE.match?(message) || CREDITS_HELD_RE.match?(body.to_s)
|
|
289
306
|
CreditsHeld.new(text, retry_after: parse_retry_after(retry_after) || CREDITS_SETTLE_DELAY, **options)
|
|
290
307
|
else
|
|
291
308
|
OutOfCredits.new(text, **options)
|
|
@@ -356,6 +373,14 @@ module Samagotchi
|
|
|
356
373
|
message.empty? ? "(empty body)" : message[0, 500]
|
|
357
374
|
end
|
|
358
375
|
|
|
376
|
+
# error.metadata.reason of a JSON error body (OpenRouter), or nil.
|
|
377
|
+
def error_reason(body)
|
|
378
|
+
parsed = parse_json(body.to_s)
|
|
379
|
+
error = parsed["error"] if parsed.is_a?(Hash)
|
|
380
|
+
metadata = error["metadata"] if error.is_a?(Hash)
|
|
381
|
+
metadata["reason"] if metadata.is_a?(Hash)
|
|
382
|
+
end
|
|
383
|
+
|
|
359
384
|
# +message+ followed by the upstream provider's name and words from
|
|
360
385
|
# +metadata+ (raw may be text or a JSON error body of its own).
|
|
361
386
|
def with_upstream(message, metadata)
|
|
@@ -69,6 +69,9 @@ module Samagotchi
|
|
|
69
69
|
**)
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
+
# true when a streamed +payload+ carries a tool-call delta.
|
|
73
|
+
def self.tool_call_delta?(payload) = Assembly.tool_call_delta?(payload)
|
|
74
|
+
|
|
72
75
|
# true when +url+ is OpenRouter's API (openrouter.ai or a subdomain).
|
|
73
76
|
def self.openrouter?(url)
|
|
74
77
|
host = URI(url.to_s).host.to_s.downcase
|
|
@@ -237,7 +240,6 @@ module Samagotchi
|
|
|
237
240
|
body = {
|
|
238
241
|
model: model,
|
|
239
242
|
messages: cache_breakpoints(Array(messages).map { |message| wire_message(message) }, model),
|
|
240
|
-
temperature: 0.0,
|
|
241
243
|
stream: @stream
|
|
242
244
|
}
|
|
243
245
|
body[:stream_options] = { include_usage: true } if @stream
|
|
@@ -246,7 +248,9 @@ module Samagotchi
|
|
|
246
248
|
body[:tools] = tools
|
|
247
249
|
body[:tool_choice] = "auto"
|
|
248
250
|
end
|
|
249
|
-
#
|
|
251
|
+
# No temperature unless configured: each model runs at its provider's
|
|
252
|
+
# default (greedy decoding made thinking models loop). A configured
|
|
253
|
+
# value replaces chi's own (max_tokens); nil drops it.
|
|
250
254
|
body.merge(options || {}).compact
|
|
251
255
|
end
|
|
252
256
|
|
|
@@ -255,13 +259,13 @@ module Samagotchi
|
|
|
255
259
|
# would pay for a cache write nothing reads, and a local model named
|
|
256
260
|
# after Claude must not get Array content.
|
|
257
261
|
def cache_breakpoints(messages, model)
|
|
258
|
-
return messages unless @remote && @purpose == "chat"
|
|
262
|
+
return PromptCache.without_split(messages) unless @remote && @purpose == "chat"
|
|
259
263
|
|
|
260
264
|
PromptCache.mark(messages, model: model)
|
|
261
265
|
end
|
|
262
266
|
|
|
263
267
|
# The body's fields beyond the conversation and the stream, for the
|
|
264
|
-
# log line: "temperature=0.6 presence_penalty=1.5".
|
|
268
|
+
# log line: "temperature=0.6 presence_penalty=1.5", nil for none.
|
|
265
269
|
def sampling_summary(body)
|
|
266
270
|
SamplingSettings.log_text(body.except(:model, :messages, :stream, :stream_options, :tools, :tool_choice))
|
|
267
271
|
end
|
|
@@ -11,6 +11,12 @@ module Samagotchi
|
|
|
11
11
|
# same prefix plus new messages) reads everything up to it.
|
|
12
12
|
# Mid and tail system messages (reminders, turn notes) never take the
|
|
13
13
|
# second mark. Other models' messages pass through untouched.
|
|
14
|
+
#
|
|
15
|
+
# A system message whose `cache_split:` says where its stable part ends
|
|
16
|
+
# (ChatLoop, from SystemPrompt#stable_length) goes as two text parts:
|
|
17
|
+
# the stable part with the mark, the per-session tail (model, location,
|
|
18
|
+
# session) without, so a new session reads the stable part from the
|
|
19
|
+
# cache. `cache_split:` never goes on the wire (#without_split).
|
|
14
20
|
module PromptCache
|
|
15
21
|
CONTROL = { type: "ephemeral" }.freeze
|
|
16
22
|
|
|
@@ -21,16 +27,27 @@ module Samagotchi
|
|
|
21
27
|
|
|
22
28
|
# +messages+ (wire messages: symbol keys, content a String or an Array
|
|
23
29
|
# of parts) with the breakpoints set when +model+ is a Claude model;
|
|
24
|
-
# the given messages as they are otherwise. The marked
|
|
25
|
-
# parts are copies: the caller's are never changed.
|
|
30
|
+
# the given messages as they are otherwise (#without_split). The marked
|
|
31
|
+
# messages and parts are copies: the caller's are never changed.
|
|
26
32
|
# @return [Array<Hash>]
|
|
27
33
|
def mark(messages, model:)
|
|
28
|
-
return messages unless claude?(model)
|
|
34
|
+
return without_split(messages) unless claude?(model)
|
|
29
35
|
|
|
30
36
|
indexes = [system_index(messages), last_index(messages)].compact.uniq
|
|
31
|
-
|
|
37
|
+
messages.each_with_index.map do |message, index|
|
|
38
|
+
next marked(message.except(:cache_split), split: message[:cache_split]) if indexes.include?(index)
|
|
32
39
|
|
|
33
|
-
|
|
40
|
+
message.key?(:cache_split) ? message.except(:cache_split) : message
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# +messages+ without the `cache_split:` hints, for a request that sets
|
|
45
|
+
# no breakpoints.
|
|
46
|
+
# @return [Array<Hash>]
|
|
47
|
+
def without_split(messages)
|
|
48
|
+
return messages unless messages.any? { |message| message.key?(:cache_split) }
|
|
49
|
+
|
|
50
|
+
messages.map { |message| message.except(:cache_split) }
|
|
34
51
|
end
|
|
35
52
|
|
|
36
53
|
# Whether any message in +messages+ carries a breakpoint.
|
|
@@ -60,11 +77,19 @@ module Samagotchi
|
|
|
60
77
|
end
|
|
61
78
|
end
|
|
62
79
|
|
|
63
|
-
# A String becomes one text part
|
|
64
|
-
# text part (the last part
|
|
65
|
-
|
|
80
|
+
# A String becomes one text part (two at +split+, the mark on the
|
|
81
|
+
# first); an Array gets the mark on its last text part (the last part
|
|
82
|
+
# when none is text).
|
|
83
|
+
def marked(message, split: nil)
|
|
66
84
|
content = message[:content]
|
|
67
|
-
|
|
85
|
+
if content.is_a?(String)
|
|
86
|
+
if split.is_a?(Integer) && split.positive? && split < content.length
|
|
87
|
+
return message.merge(content: [{ type: "text", text: content[0, split], cache_control: CONTROL },
|
|
88
|
+
{ type: "text", text: content[split..] }])
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
return message.merge(content: [{ type: "text", text: content, cache_control: CONTROL }])
|
|
92
|
+
end
|
|
68
93
|
|
|
69
94
|
target = content.rindex { |part| part.is_a?(Hash) && (part[:type] || part["type"]).to_s == "text" } ||
|
|
70
95
|
content.rindex { |part| part.is_a?(Hash) }
|
data/lib/samagotchi/llm/usage.rb
CHANGED
|
@@ -5,20 +5,35 @@ require_relative "../token_usage"
|
|
|
5
5
|
module Samagotchi
|
|
6
6
|
module LLM
|
|
7
7
|
# A response's token counts, never nil: what the server reported
|
|
8
|
-
# (:server), else zeros (:none).
|
|
9
|
-
|
|
8
|
+
# (:server), else zeros (:none). +cached_tokens+ is the prompt the server
|
|
9
|
+
# reused from its cache, +cache_write_tokens+ what it wrote to it
|
|
10
|
+
# (OpenRouter reports those for Anthropic models); 0 when not reported.
|
|
11
|
+
Usage = Data.define(:prompt_tokens, :completion_tokens, :source, :cached_tokens, :cache_write_tokens) do
|
|
10
12
|
# @return [Usage, nil] nil when the payload carries no counts
|
|
11
13
|
def self.from_payload(payload)
|
|
12
14
|
counts = TokenUsage.from_payload(payload)
|
|
13
15
|
return nil unless counts
|
|
14
16
|
|
|
15
17
|
new(prompt_tokens: counts.prompt_tokens.to_i, completion_tokens: counts.completion_tokens.to_i,
|
|
16
|
-
source: :server
|
|
18
|
+
source: :server, cached_tokens: counts.cached_tokens.to_i,
|
|
19
|
+
cache_write_tokens: counts.cache_write_tokens.to_i)
|
|
17
20
|
end
|
|
18
21
|
|
|
19
22
|
def self.none = new(prompt_tokens: 0, completion_tokens: 0, source: :none)
|
|
20
23
|
|
|
24
|
+
def initialize(prompt_tokens:, completion_tokens:, source:, cached_tokens: 0, cache_write_tokens: 0)
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
|
|
21
28
|
def total_tokens = prompt_tokens + completion_tokens
|
|
29
|
+
|
|
30
|
+
# The request's prompt-cache counts for a :generation_completed event
|
|
31
|
+
# and the log ({} when the server reported none).
|
|
32
|
+
def cache_fields
|
|
33
|
+
return {} unless source == :server
|
|
34
|
+
|
|
35
|
+
TokenUsage.cache_fields(prompt: prompt_tokens, cached: cached_tokens, cache_write: cache_write_tokens)
|
|
36
|
+
end
|
|
22
37
|
end
|
|
23
38
|
end
|
|
24
39
|
end
|
|
@@ -106,11 +106,13 @@ module Samagotchi
|
|
|
106
106
|
requested_model: event[:requested_model],
|
|
107
107
|
content_length: event[:content_length],
|
|
108
108
|
thinking_chars: event[:thinking_chars], finish_reason: event[:finish_reason],
|
|
109
|
-
stopped_by: event[:stopped_by]
|
|
109
|
+
stopped_by: event[:stopped_by], prompt: event[:prompt_tokens],
|
|
110
|
+
cached: event[:cached_tokens], cache_write: event[:cache_write_tokens])
|
|
110
111
|
return unless event[:stopped_by]
|
|
111
112
|
|
|
112
|
-
# A plugin
|
|
113
|
-
|
|
113
|
+
# A plugin (stop_generation) or a steer (Engine#cut_for_steer, by
|
|
114
|
+
# "steer") cut this generation; the turn goes on.
|
|
115
|
+
log(:info, :generation_stopped, iteration: event[:iteration], by: event[:stopped_by],
|
|
114
116
|
reason: event[:stop_reason].to_s[0, 200], thinking_chars: event[:thinking_chars], ms: ms)
|
|
115
117
|
end
|
|
116
118
|
|
|
@@ -264,8 +264,9 @@ module Samagotchi
|
|
|
264
264
|
|
|
265
265
|
# Make +bundle+'s tools in +registry+ the +specs+: its tools not in
|
|
266
266
|
# them go; a new one, or one whose schema or label changed, is
|
|
267
|
-
# registered (a changed one again,
|
|
268
|
-
#
|
|
267
|
+
# registered (a changed one again, in its place: Tools::Registry
|
|
268
|
+
# orders bundle tools by bundle and name); an unchanged one is kept
|
|
269
|
+
# as it is. A name another source has is left out. Called on
|
|
269
270
|
# the turn thread (Engine#apply_staged_tools!).
|
|
270
271
|
# @return [Hash] {changed: Boolean, skipped: [String] (why)}
|
|
271
272
|
def self.apply_tools(registry, bundle, specs, context)
|
data/lib/samagotchi/prompt.rb
CHANGED
|
@@ -4,6 +4,7 @@ require_relative "model_profile"
|
|
|
4
4
|
require_relative "prompt_literal_guard"
|
|
5
5
|
require_relative "vision_context"
|
|
6
6
|
require_relative "tool_response"
|
|
7
|
+
require_relative "steer"
|
|
7
8
|
|
|
8
9
|
module Samagotchi
|
|
9
10
|
# Formats a message list into a prompt string ready for the /completion endpoint.
|
|
@@ -168,7 +169,9 @@ module Samagotchi
|
|
|
168
169
|
# peer session), and a turn note carries an error's text: escaped like
|
|
169
170
|
# user content so it can't close its turn.
|
|
170
171
|
role = ESCAPED_KINDS.include?(message[:kind].to_s) ? "user" : message[:role]
|
|
171
|
-
|
|
172
|
+
# A steer or merged input is led by a header naming its sender (added
|
|
173
|
+
# after the escape: the header has no literals to guard).
|
|
174
|
+
Steer.wire_text(message, PromptLiteralGuard.escape(message[:content], profile: profile, role: role))
|
|
172
175
|
end
|
|
173
176
|
|
|
174
177
|
private_class_method :image_lines, :format_with_turn_markers, :ends_in_tool_response?, :tool_response_blocks,
|
|
@@ -16,7 +16,7 @@ module Samagotchi
|
|
|
16
16
|
# paths and SessionManager background workers.
|
|
17
17
|
#
|
|
18
18
|
# Each finished turn leaves a record (status, timings, model, prompt and
|
|
19
|
-
# completion tokens, tool calls, iterations, retries); session totals are
|
|
19
|
+
# completion tokens, tool calls, iterations, retries, cuts); session totals are
|
|
20
20
|
# sums over the records, kept as running counters as records are added, so
|
|
21
21
|
# a worker that stops and wakes again (a new collector) keeps counting:
|
|
22
22
|
# #session_id= loads the records already saved.
|
|
@@ -78,10 +78,11 @@ module Samagotchi
|
|
|
78
78
|
:gen_prompt_max,
|
|
79
79
|
:gen_open,
|
|
80
80
|
# The rest of this generation's server report: the largest cached and
|
|
81
|
-
# reasoning counts, the last cost, and the server's speeds and decode
|
|
81
|
+
# reasoning counts (and cache writes), the last cost, and the server's speeds and decode
|
|
82
82
|
# time (llama.cpp's timings); and when its first content, reasoning or
|
|
83
83
|
# tool call chunk came (an estimated speed runs from there).
|
|
84
84
|
:gen_cached_max,
|
|
85
|
+
:gen_cache_write_max,
|
|
85
86
|
:gen_reasoning_max,
|
|
86
87
|
:gen_cost,
|
|
87
88
|
:gen_decode_tps,
|
|
@@ -102,6 +103,7 @@ module Samagotchi
|
|
|
102
103
|
# tokens of the generations with a speed. The last speeds and where the
|
|
103
104
|
# decode one came from (server / estimate).
|
|
104
105
|
:cached_sum,
|
|
106
|
+
:cache_write_sum,
|
|
105
107
|
:reasoning_sum,
|
|
106
108
|
:cost_sum,
|
|
107
109
|
:decode_ms_sum,
|
|
@@ -110,6 +112,11 @@ module Samagotchi
|
|
|
110
112
|
:last_prefill_tps,
|
|
111
113
|
:tps_source,
|
|
112
114
|
:retries,
|
|
115
|
+
# Generations a plugin cut (stop_generation: loop-guard's thinking
|
|
116
|
+
# watch), not a steer's cut; and ones that ended at the output cap
|
|
117
|
+
# (finish_reason length). `retries` counts the network's only.
|
|
118
|
+
:cuts,
|
|
119
|
+
:capped,
|
|
113
120
|
:model,
|
|
114
121
|
:id,
|
|
115
122
|
:started_at,
|
|
@@ -265,6 +272,7 @@ module Samagotchi
|
|
|
265
272
|
end
|
|
266
273
|
when :generation_completed, :generation_cancelled
|
|
267
274
|
record_served_model(event)
|
|
275
|
+
count_cut(event)
|
|
268
276
|
record_generation_completed
|
|
269
277
|
when :generation_retrying
|
|
270
278
|
# The retry streams from the start: its counts replace the ones so far.
|
|
@@ -364,6 +372,8 @@ module Samagotchi
|
|
|
364
372
|
iterations_total: totals[:iterations] + (turn&.iteration_count || 0),
|
|
365
373
|
gen_latency_ms: (totals[:gen_ms] + (turn&.gen_latency_accum || 0)).round,
|
|
366
374
|
retries: totals[:retries] + (turn&.retries || 0),
|
|
375
|
+
cuts: totals[:cuts] + (turn&.cuts || 0),
|
|
376
|
+
capped: totals[:capped] + (turn&.capped || 0),
|
|
367
377
|
started_at: @started_at,
|
|
368
378
|
last_activity_at: @last_activity_at,
|
|
369
379
|
# From the session's first start (an earlier process's too), to now.
|
|
@@ -386,6 +396,7 @@ module Samagotchi
|
|
|
386
396
|
prompt_sum: add.call(:prompt_sum),
|
|
387
397
|
completion_sum: add.call(:completion_sum),
|
|
388
398
|
cached_sum: add.call(:cached_sum),
|
|
399
|
+
cache_write_sum: add.call(:cache_write_sum),
|
|
389
400
|
reasoning_sum: add.call(:reasoning_sum),
|
|
390
401
|
cost_sum: add.call(:cost_sum),
|
|
391
402
|
decode_ms_sum: decode_ms.round,
|
|
@@ -435,6 +446,7 @@ module Samagotchi
|
|
|
435
446
|
gen_prompt_max: 0,
|
|
436
447
|
gen_open: false,
|
|
437
448
|
gen_cached_max: 0,
|
|
449
|
+
gen_cache_write_max: 0,
|
|
438
450
|
gen_reasoning_max: 0,
|
|
439
451
|
generations: 0,
|
|
440
452
|
prompt_last: nil,
|
|
@@ -443,11 +455,14 @@ module Samagotchi
|
|
|
443
455
|
completion_sum: 0,
|
|
444
456
|
token_sources: [],
|
|
445
457
|
cached_sum: 0,
|
|
458
|
+
cache_write_sum: 0,
|
|
446
459
|
reasoning_sum: 0,
|
|
447
460
|
cost_sum: nil,
|
|
448
461
|
decode_ms_sum: 0,
|
|
449
462
|
decode_tokens_sum: 0,
|
|
450
463
|
retries: 0,
|
|
464
|
+
cuts: 0,
|
|
465
|
+
capped: 0,
|
|
451
466
|
model: nil,
|
|
452
467
|
id: event[:turn_id] || SecureRandom.uuid,
|
|
453
468
|
started_at: now.iso8601(3),
|
|
@@ -500,6 +515,15 @@ module Samagotchi
|
|
|
500
515
|
end
|
|
501
516
|
end
|
|
502
517
|
|
|
518
|
+
def count_cut(event)
|
|
519
|
+
@mutex.synchronize do
|
|
520
|
+
next unless @turn
|
|
521
|
+
|
|
522
|
+
@turn.cuts += 1 if event[:stopped_by] && event[:stopped_by].to_s != "steer"
|
|
523
|
+
@turn.capped += 1 if event[:finish_reason].to_s == "length"
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
|
|
503
527
|
def record_generation_completed
|
|
504
528
|
@mutex.synchronize { close_generation if @turn&.gen_open }
|
|
505
529
|
end
|
|
@@ -544,6 +568,7 @@ module Samagotchi
|
|
|
544
568
|
# here: it has no speed. Caller holds the mutex.
|
|
545
569
|
def close_server_report(turn, completion)
|
|
546
570
|
turn.cached_sum += turn.gen_cached_max
|
|
571
|
+
turn.cache_write_sum += turn.gen_cache_write_max
|
|
547
572
|
turn.reasoning_sum += turn.gen_reasoning_max
|
|
548
573
|
turn.cost_sum = turn.cost_sum.to_f + turn.gen_cost if turn.gen_cost
|
|
549
574
|
turn.last_prefill_tps = turn.gen_prefill_tps.round(1) if turn.gen_prefill_tps
|
|
@@ -582,6 +607,7 @@ module Samagotchi
|
|
|
582
607
|
@turn.gen_had_server = false
|
|
583
608
|
@turn.gen_estimate_sum = 0
|
|
584
609
|
@turn.gen_cached_max = 0
|
|
610
|
+
@turn.gen_cache_write_max = 0
|
|
585
611
|
@turn.gen_reasoning_max = 0
|
|
586
612
|
@turn.gen_cost = nil
|
|
587
613
|
@turn.gen_decode_tps = @turn.gen_prefill_tps = @turn.gen_decode_ms = nil
|
|
@@ -605,7 +631,9 @@ module Samagotchi
|
|
|
605
631
|
tool_calls: turn.tool_calls,
|
|
606
632
|
tool_errors: turn.tool_errors,
|
|
607
633
|
iterations: turn.iteration_count,
|
|
608
|
-
retries: turn.retries
|
|
634
|
+
retries: turn.retries,
|
|
635
|
+
cuts: turn.cuts,
|
|
636
|
+
capped: turn.capped
|
|
609
637
|
}.merge(turn_usage_fields(turn))
|
|
610
638
|
end
|
|
611
639
|
|
|
@@ -622,6 +650,7 @@ module Samagotchi
|
|
|
622
650
|
prefill_tps: turn.last_prefill_tps,
|
|
623
651
|
tps_source: turn.tps_source
|
|
624
652
|
}
|
|
653
|
+
fields[:cache_write_tokens_sum] = turn.cache_write_sum if turn.cache_write_sum.positive?
|
|
625
654
|
fields.merge!(cost: turn.cost_sum, cost_source: "reported") if turn.cost_sum
|
|
626
655
|
fields
|
|
627
656
|
end
|
|
@@ -708,6 +737,7 @@ module Samagotchi
|
|
|
708
737
|
turn.gen_prompt_max = [turn.gen_prompt_max, usage.prompt_tokens.to_i].max
|
|
709
738
|
turn.gen_completion_max = [turn.gen_completion_max, usage.completion_tokens.to_i].max
|
|
710
739
|
turn.gen_cached_max = [turn.gen_cached_max, usage.cached_tokens.to_i].max
|
|
740
|
+
turn.gen_cache_write_max = [turn.gen_cache_write_max, usage.cache_write_tokens.to_i].max
|
|
711
741
|
turn.gen_reasoning_max = [turn.gen_reasoning_max, usage.reasoning_tokens.to_i].max
|
|
712
742
|
turn.gen_cost = usage.cost if usage.cost
|
|
713
743
|
if usage.predicted_per_second && usage.predicted_ms
|
|
@@ -857,7 +887,8 @@ module Samagotchi
|
|
|
857
887
|
# (#snapshot adds the running turn). Caller holds the mutex.
|
|
858
888
|
def reset_totals
|
|
859
889
|
@totals = { cancellations: 0, prompt_sum: 0, completion_sum: 0, token_sources: [], iterations: 0,
|
|
860
|
-
gen_ms: 0, retries: 0, by_tool: {}, tool_errors: 0, cached_sum: 0,
|
|
890
|
+
gen_ms: 0, retries: 0, cuts: 0, capped: 0, by_tool: {}, tool_errors: 0, cached_sum: 0, cache_write_sum: 0,
|
|
891
|
+
reasoning_sum: 0,
|
|
861
892
|
cost_sum: 0, decode_ms_sum: 0, decode_tokens_sum: 0, last_decode_tps: nil,
|
|
862
893
|
last_prefill_tps: nil, tps_source: nil }
|
|
863
894
|
end
|
|
@@ -870,7 +901,10 @@ module Samagotchi
|
|
|
870
901
|
totals[:iterations] += number(record[:iterations])
|
|
871
902
|
totals[:gen_ms] += number(record[:gen_ms])
|
|
872
903
|
totals[:retries] += number(record[:retries])
|
|
904
|
+
totals[:cuts] += number(record[:cuts])
|
|
905
|
+
totals[:capped] += number(record[:capped])
|
|
873
906
|
totals[:cached_sum] += number(record[:cached_tokens_sum])
|
|
907
|
+
totals[:cache_write_sum] += number(record[:cache_write_tokens_sum])
|
|
874
908
|
totals[:reasoning_sum] += number(record[:reasoning_tokens])
|
|
875
909
|
totals[:cost_sum] += number(record[:cost])
|
|
876
910
|
totals[:decode_ms_sum] += number(record[:decode_ms])
|
data/lib/samagotchi/steer.rb
CHANGED
|
@@ -10,20 +10,41 @@ module Samagotchi
|
|
|
10
10
|
#
|
|
11
11
|
# The user's own lines merged into a running turn are marked kind:
|
|
12
12
|
# "input": they are part of that turn, not a turn of their own, so a
|
|
13
|
-
# reloaded history keeps the turn in one piece.
|
|
13
|
+
# reloaded history keeps the turn in one piece. A line can be a Line
|
|
14
|
+
# naming who sent it (a worker's input from chi send, a delegating parent,
|
|
15
|
+
# a plugin); its input message then carries that source:. The user's own
|
|
16
|
+
# lines carry none, so their saved bytes are as they always were.
|
|
14
17
|
module Steer
|
|
15
18
|
KIND = "steer"
|
|
16
19
|
INPUT_KIND = "input"
|
|
17
20
|
|
|
21
|
+
# A drain item for an input line with its sender (source nil = the user).
|
|
22
|
+
# A plain String is still a valid item: the user's line.
|
|
23
|
+
Line = Data.define(:text, :source)
|
|
24
|
+
|
|
25
|
+
# The client ids whose input is not the user's own words. Literals, not
|
|
26
|
+
# SendCommand::CLIENT_ID / Tools::Delegate::CLIENT_PREFIX, so this file
|
|
27
|
+
# needs no requires (a spec pins them equal).
|
|
28
|
+
CHI_SEND_CLIENT = "cli:send"
|
|
29
|
+
DELEGATE_CLIENT_PREFIX = "delegate:"
|
|
30
|
+
PLUGIN_CLIENT = "plugin"
|
|
31
|
+
|
|
18
32
|
# What one drain brought: the merged user text (nil when none) and how
|
|
19
|
-
# many lines made it,
|
|
20
|
-
|
|
33
|
+
# many lines made it, the input messages' text grouped by sender
|
|
34
|
+
# (consecutive lines from one sender form one group), and the steers in
|
|
35
|
+
# order.
|
|
36
|
+
Merge = Struct.new(:content, :count, :inputs, :steers, keyword_init: true) do
|
|
21
37
|
def empty? = content.nil? && steers.empty?
|
|
22
38
|
|
|
23
|
-
# The messages it appends: the
|
|
24
|
-
#
|
|
39
|
+
# The messages it appends: the merged input first (one message per
|
|
40
|
+
# sender run, marked kind input, source: when not the user's), then
|
|
41
|
+
# each steer.
|
|
25
42
|
def messages
|
|
26
|
-
list =
|
|
43
|
+
list = inputs.map do |input|
|
|
44
|
+
message = { role: "user", kind: INPUT_KIND }
|
|
45
|
+
message[:source] = input[:source] if input[:source]
|
|
46
|
+
message.merge(content: input[:content])
|
|
47
|
+
end
|
|
27
48
|
list + steers.map { |steer| Steer.message(**steer) }
|
|
28
49
|
end
|
|
29
50
|
|
|
@@ -36,8 +57,50 @@ module Samagotchi
|
|
|
36
57
|
end
|
|
37
58
|
end
|
|
38
59
|
|
|
60
|
+
# The one line the model reads above a steer or merged input (the wire
|
|
61
|
+
# only; the saved content stays raw): who sent it, and that it should
|
|
62
|
+
# follow it or, if it asks for nothing, carry on.
|
|
63
|
+
HEADER_TAIL = "mid-task. Follow it; if it asks for nothing, carry on with the task.]"
|
|
64
|
+
SENDERS = {
|
|
65
|
+
"user" => "from the user",
|
|
66
|
+
"parent_agent" => "from the parent agent that started this session",
|
|
67
|
+
"chi_send" => "sent with chi send",
|
|
68
|
+
"plugin_send" => "sent by a plugin"
|
|
69
|
+
}.freeze
|
|
70
|
+
USER_HEADER = "[Steer #{SENDERS["user"]}, #{HEADER_TAIL}".freeze
|
|
71
|
+
|
|
39
72
|
module_function
|
|
40
73
|
|
|
74
|
+
# The saved source of a worker input line from +client_id+: nil for the
|
|
75
|
+
# user (no client id, the web, an attached TUI, anything unknown).
|
|
76
|
+
def source_for_client(client_id)
|
|
77
|
+
id = client_id.to_s
|
|
78
|
+
if id == CHI_SEND_CLIENT then "chi_send"
|
|
79
|
+
elsif id.start_with?(DELEGATE_CLIENT_PREFIX) then "parent_agent"
|
|
80
|
+
elsif id == PLUGIN_CLIENT then "plugin_send"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The header for a steer or merged input message, else nil. No source
|
|
85
|
+
# (or "") is the user's; an input with an unknown source too; a steer
|
|
86
|
+
# with an unknown source is a plugin's (its bundle or hook label).
|
|
87
|
+
def header(message)
|
|
88
|
+
kind = (message[:kind] || message["kind"]).to_s
|
|
89
|
+
return unless [KIND, INPUT_KIND].include?(kind)
|
|
90
|
+
|
|
91
|
+
source = (message[:source] || message["source"]).to_s
|
|
92
|
+
source = "user" if source.empty?
|
|
93
|
+
sender = SENDERS[source] || (kind == KIND ? "from the #{source} plugin" : SENDERS["user"])
|
|
94
|
+
"[Steer #{sender}, #{HEADER_TAIL}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The text a steer or merged input goes out as: its header, a newline,
|
|
98
|
+
# then +text+ (the message's content by default). Other messages: +text+.
|
|
99
|
+
def wire_text(message, text = (message[:content] || message["content"]).to_s)
|
|
100
|
+
header = header(message)
|
|
101
|
+
header ? "#{header}\n#{text}" : text
|
|
102
|
+
end
|
|
103
|
+
|
|
41
104
|
def message(text:, source:)
|
|
42
105
|
{ role: "user", kind: KIND, source: source.to_s, content: text.to_s }
|
|
43
106
|
end
|
|
@@ -46,6 +109,24 @@ module Samagotchi
|
|
|
46
109
|
(message[:kind] || message["kind"]).to_s == KIND
|
|
47
110
|
end
|
|
48
111
|
|
|
112
|
+
# The sources of a steer a person sent (a continue card's text, from the
|
|
113
|
+
# user or a parent agent's chi answer), not a plugin's prod.
|
|
114
|
+
PERSON_SOURCES = %w[user parent_agent].freeze
|
|
115
|
+
|
|
116
|
+
# The senders whose message may cut a generation that has only been
|
|
117
|
+
# thinking (Engine#cut_for_steer): the user (no source), chi send, a
|
|
118
|
+
# parent agent. A plugin's never does.
|
|
119
|
+
CUT_SOURCES = ["", "user", "chi_send", "parent_agent"].freeze
|
|
120
|
+
|
|
121
|
+
def cuts?(source)
|
|
122
|
+
CUT_SOURCES.include?(source.to_s)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# A steer a person sent: what a recap and a side question keep.
|
|
126
|
+
def person?(message)
|
|
127
|
+
steer?(message) && PERSON_SOURCES.include?((message[:source] || message["source"]).to_s)
|
|
128
|
+
end
|
|
129
|
+
|
|
49
130
|
# A user message that is a prompt or the user's steering, not a steer:
|
|
50
131
|
# what "the last prompt" and "user turns" count.
|
|
51
132
|
def prompt?(message)
|
|
@@ -105,8 +186,22 @@ module Samagotchi
|
|
|
105
186
|
text = (item[:text] || item["text"]).to_s.strip
|
|
106
187
|
{ source: (item[:source] || item["source"]).to_s, text: text } unless text.empty?
|
|
107
188
|
end
|
|
108
|
-
|
|
109
|
-
|
|
189
|
+
inputs = input_groups(lines)
|
|
190
|
+
content = inputs.map { |input| input[:content] }.join("\n\n")
|
|
191
|
+
Merge.new(content: content.empty? ? nil : content, count: content.empty? ? 0 : lines.length,
|
|
192
|
+
inputs: inputs, steers: steers)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Non-blank input lines as [{content:, source:}], one per run of lines
|
|
196
|
+
# from the same sender.
|
|
197
|
+
def input_groups(lines)
|
|
198
|
+
pairs = lines.filter_map do |line|
|
|
199
|
+
text = (line.is_a?(Line) ? line.text : line).to_s.strip
|
|
200
|
+
[text, line.is_a?(Line) ? line.source : nil] unless text.empty?
|
|
201
|
+
end
|
|
202
|
+
pairs.chunk_while { |a, b| a[1] == b[1] }.map do |run|
|
|
203
|
+
{ content: run.map(&:first).join("\n\n"), source: run.first[1] }
|
|
204
|
+
end
|
|
110
205
|
end
|
|
111
206
|
end
|
|
112
207
|
end
|
|
@@ -14,8 +14,9 @@ module Samagotchi
|
|
|
14
14
|
# The system prompt an Engine gives its model: the base prompt (tool
|
|
15
15
|
# declarations and call syntax for the raw-prompt loop, none for the chat
|
|
16
16
|
# loop, the shared guidance) and around it the thinking token, rg
|
|
17
|
-
# guidance,
|
|
18
|
-
#
|
|
17
|
+
# guidance, the identity memory, the preloaded memories, AGENT.md, the
|
|
18
|
+
# memory indexes and, last, the model, where the session runs and the
|
|
19
|
+
# session (#system_prompt_with_index has the order and why).
|
|
19
20
|
#
|
|
20
21
|
# What changes during a session (the profile after a model switch, the
|
|
21
22
|
# tools a plugin replaces, the attached session, the thinking level) is
|
|
@@ -77,8 +78,16 @@ module Samagotchi
|
|
|
77
78
|
# indexes and memories again (a model or profile switch, changed tools).
|
|
78
79
|
def reset!
|
|
79
80
|
@built = nil
|
|
81
|
+
@stable_lengths = nil
|
|
80
82
|
end
|
|
81
83
|
|
|
84
|
+
# The length of +prompt+'s stable part when it is a chat prompt #build
|
|
85
|
+
# made: everything before the model, location and session lines, which
|
|
86
|
+
# a remote prompt cache can keep for every session (PromptCache). nil
|
|
87
|
+
# for any other text, or a prompt without those lines.
|
|
88
|
+
# @return [Integer, nil]
|
|
89
|
+
def stable_length(prompt) = @stable_lengths&.[](prompt)
|
|
90
|
+
|
|
82
91
|
# The base prompt (specs, plugins' declarations), with Gemma's tool
|
|
83
92
|
# declarations at its end as #build has them.
|
|
84
93
|
def base(chat: false, thinking: nil)
|
|
@@ -186,7 +195,7 @@ module Samagotchi
|
|
|
186
195
|
6. Use write for full-file rewrites or creating new files.
|
|
187
196
|
|
|
188
197
|
Memory convention:
|
|
189
|
-
Project scope: one folder per git repository, shared by its worktrees and subdirectories (path
|
|
198
|
+
Project scope: one folder per git repository, shared by its worktrees and subdirectories (its path is on the "Project memories folder:" line below)
|
|
190
199
|
System scope: ~/.config/samagotchi/memories/ (cross-project)
|
|
191
200
|
memory_read accepts optional scope (project|system).
|
|
192
201
|
memory_write requires explicit scope and entry name.
|
|
@@ -229,16 +238,28 @@ module Samagotchi
|
|
|
229
238
|
# @param chat [Boolean] no Gemma thinking token (the chat API's template
|
|
230
239
|
# decides about thinking)
|
|
231
240
|
# @param thinking [Symbol, nil] the level (Thinking); nil: the effective model's
|
|
241
|
+
#
|
|
242
|
+
# Ordered for the servers' prompt caches (they reuse only an exact token
|
|
243
|
+
# prefix): what every session shares first, what changes least before
|
|
244
|
+
# what changes more, and the per-session lines last. The global parts
|
|
245
|
+
# (identity, preloads) come before the project's AGENT.md, the memory
|
|
246
|
+
# indexes (a memory write changes them) after both; the model, the
|
|
247
|
+
# location and the session close the prompt (Gemma's tool declarations
|
|
248
|
+
# still follow them).
|
|
232
249
|
def system_prompt_with_index(base, chat: false, thinking: nil)
|
|
233
|
-
project_index = read_memory_index("project")
|
|
234
|
-
system_index = read_memory_index("system")
|
|
235
|
-
project_description = project_specific_description
|
|
236
250
|
thinking_token = chat ? "" : Thinking.native(thinking || turn_thinking, profile).system_token
|
|
237
251
|
memory_sections = [
|
|
238
|
-
"Project memories:\n#{
|
|
239
|
-
"System memories:\n#{
|
|
252
|
+
"Project memories:\n#{read_memory_index("project")}",
|
|
253
|
+
"System memories:\n#{read_memory_index("system")}"
|
|
240
254
|
].join("\n\n")
|
|
241
|
-
|
|
255
|
+
stable = [thinking_token + base, rg_guidance, system_identity_section, explicit_memory_section,
|
|
256
|
+
project_specific_description, memory_sections]
|
|
257
|
+
stable_text = stable.compact.join("\n")
|
|
258
|
+
volatile = [current_model, project_location, current_session].compact
|
|
259
|
+
return with_tail_declarations(stable_text, chat: chat) if volatile.empty?
|
|
260
|
+
|
|
261
|
+
prompt = "#{stable_text}\n#{volatile.join("\n")}"
|
|
262
|
+
(@stable_lengths ||= {})[prompt] = stable_text.length + 1 if chat
|
|
242
263
|
with_tail_declarations(prompt, chat: chat)
|
|
243
264
|
end
|
|
244
265
|
|