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,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# One edit of what the model is sent (LLMContextView): a tool output,
|
|
5
|
+
# named by its id (ToolIds), sent as a stub instead of its text. Saved
|
|
6
|
+
# on the tool_response entry it changes, under +edits+ (plan D3), keyed
|
|
7
|
+
# by the id, as plain JSON: {"t42" => {"kind" => "stale", "note" => …,
|
|
8
|
+
# "by" => …, "staged_at" => …, "applied_at" => …}}, so it goes wherever
|
|
9
|
+
# the entry goes and a save and load keeps it as written.
|
|
10
|
+
#
|
|
11
|
+
# Copies of an entry share its edits Hash: a rollback checkpoint
|
|
12
|
+
# (Engine#clone_messages), the kernel's duplicate_conversation and a
|
|
13
|
+
# plugin's sessions.fork copy each entry's top level only. So the edits
|
|
14
|
+
# are replaced, never changed in place (.store), or an edit would reach
|
|
15
|
+
# a checkpoint it was rolled back from.
|
|
16
|
+
#
|
|
17
|
+
# kind: :stale (chi saw a later read or edit supersede it) or :forget
|
|
18
|
+
# (the model forgot it, its note keeping the finding); note: what the
|
|
19
|
+
# stub says; by: who made it ("chi", the model); staged_at/applied_at:
|
|
20
|
+
# ISO 8601 times, applied_at nil while it waits to reach the prompt;
|
|
21
|
+
# keep: the output's lines a forget keeps ([[first, last], …], as the
|
|
22
|
+
# model named them; saved only when there are some), sent under the
|
|
23
|
+
# stub; keep_offset: what those numbers are past the output's own lines
|
|
24
|
+
# (a ranged read's start_line - 1: a read's keep names file lines; saved
|
|
25
|
+
# only when not 0).
|
|
26
|
+
LLMContextEdit = Data.define(:id, :kind, :note, :by, :staged_at, :applied_at, :keep, :keep_offset) do
|
|
27
|
+
def initialize(keep: [], keep_offset: 0, **fields) = super
|
|
28
|
+
|
|
29
|
+
def applied? = !applied_at.nil?
|
|
30
|
+
|
|
31
|
+
def keep? = !keep.empty?
|
|
32
|
+
|
|
33
|
+
# What the model reads instead of the output, after the output's
|
|
34
|
+
# "[name]" lead.
|
|
35
|
+
def stub
|
|
36
|
+
kind == :forget ? "(forgotten) #{note}" : note.to_s
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The saved form, without the id (it is the key).
|
|
40
|
+
def to_h
|
|
41
|
+
saved = { "kind" => kind.to_s, "note" => note, "by" => by, "staged_at" => staged_at, "applied_at" => applied_at }
|
|
42
|
+
saved = saved.merge("keep" => keep) if keep?
|
|
43
|
+
keep_offset.zero? ? saved : saved.merge("keep_offset" => keep_offset)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class LLMContextEdit
|
|
48
|
+
KINDS = %i[stale forget].freeze
|
|
49
|
+
|
|
50
|
+
# +hash+ as saved (string or symbol keys), or nil for one of no known
|
|
51
|
+
# kind.
|
|
52
|
+
def self.from_h(id, hash)
|
|
53
|
+
return nil unless hash.is_a?(Hash)
|
|
54
|
+
|
|
55
|
+
field = ->(key) { hash.key?(key.to_s) ? hash[key.to_s] : hash[key] }
|
|
56
|
+
kind = field.call(:kind).to_s.to_sym
|
|
57
|
+
return nil unless KINDS.include?(kind)
|
|
58
|
+
|
|
59
|
+
new(id: id.to_s, kind: kind, note: field.call(:note).to_s, by: field.call(:by), staged_at: field.call(:staged_at),
|
|
60
|
+
applied_at: field.call(:applied_at), keep: ranges(field.call(:keep)),
|
|
61
|
+
keep_offset: field.call(:keep_offset).is_a?(Integer) ? [field.call(:keep_offset), 0].max : 0)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Saved keep ranges as [[first, last], …]: pairs of positive Integers,
|
|
65
|
+
# first <= last; anything else is dropped.
|
|
66
|
+
def self.ranges(raw)
|
|
67
|
+
Array(raw).filter_map do |pair|
|
|
68
|
+
first, last = pair if pair.is_a?(Array) && pair.size == 2
|
|
69
|
+
[first, last] if first.is_a?(Integer) && last.is_a?(Integer) && first.positive? && first <= last
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# +entry+ without the edit +id+ (a restore): a new edits Hash, as
|
|
74
|
+
# .store; no edits key when none is left.
|
|
75
|
+
def self.remove(entry, id)
|
|
76
|
+
return entry unless entry[:edits].is_a?(Hash) && entry[:edits].key?(id)
|
|
77
|
+
|
|
78
|
+
left = entry[:edits].except(id)
|
|
79
|
+
left.empty? ? entry.delete(:edits) : entry[:edits] = left
|
|
80
|
+
entry
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# +entry+ with +edit+ saved on it: a new edits Hash, never the old one
|
|
84
|
+
# changed (copies of the entry share that one).
|
|
85
|
+
def self.store(entry, edit)
|
|
86
|
+
entry[:edits] = (entry[:edits].is_a?(Hash) ? entry[:edits] : {}).merge(edit.id => edit.to_h)
|
|
87
|
+
entry
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The edits saved on +entry+, by id.
|
|
91
|
+
# @return [Hash{String => LLMContextEdit}]
|
|
92
|
+
def self.on(entry)
|
|
93
|
+
saved = entry[:edits]
|
|
94
|
+
return {} unless saved.is_a?(Hash)
|
|
95
|
+
|
|
96
|
+
saved.each_with_object({}) do |(id, hash), edits|
|
|
97
|
+
edit = from_h(id, hash)
|
|
98
|
+
edits[edit.id] = edit if edit
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "tool_response"
|
|
5
|
+
require_relative "tool_ids"
|
|
6
|
+
require_relative "token_usage"
|
|
7
|
+
require_relative "llm_context_edit"
|
|
8
|
+
require_relative "llm_context_stale"
|
|
9
|
+
require_relative "llm_context_apply"
|
|
10
|
+
require_relative "llm_context_view"
|
|
11
|
+
require_relative "tools/forget_outputs"
|
|
12
|
+
|
|
13
|
+
module Samagotchi
|
|
14
|
+
# The forget layer (LLMContextStrategy :forget, experimental): the
|
|
15
|
+
# model's forget_outputs call on the running turn's conversation. A
|
|
16
|
+
# forget saves an LLMContextEdit of kind :forget on each output's entry
|
|
17
|
+
# (LLMContextEdit.store), staged (no applied_at); the apply rule
|
|
18
|
+
# (LLMContextApply, run at once by +apply+) then sends the batch at the
|
|
19
|
+
# next request or holds it for the turn's end, and the result says which.
|
|
20
|
+
# A restore takes a forget off (staged or applied); it goes out with the
|
|
21
|
+
# next request.
|
|
22
|
+
#
|
|
23
|
+
# Only the model's own tool outputs, named by their stored ids
|
|
24
|
+
# (ToolIds): never the system prompt, a user message, a steer or a
|
|
25
|
+
# context note (they have no id), never a legacy entry's derived id, and
|
|
26
|
+
# the call stays with its result (only the result's text is stubbed). Per
|
|
27
|
+
# id, refused with a reason:
|
|
28
|
+
# - an output from the last +protect_steps+ steps (the model may still
|
|
29
|
+
# be working with it);
|
|
30
|
+
# - a read of a file an edit or write touched in those steps
|
|
31
|
+
# (LLMContextStale.protected_ids), unless the forget keeps some of its
|
|
32
|
+
# lines (keep): wiping what the model edits against makes it redo the
|
|
33
|
+
# read (CLM's lesson);
|
|
34
|
+
# - a keep that holds all of the output, an output too small for a stub
|
|
35
|
+
# to free anything (SMALL), an output already stubbed or forgotten, an
|
|
36
|
+
# id nothing in context has.
|
|
37
|
+
# A read is never restored: reading the file again is its restore, and
|
|
38
|
+
# the file may have changed.
|
|
39
|
+
module LLMContextForget
|
|
40
|
+
BY = "model"
|
|
41
|
+
READ = "read"
|
|
42
|
+
# An output this short (its text after the lead, in chars) frees
|
|
43
|
+
# nothing as a stub ("(forgotten with t41: see its note) [restore: t42]").
|
|
44
|
+
SMALL = 80
|
|
45
|
+
|
|
46
|
+
# The turn a forget_outputs call runs in: its conversation (the
|
|
47
|
+
# entries the edits are saved on) and its ContextStatus (nil: none).
|
|
48
|
+
Turn = Data.define(:conversation, :context)
|
|
49
|
+
|
|
50
|
+
# One output a call may name: its entry, its ToolIds ref, its tool,
|
|
51
|
+
# its text (without the "[name]" lead) and its step; +offset+: what
|
|
52
|
+
# turns a line of its text into the line a keep names (a read's file
|
|
53
|
+
# lines: start_line - 1), +preview+: a read that came back as a big
|
|
54
|
+
# file's head/tail preview (no file lines to keep).
|
|
55
|
+
Output = Data.define(:index, :ref, :name, :body, :step, :offset, :preview) do
|
|
56
|
+
def read? = name == READ
|
|
57
|
+
|
|
58
|
+
# The lines a keep may name: [first, last].
|
|
59
|
+
def lines = [offset + 1, offset + body.lines.size]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
module_function
|
|
63
|
+
|
|
64
|
+
# Runs a forget_outputs +call+ on +turn+, under the turn's
|
|
65
|
+
# +llm_context+ (LLMContextStrategy::Resolved: its layers and
|
|
66
|
+
# protect_steps). The conversation's last step is the one holding the
|
|
67
|
+
# call (both loops add the model's entry before its calls run).
|
|
68
|
+
# @param apply [#call] → LLMContextApply::Outcome: the apply rule at a
|
|
69
|
+
# request, on the turn's conversation (KernelLoop#apply_llm_context!)
|
|
70
|
+
# @return [String] the tool result
|
|
71
|
+
def call(turn, call, llm_context:, apply:, root: Dir.pwd, now: Time.now.utc.iso8601(3))
|
|
72
|
+
request = Tools::ForgetOutputs.parse(call)
|
|
73
|
+
conversation = turn.conversation
|
|
74
|
+
return restore(turn, request.restore, llm_context) if request.restore? && request.ids.empty?
|
|
75
|
+
return "Error: forget_outputs needs ids to forget (or restore: ids to bring back)" if request.ids.empty?
|
|
76
|
+
if request.note.empty?
|
|
77
|
+
return "Error: forget_outputs needs a note: what you learned from these outputs (facts, file:line, " \
|
|
78
|
+
"what's ruled out, NEXT), since it is all that stays of them"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
forgotten, refused = forget(conversation, request, llm_context.protect_steps, root, now)
|
|
82
|
+
lines = if forgotten.empty?
|
|
83
|
+
["nothing forgotten"]
|
|
84
|
+
else
|
|
85
|
+
[applied_line(conversation, forgotten, apply.call, llm_context.active_layers)]
|
|
86
|
+
end
|
|
87
|
+
lines << restore(turn, request.restore, llm_context) if request.restore?
|
|
88
|
+
lines.concat(refused_lines(refused))
|
|
89
|
+
lines.join("\n")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Saves a staged forget on each output +request+ names that may go.
|
|
93
|
+
# @return [Array(Array<String>, Hash{String => String})] the ids
|
|
94
|
+
# forgotten, and the refused ones with why
|
|
95
|
+
def forget(conversation, request, protect_steps, root, now)
|
|
96
|
+
outputs = outputs(conversation)
|
|
97
|
+
last = LLMContextStale.step_count(conversation)
|
|
98
|
+
# The steps before the call's own: protected_ids counts that one too.
|
|
99
|
+
kept_reads = LLMContextStale.protected_ids(conversation, steps: protect_steps.positive? ? protect_steps + 1 : 0,
|
|
100
|
+
root: root)
|
|
101
|
+
refused = {}
|
|
102
|
+
forgotten = request.ids.select do |id|
|
|
103
|
+
why = refusal(conversation, outputs[id], request.keep[id], last, protect_steps, kept_reads)
|
|
104
|
+
refused[id] = why if why
|
|
105
|
+
why.nil?
|
|
106
|
+
end
|
|
107
|
+
forgotten.each do |id|
|
|
108
|
+
output = outputs[id]
|
|
109
|
+
keep = request.keep.fetch(id, [])
|
|
110
|
+
edit = LLMContextEdit.new(id: id, kind: :forget, note: request.note, by: BY, staged_at: now, applied_at: nil,
|
|
111
|
+
keep: keep, keep_offset: keep.empty? ? 0 : output.offset)
|
|
112
|
+
LLMContextEdit.store(conversation[output.index], edit)
|
|
113
|
+
end
|
|
114
|
+
[forgotten, refused]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Why +output+ can't go (a String for the result), nil when it can.
|
|
118
|
+
# +last+: the call's own step; the protect_steps steps before it are
|
|
119
|
+
# the model's latest outputs.
|
|
120
|
+
def refusal(conversation, output, keep, last, protect_steps, kept_reads)
|
|
121
|
+
return "no output with that id in context (ids look like t42)" unless output
|
|
122
|
+
return "already forgotten or stubbed" if LLMContextEdit.on(conversation[output.index]).key?(output.ref.id)
|
|
123
|
+
if protect_steps.positive? && output.step >= last - protect_steps
|
|
124
|
+
return "from your last #{protect_steps} step#{"s" unless protect_steps == 1}; you may still need it, forget it later"
|
|
125
|
+
end
|
|
126
|
+
return keep_refusal(output, keep) if keep
|
|
127
|
+
|
|
128
|
+
if kept_reads.include?(output.ref.id)
|
|
129
|
+
first, = output.lines
|
|
130
|
+
return "a read of a file you edited in your last #{protect_steps} steps; keep the lines you edit against " \
|
|
131
|
+
"(keep: [\"#{output.ref.id}:#{first}-#{first + 20}\"]) or forget it later"
|
|
132
|
+
end
|
|
133
|
+
return "too small: its stub would free nothing" if output.body.length <= SMALL
|
|
134
|
+
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Why +keep+ can't be kept of +output+, nil when it can: a preview has
|
|
139
|
+
# no file lines, a range must fall in the output's lines, and some of
|
|
140
|
+
# it must go.
|
|
141
|
+
def keep_refusal(output, keep)
|
|
142
|
+
return "a preview of a big file: keep needs its lines; read a range of it instead" if output.preview
|
|
143
|
+
|
|
144
|
+
first, last = output.lines
|
|
145
|
+
outside = keep.reject { |from, to| from <= last && to >= first }
|
|
146
|
+
unless outside.empty?
|
|
147
|
+
shown = outside.map { |from, to| "#{from}-#{to}" }.join(", ")
|
|
148
|
+
return "keep #{shown} is outside its lines (#{first}-#{last}#{", the file's" if output.read?})"
|
|
149
|
+
end
|
|
150
|
+
kept = keep.flat_map { |from, to| ([from, first].max..[to, last].min).to_a }.uniq.size
|
|
151
|
+
return "keep holds all of it; nothing to forget" if kept >= last - first + 1
|
|
152
|
+
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Takes the forgets of +ids+ off: any that hasn't reached the prompt
|
|
157
|
+
# yet, and one that has unless it is a read's (reading the file again
|
|
158
|
+
# is its restore). The next request sends them whole, whatever the
|
|
159
|
+
# apply rule (the model wants the text now), so the context estimate
|
|
160
|
+
# starts over (ContextStatus#edited!).
|
|
161
|
+
# @return [String] the result's line(s)
|
|
162
|
+
def restore(turn, ids, llm_context)
|
|
163
|
+
conversation = turn.conversation
|
|
164
|
+
outputs = outputs(conversation)
|
|
165
|
+
restored = []
|
|
166
|
+
refused = {}
|
|
167
|
+
sent = false
|
|
168
|
+
ids.each do |id|
|
|
169
|
+
output = outputs[id]
|
|
170
|
+
edit = output && LLMContextEdit.on(conversation[output.index])[id]
|
|
171
|
+
if edit.nil? || edit.kind != :forget
|
|
172
|
+
refused[id] = "not forgotten"
|
|
173
|
+
elsif output.read? && edit.applied?
|
|
174
|
+
refused[id] = "a read: read the file again instead (it may have changed)"
|
|
175
|
+
else
|
|
176
|
+
LLMContextEdit.remove(conversation[output.index], id)
|
|
177
|
+
restored << output
|
|
178
|
+
sent ||= edit.applied?
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
lines = restored.empty? ? [] : [restored_line(turn, restored, sent, llm_context)]
|
|
182
|
+
(lines + refused_lines(refused)).join("\n")
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def restored_line(turn, restored, sent, llm_context)
|
|
186
|
+
ids = restored.map { |output| output.ref.id }.join(", ")
|
|
187
|
+
return "restored #{ids}: its stub was never sent" unless sent
|
|
188
|
+
|
|
189
|
+
turn.context&.edited!
|
|
190
|
+
view = LLMContextView.new(strategy: llm_context.active_layers)
|
|
191
|
+
tail = LLMContextView.chars(view.messages(turn.conversation)[restored.map(&:index).min..])
|
|
192
|
+
"restored #{ids}: the next request sends it whole again, now whatever llm_context.apply says (the server " \
|
|
193
|
+
"reads the #{k(tail)} from it on again)"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Every output in +conversation+ a stored id names and that pairs with
|
|
197
|
+
# its call for sure (LLMContextStale.paired_runs), by id.
|
|
198
|
+
# @return [Hash{String => Output}]
|
|
199
|
+
def outputs(conversation)
|
|
200
|
+
LLMContextStale.paired_runs(conversation).each_with_object({}) do |run, found|
|
|
201
|
+
next if run.ref.derived?
|
|
202
|
+
|
|
203
|
+
name = run.call[:name].to_s
|
|
204
|
+
body = run.text.sub(ToolResponse::LEAD, "")
|
|
205
|
+
found[run.ref.id] = Output.new(index: run.index, ref: run.ref, name: name, body: body, step: run.step,
|
|
206
|
+
offset: name == READ ? read_offset(run.call) : 0,
|
|
207
|
+
preview: name == READ && LLMContextStale.partial?(body))
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# A read's first line, less one (start_line; 1 without one).
|
|
212
|
+
def read_offset(call)
|
|
213
|
+
first = Integer(call[:start_line].to_s.strip, 10)
|
|
214
|
+
first.positive? ? first - 1 : 0
|
|
215
|
+
rescue ArgumentError, TypeError
|
|
216
|
+
0
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# What the apply rule did with the batch the forgotten ids are in.
|
|
220
|
+
def applied_line(conversation, forgotten, outcome, layers)
|
|
221
|
+
ids = forgotten.join(", ")
|
|
222
|
+
applied = outcome.applied.map(&:id)
|
|
223
|
+
if forgotten.all? { |id| applied.include?(id) }
|
|
224
|
+
return "forgot #{ids}: applied, the next request sends the stubs (#{frees(outcome.freed_chars)}; the " \
|
|
225
|
+
"server reads the #{k(outcome.tail_chars)} after them again)"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
freed, = LLMContextApply.weigh(conversation, layers: layers, edits: staged(conversation))
|
|
229
|
+
"forgot #{ids}: staged until the turn ends (the staged stubs: #{frees(freed)} then)"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# The edits saved unapplied, as [entry index, edit] pairs.
|
|
233
|
+
def staged(conversation)
|
|
234
|
+
conversation.each_with_index.flat_map do |entry, index|
|
|
235
|
+
next [] unless entry[:role].to_s == "tool_response"
|
|
236
|
+
|
|
237
|
+
LLMContextEdit.on(entry).values.reject(&:applied?).map { |edit| [index, edit] }
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def refused_lines(refused)
|
|
242
|
+
return [] if refused.empty?
|
|
243
|
+
|
|
244
|
+
["not done:"] + refused.map { |id, why| "- #{id}: #{why}" }
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def frees(chars) = chars.positive? ? "frees #{k(chars)}" : "frees nothing, the stubs are as long"
|
|
248
|
+
|
|
249
|
+
# "12.4k tokens", chars/4.
|
|
250
|
+
def k(chars)
|
|
251
|
+
tokens = (chars / TokenUsage::CHARS_PER_TOKEN).ceil
|
|
252
|
+
tokens >= 1000 ? format("%.1fk tokens", tokens / 1000.0) : "#{tokens} tokens"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
private_class_method :forget, :refusal, :keep_refusal, :restored_line, :read_offset, :applied_line, :staged,
|
|
256
|
+
:refused_lines, :frees, :k
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -0,0 +1,271 @@
|
|
|
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 "model_profile"
|
|
8
|
+
require_relative "tool_call_parser"
|
|
9
|
+
require_relative "tools/builtin_calls"
|
|
10
|
+
require_relative "tools/tool_path"
|
|
11
|
+
|
|
12
|
+
module Samagotchi
|
|
13
|
+
# The stale layer (LLMContextStrategy :stale): a file read that a later
|
|
14
|
+
# read superseded is sent as a stub, "[read] lib/x.rb lines 1-200:
|
|
15
|
+
# superseded by a later read". Deterministic: no model call, no note of
|
|
16
|
+
# the model's. Superseded means a later read of the same file covering
|
|
17
|
+
# the read's lines, or (#found's +changes+) a successful edit or write of
|
|
18
|
+
# the file: "superseded by a later edit". A run that failed (its output
|
|
19
|
+
# an "Error") or holds only part of what it asked for supersedes nothing.
|
|
20
|
+
# Edit-driven stubs are opt-in (llm_context.stale_edits); when one may be
|
|
21
|
+
# sent is LLMContextApply's rule (the user, 2026-10-07: never mid-edit),
|
|
22
|
+
# and no read of a file the last steps edited is stubbed
|
|
23
|
+
# (#protected_ids). Shell reads (cat, sed -n) don't count.
|
|
24
|
+
#
|
|
25
|
+
# Nothing per run says what a built-in call read, so the calls are worked
|
|
26
|
+
# out from the model entries, in both formats: a chat entry's tool_calls,
|
|
27
|
+
# paired with each result by tool_call_id; a native entry's call markup
|
|
28
|
+
# (Qwen's <tool_call>, Gemma's <|tool_call>, whichever the entry holds: a
|
|
29
|
+
# session may have switched models), paired in order with the runs of the
|
|
30
|
+
# joined result after it. A result whose runs don't open with their calls'
|
|
31
|
+
# "[name]" leads (ToolResponse.runs_named; a corrected call's "ran as:"
|
|
32
|
+
# line) is left alone.
|
|
33
|
+
#
|
|
34
|
+
# A stub names its run by its ToolIds id (ToolIds.refs_at: stored, or
|
|
35
|
+
# derived for a legacy entry); LLMContextApply saves it on the entry
|
|
36
|
+
# (LLMContextEdit.store) when its rule lets it through. A run with any
|
|
37
|
+
# edit already keeps it.
|
|
38
|
+
module LLMContextStale
|
|
39
|
+
BY = "chi"
|
|
40
|
+
READ = "read"
|
|
41
|
+
CHANGES = %w[edit write].freeze
|
|
42
|
+
WHOLE = (1..Float::INFINITY)
|
|
43
|
+
|
|
44
|
+
# A file call's run: the entry it sits in (+index+) and its ToolIds
|
|
45
|
+
# ref, the call's tool, the path as the model wrote it (+shown+) and
|
|
46
|
+
# expanded, the lines a read asked for (WHOLE for all), whether its
|
|
47
|
+
# output is an error, whether it holds only part of what was asked
|
|
48
|
+
# (#partial?), the output's length, and its step (1 for the
|
|
49
|
+
# conversation's first model entry with calls, one more per such
|
|
50
|
+
# entry).
|
|
51
|
+
FileRun = Data.define(:index, :ref, :name, :shown, :path, :lines, :failed, :partial, :size, :step) do
|
|
52
|
+
def read? = name == READ
|
|
53
|
+
|
|
54
|
+
# +other+, an earlier read of the same file, is stale after this
|
|
55
|
+
# run: a read covering its lines, or (+changes+) an edit or write.
|
|
56
|
+
def supersedes?(other, changes: false)
|
|
57
|
+
return false if failed || partial || path != other.path
|
|
58
|
+
return changes unless read?
|
|
59
|
+
|
|
60
|
+
lines.begin <= other.lines.begin && lines.end >= other.lines.end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A read to stub (#found). #change? when only an edit or write
|
|
65
|
+
# superseded it (+changes+), no later read.
|
|
66
|
+
Found = Data.define(:run, :by, :note) do
|
|
67
|
+
def change? = !by.read?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
module_function
|
|
71
|
+
|
|
72
|
+
# The reads to stub that have no edit yet: each read, the first later
|
|
73
|
+
# run that superseded it (+by+; the replay benchmark applies the edit
|
|
74
|
+
# at the request after it, as chi does) and the stub's note. A stub
|
|
75
|
+
# that wouldn't be shorter than the output is left out. +changes+: a
|
|
76
|
+
# successful edit or write of the file supersedes a read too, when no
|
|
77
|
+
# later read does (a later read is +by+ whenever there is one, so
|
|
78
|
+
# Found#change? holds only for a read nothing but a change superseded).
|
|
79
|
+
# Whether such a stub may be sent yet is LLMContextApply's call (the
|
|
80
|
+
# user, 2026-10-07: right after an edit the model is usually still
|
|
81
|
+
# editing against the read): at turn end, or when it pays off, and
|
|
82
|
+
# never the read #protected_ids names.
|
|
83
|
+
# @return [Array<Found>]
|
|
84
|
+
def found(conversation, root: Dir.pwd, changes: false)
|
|
85
|
+
runs = file_runs(conversation, root)
|
|
86
|
+
runs.each_with_index.filter_map do |run, at|
|
|
87
|
+
next unless run.read?
|
|
88
|
+
next if LLMContextEdit.on(conversation[run.index]).key?(run.ref.id)
|
|
89
|
+
|
|
90
|
+
later = runs[(at + 1)..]
|
|
91
|
+
by = later.find { |other| other.read? && other.supersedes?(run) } ||
|
|
92
|
+
(changes && later.find { |other| other.supersedes?(run, changes: true) })
|
|
93
|
+
next unless by
|
|
94
|
+
|
|
95
|
+
note = note(run, by)
|
|
96
|
+
Found.new(run: run, by: by, note: note) if run.size > "[#{run.name}] #{note}".length
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The ids of the reads stale never stubs: every read of each file an
|
|
101
|
+
# edit or write (successful or not: a failed one is retried) touched in
|
|
102
|
+
# the conversation's last +steps+ steps (protect_steps; 0: none). The
|
|
103
|
+
# model is likely still editing against them, and any of them may hold
|
|
104
|
+
# the lines it edits (an earlier range, or the only whole copy when a
|
|
105
|
+
# later read came back cut). Steps count across the conversation.
|
|
106
|
+
# @return [Set<String>]
|
|
107
|
+
def protected_ids(conversation, steps:, root: Dir.pwd)
|
|
108
|
+
return Set.new unless steps.positive?
|
|
109
|
+
|
|
110
|
+
runs = file_runs(conversation, root)
|
|
111
|
+
last = step_count(conversation)
|
|
112
|
+
edited = runs.select { |run| !run.read? && run.step > last - steps }.to_set(&:path)
|
|
113
|
+
runs.select { |run| run.read? && edited.include?(run.path) }.to_set { |run| run.ref.id }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The conversation's steps: its model entries with calls.
|
|
117
|
+
def step_count(conversation)
|
|
118
|
+
conversation.count { |entry| entry[:role].to_s == "model" && !calls(entry).empty? }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# What the stub says after the "[read]" lead.
|
|
122
|
+
def note(run, by)
|
|
123
|
+
lines = if run.lines == WHOLE
|
|
124
|
+
""
|
|
125
|
+
else
|
|
126
|
+
" lines #{run.lines.begin}-#{run.lines.end.infinite? ? "end" : run.lines.end}"
|
|
127
|
+
end
|
|
128
|
+
"#{run.shown}#{lines}: superseded by a later #{by.name}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# One run of a tool_response entry paired with its call for sure
|
|
132
|
+
# (#results: by tool_call_id, or the batch's "[name]" leads in order):
|
|
133
|
+
# the entry's index, its ToolIds ref, the call, its text and its step.
|
|
134
|
+
PairedRun = Data.define(:index, :ref, :call, :text, :step)
|
|
135
|
+
|
|
136
|
+
# Every run of +conversation+ that pairs with its call, in order. A
|
|
137
|
+
# result whose runs don't open with their calls' leads (a "ran as:"
|
|
138
|
+
# line, an output holding the SEPARATOR and a lead) has none: what
|
|
139
|
+
# names a run (forget_outputs, the view's ids) leaves it alone.
|
|
140
|
+
# @return [Array<PairedRun>]
|
|
141
|
+
def paired_runs(conversation)
|
|
142
|
+
batch = []
|
|
143
|
+
step = 0
|
|
144
|
+
conversation.each_with_index.with_object([]) do |(entry, index), runs|
|
|
145
|
+
case entry[:role].to_s
|
|
146
|
+
when "model"
|
|
147
|
+
batch = calls(entry)
|
|
148
|
+
step += 1 unless batch.empty?
|
|
149
|
+
when "tool_response"
|
|
150
|
+
results(conversation, index, batch).each do |ref, call, text|
|
|
151
|
+
runs << PairedRun.new(index: index, ref: ref, call: call, text: text, step: step)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The read, edit and write runs of +conversation+, in order.
|
|
158
|
+
# @return [Array<FileRun>]
|
|
159
|
+
def file_runs(conversation, root)
|
|
160
|
+
paired_runs(conversation).filter_map do |run|
|
|
161
|
+
file_run(run.index, run.ref, run.call, run.text, root, run.step)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# A model entry's calls, each [tool_call_id (nil for a native one), the
|
|
166
|
+
# call as dispatched (Tools::BuiltinCalls.build)].
|
|
167
|
+
def calls(entry)
|
|
168
|
+
if entry[:tool_calls].is_a?(Array) && !entry[:tool_calls].empty?
|
|
169
|
+
return entry[:tool_calls].map do |call|
|
|
170
|
+
[field(call, :id), Tools::BuiltinCalls.build(field(call, :name).to_s, arguments(field(call, :arguments)))]
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
content = entry[:content].to_s
|
|
175
|
+
parser = native_parser(content)
|
|
176
|
+
return [] unless parser
|
|
177
|
+
|
|
178
|
+
parser.read(parser.strip_thought(content)).map do |call|
|
|
179
|
+
[nil, Tools::BuiltinCalls.build(call[:name], call[:args], raw: call[:raw])]
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# A tool_response entry's runs, each [its ref, its call, its text]; none
|
|
184
|
+
# when they can't be paired for sure.
|
|
185
|
+
def results(conversation, index, batch)
|
|
186
|
+
entry = conversation[index]
|
|
187
|
+
refs = ToolIds.refs_at(conversation, index)
|
|
188
|
+
if entry[:tool_call_id]
|
|
189
|
+
# Paired by an id its batch holds once: a provider may send an
|
|
190
|
+
# empty or repeated id, and a wrong pairing names the wrong file.
|
|
191
|
+
id = entry[:tool_call_id].to_s
|
|
192
|
+
matches = id.empty? ? [] : batch.select { |call_id, _| call_id.to_s == id }
|
|
193
|
+
call = matches.one? ? matches.first.last : nil
|
|
194
|
+
texts = call && refs.size == 1 ? ToolResponse.runs_named(entry[:content], [call[:name]]) : nil
|
|
195
|
+
return texts ? [[refs.first, call, texts.first.text]] : []
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
calls = batch.map(&:last)
|
|
199
|
+
texts = ToolResponse.runs_named(entry[:content], calls.map { |call| call[:name] })
|
|
200
|
+
return [] unless texts && texts.size == refs.size
|
|
201
|
+
|
|
202
|
+
refs.zip(calls, texts.map(&:text))
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def file_run(index, ref, call, text, root, step)
|
|
206
|
+
name = call[:name].to_s
|
|
207
|
+
return nil unless name == READ || CHANGES.include?(name)
|
|
208
|
+
|
|
209
|
+
shown = (name == READ ? call[:content] : call[:path]).to_s.strip
|
|
210
|
+
return nil if shown.empty?
|
|
211
|
+
|
|
212
|
+
body = text.sub(ToolResponse::LEAD, "")
|
|
213
|
+
FileRun.new(index: index, ref: ref, name: name, shown: shown,
|
|
214
|
+
path: File.expand_path(Tools::ToolPath.normalize(shown), root),
|
|
215
|
+
lines: name == READ ? lines(call) : WHOLE, failed: body.lstrip.start_with?("Error"),
|
|
216
|
+
partial: partial?(body), size: text.length, step: step)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# The read tool's head/tail preview of a big file (Tools::Read) or an
|
|
220
|
+
# output ToolRunner cut at max_tool_output_chars: it doesn't hold all
|
|
221
|
+
# the lines asked for, so it supersedes nothing.
|
|
222
|
+
PREVIEW = "[TRUNCATED_PREVIEW_HEAD]"
|
|
223
|
+
CUT = /\[cut: \d+ of \d+ chars; read it in parts\]\z/
|
|
224
|
+
|
|
225
|
+
def partial?(body)
|
|
226
|
+
body.start_with?("truncated=true") || body.include?(PREVIEW) || body.match?(CUT)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# The lines a read asked for: from start_line (1) to end_line (the end).
|
|
230
|
+
def lines(call)
|
|
231
|
+
first = line(call[:start_line]) || 1
|
|
232
|
+
last = line(call[:end_line]) || Float::INFINITY
|
|
233
|
+
first..last
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def line(value)
|
|
237
|
+
number = value.is_a?(Integer) ? value : Integer(value.to_s.strip, 10)
|
|
238
|
+
number.positive? ? number : nil
|
|
239
|
+
rescue ArgumentError, TypeError
|
|
240
|
+
nil
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def field(hash, key)
|
|
244
|
+
return nil unless hash.is_a?(Hash)
|
|
245
|
+
|
|
246
|
+
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def arguments(value)
|
|
250
|
+
return value if value.is_a?(Hash)
|
|
251
|
+
|
|
252
|
+
parsed = JSON.parse(value.to_s)
|
|
253
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
254
|
+
rescue JSON::ParserError
|
|
255
|
+
{}
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
QWEN_CALL = "<tool_call>"
|
|
259
|
+
GEMMA_CALL = "<|tool_call>"
|
|
260
|
+
|
|
261
|
+
def native_parser(content)
|
|
262
|
+
if content.include?(GEMMA_CALL)
|
|
263
|
+
@gemma ||= ToolCallParser::Gemma.new(ModelProfile.gemma4)
|
|
264
|
+
elsif content.include?(QWEN_CALL)
|
|
265
|
+
@qwen ||= ToolCallParser::Qwen.new(ModelProfile.qwen36)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
private_class_method :file_runs, :results, :file_run, :lines, :line, :field, :arguments, :native_parser
|
|
270
|
+
end
|
|
271
|
+
end
|