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
|
@@ -23,6 +23,7 @@ require_relative "working_set_command"
|
|
|
23
23
|
require_relative "recap_command"
|
|
24
24
|
require_relative "guardrails/parent_policy"
|
|
25
25
|
require_relative "system_one/log"
|
|
26
|
+
require_relative "system_one/approval_label"
|
|
26
27
|
require_relative "system_one/record"
|
|
27
28
|
|
|
28
29
|
module Samagotchi
|
|
@@ -480,6 +481,8 @@ module Samagotchi
|
|
|
480
481
|
return reply("could not label System 1 auto-allow #{index} (see the guardrails log)", status: :error)
|
|
481
482
|
end
|
|
482
483
|
|
|
484
|
+
SystemOne::ApprovalLabel.save(SystemOne::Runs.for_sessions_dir(@engine.session_state_dir), entry["id"], SystemOne::ApprovalLabel::WRONG)
|
|
485
|
+
|
|
483
486
|
call = shorten_call(entry["call"].to_s.gsub(/\s+/, " "))
|
|
484
487
|
probability = entry["p"].nil? ? "unknown" : format("%.2f", entry["p"].to_f)
|
|
485
488
|
reply("labelled auto-allow #{index}: #{entry["rule"]}: #{call} (p=#{probability}); " \
|
|
@@ -6,6 +6,7 @@ require "time"
|
|
|
6
6
|
require "securerandom"
|
|
7
7
|
require "rbconfig"
|
|
8
8
|
require_relative "chi_home"
|
|
9
|
+
require_relative "system_one/runs"
|
|
9
10
|
require_relative "child_env"
|
|
10
11
|
require_relative "client_id"
|
|
11
12
|
require_relative "analytics_file"
|
|
@@ -1043,7 +1044,9 @@ module Samagotchi
|
|
|
1043
1044
|
# @raise [OwnedByTUI] a chi REPL owns it (never stopped from here)
|
|
1044
1045
|
# @raise [DeleteRefused] a worker owns it and +stop+ is false, or it
|
|
1045
1046
|
# outlived the wait
|
|
1046
|
-
|
|
1047
|
+
# @param purge_runs [Boolean] also delete System 1's saved runs of it; a
|
|
1048
|
+
# session a worker left empty has none worth the read
|
|
1049
|
+
def self.delete_session(id_or_prefix, state_dir: nil, stop: false, wait: STOP_WAIT, purge_runs: true)
|
|
1047
1050
|
sd = state_dir || Session.default_state_dir
|
|
1048
1051
|
given = id_or_prefix.to_s
|
|
1049
1052
|
# Only a plain id: anything else could name a path outside the state dir.
|
|
@@ -1072,9 +1075,17 @@ module Samagotchi
|
|
|
1072
1075
|
PluginSessionState.remove(id)
|
|
1073
1076
|
removed.concat(ContextSources.remove_session(id, state_dir: sd))
|
|
1074
1077
|
Participants.end_sessions([id], by: "delete", state_dir: sd)
|
|
1078
|
+
purge_session_runs(id, sd) if purge_runs
|
|
1075
1079
|
{ id: id, removed: removed, stopped: !owner.nil? }
|
|
1076
1080
|
end
|
|
1077
1081
|
|
|
1082
|
+
# Never a reason a delete fails: one warn, and the sweep ages them out.
|
|
1083
|
+
private_class_method def self.purge_session_runs(id, state_dir)
|
|
1084
|
+
SystemOne::Runs.purge_session(id, sessions_dir: state_dir)
|
|
1085
|
+
rescue StandardError => e
|
|
1086
|
+
Log.warn(:worker, "session_runs_purge_failed", sid: id, error: e.class.name, msg: e.message.to_s[0, 200])
|
|
1087
|
+
end
|
|
1088
|
+
|
|
1078
1089
|
# @return [Boolean] whether the session had no owner within +timeout+ seconds
|
|
1079
1090
|
def self.wait_for_owner_release(session_id, timeout:, state_dir:)
|
|
1080
1091
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout.to_f
|
|
@@ -1216,7 +1227,7 @@ module Samagotchi
|
|
|
1216
1227
|
private_class_method def self.discard_left_session(session_id, state_dir:, default_model:)
|
|
1217
1228
|
return unless discardable?(session_id, state_dir: state_dir, default_model: default_model)
|
|
1218
1229
|
|
|
1219
|
-
delete_session(session_id, state_dir: state_dir)
|
|
1230
|
+
delete_session(session_id, state_dir: state_dir, purge_runs: false)
|
|
1220
1231
|
Log.info(:worker, "discarded_empty", sid: session_id)
|
|
1221
1232
|
rescue DeleteRefused, OwnedByTUI, ArgumentError, SystemCallError => e
|
|
1222
1233
|
Log.info(:worker, "kept_session", sid: session_id, error: e.class.name, msg: e.message)
|
|
@@ -10,6 +10,7 @@ require_relative "model_profile"
|
|
|
10
10
|
require_relative "session_manager"
|
|
11
11
|
require_relative "plugin_session_state"
|
|
12
12
|
require_relative "context_sources"
|
|
13
|
+
require_relative "system_one/runs"
|
|
13
14
|
|
|
14
15
|
module Samagotchi
|
|
15
16
|
# Which sessions go: `chi sessions prune` / `clean` (.prune) and the lazy
|
|
@@ -109,6 +110,7 @@ module Samagotchi
|
|
|
109
110
|
return if age < interval
|
|
110
111
|
end
|
|
111
112
|
result = prune(state_dir: sd)
|
|
113
|
+
sweep_runs(sd)
|
|
112
114
|
FileUtils.touch(marker)
|
|
113
115
|
if result[:deleted].any?
|
|
114
116
|
Log.info(:worker, "retention_pruned", echo: "[retention] pruned #{result[:deleted].size} sessions (kept #{result[:kept].size})", deleted: result[:deleted].size, kept: result[:kept].size)
|
|
@@ -119,6 +121,14 @@ module Samagotchi
|
|
|
119
121
|
nil
|
|
120
122
|
end
|
|
121
123
|
|
|
124
|
+
# System 1's saved runs last as long as sessions, by day file. Never a
|
|
125
|
+
# reason the session sweep fails.
|
|
126
|
+
private_class_method def self.sweep_runs(state_dir)
|
|
127
|
+
SystemOne::Runs.sweep_at(File.dirname(state_dir))
|
|
128
|
+
rescue StandardError => e
|
|
129
|
+
Log.warn(:worker, "retention_runs_failed", echo: "[retention] saved runs: #{e.class}: #{e.message}", error: e.class.name)
|
|
130
|
+
end
|
|
131
|
+
|
|
122
132
|
# The caller's value (a sessions prune flag) or session.retention_days.
|
|
123
133
|
private_class_method def self.resolve_days(val)
|
|
124
134
|
return val.to_i if !val.nil? && val.to_s.strip != ""
|
|
@@ -16,6 +16,7 @@ module Samagotchi
|
|
|
16
16
|
class SideJudgments
|
|
17
17
|
def initialize
|
|
18
18
|
@threads = {}
|
|
19
|
+
@asides = []
|
|
19
20
|
@mutex = Mutex.new
|
|
20
21
|
end
|
|
21
22
|
|
|
@@ -42,9 +43,19 @@ module Samagotchi
|
|
|
42
43
|
end
|
|
43
44
|
end
|
|
44
45
|
|
|
46
|
+
# Run the block on its own thread, in no kind: telling a caller there
|
|
47
|
+
# was no ask. #join waits for it too. A raise goes to +warn+.
|
|
48
|
+
# @return [Thread]
|
|
49
|
+
def aside(warn:, &block)
|
|
50
|
+
@mutex.synchronize do
|
|
51
|
+
@asides.select!(&:alive?)
|
|
52
|
+
Thread.new { run(warn, nil, &block) }.tap { |thread| @asides << thread }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
45
56
|
# Wait for every running ask, up to +seconds+ in all.
|
|
46
57
|
def join(seconds)
|
|
47
|
-
threads = @mutex.synchronize { @threads.values }
|
|
58
|
+
threads = @mutex.synchronize { @threads.values + @asides }
|
|
48
59
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
|
|
49
60
|
threads.each do |thread|
|
|
50
61
|
next if thread == Thread.current
|
|
@@ -6,16 +6,20 @@ module Samagotchi
|
|
|
6
6
|
module SystemOne
|
|
7
7
|
# One judgment the System One API answered: for an :noul question,
|
|
8
8
|
# `noul` is the probability of "yes"; a :choice answer carries `choice`
|
|
9
|
-
# (the criterion chosen) and `confidence`; a :score answer carries
|
|
10
|
-
# `
|
|
11
|
-
#
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
# (the criterion chosen) and `confidence`; a :score answer carries `score`
|
|
10
|
+
# and `level`. The server's `score` is the probability-weighted level index
|
|
11
|
+
# (Σ index × its probability), not a level number: with levels 0..n-1 it
|
|
12
|
+
# sits between 0 and n-1, and for a two-level question it is exactly the
|
|
13
|
+
# probability of level 1. `level` is the name of the level with the most
|
|
14
|
+
# probability, from the response's `legend`. `probabilities` is what the
|
|
15
|
+
# model put on each option, when it says (for a score, keyed by level index).
|
|
16
|
+
Answer = Data.define(:id, :type, :noul, :choice, :score, :level, :confidence, :probabilities) do
|
|
17
|
+
def initialize(id:, type:, noul: nil, choice: nil, score: nil, level: nil, confidence: nil, probabilities: nil) = super
|
|
14
18
|
|
|
15
19
|
# The response's answers entry as the wire sends it, or nil when it is
|
|
16
|
-
# not usable: no type, a type the contract doesn't have, a noul
|
|
17
|
-
#
|
|
18
|
-
# judgment for this question", never a guess.
|
|
20
|
+
# not usable: no type, a type the contract doesn't have, a noul outside
|
|
21
|
+
# 0..1, a score that isn't a number or past the last level, … A nil means
|
|
22
|
+
# "no judgment for this question", never a guess.
|
|
19
23
|
def self.from_wire(id, raw)
|
|
20
24
|
return nil unless raw.is_a?(Hash)
|
|
21
25
|
|
|
@@ -37,9 +41,13 @@ module Samagotchi
|
|
|
37
41
|
choice = raw["choice"].to_s.strip
|
|
38
42
|
choice.empty? ? nil : new(**answer, choice: choice)
|
|
39
43
|
else
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
# The legend names the levels; without it there is no name for the
|
|
45
|
+
# answer, but the weighted score still stands.
|
|
46
|
+
legend = levels(raw["legend"])
|
|
47
|
+
return nil if bad_field(raw, "legend", legend)
|
|
48
|
+
|
|
49
|
+
score = level_score(raw["score"], legend, odds)
|
|
50
|
+
score.nil? ? nil : new(**answer, score: score, level: top_level(legend, odds))
|
|
43
51
|
end
|
|
44
52
|
end
|
|
45
53
|
|
|
@@ -73,6 +81,50 @@ module Samagotchi
|
|
|
73
81
|
weights
|
|
74
82
|
end
|
|
75
83
|
end
|
|
84
|
+
|
|
85
|
+
# The legend as the server writes it: a map of level index => name, or
|
|
86
|
+
# the level names in order. nil when absent or unreadable.
|
|
87
|
+
private_class_method def self.levels(value)
|
|
88
|
+
case value
|
|
89
|
+
when Hash
|
|
90
|
+
pairs = value.map { |index, name| [index.to_s.strip, name.to_s.strip] }
|
|
91
|
+
return nil if pairs.empty? || pairs.any? { |index, name| !index.match?(/\A\d+\z/) || name.empty? }
|
|
92
|
+
|
|
93
|
+
pairs.to_h { |index, name| [index.to_i, name] }
|
|
94
|
+
when Array
|
|
95
|
+
names = value.map { |name| name.to_s.strip }
|
|
96
|
+
return nil if names.empty? || names.any?(&:empty?)
|
|
97
|
+
|
|
98
|
+
names.each_with_index.to_h { |name, index| [index, name] }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The score as sent: the weighted level index, a whole number kept whole.
|
|
103
|
+
# Not a number, or past the last level the response names, is no answer.
|
|
104
|
+
private_class_method def self.level_score(value, legend, odds)
|
|
105
|
+
return nil unless value.is_a?(Numeric)
|
|
106
|
+
|
|
107
|
+
score = value.to_f
|
|
108
|
+
return nil if score.nan? || score.infinite? || score.negative?
|
|
109
|
+
|
|
110
|
+
count = legend&.size || (odds.is_a?(Array) ? odds.size : odds&.size)
|
|
111
|
+
# A hair past the last level is the probabilities' own rounding, not a
|
|
112
|
+
# level out of range.
|
|
113
|
+
return nil if count && score > count - 1 + 1e-9
|
|
114
|
+
|
|
115
|
+
score == score.to_i ? score.to_i : score
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The level with the most probability, named by the legend; nil when
|
|
119
|
+
# there are no probabilities, or no legend to name it with.
|
|
120
|
+
private_class_method def self.top_level(legend, odds)
|
|
121
|
+
return nil if legend.nil? || legend.empty? || odds.nil? || odds.empty?
|
|
122
|
+
|
|
123
|
+
weights = legend.keys.map { |index| odds.is_a?(Array) ? odds[index] : odds[index.to_s] }
|
|
124
|
+
return nil if weights.any?(&:nil?)
|
|
125
|
+
|
|
126
|
+
legend[legend.keys[weights.index(weights.max)]]
|
|
127
|
+
end
|
|
76
128
|
end
|
|
77
129
|
end
|
|
78
130
|
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../question_answer"
|
|
4
|
+
require_relative "approval_question"
|
|
5
|
+
require_relative "runs"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
module SystemOne
|
|
9
|
+
# What an answer to an approval says about the ask, for the judgments log
|
|
10
|
+
# (its value) and the saved run (all of it). The scope they took, a
|
|
11
|
+
# denial, or none for an ask that was cancelled, which is not an answer.
|
|
12
|
+
# A parent agent's answer is not the user's: its allow labels nothing, and
|
|
13
|
+
# its deny is a weak label only where the parent could have allowed — a
|
|
14
|
+
# parent that was not allowed to permit says nothing about what the user
|
|
15
|
+
# would have done (docs/guardrails.md, "System 1").
|
|
16
|
+
module ApprovalLabel
|
|
17
|
+
Label = Data.define(:value, :by, :strength)
|
|
18
|
+
|
|
19
|
+
# `/guardrails wrong`, the web's "should have asked".
|
|
20
|
+
WRONG = Label.new(value: "should_have_asked", by: "user", strength: "strong")
|
|
21
|
+
NONE = Label.new(value: "none", by: "none", strength: nil)
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# @param answer [QuestionAnswer, Object] anything else: the ask was cancelled
|
|
26
|
+
# @param scopes [Array<String>] the scopes the ask offered
|
|
27
|
+
# @param parent_may_permit [#call] whether the session's parent may
|
|
28
|
+
# allow this ask (read only for a parent's deny)
|
|
29
|
+
# @return [Label, nil] nil: this answer is no label
|
|
30
|
+
def of(answer, scopes:, parent_may_permit:)
|
|
31
|
+
return NONE unless answer.is_a?(Samagotchi::QuestionAnswer)
|
|
32
|
+
|
|
33
|
+
index = answer.selected_indices.first
|
|
34
|
+
allowed = !index.nil? && index < scopes.size
|
|
35
|
+
if answer.parent_agent?
|
|
36
|
+
return nil if allowed || !parent_may_permit.call
|
|
37
|
+
|
|
38
|
+
return Label.new(value: "deny", by: "parent", strength: "weak")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
value = if allowed
|
|
42
|
+
scopes[index] == "once" ? "allow_once" : "allow_wide"
|
|
43
|
+
else
|
|
44
|
+
"deny"
|
|
45
|
+
end
|
|
46
|
+
Label.new(value: value, by: "user", strength: "strong")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# On the saved run +id+ names; an id from before runs has none.
|
|
50
|
+
# @param runs [Runs, nil]
|
|
51
|
+
def save(runs, id, label)
|
|
52
|
+
runs&.label(id, label.value, by: label.by, strength: label.strength, question: ApprovalQuestion::ID)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -15,6 +15,8 @@ module Samagotchi
|
|
|
15
15
|
# the memory an ask names and the earlier asks it resembles.
|
|
16
16
|
module ApprovalQuestion
|
|
17
17
|
ID = "allow"
|
|
18
|
+
# Its saved asks' name (Runs).
|
|
19
|
+
JUDGMENT = "approvals.allow"
|
|
18
20
|
# What the judgment is asked, in the model's terms. Frozen: a change
|
|
19
21
|
# here is a change in behaviour, not a wording tweak.
|
|
20
22
|
INSTRUCTIONS = "A coding agent wants to run this tool call, and a guardrail rule asked its user to approve it. " \
|
|
@@ -17,7 +17,7 @@ module Samagotchi
|
|
|
17
17
|
# while the judge is asked is not a failed judgment: its
|
|
18
18
|
# LLM::RequestCancelled goes on to the caller, which asks no one.
|
|
19
19
|
#
|
|
20
|
-
# client = SystemOne::Client.from_config(host_registry: registry)
|
|
20
|
+
# client = SystemOne::Client.from_config(host_registry: registry, mode: "audit")
|
|
21
21
|
# client&.ask(state: "git push origin main", questions: {"allow" => question})
|
|
22
22
|
class Client
|
|
23
23
|
MODES = %w[off audit on].freeze
|
|
@@ -33,19 +33,26 @@ module Samagotchi
|
|
|
33
33
|
@tag = tag.to_sym
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
# nil when
|
|
37
|
-
#
|
|
38
|
-
#
|
|
36
|
+
# nil when +mode+ is off or no host is named, so the caller needs no
|
|
37
|
+
# other check. The settings are read every call: a config.yml change
|
|
38
|
+
# takes effect on the next ask.
|
|
39
|
+
# @param mode [String, Symbol] the caller's own: there is no default,
|
|
40
|
+
# so nothing gets "on" by leaving it out, and no use reads another's
|
|
41
|
+
# mode key (approvals pass guardrails.system_one.mode); host, model and
|
|
42
|
+
# timeout are system_one.*, every use's
|
|
39
43
|
# @param model [String, nil] a model for this call only (chi guardrails
|
|
40
|
-
# replay --model), else the configured one
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
# replay --model, a bundle's judgment's own), else the configured one
|
|
45
|
+
# @param host [String, nil] a host for this call only (a bundle's
|
|
46
|
+
# judgment's own, never core's: JudgmentSettings), else the configured one
|
|
47
|
+
def self.from_config(host_registry:, mode:, env: ENV, model: nil, host: nil, tag: :guardrails)
|
|
48
|
+
mode = mode.to_s.strip.downcase
|
|
43
49
|
return nil unless MODES.include?(mode) && mode != "off"
|
|
50
|
+
return nil if moved_keys_left?
|
|
44
51
|
|
|
45
|
-
host = Config.get("
|
|
52
|
+
host = (host.nil? ? Config.get("system_one.host") : host).to_s.strip
|
|
46
53
|
return nil if host.empty?
|
|
47
54
|
|
|
48
|
-
model = (model.nil? ? Config.get("
|
|
55
|
+
model = (model.nil? ? Config.get("system_one.model") : model).to_s.strip
|
|
49
56
|
timeout_ms = configured_timeout_ms
|
|
50
57
|
|
|
51
58
|
new(backend: HttpBackend.new(host_registry: host_registry, host: host, model: model.empty? ? nil : model,
|
|
@@ -53,39 +60,57 @@ module Samagotchi
|
|
|
53
60
|
mode: mode, host: host, model: model.empty? ? nil : model, tag: tag)
|
|
54
61
|
end
|
|
55
62
|
|
|
56
|
-
#
|
|
63
|
+
# system_one.timeout_ms, else the default: the whole
|
|
57
64
|
# request's limit, and how long a turn waits for an answer (toolkit
|
|
58
65
|
# suggestion's on mode).
|
|
59
66
|
# @return [Integer]
|
|
60
67
|
def self.configured_timeout_ms
|
|
61
|
-
timeout_ms = Config.get("
|
|
68
|
+
timeout_ms = Config.get("system_one.timeout_ms").to_i
|
|
62
69
|
timeout_ms.positive? ? timeout_ms : DEFAULT_TIMEOUT_MS
|
|
63
70
|
end
|
|
64
71
|
|
|
72
|
+
# Whether config.yml still sets a key that moved to system_one (the
|
|
73
|
+
# warning names it). A file half moved is no judgment: with the host
|
|
74
|
+
# moved and the model not, the host's first model would answer under
|
|
75
|
+
# approvals' clean record (Record is model-blind) and could auto-allow.
|
|
76
|
+
def self.moved_keys_left?
|
|
77
|
+
section = ConfigFile.section("guardrails")
|
|
78
|
+
own = section.is_a?(Hash) ? section["system_one"] || section["system-one"] : nil
|
|
79
|
+
own.is_a?(Hash) && own.keys.map { |key| key.to_s.tr("-", "_") }.intersect?(Config::SYSTEM_ONE_MOVED_KEYS)
|
|
80
|
+
end
|
|
81
|
+
private_class_method :moved_keys_left?
|
|
82
|
+
|
|
65
83
|
# @param state [String, Hash, Array] what the question is about, as the
|
|
66
84
|
# contract takes it
|
|
67
85
|
# @param questions [Hash{String => Question|Hash}] the questions, by id
|
|
86
|
+
# @param observer [#call, nil] told (**facts) what the return value
|
|
87
|
+
# hides: why it is nil (failure:, failure_text:) and what the backend
|
|
88
|
+
# said (model:, served_model:, raw:, usage:), on this thread, before
|
|
89
|
+
# #ask returns (Recording)
|
|
90
|
+
# @param run_id [String, nil] Recording's: a bare client saves no run,
|
|
91
|
+
# and a caller holding a bare client asks the same way
|
|
68
92
|
# @return [Hash{String => Answer}, nil] one Answer per question, or nil
|
|
69
93
|
# when there is no judgment at all
|
|
70
|
-
def ask(state:, questions:, cancel_controller: nil)
|
|
94
|
+
def ask(state:, questions:, cancel_controller: nil, observer: nil, run_id: nil)
|
|
71
95
|
questions = SystemOne.normalize_questions(questions)
|
|
72
96
|
return nil if questions.empty?
|
|
73
97
|
|
|
74
98
|
answers = begin
|
|
75
|
-
@backend.ask(state: state, questions: questions, cancel_controller: cancel_controller
|
|
99
|
+
@backend.ask(state: state, questions: questions, cancel_controller: cancel_controller,
|
|
100
|
+
**(observer ? { observer: observer } : {}))
|
|
76
101
|
rescue LLM::RequestCancelled
|
|
77
102
|
raise
|
|
78
103
|
rescue StandardError => e
|
|
79
|
-
return no_judgment("#{e.class}: #{e.message}", error: e.class.name)
|
|
104
|
+
return no_judgment("#{e.class}: #{e.message}", failure: e.class.name, observer: observer, error: e.class.name)
|
|
80
105
|
end
|
|
81
106
|
|
|
82
107
|
unless answers.is_a?(Hash)
|
|
83
|
-
return no_judgment("the backend returned #{answers.inspect}")
|
|
108
|
+
return no_judgment("the backend returned #{answers.inspect}", failure: "bad_response", observer: observer)
|
|
84
109
|
end
|
|
85
110
|
|
|
86
111
|
answers = questions.to_h { |question| [question.id, answers[question.id]] }
|
|
87
112
|
missing = questions.reject { |question| answers[question.id].is_a?(Answer) }
|
|
88
|
-
return no_judgment("no answer for #{missing.map(&:id).join(", ")}") unless missing.empty?
|
|
113
|
+
return no_judgment("no answer for #{missing.map(&:id).join(", ")}", failure: "no_answer", observer: observer) unless missing.empty?
|
|
89
114
|
|
|
90
115
|
answers
|
|
91
116
|
end
|
|
@@ -93,7 +118,8 @@ module Samagotchi
|
|
|
93
118
|
private
|
|
94
119
|
|
|
95
120
|
# One warn, one nil: the ask goes to the user.
|
|
96
|
-
def no_judgment(reason, **fields)
|
|
121
|
+
def no_judgment(reason, failure:, observer:, **fields)
|
|
122
|
+
observer&.call(failure: failure, failure_text: reason)
|
|
97
123
|
# Qualified: inside SystemOne, the bare Log is now SystemOne::Log.
|
|
98
124
|
Samagotchi::Log.warn(@tag, "system_one_failed", echo: "[samagotchi:#{@tag}] system one: #{reason}", host: @host, **fields)
|
|
99
125
|
nil
|
|
@@ -36,13 +36,17 @@ module Samagotchi
|
|
|
36
36
|
@env = env
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
# @param observer [#call, nil] told (**facts) the model it resolved,
|
|
40
|
+
# before the request, then what the response said beside the parsed
|
|
41
|
+
# answers: served_model:, raw: (each answer as sent, score legends
|
|
42
|
+
# and all) and usage: (Splash's input_tokens, output_tokens)
|
|
39
43
|
# @return [Hash{String => Answer}] one Answer per question, nil for a
|
|
40
44
|
# question the response answered badly
|
|
41
45
|
# @raise [UnsupportedHost] the host is unknown, not an openai host, or
|
|
42
46
|
# names no model
|
|
43
47
|
# @raise [BackendError] the request failed, the status isn't 200, or
|
|
44
48
|
# the body isn't a usable response
|
|
45
|
-
def ask(state:, questions:, cancel_controller: nil)
|
|
49
|
+
def ask(state:, questions:, cancel_controller: nil, observer: nil)
|
|
46
50
|
entry = @host_registry.find_entry(@host)
|
|
47
51
|
raise UnsupportedHost, "host #{@host.inspect} is not in hosts:" unless entry
|
|
48
52
|
unless entry.api&.to_sym == :openai
|
|
@@ -52,6 +56,7 @@ module Samagotchi
|
|
|
52
56
|
model = @model || host_model(entry)
|
|
53
57
|
raise UnsupportedHost, "host #{@host} names no model for System One" if model.nil? || model.empty?
|
|
54
58
|
|
|
59
|
+
observer&.call(model: model)
|
|
55
60
|
url = "#{entry.openai_base_url.to_s.chomp("/")}/systemone"
|
|
56
61
|
questions = SystemOne.normalize_questions(questions)
|
|
57
62
|
body = { model: model, state: state, questions: questions.to_h { |question| [question.id, question.to_wire] } }
|
|
@@ -64,7 +69,9 @@ module Samagotchi
|
|
|
64
69
|
raise BackendError, "#{@host}: #{status} #{error_detail(response.body)}"
|
|
65
70
|
end
|
|
66
71
|
|
|
67
|
-
|
|
72
|
+
data = reply_of(response.body)
|
|
73
|
+
answers = data["answers"]
|
|
74
|
+
observer&.call(served_model: data["model"], raw: answers, usage: data["usage"])
|
|
68
75
|
questions.to_h { |question| [question.id, Answer.from_wire(question.id, answers[question.id])] }
|
|
69
76
|
end
|
|
70
77
|
|
|
@@ -99,14 +106,15 @@ module Samagotchi
|
|
|
99
106
|
end
|
|
100
107
|
end
|
|
101
108
|
|
|
102
|
-
|
|
109
|
+
# The response's JSON, its answers map checked.
|
|
110
|
+
def reply_of(body)
|
|
103
111
|
data = JSON.parse(body.to_s)
|
|
104
112
|
raise BackendError, "the response is not JSON: #{body.to_s[0, 200]}" unless data.is_a?(Hash)
|
|
105
113
|
|
|
106
114
|
answers = data["answers"]
|
|
107
115
|
raise BackendError, "the response has no answers map" unless answers.is_a?(Hash)
|
|
108
116
|
|
|
109
|
-
|
|
117
|
+
data
|
|
110
118
|
rescue JSON::ParserError => e
|
|
111
119
|
raise BackendError, "the response is not JSON: #{e.message[0, 200]}"
|
|
112
120
|
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../config"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
module SystemOne
|
|
7
|
+
# A bundle's judgment's settings: its entry in config.yml's
|
|
8
|
+
# system_one.judgments, read at each ask, as Client's are. Off until the
|
|
9
|
+
# user sets a mode. +host+ and +model+ are nil for the shared
|
|
10
|
+
# system_one.host and .model.
|
|
11
|
+
#
|
|
12
|
+
# Core's judgments have none, whatever the file says (Config::
|
|
13
|
+
# RESERVED_JUDGMENTS): Record and Similar are model-blind, so a model
|
|
14
|
+
# swapped for approvals alone would inherit a clean record and could
|
|
15
|
+
# auto-allow.
|
|
16
|
+
JudgmentSettings = Data.define(:mode, :host, :model) do
|
|
17
|
+
def initialize(mode: "off", host: nil, model: nil) = super
|
|
18
|
+
|
|
19
|
+
def off? = mode == "off"
|
|
20
|
+
|
|
21
|
+
# The model for Client.from_config: its own; else, on a host of its own,
|
|
22
|
+
# that host's first ("": the shared model belongs to the shared host);
|
|
23
|
+
# else nil, the shared one.
|
|
24
|
+
def client_model = model || (host ? "" : nil)
|
|
25
|
+
|
|
26
|
+
# @param name [String] the judgment's full name
|
|
27
|
+
# @param section [Hash, nil] config.yml's system_one section
|
|
28
|
+
# @return [JudgmentSettings]
|
|
29
|
+
def self.of(name, section: ConfigFile.section("system_one"))
|
|
30
|
+
return new if Config::RESERVED_JUDGMENTS.include?(name.to_s.split(".").first)
|
|
31
|
+
|
|
32
|
+
entry = entries(section)[name.to_s]
|
|
33
|
+
return new unless entry.is_a?(Hash)
|
|
34
|
+
|
|
35
|
+
new(mode: Config.judgment_mode(entry["mode"]) || "off", host: text(entry["host"]), model: text(entry["model"]))
|
|
36
|
+
rescue StandardError
|
|
37
|
+
new
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The names system_one.judgments sets.
|
|
41
|
+
# @return [Array<String>]
|
|
42
|
+
def self.names(section: ConfigFile.section("system_one"))
|
|
43
|
+
entries(section).keys
|
|
44
|
+
rescue StandardError
|
|
45
|
+
[]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.entries(section)
|
|
49
|
+
judgments = section["judgments"] if section.is_a?(Hash)
|
|
50
|
+
judgments.is_a?(Hash) ? judgments.transform_keys(&:to_s) : {}
|
|
51
|
+
end
|
|
52
|
+
private_class_method :entries
|
|
53
|
+
|
|
54
|
+
def self.text(value)
|
|
55
|
+
word = value.to_s.strip
|
|
56
|
+
word.empty? ? nil : word
|
|
57
|
+
end
|
|
58
|
+
private_class_method :text
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|