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.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -1
  3. data/README.md +1 -1
  4. data/bin/chi +2 -0
  5. data/docs/architecture.md +12 -283
  6. data/docs/bundle-authoring.md +1 -0
  7. data/docs/cli.md +15 -7
  8. data/docs/configuration.md +57 -18
  9. data/docs/context.md +2 -2
  10. data/docs/guardrails.md +20 -11
  11. data/docs/hooks.md +9 -7
  12. data/docs/internals/attached-context.md +226 -0
  13. data/docs/internals/background-tasks.md +1 -1
  14. data/docs/internals/bridge.md +380 -0
  15. data/docs/internals/bundles.md +1 -0
  16. data/docs/internals/context-telemetry.md +1 -1
  17. data/docs/internals/engine.md +24 -18
  18. data/docs/internals/participants.md +13 -12
  19. data/docs/internals/remote-sessions.md +1 -1
  20. data/docs/internals/{tool-guardrails.md → tool-output-limits.md} +21 -2
  21. data/docs/memory.md +31 -1
  22. data/docs/plugins.md +122 -1
  23. data/docs/sessions.md +50 -26
  24. data/docs/system-one.md +122 -0
  25. data/docs/testing.md +3 -1
  26. data/lib/samagotchi/bridge.rb +2 -0
  27. data/lib/samagotchi/bundles/check-in/manifest.yml +6 -3
  28. data/lib/samagotchi/bundles/check-in/plugin.rb +101 -4
  29. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  31. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  32. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  33. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  34. data/lib/samagotchi/config.rb +74 -14
  35. data/lib/samagotchi/context_candidate_choice.rb +8 -0
  36. data/lib/samagotchi/context_candidates_log.rb +25 -1
  37. data/lib/samagotchi/context_judge.rb +6 -2
  38. data/lib/samagotchi/engine.rb +68 -7
  39. data/lib/samagotchi/extension_load.rb +3 -2
  40. data/lib/samagotchi/guardrail_wiring.rb +36 -31
  41. data/lib/samagotchi/guardrails/chi_dirs.rb +7 -4
  42. data/lib/samagotchi/guardrails/path_resolve.rb +1 -1
  43. data/lib/samagotchi/guardrails/protected_paths.rb +7 -4
  44. data/lib/samagotchi/guardrails_command.rb +3 -3
  45. data/lib/samagotchi/judgments_command.rb +155 -0
  46. data/lib/samagotchi/kernel_loop.rb +13 -1
  47. data/lib/samagotchi/llm/chat_loop.rb +74 -10
  48. data/lib/samagotchi/llm/errors.rb +7 -5
  49. data/lib/samagotchi/llm/stray_tool_call.rb +61 -0
  50. data/lib/samagotchi/log_line.rb +1 -1
  51. data/lib/samagotchi/memory_bundle/entry_size.rb +100 -0
  52. data/lib/samagotchi/plugin/api.rb +33 -2
  53. data/lib/samagotchi/plugin/context.rb +49 -1
  54. data/lib/samagotchi/plugin/judgments.rb +72 -0
  55. data/lib/samagotchi/plugin/loader.rb +3 -2
  56. data/lib/samagotchi/run_options.rb +1 -0
  57. data/lib/samagotchi/self_report.rb +36 -0
  58. data/lib/samagotchi/session_access/contract.rb +1 -1
  59. data/lib/samagotchi/session_commands.rb +3 -0
  60. data/lib/samagotchi/session_manager.rb +13 -2
  61. data/lib/samagotchi/session_retention.rb +10 -0
  62. data/lib/samagotchi/side_judgments.rb +12 -1
  63. data/lib/samagotchi/system_one/answer.rb +63 -11
  64. data/lib/samagotchi/system_one/approval_label.rb +56 -0
  65. data/lib/samagotchi/system_one/approval_question.rb +2 -0
  66. data/lib/samagotchi/system_one/client.rb +43 -17
  67. data/lib/samagotchi/system_one/fake_backend.rb +1 -1
  68. data/lib/samagotchi/system_one/http_backend.rb +12 -4
  69. data/lib/samagotchi/system_one/judgment_settings.rb +61 -0
  70. data/lib/samagotchi/system_one/judgments.rb +260 -0
  71. data/lib/samagotchi/system_one/log.rb +4 -2
  72. data/lib/samagotchi/system_one/recording.rb +117 -0
  73. data/lib/samagotchi/system_one/runs.rb +179 -0
  74. data/lib/samagotchi/system_one/runs_store.rb +230 -0
  75. data/lib/samagotchi/system_one.rb +9 -0
  76. data/lib/samagotchi/testing/bundle.rb +9 -2
  77. data/lib/samagotchi/testing/host.rb +2 -1
  78. data/lib/samagotchi/testing/judgments.rb +185 -0
  79. data/lib/samagotchi/testing/recorder.rb +5 -0
  80. data/lib/samagotchi/testing/recording_api.rb +1 -1
  81. data/lib/samagotchi/tool_output_cut.rb +26 -6
  82. data/lib/samagotchi/tool_response.rb +1 -1
  83. data/lib/samagotchi/tool_runner.rb +63 -4
  84. data/lib/samagotchi/toolkit_suggest.rb +11 -2
  85. data/lib/samagotchi/tools/memory.rb +28 -0
  86. data/lib/samagotchi/turn_note.rb +9 -0
  87. data/lib/samagotchi/version.rb +1 -1
  88. 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
- def self.delete_session(id_or_prefix, state_dir: nil, stop: false, wait: STOP_WAIT)
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
- # `score` (the level's index) and `confidence`. `probabilities` is what
11
- # the model put on each option, when it says.
12
- Answer = Data.define(:id, :type, :noul, :choice, :score, :confidence, :probabilities) do
13
- def initialize(id:, type:, noul: nil, choice: nil, score: nil, confidence: nil, probabilities: nil) = super
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
- # outside 0..1, a score that isn't a whole number, … A nil means "no
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
- score = raw["score"]
41
- score = score.to_i if score.is_a?(Float) && score.to_i == score
42
- score.is_a?(Integer) && score >= 0 ? new(**answer, score: score) : nil
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 judgment is off (the default) or no host is named, so the
37
- # caller needs no other check. The settings are read every call: a
38
- # config.yml change takes effect on the next ask.
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
- def self.from_config(host_registry:, env: ENV, model: nil, mode: nil, tag: :guardrails)
42
- mode = (mode.nil? ? Config.get("guardrails.system_one.mode") : mode).to_s.strip.downcase
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("guardrails.system_one.host").to_s.strip
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("guardrails.system_one.model") : model).to_s.strip
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
- # guardrails.system_one.timeout_ms, else the default: the whole
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("guardrails.system_one.timeout_ms").to_i
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
@@ -26,7 +26,7 @@ module Samagotchi
26
26
  @calls = 0
27
27
  end
28
28
 
29
- def ask(state:, questions:, cancel_controller: nil)
29
+ def ask(state:, questions:, cancel_controller: nil, observer: nil)
30
30
  @calls += 1
31
31
  raise BackendError, @error if @failing
32
32
 
@@ -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
- answers = answers_of(response.body)
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
- def answers_of(body)
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
- answers
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