samagotchi 0.38.0 → 0.40.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 +64 -1
- data/docs/cli.md +2 -1
- data/docs/configuration.md +58 -2
- data/docs/hooks.md +1 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/context-telemetry.md +16 -0
- data/docs/internals/llm-context-bench.md +133 -0
- data/docs/internals/llm-context-forget.md +45 -0
- data/docs/internals/prompt-caching.md +3 -2
- data/docs/plugins.md +4 -3
- data/docs/testing.md +8 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +10 -10
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +4 -0
- data/lib/samagotchi/config.rb +43 -4
- data/lib/samagotchi/context_note.rb +7 -3
- data/lib/samagotchi/context_status.rb +112 -11
- data/lib/samagotchi/engine.rb +36 -9
- data/lib/samagotchi/guardrails/targets.rb +15 -3
- data/lib/samagotchi/host_registry.rb +8 -3
- data/lib/samagotchi/kernel_loop.rb +142 -14
- data/lib/samagotchi/llm/chat_loop.rb +57 -18
- data/lib/samagotchi/llm/turn_settings.rb +5 -4
- data/lib/samagotchi/llm_context_apply.rb +165 -0
- data/lib/samagotchi/llm_context_edit.rb +102 -0
- data/lib/samagotchi/llm_context_forget.rb +258 -0
- data/lib/samagotchi/llm_context_stale.rb +271 -0
- data/lib/samagotchi/llm_context_strategy.rb +177 -0
- data/lib/samagotchi/llm_context_view.rb +201 -0
- data/lib/samagotchi/log_subscriber.rb +2 -1
- data/lib/samagotchi/prompt.rb +4 -11
- data/lib/samagotchi/session_manager.rb +9 -1
- data/lib/samagotchi/session_metrics.rb +18 -1
- data/lib/samagotchi/system_prompt.rb +20 -15
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
- data/lib/samagotchi/token_usage.rb +3 -0
- data/lib/samagotchi/tool_activity.rb +18 -0
- data/lib/samagotchi/tool_declarations.rb +60 -4
- data/lib/samagotchi/tool_ids.rb +84 -0
- data/lib/samagotchi/tool_response.rb +67 -6
- data/lib/samagotchi/tools/builtin_calls.rb +3 -2
- data/lib/samagotchi/tools/builtins.rb +15 -5
- data/lib/samagotchi/tools/context_read.rb +27 -10
- data/lib/samagotchi/tools/forget_outputs.rb +62 -0
- data/lib/samagotchi/tools/registry.rb +25 -8
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +1 -1
- data/lib/samagotchi/worker.rb +4 -0
- metadata +11 -1
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# Which LLM context strategy a turn runs under (LLMContextView): none,
|
|
5
|
+
# the default, or a list of layers (stale, forget). Set by
|
|
6
|
+
# llm_context.strategy, and per model or host by the flat key
|
|
7
|
+
# llm_context_strategy (models.<key>, hosts.<name>), each a layer name,
|
|
8
|
+
# "|"-separated names or a YAML list.
|
|
9
|
+
#
|
|
10
|
+
# Resolution, first set wins: the session's own (a later per-session
|
|
11
|
+
# override; nothing sets it yet), models.<key> (by the model's lookup
|
|
12
|
+
# names, as window_tokens), hosts.<name>, then llm_context.strategy.
|
|
13
|
+
# An unknown layer warns and is none (every layer is built: stale, and
|
|
14
|
+
# forget, experimental, with its tool forget_outputs).
|
|
15
|
+
#
|
|
16
|
+
# When a layer's edits reach the prompt (LLMContextApply) is set by
|
|
17
|
+
# llm_context.apply, and per model or host by the flat key
|
|
18
|
+
# llm_context_apply: payoff (the default), next_request or turn_end,
|
|
19
|
+
# resolved as the strategy is (the session's own, the model's, the
|
|
20
|
+
# host's, then llm_context.apply), each layer on its own: a model may
|
|
21
|
+
# set the strategy and its host the apply rule. An unknown value warns
|
|
22
|
+
# and is payoff. llm_context.protect_steps (3) and llm_context.stale_edits
|
|
23
|
+
# (false: edit-driven stale stubs are opt-in, experimental) are global only.
|
|
24
|
+
#
|
|
25
|
+
# A soft context budget, llm_context.budget_tokens, and per model or host
|
|
26
|
+
# the flat key llm_context_budget_tokens (a positive number of tokens),
|
|
27
|
+
# resolved as the apply rule is, each on its own: off (nil) by default.
|
|
28
|
+
# When set, ContextStatus counts its bands against it instead of the
|
|
29
|
+
# window (the smaller of the two), so the forget layer's offers come
|
|
30
|
+
# under it.
|
|
31
|
+
module LLMContextStrategy
|
|
32
|
+
SETTING = "llm_context.strategy"
|
|
33
|
+
KEY = "llm_context_strategy"
|
|
34
|
+
NONE = :none
|
|
35
|
+
LAYERS = %i[stale forget].freeze
|
|
36
|
+
# The layers a turn can run under so far.
|
|
37
|
+
BUILT = %i[stale forget].freeze
|
|
38
|
+
# The sentence forget_outputs' description carries (global only):
|
|
39
|
+
# the plan's D7 line, adapted from CLM's steering example.
|
|
40
|
+
POLICY_SETTING = "llm_context.policy"
|
|
41
|
+
DEFAULT_POLICY = "Tidy at subtask boundaries: once a subtask is done, forget its tool outputs and note what it " \
|
|
42
|
+
"established; keep anything you'll still edit against."
|
|
43
|
+
|
|
44
|
+
APPLY_SETTING = "llm_context.apply"
|
|
45
|
+
APPLY_KEY = "llm_context_apply"
|
|
46
|
+
APPLIES = %i[payoff next_request turn_end].freeze
|
|
47
|
+
DEFAULT_APPLY = :payoff
|
|
48
|
+
PROTECT_SETTING = "llm_context.protect_steps"
|
|
49
|
+
DEFAULT_PROTECT_STEPS = 3
|
|
50
|
+
STALE_EDITS_SETTING = "llm_context.stale_edits"
|
|
51
|
+
BUDGET_SETTING = "llm_context.budget_tokens"
|
|
52
|
+
BUDGET_KEY = "llm_context_budget_tokens"
|
|
53
|
+
|
|
54
|
+
# A turn's strategy: the layers as configured, the strategy the view
|
|
55
|
+
# gets (NONE, or the layers), and where it was set (:session,
|
|
56
|
+
# :model_setting, :host_setting, :config); the apply rule
|
|
57
|
+
# (LLMContextApply), the steps whose edited files' reads are kept
|
|
58
|
+
# (protect_steps), whether an edit or write makes a read stale
|
|
59
|
+
# (stale_edits; off: later reads only), and the context budget in
|
|
60
|
+
# tokens (budget_tokens; nil: off).
|
|
61
|
+
Resolved = Data.define(:layers, :strategy, :source, :apply, :protect_steps, :stale_edits, :budget_tokens) do
|
|
62
|
+
def initialize(apply: DEFAULT_APPLY, protect_steps: DEFAULT_PROTECT_STEPS, stale_edits: false, budget_tokens: nil,
|
|
63
|
+
**fields) = super
|
|
64
|
+
|
|
65
|
+
def forget? = active_layers.include?(:forget)
|
|
66
|
+
|
|
67
|
+
# The layers the turn runs under: none of them under none (an
|
|
68
|
+
# unbuilt or unknown layer's strategy too).
|
|
69
|
+
def active_layers = strategy == NONE ? [] : Array(strategy)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
module_function
|
|
73
|
+
|
|
74
|
+
# +raw+ as written (a String, "|"-separated, or a YAML list) as layers;
|
|
75
|
+
# [] for none, nil for unset. An unknown name warns, naming +where+,
|
|
76
|
+
# and is none.
|
|
77
|
+
# @return [Array<Symbol>, nil]
|
|
78
|
+
def parse(raw, where)
|
|
79
|
+
return nil if raw.nil?
|
|
80
|
+
|
|
81
|
+
names = (raw.is_a?(Array) ? raw : raw.to_s.split("|")).map { |name| name.to_s.strip.downcase }.reject(&:empty?)
|
|
82
|
+
return [] if names.empty? || names == [NONE.to_s]
|
|
83
|
+
|
|
84
|
+
unknown = names - LAYERS.map(&:to_s)
|
|
85
|
+
unless unknown.empty?
|
|
86
|
+
warn_once "Warning: #{where}: unknown llm_context strategy #{unknown.join(", ")} (none, or a list of " \
|
|
87
|
+
"#{LAYERS.join(", ")}); using none"
|
|
88
|
+
return []
|
|
89
|
+
end
|
|
90
|
+
names.uniq.map(&:to_sym)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# +raw+ as written, an apply rule; nil for unset. An unknown one warns,
|
|
94
|
+
# naming +where+, and is payoff.
|
|
95
|
+
# @return [Symbol, nil]
|
|
96
|
+
def parse_apply(raw, where)
|
|
97
|
+
return nil if raw.nil? || raw.to_s.strip.empty?
|
|
98
|
+
|
|
99
|
+
rule = raw.to_s.strip.downcase.to_sym
|
|
100
|
+
return rule if APPLIES.include?(rule)
|
|
101
|
+
|
|
102
|
+
warn_once "Warning: #{where}: unknown llm_context apply #{raw} (#{APPLIES.join(", ")}); using #{DEFAULT_APPLY}"
|
|
103
|
+
DEFAULT_APPLY
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# +raw+ as written, a context budget in tokens; nil for unset or 0
|
|
107
|
+
# (off). Anything but a number of tokens warns, naming +where+, and is
|
|
108
|
+
# unset.
|
|
109
|
+
# @return [Integer, nil]
|
|
110
|
+
def parse_budget(raw, where)
|
|
111
|
+
return nil if raw.nil? || raw.to_s.strip.empty?
|
|
112
|
+
|
|
113
|
+
tokens = Integer(raw.to_s.strip, exception: false)
|
|
114
|
+
return nil if tokens&.zero?
|
|
115
|
+
return tokens if tokens&.positive?
|
|
116
|
+
|
|
117
|
+
warn_once "Warning: #{where}: llm_context budget_tokens must be a positive number of tokens; ignored"
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The strategy for +target+'s turn.
|
|
122
|
+
# @param target [HostRegistry::ModelTarget, nil]
|
|
123
|
+
# @param names [Array<String>] HostRegistry#lookup_names
|
|
124
|
+
# @param models [Hash, nil] ConfigFile.model_settings (specs)
|
|
125
|
+
# @param session [Array<Symbol>, nil] the session's own layers (none yet)
|
|
126
|
+
# @param session_apply [Symbol, nil] the session's own apply rule (none yet)
|
|
127
|
+
# @param session_budget [Integer, nil] the session's own budget (none yet)
|
|
128
|
+
# @return [Resolved]
|
|
129
|
+
def resolve(target, names:, models: nil, session: nil, session_apply: nil, session_budget: nil)
|
|
130
|
+
models ||= ConfigFile.model_settings
|
|
131
|
+
layers, source, where = layers_for(target, names, models, session)
|
|
132
|
+
resolved(layers, source, where).with(apply: apply_for(target, names, models, session_apply),
|
|
133
|
+
protect_steps: protect_steps, stale_edits: Config.get(STALE_EDITS_SETTING) == true,
|
|
134
|
+
budget_tokens: budget_for(target, names, models, session_budget))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def budget_for(target, names, models, session_budget)
|
|
138
|
+
session_budget || ConfigFile.model_setting(names, :llm_context_budget_tokens, models: models)&.last ||
|
|
139
|
+
target&.entry&.llm_context_budget_tokens || parse_budget(Config.get(BUDGET_SETTING), BUDGET_SETTING)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def layers_for(target, names, models, session)
|
|
143
|
+
return [session, :session, "the session"] if session
|
|
144
|
+
|
|
145
|
+
key, layers = ConfigFile.model_setting(names, :llm_context_strategy, models: models)
|
|
146
|
+
return [layers, :model_setting, "models: #{key}"] if layers
|
|
147
|
+
|
|
148
|
+
layers = target&.entry&.llm_context_strategy
|
|
149
|
+
return [layers, :host_setting, "hosts entry '#{target.entry.name}'"] if layers
|
|
150
|
+
|
|
151
|
+
[parse(Config.get(SETTING), SETTING) || [], :config, SETTING]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def apply_for(target, names, models, session_apply)
|
|
155
|
+
session_apply || ConfigFile.model_setting(names, :llm_context_apply, models: models)&.last ||
|
|
156
|
+
target&.entry&.llm_context_apply || parse_apply(Config.get(APPLY_SETTING), APPLY_SETTING) || DEFAULT_APPLY
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# llm_context.protect_steps: 0 or more (a negative one is the default).
|
|
160
|
+
def protect_steps
|
|
161
|
+
steps = Config.get(PROTECT_SETTING)
|
|
162
|
+
steps.is_a?(Integer) && !steps.negative? ? steps : DEFAULT_PROTECT_STEPS
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def resolved(layers, source, where)
|
|
166
|
+
unbuilt = layers - BUILT
|
|
167
|
+
unless unbuilt.empty?
|
|
168
|
+
warn_once "Warning: #{where}: llm_context strategy #{unbuilt.join(", ")} is not built yet; using none"
|
|
169
|
+
end
|
|
170
|
+
Resolved.new(layers: layers, strategy: layers.empty? || !unbuilt.empty? ? NONE : layers, source: source)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def warn_once(message) = ConfigFile.warn_once(message)
|
|
174
|
+
|
|
175
|
+
private_class_method :resolved, :warn_once, :layers_for, :apply_for, :budget_for
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "tool_response"
|
|
5
|
+
require_relative "tool_ids"
|
|
6
|
+
require_relative "llm_context_edit"
|
|
7
|
+
require_relative "llm_context_stale"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
# What the model is sent of the stored conversation: the one step
|
|
11
|
+
# between a session's messages and the three places that format them
|
|
12
|
+
# for a request (KernelLoop#format_prompt and #warmup_prompt, the native
|
|
13
|
+
# prompt; ChatLoop#wire_messages, the chat messages). A strategy may
|
|
14
|
+
# change what is sent; the session keeps the originals.
|
|
15
|
+
#
|
|
16
|
+
# Under +none+ (the default) nothing changes: #messages returns the
|
|
17
|
+
# conversation it was given, the same Array of the same entries, so the
|
|
18
|
+
# prompt is byte for byte what it was before the view existed.
|
|
19
|
+
#
|
|
20
|
+
# Under a strategy of layers (:stale, :forget), each applied edit saved
|
|
21
|
+
# on a tool_response entry (LLMContextEdit) whose kind is one of the
|
|
22
|
+
# layers sends that run's output as its stub. The entry stays where it
|
|
23
|
+
# is, with its role and tool_call_id, so a call keeps its response and
|
|
24
|
+
# the image plan (which indexes by entry) lines up. A stub keeps the
|
|
25
|
+
# output's "[name]" lead (Gemma's prompt writes response:NAME from it),
|
|
26
|
+
# and the run's images go with its text (images and image_counts kept
|
|
27
|
+
# consistent). An entry the view can't split for sure (its runs don't
|
|
28
|
+
# match its ids, or its images can't be told apart by run) is sent whole.
|
|
29
|
+
# The view only counts the runs: whatever saves an edit on a run checks
|
|
30
|
+
# the entry's runs against its batch's call names first
|
|
31
|
+
# (ToolResponse.runs_named), and the entry's content never changes.
|
|
32
|
+
#
|
|
33
|
+
# With the forget layer on, every output with a stored id is sent with
|
|
34
|
+
# it after its lead, "[read]\n[#t41] …" (plan D2: only then), so the
|
|
35
|
+
# model can name it to forget_outputs; a legacy entry's derived ids are
|
|
36
|
+
# never shown (they can't be forgotten), nor is the id of a run that
|
|
37
|
+
# doesn't pair with its call for sure (LLMContextStale.paired_runs: a
|
|
38
|
+
# "ran as:" line, an output holding a run separator), which
|
|
39
|
+
# forget_outputs can't name either. A forget's stub carries its
|
|
40
|
+
# note once: the outputs one forget_outputs call forgot that follow one
|
|
41
|
+
# another (no other output between them) point to the first one's
|
|
42
|
+
# instead, and a forget's kept lines (LLMContextEdit#keep) follow its
|
|
43
|
+
# stub. A forgotten output that isn't a read says how to restore it
|
|
44
|
+
# (re-reading the file is a read's restore).
|
|
45
|
+
class LLMContextView
|
|
46
|
+
NONE = :none
|
|
47
|
+
LAYERS = LLMContextEdit::KINDS
|
|
48
|
+
|
|
49
|
+
# @return [Array<Symbol>] the layers; empty under none
|
|
50
|
+
attr_reader :layers
|
|
51
|
+
|
|
52
|
+
# What +entries+ send, in chars: their contents (a parts list's text
|
|
53
|
+
# parts) and their calls' names and arguments. The chat loop's context
|
|
54
|
+
# estimate and the apply rule's payoff count so.
|
|
55
|
+
def self.chars(entries)
|
|
56
|
+
entries.sum do |entry|
|
|
57
|
+
content = entry[:content]
|
|
58
|
+
chars = if content.is_a?(Array)
|
|
59
|
+
content.sum { |part| part.is_a?(Hash) ? (part[:text] || part["text"]).to_s.length : 0 }
|
|
60
|
+
else
|
|
61
|
+
content.to_s.length
|
|
62
|
+
end
|
|
63
|
+
chars + Array(entry[:tool_calls]).sum do |call|
|
|
64
|
+
call.is_a?(Hash) ? call[:name].to_s.length + call[:arguments].to_json.length : 0
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @param strategy [Symbol, Array<Symbol>] :none, or the layers
|
|
70
|
+
def initialize(strategy: NONE)
|
|
71
|
+
@layers = strategy == NONE ? [] : Array(strategy).map(&:to_sym) & LAYERS
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def none? = layers.empty?
|
|
75
|
+
|
|
76
|
+
def strategy = none? ? NONE : layers
|
|
77
|
+
|
|
78
|
+
# @param conversation [Array<Hash>] the stored entries, in order
|
|
79
|
+
# @return [Array<Hash>] the entries to format
|
|
80
|
+
def messages(conversation)
|
|
81
|
+
return conversation if none?
|
|
82
|
+
|
|
83
|
+
group = Group.new
|
|
84
|
+
group.paired = forget? ? LLMContextStale.paired_runs(conversation).to_set { |run| run.ref.id } : Set.new
|
|
85
|
+
conversation.each_with_index.map { |entry, index| edited(conversation, entry, index, group) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def forget? = layers.include?(:forget)
|
|
89
|
+
|
|
90
|
+
# The forget whose stub carries the note the next forgotten output of
|
|
91
|
+
# the same call points to: its first id and its note, author and stamp.
|
|
92
|
+
# +paired+: the ids of the runs that pair with their calls.
|
|
93
|
+
Group = Struct.new(:first, :key, :paired)
|
|
94
|
+
private_constant :Group
|
|
95
|
+
|
|
96
|
+
READ = "read"
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def edited(conversation, entry, index, group)
|
|
101
|
+
return entry unless entry[:role].to_s == "tool_response"
|
|
102
|
+
|
|
103
|
+
edits = LLMContextEdit.on(entry).select { |_id, edit| edit.applied? && layers.include?(edit.kind) }
|
|
104
|
+
return entry if edits.empty? && !forget?
|
|
105
|
+
|
|
106
|
+
refs = ToolIds.refs_at(conversation, index)
|
|
107
|
+
stubbed = refs.each_index.select { |run| edits.key?(refs[run].id) }
|
|
108
|
+
shown = forget? ? refs.each_index.select { |run| group.paired.include?(refs[run].id) && !refs[run].derived? } : []
|
|
109
|
+
return whole(entry, group) if stubbed.empty? && shown.empty?
|
|
110
|
+
|
|
111
|
+
texts = ToolResponse.runs(entry[:content], refs.size)
|
|
112
|
+
return whole(entry, group) unless texts.size == refs.size
|
|
113
|
+
|
|
114
|
+
images = stubbed.empty? ? :kept : kept_images(entry, refs.size, stubbed)
|
|
115
|
+
return whole(entry, group) if images.nil?
|
|
116
|
+
|
|
117
|
+
texts = texts.each_with_index.map do |text, run|
|
|
118
|
+
edit = edits[refs[run].id] if stubbed.include?(run)
|
|
119
|
+
text = edit ? stub(text, edit, refs[run], group) : whole(text, group)
|
|
120
|
+
shown.include?(run) ? with_id(text, refs[run].id) : text
|
|
121
|
+
end
|
|
122
|
+
sent = entry.merge(content: texts.map(&:text).join(ToolResponse::SEPARATOR))
|
|
123
|
+
images == :kept ? sent : with_images(sent, images)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# +sent+ as it is: an output between two forgotten ones ends their run.
|
|
127
|
+
def whole(sent, group)
|
|
128
|
+
group.key = nil
|
|
129
|
+
sent
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The run's text with its id after its lead.
|
|
133
|
+
def with_id(text, id)
|
|
134
|
+
text.with(body: "[##{id}] #{text.body}")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def stub(text, edit, ref, group)
|
|
138
|
+
lead = text.name ? "[#{text.name}] " : ""
|
|
139
|
+
ToolResponse::RunText.new(name: text.name, lead: lead, body: stub_body(text, edit, ref, group))
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# A stale stub's note; a forget's note (or, after the first of its
|
|
143
|
+
# call's forgotten outputs in a row, a pointer to that one), its
|
|
144
|
+
# restore hint (not for a derived id: it names nothing the model can
|
|
145
|
+
# restore) and its kept lines.
|
|
146
|
+
def stub_body(text, edit, ref, group)
|
|
147
|
+
return whole(edit.stub, group) unless edit.kind == :forget
|
|
148
|
+
|
|
149
|
+
key = [edit.note, edit.by, edit.staged_at]
|
|
150
|
+
if group.key == key
|
|
151
|
+
head = "(forgotten with #{group.first}: see its note)"
|
|
152
|
+
else
|
|
153
|
+
group.first = ref.id
|
|
154
|
+
group.key = key
|
|
155
|
+
head = edit.stub
|
|
156
|
+
end
|
|
157
|
+
head += " [restore: #{ref.id}]" unless text.name == READ || ref.derived?
|
|
158
|
+
[head, *kept_lines(text.body, edit.keep, edit.keep_offset)].join("\n")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# The kept ranges of +body+'s lines, each under a "lines A-B kept:"
|
|
162
|
+
# line, A and B as the model named them (+offset+ past the body's own:
|
|
163
|
+
# a ranged read's file lines); a range is cut to the body, one wholly
|
|
164
|
+
# outside it goes.
|
|
165
|
+
def kept_lines(body, keep, offset)
|
|
166
|
+
lines = body.lines
|
|
167
|
+
keep.filter_map do |first, last|
|
|
168
|
+
from = [first - offset, 1].max
|
|
169
|
+
to = [last - offset, lines.size].min
|
|
170
|
+
next if from > to
|
|
171
|
+
|
|
172
|
+
"lines #{from + offset}-#{to + offset} kept:\n#{lines[(from - 1)..(to - 1)].join.chomp}"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# The entry's [images, image_counts] without the stubbed runs' ones;
|
|
177
|
+
# nil when they can't be told apart by run.
|
|
178
|
+
def kept_images(entry, run_count, stubbed)
|
|
179
|
+
images = Array(entry[:images])
|
|
180
|
+
return [[], nil] if images.empty?
|
|
181
|
+
return [[], nil] if run_count == 1
|
|
182
|
+
|
|
183
|
+
counts = entry[:image_counts]
|
|
184
|
+
return nil unless counts.is_a?(Array) && counts.size == run_count && counts.sum == images.size
|
|
185
|
+
|
|
186
|
+
offset = 0
|
|
187
|
+
kept = counts.each_with_index.flat_map do |count, run|
|
|
188
|
+
slice = images[offset, count]
|
|
189
|
+
offset += count
|
|
190
|
+
stubbed.include?(run) ? [] : slice
|
|
191
|
+
end
|
|
192
|
+
[kept, counts.each_with_index.map { |count, run| stubbed.include?(run) ? 0 : count }]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def with_images(entry, (images, counts))
|
|
196
|
+
return entry.except(:images, :image_counts) if images.empty?
|
|
197
|
+
|
|
198
|
+
entry.merge(images: images, image_counts: counts)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -107,7 +107,8 @@ module Samagotchi
|
|
|
107
107
|
content_length: event[:content_length],
|
|
108
108
|
thinking_chars: event[:thinking_chars], finish_reason: event[:finish_reason],
|
|
109
109
|
stopped_by: event[:stopped_by], prompt: event[:prompt_tokens],
|
|
110
|
-
cached: event[:cached_tokens], cache_write: event[:cache_write_tokens]
|
|
110
|
+
cached: event[:cached_tokens], cache_write: event[:cache_write_tokens],
|
|
111
|
+
reprefill: event[:reprefill_tokens])
|
|
111
112
|
return unless event[:stopped_by]
|
|
112
113
|
|
|
113
114
|
# A plugin (stop_generation) or a steer (Engine#cut_for_steer, by
|
data/lib/samagotchi/prompt.rb
CHANGED
|
@@ -112,18 +112,11 @@ module Samagotchi
|
|
|
112
112
|
|
|
113
113
|
# A native tool_response entry (ToolResponse.joined: "[name]" outputs
|
|
114
114
|
# joined by its SEPARATOR) as Gemma's template writes the results: one
|
|
115
|
-
# response:NAME{value:<|"|>…<|"|>} block per call.
|
|
116
|
-
# open with a "[name]" is the previous output's own
|
|
115
|
+
# response:NAME{value:<|"|>…<|"|>} block per call (ToolResponse.split:
|
|
116
|
+
# a part that doesn't open with a "[name]" is the previous output's own
|
|
117
|
+
# text).
|
|
117
118
|
def self.tool_response_blocks(content, profile)
|
|
118
|
-
runs =
|
|
119
|
-
if (match = part.match(/\A\[([^\]\s]+)\](?:\n| |\z)/))
|
|
120
|
-
acc << [match[1], match.post_match]
|
|
121
|
-
elsif acc.empty?
|
|
122
|
-
acc << ["unknown", part]
|
|
123
|
-
else
|
|
124
|
-
acc.last[1] = "#{acc.last[1]}#{ToolResponse::SEPARATOR}#{part}"
|
|
125
|
-
end
|
|
126
|
-
end
|
|
119
|
+
runs = ToolResponse.split(content).map { |run| [run.name || "unknown", run.body] }
|
|
127
120
|
runs = [["unknown", ""]] if runs.empty?
|
|
128
121
|
q = profile.string_delim
|
|
129
122
|
runs.map do |name, body|
|
|
@@ -178,7 +178,8 @@ module Samagotchi
|
|
|
178
178
|
# is when the worker starts); opts[:env] adds to it (merged, not
|
|
179
179
|
# replaced) what it can't read there: this `chi`'s CLI settings
|
|
180
180
|
# (Config.cli_env), the hosts and the absolute log file. It unsets
|
|
181
|
-
# SAMAGOTCHI_GUARDRAILS_
|
|
181
|
+
# SAMAGOTCHI_GUARDRAILS_* and the session markers (SESSION_MARKER_ENV).
|
|
182
|
+
# XDG_CONFIG_HOME passes: whoever starts or
|
|
182
183
|
# wakes the worker picks which config.yml it reads (docs/guardrails.md).
|
|
183
184
|
private_class_method def self.spawn_options(session)
|
|
184
185
|
# Own process group: workers outlive `chi web`, and a Ctrl-C in its
|
|
@@ -218,6 +219,11 @@ module Samagotchi
|
|
|
218
219
|
# key mapped to nil. config.yml no longer reads them; this keeps an
|
|
219
220
|
# older or future env-exposed one out as well.
|
|
220
221
|
ENV.each_key { |key| child_env[key] = nil if key.start_with?(GUARDRAILS_ENV_PREFIX) }
|
|
222
|
+
# The markers a session's execute exports (Tools::Builtins.parent_env)
|
|
223
|
+
# are that session's: a worker started from its commands (chi send)
|
|
224
|
+
# is another session. Its execute and tasks set its own; nothing else
|
|
225
|
+
# it runs (a user's !cmd, an MCP server, a context command) has one.
|
|
226
|
+
SESSION_MARKER_ENV.each { |key| child_env[key] = nil }
|
|
221
227
|
# A smoke's fake "newest installed" is for this process's notices only.
|
|
222
228
|
child_env[InstalledVersions::ENV_KEY] = nil
|
|
223
229
|
# A gem-mode worker activates the newest installed chi itself: under
|
|
@@ -238,6 +244,8 @@ module Samagotchi
|
|
|
238
244
|
end
|
|
239
245
|
|
|
240
246
|
GUARDRAILS_ENV_PREFIX = "SAMAGOTCHI_GUARDRAILS_"
|
|
247
|
+
# Tools::Builtins.parent_env's (Tools::TaskRuntime::MARKER_ENV_KEYS).
|
|
248
|
+
SESSION_MARKER_ENV = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL].freeze
|
|
241
249
|
|
|
242
250
|
# List all sessions, reading status from persisted session.json files.
|
|
243
251
|
# +project_root+: only that project's sessions (nil: every session).
|
|
@@ -6,6 +6,7 @@ require "time"
|
|
|
6
6
|
require "fileutils"
|
|
7
7
|
require "securerandom"
|
|
8
8
|
require_relative "atomic_file"
|
|
9
|
+
require_relative "token_usage"
|
|
9
10
|
require_relative "session"
|
|
10
11
|
|
|
11
12
|
module Samagotchi
|
|
@@ -104,6 +105,9 @@ module Samagotchi
|
|
|
104
105
|
# decode one came from (server / estimate).
|
|
105
106
|
:cached_sum,
|
|
106
107
|
:cache_write_sum,
|
|
108
|
+
# What the server prefilled again of the previous request's prompt
|
|
109
|
+
# (KernelLoop#reprefilled_tokens; an LLM context edit's cache break).
|
|
110
|
+
:reprefill_sum,
|
|
107
111
|
:reasoning_sum,
|
|
108
112
|
:cost_sum,
|
|
109
113
|
:decode_ms_sum,
|
|
@@ -273,6 +277,7 @@ module Samagotchi
|
|
|
273
277
|
when :generation_completed, :generation_cancelled
|
|
274
278
|
record_served_model(event)
|
|
275
279
|
count_cut(event)
|
|
280
|
+
count_reprefill(event)
|
|
276
281
|
record_generation_completed
|
|
277
282
|
when :generation_retrying
|
|
278
283
|
# The retry streams from the start: its counts replace the ones so far.
|
|
@@ -397,6 +402,7 @@ module Samagotchi
|
|
|
397
402
|
completion_sum: add.call(:completion_sum),
|
|
398
403
|
cached_sum: add.call(:cached_sum),
|
|
399
404
|
cache_write_sum: add.call(:cache_write_sum),
|
|
405
|
+
reprefill_sum: add.call(:reprefill_sum),
|
|
400
406
|
reasoning_sum: add.call(:reasoning_sum),
|
|
401
407
|
cost_sum: add.call(:cost_sum),
|
|
402
408
|
decode_ms_sum: decode_ms.round,
|
|
@@ -456,6 +462,7 @@ module Samagotchi
|
|
|
456
462
|
token_sources: [],
|
|
457
463
|
cached_sum: 0,
|
|
458
464
|
cache_write_sum: 0,
|
|
465
|
+
reprefill_sum: 0,
|
|
459
466
|
reasoning_sum: 0,
|
|
460
467
|
cost_sum: nil,
|
|
461
468
|
decode_ms_sum: 0,
|
|
@@ -527,6 +534,14 @@ module Samagotchi
|
|
|
527
534
|
end
|
|
528
535
|
end
|
|
529
536
|
|
|
537
|
+
# A generation's re-prefilled tokens (its :generation_completed's), into the turn.
|
|
538
|
+
def count_reprefill(event)
|
|
539
|
+
tokens = event[:reprefill_tokens]
|
|
540
|
+
return unless tokens.is_a?(Integer) && tokens >= TokenUsage::REPREFILL_MIN_TOKENS
|
|
541
|
+
|
|
542
|
+
@mutex.synchronize { @turn.reprefill_sum += tokens if @turn }
|
|
543
|
+
end
|
|
544
|
+
|
|
530
545
|
def record_generation_completed
|
|
531
546
|
@mutex.synchronize { close_generation if @turn&.gen_open }
|
|
532
547
|
end
|
|
@@ -654,6 +669,7 @@ module Samagotchi
|
|
|
654
669
|
tps_source: turn.tps_source
|
|
655
670
|
}
|
|
656
671
|
fields[:cache_write_tokens_sum] = turn.cache_write_sum if turn.cache_write_sum.positive?
|
|
672
|
+
fields[:reprefill_tokens_sum] = turn.reprefill_sum if turn.reprefill_sum.positive?
|
|
657
673
|
fields.merge!(cost: turn.cost_sum, cost_source: "reported") if turn.cost_sum
|
|
658
674
|
fields
|
|
659
675
|
end
|
|
@@ -891,7 +907,7 @@ module Samagotchi
|
|
|
891
907
|
def reset_totals
|
|
892
908
|
@totals = { cancellations: 0, prompt_sum: 0, completion_sum: 0, token_sources: [], iterations: 0,
|
|
893
909
|
gen_ms: 0, retries: 0, cuts: 0, capped: 0, by_tool: {}, tool_errors: 0, cached_sum: 0, cache_write_sum: 0,
|
|
894
|
-
reasoning_sum: 0,
|
|
910
|
+
reprefill_sum: 0, reasoning_sum: 0,
|
|
895
911
|
cost_sum: 0, decode_ms_sum: 0, decode_tokens_sum: 0, last_decode_tps: nil,
|
|
896
912
|
last_prefill_tps: nil, tps_source: nil }
|
|
897
913
|
end
|
|
@@ -908,6 +924,7 @@ module Samagotchi
|
|
|
908
924
|
totals[:capped] += number(record[:capped])
|
|
909
925
|
totals[:cached_sum] += number(record[:cached_tokens_sum])
|
|
910
926
|
totals[:cache_write_sum] += number(record[:cache_write_tokens_sum])
|
|
927
|
+
totals[:reprefill_sum] += number(record[:reprefill_tokens_sum])
|
|
911
928
|
totals[:reasoning_sum] += number(record[:reasoning_tokens])
|
|
912
929
|
totals[:cost_sum] += number(record[:cost])
|
|
913
930
|
totals[:decode_ms_sum] += number(record[:decode_ms])
|
|
@@ -68,10 +68,13 @@ module Samagotchi
|
|
|
68
68
|
# server's KV cache for it, stay stable.
|
|
69
69
|
# @param chat [Boolean] for the chat loop
|
|
70
70
|
# @param thinking [Symbol, nil] the level (Thinking); nil: the effective model's
|
|
71
|
-
|
|
71
|
+
# @param layers [Array<Symbol>] the turn's LLM context layers: the
|
|
72
|
+
# native prompt declares the tools they add (Tools::Registry#entries)
|
|
73
|
+
def build(chat: false, thinking: nil, layers: [])
|
|
72
74
|
@built ||= {}
|
|
73
|
-
@built[[chat, thinking]] ||= system_prompt_with_index(
|
|
74
|
-
|
|
75
|
+
@built[[chat, thinking, layers]] ||= system_prompt_with_index(
|
|
76
|
+
assist_system_prompt(chat: chat, thinking: thinking, layers: layers), chat: chat, thinking: thinking, layers: layers
|
|
77
|
+
)
|
|
75
78
|
end
|
|
76
79
|
|
|
77
80
|
# Drops the built prompts: the next #build reads the profile, tools,
|
|
@@ -90,8 +93,9 @@ module Samagotchi
|
|
|
90
93
|
|
|
91
94
|
# The base prompt (specs, plugins' declarations), with Gemma's tool
|
|
92
95
|
# declarations at its end as #build has them.
|
|
93
|
-
def base(chat: false, thinking: nil)
|
|
94
|
-
with_tail_declarations(assist_system_prompt(chat: chat, thinking: thinking), chat: chat
|
|
96
|
+
def base(chat: false, thinking: nil, layers: [])
|
|
97
|
+
with_tail_declarations(assist_system_prompt(chat: chat, thinking: thinking, layers: layers), chat: chat,
|
|
98
|
+
layers: layers)
|
|
95
99
|
end
|
|
96
100
|
|
|
97
101
|
# Names activated via preloaded --memory entries during prompt
|
|
@@ -113,13 +117,14 @@ module Samagotchi
|
|
|
113
117
|
|
|
114
118
|
# ── Tool declarations ──────────────────────────────────────────────────────
|
|
115
119
|
|
|
116
|
-
def tool_declarations
|
|
120
|
+
def tool_declarations(layers)
|
|
121
|
+
schemas = ToolDeclarations.native_schemas(@tools_lookup.call, layers: layers)
|
|
117
122
|
case profile.name
|
|
118
123
|
when "qwen36"
|
|
119
|
-
ToolDeclarations.qwen_declarations(
|
|
124
|
+
ToolDeclarations.qwen_declarations(schemas)
|
|
120
125
|
else
|
|
121
126
|
# Gemma 4 format
|
|
122
|
-
ToolDeclarations.gemma_declarations(
|
|
127
|
+
ToolDeclarations.gemma_declarations(schemas)
|
|
123
128
|
end
|
|
124
129
|
end
|
|
125
130
|
|
|
@@ -148,7 +153,7 @@ module Samagotchi
|
|
|
148
153
|
# @param chat [Boolean] for the chat loop: no tool declarations, call
|
|
149
154
|
# syntax or turn preamble (its tools go as schemas with each request)
|
|
150
155
|
# @param thinking [Symbol, nil] the level (Thinking); nil: the effective model's
|
|
151
|
-
def assist_system_prompt(chat: false, thinking: nil)
|
|
156
|
+
def assist_system_prompt(chat: false, thinking: nil, layers: [])
|
|
152
157
|
return chat_system_prompt if chat
|
|
153
158
|
|
|
154
159
|
hint = tool_call_hint
|
|
@@ -158,7 +163,7 @@ module Samagotchi
|
|
|
158
163
|
tools = if gemma_tail_declarations?
|
|
159
164
|
"Your tools are declared at the end of this system prompt."
|
|
160
165
|
else
|
|
161
|
-
"You have access to the following tools:\n\n#{tool_declarations}\n"
|
|
166
|
+
"You have access to the following tools:\n\n#{tool_declarations(layers)}\n"
|
|
162
167
|
end
|
|
163
168
|
|
|
164
169
|
<<~SYS
|
|
@@ -247,7 +252,7 @@ module Samagotchi
|
|
|
247
252
|
# indexes (a memory write changes them) after both; the model, the
|
|
248
253
|
# location and the session close the prompt (Gemma's tool declarations
|
|
249
254
|
# still follow them).
|
|
250
|
-
def system_prompt_with_index(base, chat: false, thinking: nil)
|
|
255
|
+
def system_prompt_with_index(base, chat: false, thinking: nil, layers: [])
|
|
251
256
|
thinking_token = chat ? "" : Thinking.native(thinking || turn_thinking, profile).system_token
|
|
252
257
|
memory_sections = [
|
|
253
258
|
"Project memories:\n#{read_memory_index("project")}",
|
|
@@ -257,15 +262,15 @@ module Samagotchi
|
|
|
257
262
|
project_specific_description, memory_sections]
|
|
258
263
|
stable_text = stable.compact.join("\n")
|
|
259
264
|
volatile = [current_model, project_location, current_session].compact
|
|
260
|
-
return with_tail_declarations(stable_text, chat: chat) if volatile.empty?
|
|
265
|
+
return with_tail_declarations(stable_text, chat: chat, layers: layers) if volatile.empty?
|
|
261
266
|
|
|
262
267
|
prompt = "#{stable_text}\n#{volatile.join("\n")}"
|
|
263
268
|
(@stable_lengths ||= {})[prompt] = stable_text.length + 1 if chat
|
|
264
|
-
with_tail_declarations(prompt, chat: chat)
|
|
269
|
+
with_tail_declarations(prompt, chat: chat, layers: layers)
|
|
265
270
|
end
|
|
266
271
|
|
|
267
|
-
def with_tail_declarations(prompt, chat:)
|
|
268
|
-
chat || !gemma_tail_declarations? ? prompt : prompt.rstrip + tool_declarations
|
|
272
|
+
def with_tail_declarations(prompt, chat:, layers:)
|
|
273
|
+
chat || !gemma_tail_declarations? ? prompt : prompt.rstrip + tool_declarations(layers)
|
|
269
274
|
end
|
|
270
275
|
|
|
271
276
|
def gemma_tail_declarations? = profile.name == "gemma4"
|
|
@@ -471,8 +471,9 @@ module Samagotchi
|
|
|
471
471
|
lines.join("\n")
|
|
472
472
|
end
|
|
473
473
|
|
|
474
|
-
# ", cached 4864 (93%), cache writes 312,
|
|
475
|
-
# line; the counts a server didn't report are left
|
|
474
|
+
# ", cached 4864 (93%), cache writes 312, re-prefilled 2100, reasoning
|
|
475
|
+
# 212" for the tokens line; the counts a server didn't report are left
|
|
476
|
+
# out (re-prefilled: SessionMetrics' reprefill_sum).
|
|
476
477
|
def token_breakdown_text(tokens)
|
|
477
478
|
cached = tokens[:cached_sum].to_i
|
|
478
479
|
reasoning = tokens[:reasoning_sum].to_i
|
|
@@ -480,6 +481,7 @@ module Samagotchi
|
|
|
480
481
|
text = +""
|
|
481
482
|
text << ", cached #{cached} (#{(cached * 100.0 / prompt).round}%)" if cached.positive? && prompt.positive?
|
|
482
483
|
text << ", cache writes #{tokens[:cache_write_sum].to_i}" if tokens[:cache_write_sum].to_i.positive?
|
|
484
|
+
text << ", re-prefilled #{tokens[:reprefill_sum].to_i}" if tokens[:reprefill_sum].to_i.positive?
|
|
483
485
|
text << ", reasoning #{reasoning}" if reasoning.positive?
|
|
484
486
|
text
|
|
485
487
|
end
|
|
@@ -23,6 +23,9 @@ module Samagotchi
|
|
|
23
23
|
class TokenUsage < Data.define(:prompt_tokens, :completion_tokens, :cached_tokens, :cache_write_tokens,
|
|
24
24
|
:reasoning_tokens, :cost, :predicted_per_second, :prompt_per_second, :predicted_ms, :source)
|
|
25
25
|
CHARS_PER_TOKEN = 4.0
|
|
26
|
+
# Re-prefilled tokens under this are none (KernelLoop#reprefilled_tokens,
|
|
27
|
+
# SessionMetrics): servers count the cache in blocks (OpenAI's 128).
|
|
28
|
+
REPREFILL_MIN_TOKENS = 128
|
|
26
29
|
|
|
27
30
|
class << self
|
|
28
31
|
# @param payload [Hash, nil] the raw streamed chunk payload
|