samagotchi 0.141.0 → 0.142.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 +46 -1
- data/README.md +1 -1
- data/bin/chi +2 -0
- data/docs/architecture.md +12 -283
- data/docs/bundle-authoring.md +1 -0
- data/docs/cli.md +15 -7
- data/docs/configuration.md +57 -18
- data/docs/context.md +2 -2
- data/docs/guardrails.md +20 -11
- data/docs/hooks.md +9 -7
- data/docs/internals/attached-context.md +226 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bridge.md +380 -0
- data/docs/internals/bundles.md +1 -0
- data/docs/internals/context-telemetry.md +1 -1
- data/docs/internals/engine.md +24 -18
- data/docs/internals/participants.md +13 -12
- data/docs/internals/remote-sessions.md +1 -1
- data/docs/internals/{tool-guardrails.md → tool-output-limits.md} +21 -2
- data/docs/memory.md +31 -1
- data/docs/plugins.md +122 -1
- data/docs/sessions.md +50 -26
- data/docs/system-one.md +122 -0
- data/docs/testing.md +3 -1
- data/lib/samagotchi/bridge.rb +2 -0
- data/lib/samagotchi/bundles/check-in/manifest.yml +6 -3
- data/lib/samagotchi/bundles/check-in/plugin.rb +101 -4
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/config.rb +74 -14
- data/lib/samagotchi/context_candidate_choice.rb +8 -0
- data/lib/samagotchi/context_candidates_log.rb +25 -1
- data/lib/samagotchi/context_judge.rb +6 -2
- data/lib/samagotchi/engine.rb +68 -7
- data/lib/samagotchi/extension_load.rb +3 -2
- data/lib/samagotchi/guardrail_wiring.rb +36 -31
- data/lib/samagotchi/guardrails/chi_dirs.rb +7 -4
- data/lib/samagotchi/guardrails/path_resolve.rb +1 -1
- data/lib/samagotchi/guardrails/protected_paths.rb +7 -4
- data/lib/samagotchi/guardrails_command.rb +3 -3
- data/lib/samagotchi/judgments_command.rb +155 -0
- data/lib/samagotchi/kernel_loop.rb +13 -1
- data/lib/samagotchi/llm/chat_loop.rb +74 -10
- data/lib/samagotchi/llm/errors.rb +7 -5
- data/lib/samagotchi/llm/stray_tool_call.rb +61 -0
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/memory_bundle/entry_size.rb +100 -0
- data/lib/samagotchi/plugin/api.rb +33 -2
- data/lib/samagotchi/plugin/context.rb +49 -1
- data/lib/samagotchi/plugin/judgments.rb +72 -0
- data/lib/samagotchi/plugin/loader.rb +3 -2
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/self_report.rb +36 -0
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_commands.rb +3 -0
- data/lib/samagotchi/session_manager.rb +13 -2
- data/lib/samagotchi/session_retention.rb +10 -0
- data/lib/samagotchi/side_judgments.rb +12 -1
- data/lib/samagotchi/system_one/answer.rb +63 -11
- data/lib/samagotchi/system_one/approval_label.rb +56 -0
- data/lib/samagotchi/system_one/approval_question.rb +2 -0
- data/lib/samagotchi/system_one/client.rb +43 -17
- data/lib/samagotchi/system_one/fake_backend.rb +1 -1
- data/lib/samagotchi/system_one/http_backend.rb +12 -4
- data/lib/samagotchi/system_one/judgment_settings.rb +61 -0
- data/lib/samagotchi/system_one/judgments.rb +260 -0
- data/lib/samagotchi/system_one/log.rb +4 -2
- data/lib/samagotchi/system_one/recording.rb +117 -0
- data/lib/samagotchi/system_one/runs.rb +179 -0
- data/lib/samagotchi/system_one/runs_store.rb +230 -0
- data/lib/samagotchi/system_one.rb +9 -0
- data/lib/samagotchi/testing/bundle.rb +9 -2
- data/lib/samagotchi/testing/host.rb +2 -1
- data/lib/samagotchi/testing/judgments.rb +185 -0
- data/lib/samagotchi/testing/recorder.rb +5 -0
- data/lib/samagotchi/testing/recording_api.rb +1 -1
- data/lib/samagotchi/tool_output_cut.rb +26 -6
- data/lib/samagotchi/tool_response.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +63 -4
- data/lib/samagotchi/toolkit_suggest.rb +11 -2
- data/lib/samagotchi/tools/memory.rb +28 -0
- data/lib/samagotchi/turn_note.rb +9 -0
- data/lib/samagotchi/version.rb +1 -1
- metadata +16 -2
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../config"
|
|
4
|
+
require_relative "../log"
|
|
5
|
+
require_relative "../llm/errors"
|
|
6
|
+
require_relative "../side_judgments"
|
|
7
|
+
require_relative "judgment_settings"
|
|
8
|
+
require_relative "question"
|
|
9
|
+
require_relative "recording"
|
|
10
|
+
require_relative "runs"
|
|
11
|
+
|
|
12
|
+
module Samagotchi
|
|
13
|
+
module SystemOne
|
|
14
|
+
# Bundles' judgments (chi.judgment, ctx.judge, ctx.judge_later): what the
|
|
15
|
+
# user set for each, then the same Client and Recording core's uses ask
|
|
16
|
+
# through. It adds no power: a bundle's run is scoped to its name, and off
|
|
17
|
+
# until the user sets a mode. Core's uses stay outside (their threads,
|
|
18
|
+
# gates and contention are their own) and can't be set here (D2,
|
|
19
|
+
# JudgmentSettings).
|
|
20
|
+
class Judgments
|
|
21
|
+
# What chi.judgment declared. +questions+ is an Array of Question, or a
|
|
22
|
+
# callable given the ctx, returning what normalize_questions takes.
|
|
23
|
+
Declaration = Data.define(:name, :questions, :positive, :negative, :shared_host) do
|
|
24
|
+
# @raise [ArgumentError] anything the declaration can't be
|
|
25
|
+
def self.build(bundle:, name:, questions:, positive: nil, negative: nil, shared_host: :skip)
|
|
26
|
+
full = "#{bundle}.#{name}"
|
|
27
|
+
# The first word is the bundle: "a.b" + "x" and "a" + "b.x" must not meet.
|
|
28
|
+
raise ArgumentError, "judgment #{full.inspect}: a bundle with a dot in its name can't declare one" if bundle.to_s.include?(".")
|
|
29
|
+
|
|
30
|
+
unless Runs.declarable?(full)
|
|
31
|
+
raise ArgumentError, "judgment #{full.inspect}: a full name is dot-separated words of a-z, 0-9, _ and -, " \
|
|
32
|
+
"with no first word of #{Runs::RESERVED.join(", ")} and no #{Runs::RESERVED_SUFFIX} at its end"
|
|
33
|
+
end
|
|
34
|
+
unless SHARED_HOST.include?(shared_host)
|
|
35
|
+
raise ArgumentError, "judgment #{full}: shared_host: is #{SHARED_HOST.map(&:inspect).join(" or ")}, not #{shared_host.inspect}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
new(name: full, questions: questions.respond_to?(:call) ? questions : checked(full, questions),
|
|
39
|
+
positive: Array(positive).map(&:to_s).freeze, negative: Array(negative).map(&:to_s).freeze, shared_host: shared_host)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The questions of an ask now.
|
|
43
|
+
# @return [Array<Question>]
|
|
44
|
+
def questions_for(context)
|
|
45
|
+
questions.respond_to?(:call) ? self.class.checked(name, questions.call(context)) : questions
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.checked(full, questions)
|
|
49
|
+
list = SystemOne.normalize_questions(questions)
|
|
50
|
+
raise ArgumentError, "judgment #{full} has no questions" if list.empty?
|
|
51
|
+
|
|
52
|
+
list.freeze
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# What each of its runs says of the label values, if anything.
|
|
56
|
+
def run_fields = { positive: positive, negative: negative }.reject { |_key, values| values.empty? }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
SHARED_HOST = %i[ask skip].freeze
|
|
60
|
+
|
|
61
|
+
# What an ask came to. +reason+ says why there's no answer: :off,
|
|
62
|
+
# :no_host, :no_session, :failed, :cancelled, :shared_host or :busy; nil
|
|
63
|
+
# with answers. +mode+ is the judgment's ("off", "audit", "on"): in audit
|
|
64
|
+
# the bundle looks and doesn't act. +run_id+ is nil when no run is saved.
|
|
65
|
+
Result = Data.define(:answers, :run_id, :mode, :reason) do
|
|
66
|
+
def self.none(reason, mode: "off", run_id: nil) = new(answers: {}.freeze, run_id: run_id, mode: mode, reason: reason)
|
|
67
|
+
|
|
68
|
+
def no_judgment? = answers.empty?
|
|
69
|
+
def audit? = mode == "audit"
|
|
70
|
+
|
|
71
|
+
# An :noul question's probability of yes; nil with no answer, or for
|
|
72
|
+
# another type (its answer is #answers[id]).
|
|
73
|
+
# @param id [String, Symbol, nil] nil: the only question's
|
|
74
|
+
# @return [Float, nil]
|
|
75
|
+
def p(id = nil)
|
|
76
|
+
answer = id.nil? ? (answers.values.first if answers.size == 1) : answers[id.to_s]
|
|
77
|
+
answer&.noul
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# One ask, made ready on the caller's thread.
|
|
82
|
+
Ask = Data.define(:client, :questions, :state, :run_id, :mode, :shared_host)
|
|
83
|
+
|
|
84
|
+
# The seams are what the Engine knows, read at each ask. They default to
|
|
85
|
+
# nothing, for a chi with no Engine (a bundle's install check): nothing
|
|
86
|
+
# is ever answered there.
|
|
87
|
+
# @param settings [#call] (name) → JudgmentSettings
|
|
88
|
+
# @param client_for [#call] (name, settings) → a Client, nil with no host
|
|
89
|
+
# @param runs [#call] → the Runs to save in
|
|
90
|
+
# @param where [#call] → Recording::Context, nil with no session
|
|
91
|
+
# @param side [SideJudgments]
|
|
92
|
+
# @param shared_host_busy [#call] (host) → whether a turn is running on it
|
|
93
|
+
# @param participant [#call] → whether a participant's turn runs: its
|
|
94
|
+
# text is never saved
|
|
95
|
+
def initialize(settings: JudgmentSettings.method(:of), client_for: ->(_name, _settings) {}, runs: -> { Runs.new }, where: -> {},
|
|
96
|
+
side: SideJudgments.new, shared_host_busy: ->(_host) { false }, participant: -> { false })
|
|
97
|
+
@settings = settings
|
|
98
|
+
@client_for = client_for
|
|
99
|
+
@runs = runs
|
|
100
|
+
@where = where
|
|
101
|
+
@side = side
|
|
102
|
+
@shared_host_busy = shared_host_busy
|
|
103
|
+
@participant = participant
|
|
104
|
+
@declarations = {}
|
|
105
|
+
@mutex = Mutex.new
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @param declaration [Declaration]
|
|
109
|
+
def declare(declaration)
|
|
110
|
+
@mutex.synchronize { @declarations[declaration.name] = declaration }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def declared?(name) = @mutex.synchronize { @declarations.key?(name.to_s) }
|
|
114
|
+
|
|
115
|
+
# Ask now, on this thread.
|
|
116
|
+
# @param context [Object] what a questions builder gets (the ctx)
|
|
117
|
+
# @return [Result]
|
|
118
|
+
# @raise [ArgumentError] +name+ isn't declared, or +state+ can't be one
|
|
119
|
+
def judge(name, state:, context: nil, cancel_controller: nil)
|
|
120
|
+
ask = prepare(name, state, context)
|
|
121
|
+
return ask if ask.is_a?(Result)
|
|
122
|
+
|
|
123
|
+
shared_host?(ask) ? skipped(ask, :shared_host) : asked(ask, cancel_controller)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Ask on +name+'s side thread, one at a time; the block gets the Result
|
|
127
|
+
# on another thread, always once.
|
|
128
|
+
# @return [String, nil] the run's id, nil when no run will be saved
|
|
129
|
+
# @raise [ArgumentError] as #judge
|
|
130
|
+
def later(name, state:, context: nil, &block)
|
|
131
|
+
ask = prepare(name, state, context)
|
|
132
|
+
return tell(name, block, ask).run_id if ask.is_a?(Result)
|
|
133
|
+
return tell(name, block, skipped(ask, :shared_host)).run_id if shared_host?(ask)
|
|
134
|
+
|
|
135
|
+
started = @side.start(name, warn: warner(name, "judge_later_failed"),
|
|
136
|
+
finish: ->(result) { deliver(name, block, result || Result.none(:failed, mode: ask.mode, run_id: ask.run_id)) }) do
|
|
137
|
+
asked(ask, nil)
|
|
138
|
+
end
|
|
139
|
+
tell(name, block, skipped(ask, :busy)) unless started
|
|
140
|
+
ask.run_id
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# A later line on a run (Runs#label, #acted); false when it isn't saved.
|
|
144
|
+
# Nothing is saved in a participant's turn, as an ask there saves no run.
|
|
145
|
+
def label(run_id, value, by:, strength: nil, question: nil)
|
|
146
|
+
return false if @participant.call
|
|
147
|
+
|
|
148
|
+
@runs.call.label(run_id, value, by: by, strength: strength, question: question)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def acted(run_id, what)
|
|
152
|
+
return false if @participant.call
|
|
153
|
+
|
|
154
|
+
@runs.call.acted(run_id, what)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The system_one.judgments names no plugin declared, once the plugins
|
|
158
|
+
# have loaded: a typo there is a judgment that stays off. Core's names
|
|
159
|
+
# have their own warning (Config.validate_yaml_sections).
|
|
160
|
+
# @return [Array<String>]
|
|
161
|
+
def undeclared
|
|
162
|
+
known = @mutex.synchronize { @declarations.keys }
|
|
163
|
+
(JudgmentSettings.names - known).reject { |name| Config::RESERVED_JUDGMENTS.include?(name.split(".").first) }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
# The Ask, or the Result that says why there is none.
|
|
169
|
+
def prepare(name, state, context)
|
|
170
|
+
declaration = @mutex.synchronize { @declarations[name.to_s] }
|
|
171
|
+
raise ArgumentError, "no judgment #{name} declared (chi.judgment)" unless declaration
|
|
172
|
+
unless state.is_a?(String) || state.is_a?(Hash) || state.is_a?(Array)
|
|
173
|
+
raise ArgumentError, "judgment #{name}: state: is a String, a Hash or an Array, not #{state.class}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
settings = @settings.call(declaration.name)
|
|
177
|
+
return Result.none(:off) if settings.off?
|
|
178
|
+
|
|
179
|
+
client = client_for(declaration, settings)
|
|
180
|
+
return Result.none(:no_host, mode: settings.mode) if client.nil?
|
|
181
|
+
|
|
182
|
+
where = where_now(declaration)
|
|
183
|
+
return Result.none(:no_session, mode: settings.mode) if where.nil?
|
|
184
|
+
|
|
185
|
+
ready(declaration, settings, client, where, state, context)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def where_now(declaration)
|
|
189
|
+
@where.call
|
|
190
|
+
rescue StandardError => e
|
|
191
|
+
warner(declaration.name, "judgment_session_unread").call(e)
|
|
192
|
+
nil
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def ready(declaration, settings, client, where, state, context)
|
|
196
|
+
runs = @participant.call ? Runs.new : @runs.call
|
|
197
|
+
Ask.new(client: Recording.new(client, judgment: declaration.name, runs: runs, context: where, fields: declaration.run_fields),
|
|
198
|
+
questions: declaration.questions_for(context), state: copy_of(declaration, state),
|
|
199
|
+
run_id: runs.on? ? Runs.new_id(declaration.name) : nil, mode: settings.mode, shared_host: declaration.shared_host)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# A state nested too deep to copy (a cycle) is a bad state: a
|
|
203
|
+
# SystemStackError would go past a hook's rescue and end the turn.
|
|
204
|
+
def copy_of(declaration, state)
|
|
205
|
+
Runs.scrubbed(state)
|
|
206
|
+
rescue SystemStackError
|
|
207
|
+
raise ArgumentError, "judgment #{declaration.name}: state: is nested too deep"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def client_for(declaration, settings)
|
|
211
|
+
@client_for.call(declaration.name, settings)
|
|
212
|
+
rescue StandardError => e
|
|
213
|
+
warner(declaration.name, "judgment_client_failed").call(e)
|
|
214
|
+
nil
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Whether this ask would evict a running turn's cached prompt, and its
|
|
218
|
+
# bundle said to skip then. A check that fails counts as yes: no ask.
|
|
219
|
+
def shared_host?(ask)
|
|
220
|
+
ask.shared_host == :skip && @shared_host_busy.call(ask.client.host)
|
|
221
|
+
rescue StandardError => e
|
|
222
|
+
Samagotchi::Log.warn(:plugins, "judgment_shared_host_check_failed", error: e.class.name)
|
|
223
|
+
true
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def skipped(ask, reason)
|
|
227
|
+
ask.client.skip(state: ask.state, questions: ask.questions, reason: reason.to_s, run_id: ask.run_id)
|
|
228
|
+
Result.none(reason, mode: ask.mode, run_id: ask.run_id)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def asked(ask, cancel_controller)
|
|
232
|
+
answers = ask.client.ask(state: ask.state, questions: ask.questions, cancel_controller: cancel_controller, run_id: ask.run_id)
|
|
233
|
+
return Result.none(:failed, mode: ask.mode, run_id: ask.run_id) if answers.nil?
|
|
234
|
+
|
|
235
|
+
Result.new(answers: answers.freeze, run_id: ask.run_id, mode: ask.mode, reason: nil)
|
|
236
|
+
rescue LLM::RequestCancelled
|
|
237
|
+
Result.none(:cancelled, mode: ask.mode, run_id: ask.run_id)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# The block gets +result+ on another thread, as an asked one does.
|
|
241
|
+
# @return [Result] +result+
|
|
242
|
+
def tell(name, block, result)
|
|
243
|
+
@side.aside(warn: warner(name, "judge_later_failed")) { deliver(name, block, result) } if block
|
|
244
|
+
result
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def deliver(name, block, result)
|
|
248
|
+
block&.call(result)
|
|
249
|
+
rescue StandardError => e
|
|
250
|
+
warner(name, "judge_later_block_failed").call(e)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def warner(name, event)
|
|
254
|
+
lambda { |error|
|
|
255
|
+
Samagotchi::Log.warn(:plugins, event, judgment: name, error: "#{error.class}: #{error.message}"[0, 300])
|
|
256
|
+
}
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
@@ -48,10 +48,12 @@ module Samagotchi
|
|
|
48
48
|
append(line)
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
# @param fields [Hash] the judgment's own fields (rule, tool, p, …)
|
|
51
|
+
# @param fields [Hash] the judgment's own fields (rule, tool, p, …); its
|
|
52
|
+
# :id if the caller has one (a saved run's), else a new one
|
|
52
53
|
# @return [String, nil] the judgment's id, nil when nothing was written
|
|
53
54
|
def judgment(fields)
|
|
54
|
-
|
|
55
|
+
fields = fields.dup
|
|
56
|
+
id = fields.delete(:id) || SecureRandom.uuid
|
|
55
57
|
entry("judgment", { "id" => id }.merge(fields)) ? id : nil
|
|
56
58
|
end
|
|
57
59
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../llm/errors"
|
|
4
|
+
require_relative "../log"
|
|
5
|
+
require_relative "question"
|
|
6
|
+
require_relative "runs"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
module SystemOne
|
|
10
|
+
# A Client that saves each ask as a run (Runs): the same duck type, the
|
|
11
|
+
# same answers and the same raises. It sits where a use builds its
|
|
12
|
+
# client, so gates, threads and domain logs stay the use's own. A run
|
|
13
|
+
# that can't be saved is one warn and never changes the answer.
|
|
14
|
+
#
|
|
15
|
+
# client = Recording.wrap(Client.from_config(...), judgment: "approvals.allow", runs: runs, context: context)
|
|
16
|
+
# client&.ask(state:, questions:, cancel_controller:)
|
|
17
|
+
class Recording
|
|
18
|
+
# What a run says about its session, taken on the caller's thread:
|
|
19
|
+
# the ask itself may run on its own. +chain_link+ is the parent or the
|
|
20
|
+
# session this one continues (a chain is derived, SessionChain, and
|
|
21
|
+
# following it costs a listing), +session_model+ the session's model.
|
|
22
|
+
Context = Data.define(:session_id, :chain_link, :session_model, :test_run) do
|
|
23
|
+
def initialize(session_id: nil, chain_link: nil, session_model: nil, test_run: nil) = super
|
|
24
|
+
|
|
25
|
+
def to_h = super.transform_keys(&:to_s)
|
|
26
|
+
|
|
27
|
+
def self.of(session, session_model: nil)
|
|
28
|
+
new(session_id: session&.id, chain_link: session&.parent_id || session&.continues,
|
|
29
|
+
session_model: session_model, test_run: session&.test_run ? true : nil)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The wrapped client, or nil for none: the caller's nil check stays.
|
|
34
|
+
# @param recorded_mode [String, nil] the use's mode as a run says it,
|
|
35
|
+
# where the client's isn't it (toolkit and context build theirs "on"
|
|
36
|
+
# in audit too); nil: the client's
|
|
37
|
+
def self.wrap(client, judgment:, runs:, context: Context.new, recorded_mode: nil)
|
|
38
|
+
client && new(client, judgment: judgment, runs: runs, context: context, recorded_mode: recorded_mode)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param fields [Hash] more for each of its runs (a bundle's judgment's
|
|
42
|
+
# positive: and negative: label values)
|
|
43
|
+
def initialize(client, judgment:, runs:, context: Context.new, recorded_mode: nil, fields: {})
|
|
44
|
+
@client = client
|
|
45
|
+
@judgment = judgment
|
|
46
|
+
@runs = runs
|
|
47
|
+
@context = context
|
|
48
|
+
@recorded_mode = recorded_mode
|
|
49
|
+
@fields = fields
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def host = @client.host
|
|
53
|
+
def model = @client.model
|
|
54
|
+
def mode = @client.mode
|
|
55
|
+
def backend = @client.backend
|
|
56
|
+
|
|
57
|
+
# Client#ask, recorded. A cancelled ask or one that raises records its
|
|
58
|
+
# run, then raises as before.
|
|
59
|
+
# @param run_id [String, nil] the run's id when the caller needs it at
|
|
60
|
+
# once (it is passed in, not read back: one client serves several
|
|
61
|
+
# threads); nil: a new one
|
|
62
|
+
def ask(state:, questions:, cancel_controller: nil, run_id: nil)
|
|
63
|
+
facts = {}
|
|
64
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
65
|
+
answers = @client.ask(state: state, questions: questions, cancel_controller: cancel_controller,
|
|
66
|
+
observer: ->(**told) { facts.merge!(told) })
|
|
67
|
+
record(run_id, state, questions, answers, facts, started)
|
|
68
|
+
answers
|
|
69
|
+
rescue LLM::RequestCancelled
|
|
70
|
+
record(run_id, state, questions, nil, facts.merge(failure: "cancelled"), started)
|
|
71
|
+
raise
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
record(run_id, state, questions, nil, facts.merge(failure: e.class.name, failure_text: e.message), started)
|
|
74
|
+
raise
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# An ask that wasn't made: its run, at once, asked: false, so a later
|
|
78
|
+
# replay can answer the state with no eviction today (docs/system-one.md).
|
|
79
|
+
# @param reason [String] why: the run's +skipped+
|
|
80
|
+
def skip(state:, questions:, reason:, run_id: nil)
|
|
81
|
+
save(questions) do
|
|
82
|
+
{ id: run_id || Runs.new_id(@judgment), state: state, asked: false, skipped: reason, model: said(:model) }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def record(run_id, state, questions, answers, facts, started)
|
|
89
|
+
save(questions) do
|
|
90
|
+
{ id: run_id || Runs.new_id(@judgment), state: state, asked: true, model: facts[:model] || said(:model),
|
|
91
|
+
served_model: facts[:served_model], answers: facts[:raw] || answers&.transform_values(&:to_h), usage: facts[:usage],
|
|
92
|
+
failure: facts[:failure], failure_text: facts[:failure_text]&.to_s&.slice(0, 500),
|
|
93
|
+
latency_ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# One run: the block's fields, and what every run says.
|
|
98
|
+
def save(questions)
|
|
99
|
+
return unless @runs.on?
|
|
100
|
+
|
|
101
|
+
questions = SystemOne.normalize_questions(questions)
|
|
102
|
+
return if questions.empty? # Client#ask asked nothing.
|
|
103
|
+
|
|
104
|
+
@runs.record(judgment: @judgment, questions: questions, **@context.to_h.transform_keys(&:to_sym), **@fields,
|
|
105
|
+
origin: "live", mode: @recorded_mode || said(:mode), host: said(:host), **yield)
|
|
106
|
+
rescue StandardError, SystemStackError => e # a state nested too deep to copy
|
|
107
|
+
# Qualified: inside SystemOne, the bare Log is SystemOne::Log.
|
|
108
|
+
Samagotchi::Log.warn(:system_one, "system_one_run_failed",
|
|
109
|
+
echo: "[samagotchi:system_one] a run wasn't saved: #{e.class}: #{e.message}",
|
|
110
|
+
judgment: @judgment, error: e.class.name)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# What the client says about itself; a stand-in may say nothing.
|
|
114
|
+
def said(name) = @client.respond_to?(name) ? @client.public_send(name) : nil
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
require_relative "../config"
|
|
9
|
+
require_relative "../log"
|
|
10
|
+
require_relative "question"
|
|
11
|
+
require_relative "runs_store"
|
|
12
|
+
|
|
13
|
+
module Samagotchi
|
|
14
|
+
module SystemOne
|
|
15
|
+
# The saved asks: one run per System 1 ask (Recording), what was sent and
|
|
16
|
+
# answered, and later lines on it, for a later comparison or fine-tune
|
|
17
|
+
# (docs/system-one.md). A Runs without a store saves nothing. Local only.
|
|
18
|
+
#
|
|
19
|
+
# A run's id is "<judgment>/<uuid>": it carries its judgment, so a later
|
|
20
|
+
# line can find its way with the id alone. Later lines never hold up the
|
|
21
|
+
# caller: a failed one is one warn and a false.
|
|
22
|
+
class Runs
|
|
23
|
+
# What the specs read: lines in memory, by judgment.
|
|
24
|
+
class MemoryStore
|
|
25
|
+
attr_reader :lines, :versions
|
|
26
|
+
|
|
27
|
+
def initialize
|
|
28
|
+
@lines = Hash.new { |hash, judgment| hash[judgment] = [] }
|
|
29
|
+
@versions = Hash.new { |hash, judgment| hash[judgment] = [] }
|
|
30
|
+
@mutex = Mutex.new
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def append(judgment, line)
|
|
34
|
+
@mutex.synchronize { @lines[judgment] << line }
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def version(judgment, line)
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
@versions[judgment] << line unless @versions[judgment].include?(line)
|
|
41
|
+
end
|
|
42
|
+
true
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Names core keeps (a first word, as a bundle's scope would be) and the
|
|
47
|
+
# suffix a later replay's runs use: a bundle's judgment takes none.
|
|
48
|
+
RESERVED = Config::RESERVED_JUDGMENTS
|
|
49
|
+
RESERVED_SUFFIX = ".replay"
|
|
50
|
+
# The total the sweep keeps (docs/system-one.md).
|
|
51
|
+
MAX_BYTES = 100 * 1024 * 1024
|
|
52
|
+
|
|
53
|
+
# Runs on the state dir +root+ (where the sessions folder sits), saving
|
|
54
|
+
# unless system_one.runs is off.
|
|
55
|
+
def self.in(root)
|
|
56
|
+
new(store: Config.get("system_one.runs").to_s == "off" ? nil : RunsStore.new(root))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# .in, from the sessions folder.
|
|
60
|
+
def self.for_sessions_dir(sessions_dir) = self.in(File.dirname(sessions_dir))
|
|
61
|
+
|
|
62
|
+
# The session sweep: runs past session.retention_days (0: forever), then
|
|
63
|
+
# the oldest over the total cap. Whatever system_one.runs says now.
|
|
64
|
+
# @return [Integer] the day files deleted
|
|
65
|
+
def self.sweep_at(root, days: Config.get("session.retention_days").to_i, max_bytes: MAX_BYTES)
|
|
66
|
+
RunsStore.new(root).sweep(days: days, max_bytes: max_bytes)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Explicit `chi sessions delete`: that session's runs and their later
|
|
70
|
+
# lines go, whatever system_one.runs says now.
|
|
71
|
+
def self.purge_session(session_id, sessions_dir:)
|
|
72
|
+
RunsStore.new(File.dirname(sessions_dir)).purge_session(session_id)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Whether a bundle may declare +name+ (safe as a folder, not core's).
|
|
76
|
+
def self.declarable?(name)
|
|
77
|
+
text = name.to_s
|
|
78
|
+
text.match?(RunsStore::NAME) && !RESERVED.include?(text.split(".").first) && !text.end_with?(RESERVED_SUFFIX)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A new run's id.
|
|
82
|
+
def self.new_id(judgment) = "#{judgment}/#{SecureRandom.uuid}"
|
|
83
|
+
|
|
84
|
+
# The judgment a run id carries; nil for anything else (a judgments
|
|
85
|
+
# log's id from before runs, a name that isn't safe as a folder).
|
|
86
|
+
def self.judgment_of(run_id)
|
|
87
|
+
judgment, uuid = run_id.to_s.split("/", 2)
|
|
88
|
+
judgment if judgment.to_s.match?(RunsStore::NAME) && !uuid.to_s.empty?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The digests a run groups by: the questions' wording and, apart, their
|
|
92
|
+
# criteria, which can vary per session (a toolkit's kits).
|
|
93
|
+
# @param questions [Array<Question>]
|
|
94
|
+
# @return [Hash{String => String}]
|
|
95
|
+
def self.digests(questions)
|
|
96
|
+
wording = questions.map { |question| [question.id, question.type.to_s, question.instructions] }
|
|
97
|
+
criteria = questions.map { |question| [question.id, question.criteria] }
|
|
98
|
+
{ "instructions_digest" => digest(wording), "criteria_digest" => digest(criteria) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def self.digest(value) = Digest::SHA256.hexdigest(JSON.generate(value))[0, 16]
|
|
102
|
+
private_class_method :digest
|
|
103
|
+
|
|
104
|
+
# A copy +value+ can be written as JSON from: bytes that aren't UTF-8
|
|
105
|
+
# (a command's output in a state) are replaced, in strings and keys.
|
|
106
|
+
def self.scrubbed(value)
|
|
107
|
+
case value
|
|
108
|
+
when String then value.dup.force_encoding(Encoding::UTF_8).scrub("�")
|
|
109
|
+
when Symbol then scrubbed(value.to_s)
|
|
110
|
+
when Hash then value.to_h { |key, item| [scrubbed(key.to_s), scrubbed(item)] }
|
|
111
|
+
when Array then value.map { |item| scrubbed(item) }
|
|
112
|
+
else value
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @param store [#append, #version, nil] nil: nothing is saved
|
|
117
|
+
def initialize(store: nil)
|
|
118
|
+
@store = store
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def on? = !@store.nil?
|
|
122
|
+
|
|
123
|
+
# Save one ask, its questions once per digest pair. Raises what its
|
|
124
|
+
# store raises: Recording holds the one warn.
|
|
125
|
+
# @param id [String] the run's id
|
|
126
|
+
# @param judgment [String] its judgment's name
|
|
127
|
+
# @param questions [Array<Question>] as asked
|
|
128
|
+
# @param fields [Hash] the run's other fields; nil ones are left out
|
|
129
|
+
# @return [Boolean] whether it was saved
|
|
130
|
+
def record(id:, judgment:, questions:, **fields)
|
|
131
|
+
return false unless on?
|
|
132
|
+
|
|
133
|
+
digests = self.class.digests(questions)
|
|
134
|
+
wire = questions.to_h { |question| [question.id, question.to_wire] }
|
|
135
|
+
@store.version(judgment, digests.merge("questions" => self.class.scrubbed(wire)))
|
|
136
|
+
@store.append(judgment, { "kind" => "run", "id" => id, "judgment" => judgment, "at" => now }
|
|
137
|
+
.merge(digests).merge(self.class.scrubbed(fields.compact)))
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# What the user (or +by+) said about a run.
|
|
141
|
+
# @param value [String] the use's own word
|
|
142
|
+
# @param by [String] who said it
|
|
143
|
+
# @param strength [String, nil] "strong" or "weak", where the use has
|
|
144
|
+
# the difference
|
|
145
|
+
# @param question [String, nil] the question it answers
|
|
146
|
+
# @return [Boolean] whether it was saved
|
|
147
|
+
def label(run_id, value, by:, strength: nil, question: nil)
|
|
148
|
+
later(run_id, "label", value: value, by: by, strength: strength, question: question)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# What the use did with the answer: never known when the run is written.
|
|
152
|
+
# @return [Boolean] whether it was saved
|
|
153
|
+
def acted(run_id, what) = later(run_id, "acted", what: what)
|
|
154
|
+
|
|
155
|
+
# What the gates said at ask time, which can't be recomputed later.
|
|
156
|
+
# @return [Boolean] whether it was saved
|
|
157
|
+
def gates(run_id, **fields) = later(run_id, "gates", **fields)
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def later(run_id, kind, **fields)
|
|
162
|
+
return false unless on?
|
|
163
|
+
|
|
164
|
+
judgment = self.class.judgment_of(run_id)
|
|
165
|
+
return false if judgment.nil?
|
|
166
|
+
|
|
167
|
+
@store.append(judgment, { "kind" => kind, "run_id" => run_id.to_s, "at" => now }.merge(self.class.scrubbed(fields.compact)))
|
|
168
|
+
rescue StandardError, SystemStackError => e
|
|
169
|
+
# Qualified: inside SystemOne, the bare Log is SystemOne::Log.
|
|
170
|
+
Samagotchi::Log.warn(:system_one, "system_one_run_line_failed",
|
|
171
|
+
echo: "[samagotchi:system_one] a #{kind} line wasn't saved: #{e.class}: #{e.message}",
|
|
172
|
+
judgment: judgment, error: e.class.name)
|
|
173
|
+
false
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def now = Time.now.utc.iso8601(3)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|