samagotchi 0.99.0 → 0.100.1

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.
@@ -0,0 +1,401 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The work-cap bundle (docs/plugins.md, The work-cap bundle): a per-session
4
+ # cap on the work a session does across its turns (ctx.usage: tool calls,
5
+ # active minutes), the session-wide bound the per-turn step limit and
6
+ # check-in don't give. Before each generation it compares the work with the
7
+ # caps: at warn_at of a cap the user (only) gets one notice, at a cap the
8
+ # turn pauses on a question, Continue (the cap goes up by a step) or Stop
9
+ # (the turn stops, with a card). With no one to ask it stops. The question
10
+ # names the session's totals, so a parent agent answering it decides on the
11
+ # whole picture. The model never sees any of it: no tool, no steer, no
12
+ # prompt text. cost-cap (the $ cap) is its sibling; the wordings follow it.
13
+ #
14
+ # Settings (config.yml, bundles: work-cap:), from ctx.settings, never at
15
+ # load: the caps are resolved once per session and settings (see State
16
+ # below), again when ctx.settings changes, so a config change reaches a
17
+ # running session (the state file still wins per meter); warn_at and
18
+ # ignore_tools are read at each check.
19
+ # tool_calls: 400 this session's own tool-call cap (session_settings:,
20
+ # chi --set work-cap.tool_calls=400); 0/off: none
21
+ # minutes: 60 this session's own cap on active minutes, same rules
22
+ # child: {tool_calls: 500, minutes: 90} delegates (ctx.delegate?) with
23
+ # no own value
24
+ # default: {} every other session (and a delegate without child:)
25
+ # warn_at: 0.8 the share of a cap that warns; 0/off: no warning
26
+ # ignore_tools: [task_wait, task_get, delegate, delegate_result]
27
+ # tools whose calls and time aren't counted (waiting:
28
+ # delegate blocks on its child by default)
29
+ # Each meter resolves on its own: its own value, else child:'s for a
30
+ # delegate, else default:'s. An empty key (YAML nil) is unset and falls
31
+ # through; off, false and 0 are an explicit "no cap". A value that doesn't
32
+ # read (tool_calls: many) warns the user once and the next one is used,
33
+ # never a silent "no cap".
34
+ #
35
+ # What counts: ctx.usage's tool calls and active time (generations plus
36
+ # finished tool calls, never a question's wait), less ignore_tools'.
37
+ #
38
+ # State: <data_dir>/sessions/<session id>.json, {"tool_calls": {"cap": 800,
39
+ # "step": 400}, "minutes": {"cap": null}}, written by /workcap and by
40
+ # Continue; a meter's "cap": null is off for the session, over the config;
41
+ # a meter missing from the file comes from the settings. Resolved once per
42
+ # session id and settings (ctx.delegate? parses the session file) and kept
43
+ # until /workcap or Continue writes a new file, or the settings change.
44
+ require "json"
45
+ require "fileutils"
46
+
47
+ class Plugin
48
+ USAGE = "usage: /workcap [N | Nm | Nh | off] (N tool calls, Nm or Nh active time; /workcap alone shows the totals)"
49
+ OPTIONS = %w[Continue Stop].freeze
50
+ # For a parent agent's answer (guardrails.parent_answers): Continue
51
+ # spends more, Stop denies; under hard a parent may only Stop.
52
+ EFFECTS = { "Continue" => :spend, "Stop" => :deny }.freeze
53
+ WARN_AT = 0.8
54
+ METERS = %w[tool_calls minutes].freeze
55
+ DEFAULT_IGNORE = %w[task_wait task_get delegate delegate_result].freeze
56
+ # A cap as text: 3, 2.5, .5, 1e1, -1 (a negative is none).
57
+ NUMBER = /\A-?(?:\d+(?:\.\d*)?|\.\d+)(?:e[-+]?\d+)?\z/i
58
+ # Minutes as text: a NUMBER, or one with m/min (minutes) or h (hours).
59
+ DURATION = /\A(?<number>.+?)\s*(?<unit>m|min|mins|minutes|h|hours?)?\z/i
60
+
61
+ def initialize(_settings)
62
+ @mutex = Mutex.new
63
+ @resolved = nil # {session:, settings:, meters: {meter => {cap:, step:, key:}}, unreadable: {name => value}}
64
+ @warned = {} # meter => [session id, cap] the share warning was given for
65
+ @unread_warned = nil # the session id the "isn't a number" notice was given for
66
+ end
67
+
68
+ def register(chi)
69
+ chi.on(:before_generation) { |_event, ctx| check(ctx) }
70
+ chi.command("/workcap", "see or set this session's work cap: /workcap, /workcap 400, /workcap 90m, /workcap off",
71
+ anytime: true) do |args, ctx|
72
+ command(args, ctx)
73
+ end
74
+ end
75
+
76
+ # A cap (a positive Float) or nil (none, 0, off, false, or a value that
77
+ # isn't a number: .readable? tells them apart). Minutes take a unit:
78
+ # 90m, 1.5h.
79
+ def self.amount(value, meter)
80
+ number = number(value, meter)
81
+ number&.positive? ? number : nil
82
+ end
83
+
84
+ # +value+ as a finite Float, else nil. "5." gets its 0 first
85
+ # (.float_text: Ruby 3.3's Float wants one).
86
+ def self.number(value, meter)
87
+ number = case value
88
+ when Numeric then value.to_f
89
+ when String then text_number(value.strip, meter)
90
+ end
91
+ number if number&.finite?
92
+ end
93
+
94
+ def self.text_number(text, meter)
95
+ return Float(float_text(text)) if text.match?(NUMBER)
96
+ return nil unless meter == "minutes" && (match = DURATION.match(text)) && match[:unit]
97
+ return nil unless match[:number].match?(NUMBER)
98
+
99
+ Float(float_text(match[:number])) * (match[:unit].downcase.start_with?("h") ? 60 : 1)
100
+ end
101
+
102
+ # A NUMBER as Float reads it on every Ruby: a bare point ("5.", "5.e1")
103
+ # gets a 0 after it.
104
+ def self.float_text(text) = text.sub(/\.(?=e|\z)/i, ".0")
105
+
106
+ # Whether +value+ reads as a cap or as none: a number (text too), off,
107
+ # false or nil (nil is unset, not none: #first_readable skips it); not
108
+ # "many", true or a list.
109
+ def self.readable?(value, meter)
110
+ case value
111
+ when false, nil then true
112
+ when Numeric then value.finite?
113
+ when String then value.strip.casecmp?("off") || !number(value, meter).nil?
114
+ else false
115
+ end
116
+ end
117
+
118
+ # 400, 2.5: a whole number without its ".0".
119
+ def self.count(value)
120
+ rounded = value.round(1)
121
+ rounded == rounded.to_i ? rounded.to_i.to_s : rounded.to_s
122
+ end
123
+
124
+ private
125
+
126
+ def check(ctx)
127
+ resolved = resolve(ctx)
128
+ unreadable(ctx, resolved)
129
+ capped = resolved[:meters].select { |_meter, meter_cap| meter_cap[:cap] }
130
+ return if capped.empty?
131
+
132
+ usage = ctx.usage
133
+ used = used(ctx, usage)
134
+ reached = capped.select { |meter, meter_cap| used[meter] >= meter_cap[:cap] }
135
+ return at_cap(ctx, resolved, reached, usage, used) unless reached.empty?
136
+
137
+ share = warn_share(ctx)
138
+ return unless share
139
+
140
+ capped.each do |meter, meter_cap|
141
+ cap = meter_cap[:cap]
142
+ next unless used[meter] >= share * cap && first_warning?(meter, [resolved[:session], cap])
143
+
144
+ ctx.notify("#{count(used[meter])} of #{count(cap)} #{unit(meter)} (#{(used[meter] / cap * 100).floor}%)",
145
+ level: :warn)
146
+ end
147
+ end
148
+
149
+ # The counted work per meter: tool calls and active minutes, less the
150
+ # ignored tools' share.
151
+ def used(ctx, usage)
152
+ ignored = ignore_tools(ctx)
153
+ calls = usage.tool_calls - ignored.sum { |tool| usage.tool_calls_by_tool.fetch(tool, 0) }
154
+ active_ms = usage.active_ms - ignored.sum { |tool| usage.tool_ms_by_tool.fetch(tool, 0) }
155
+ { "tool_calls" => [calls, 0].max.to_f, "minutes" => [active_ms, 0].max / 60_000.0 }
156
+ end
157
+
158
+ # Once per session: the settings whose cap didn't read, and what is used.
159
+ def unreadable(ctx, resolved)
160
+ bad = resolved[:unreadable]
161
+ return if bad.empty? || !first_time?(:@unread_warned, resolved[:session])
162
+
163
+ named = bad.map { |key, value| "#{key}: #{value.inspect}" }.join(" and ")
164
+ used = resolved[:meters].values.filter_map do |meter_cap|
165
+ "#{meter_cap[:key]}: (#{count(meter_cap[:cap])})" if meter_cap[:cap]
166
+ end
167
+ using = used.empty? ? "no cap" : "using #{used.join(", ")}"
168
+ ctx.notify("work-cap: #{named} #{bad.size == 1 ? "isn't" : "aren't"} a number or off; #{using}", level: :warn)
169
+ end
170
+
171
+ def at_cap(ctx, resolved, reached, usage, used)
172
+ raised = reached.to_h do |meter, meter_cap|
173
+ [meter, raised_cap(meter_cap[:cap], meter_cap[:step] || meter_cap[:cap], used[meter])]
174
+ end
175
+ answer = ctx.ask_user(question: "Work cap reached: #{reached_text(reached, used)} (#{totals(ctx, usage, used, reached.keys)}). " \
176
+ "Continue raises #{raised_text(raised)}.",
177
+ header: "Work cap", options: OPTIONS, effects: EFFECTS)
178
+ if continue?(answer)
179
+ raised.each do |meter, cap|
180
+ save_meter(ctx, resolved[:session], meter, "cap" => cap, "step" => reached[meter][:step] || reached[meter][:cap])
181
+ end
182
+ ctx.notify("#{raised.size == 1 ? "cap" : "caps"} raised to " \
183
+ "#{raised.map { |meter, cap| "#{count(cap)} #{unit(meter)}" }.join(" and ")}")
184
+ else
185
+ stop(ctx, reached, usage, used)
186
+ end
187
+ end
188
+
189
+ # "412 tool calls of 400", and "61 active minutes of 60" for the other.
190
+ def reached_text(reached, used)
191
+ reached.map { |meter, meter_cap| "#{count(used[meter])} #{unit(meter)} of #{count(meter_cap[:cap])}" }.join(", ")
192
+ end
193
+
194
+ # "it to 800", or "them to 800 tool calls and 120 active minutes".
195
+ def raised_text(raised)
196
+ return "it to #{count(raised.values.first)}" if raised.size == 1
197
+
198
+ "them to #{raised.map { |meter, cap| "#{count(cap)} #{unit(meter)}" }.join(" and ")}"
199
+ end
200
+
201
+ # The session's totals a reader decides on, the reached meters left out
202
+ # (already named): turns, tool calls, active minutes and the spend.
203
+ def totals(ctx, usage, used, skip = [])
204
+ parts = ["#{usage.turns} #{usage.turns == 1 ? "turn" : "turns"}"]
205
+ parts << "#{count(used["tool_calls"])} tool calls" unless skip.include?("tool_calls")
206
+ parts << "#{active_text(used["minutes"])} active" unless skip.include?("minutes")
207
+ cost = ctx.cost
208
+ parts << format("$%.2f", cost.total) if cost.known? && cost.total.positive?
209
+ parts.join(", ")
210
+ end
211
+
212
+ # "47 min", or "<1 min" before a whole minute of work.
213
+ def active_text(minutes) = minutes < 1 ? "<1 min" : "#{count(minutes.floor)} min"
214
+
215
+ # Another step on the cap, as many steps as it takes to pass the work
216
+ # (a turn that overshot by more than a step asks once, not each step):
217
+ # Continue always leaves the cap past the work now.
218
+ def raised_cap(cap, step, used)
219
+ raised = cap + step
220
+ raised += step while raised <= used
221
+ raised.round(6)
222
+ end
223
+
224
+ def continue?(answer)
225
+ return false unless answer
226
+
227
+ Array(answer[:selected]).first == "Continue" || Array(answer[:selected_indices]).first&.zero?
228
+ end
229
+
230
+ def stop(ctx, reached, usage, used)
231
+ return unless ctx.stop_turn("work cap reached (#{reached_text(reached, used)})")
232
+
233
+ ctx.card(title: "work-cap stopped the turn",
234
+ body: "Used #{reached_text(reached, used)} (#{totals(ctx, usage, used, reached.keys)}).\n\n" \
235
+ "`/workcap N` raises the tool-call cap, `/workcap Nm` the active minutes, `/workcap off` removes both.",
236
+ level: :warn)
237
+ end
238
+
239
+ def command(args, ctx)
240
+ arg = args.to_s.strip.downcase
241
+ return show(ctx) if arg.empty?
242
+
243
+ session = ctx.session_id
244
+ return "work-cap: no session yet" unless session
245
+
246
+ if %w[off 0].include?(arg)
247
+ METERS.each { |meter| save_meter(ctx, session, meter, "cap" => nil) }
248
+ return "work-cap: no cap for this session"
249
+ end
250
+ meter = arg.match?(NUMBER) ? "tool_calls" : "minutes"
251
+ value = self.class.amount(arg, meter)
252
+ return USAGE unless value
253
+
254
+ save_meter(ctx, session, meter, "cap" => value, "step" => value)
255
+ "work-cap: #{unit(meter)} cap set to #{count(value)} (#{count(used(ctx, ctx.usage)[meter])} so far)"
256
+ rescue StandardError => e
257
+ ctx.log.warn(:workcap_command_failed, error: "#{e.class}: #{e.message}")
258
+ "work-cap: #{e.message}"
259
+ end
260
+
261
+ def show(ctx)
262
+ usage = ctx.usage
263
+ used = used(ctx, usage)
264
+ meters = resolve(ctx)[:meters]
265
+ return "work-cap: no cap (#{totals(ctx, usage, used)})" if meters.values.none? { |meter_cap| meter_cap[:cap] }
266
+
267
+ capped = meters.select { |_meter, meter_cap| meter_cap[:cap] }
268
+ caps = capped.map { |meter, meter_cap| "#{count(used[meter])} of #{count(meter_cap[:cap])} #{unit(meter)}" }
269
+ "work-cap: #{caps.join(", ")} (#{totals(ctx, usage, used, capped.keys)})"
270
+ end
271
+
272
+ def unit(meter) = meter == "tool_calls" ? "tool calls" : "active minutes"
273
+
274
+ # The session's caps now: per meter the state file's, else the session's
275
+ # own value (--set), else child:'s for a delegate, else default:'s.
276
+ # Cached per session id and settings (a config change resolves again).
277
+ def resolve(ctx)
278
+ session = ctx.session_id
279
+ settings = ctx.settings
280
+ cached = @mutex.synchronize { @resolved }
281
+ return cached if cached && cached[:session] == session && cached[:settings] == settings
282
+
283
+ saved = state(ctx, session)
284
+ configured = configured(ctx)
285
+ meters = METERS.to_h { |meter| [meter, saved[meter] || configured[:meters][meter]] }
286
+ resolved = { session: session, settings: settings, meters: meters, unreadable: configured[:unreadable] }
287
+ @mutex.synchronize { @resolved = resolved }
288
+ end
289
+
290
+ # The state file's meters ({meter => {cap:, step:}}), {} without one.
291
+ def state(ctx, session)
292
+ return {} unless session
293
+
294
+ saved = JSON.parse(File.read(state_path(ctx, session)))
295
+ return {} unless saved.is_a?(Hash)
296
+
297
+ METERS.each_with_object({}) do |meter, meters|
298
+ entry = saved[meter]
299
+ next unless entry.is_a?(Hash) && entry.key?("cap")
300
+
301
+ cap = self.class.amount(entry["cap"], meter)
302
+ meters[meter] = { cap: cap, step: self.class.amount(entry["step"], meter) || cap, key: "/workcap" }
303
+ end
304
+ rescue Errno::ENOENT, JSON::ParserError
305
+ {}
306
+ end
307
+
308
+ # Per meter the first of its own value, child:'s (a delegate's) and
309
+ # default:'s that is set (an empty key, nil, isn't) and reads
310
+ # (.readable?); the ones set that don't read (and a child:/default: that
311
+ # isn't a mapping) are kept for the notice (#unreadable).
312
+ def configured(ctx)
313
+ settings = ctx.settings
314
+ unreadable = {}
315
+ groups = %w[child default].select { |group| settings.key?(group) }
316
+ groups.delete("child") if groups.include?("child") && !ctx.delegate?
317
+ groups = groups.select do |group|
318
+ next true if settings[group].is_a?(Hash)
319
+
320
+ unreadable[group] = settings[group] unless settings[group].nil?
321
+ false
322
+ end
323
+ meters = METERS.to_h do |meter|
324
+ sources = [[meter, settings, meter]] + groups.map { |group| ["#{group}.#{meter}", settings[group], meter] }
325
+ [meter, first_readable(sources, unreadable)]
326
+ end
327
+ { meters: meters, unreadable: unreadable }
328
+ end
329
+
330
+ def first_readable(sources, unreadable)
331
+ sources.each do |name, values, meter|
332
+ next unless values.key?(meter)
333
+
334
+ value = values[meter]
335
+ next if value.nil?
336
+
337
+ unless self.class.readable?(value, meter)
338
+ unreadable[name] = value
339
+ next
340
+ end
341
+ cap = self.class.amount(value, meter)
342
+ return { cap: cap, step: cap, key: name }
343
+ end
344
+ { cap: nil, step: nil, key: nil }
345
+ end
346
+
347
+ def ignore_tools(ctx)
348
+ settings = ctx.settings
349
+ settings.key?("ignore_tools") ? Array(settings["ignore_tools"]).map(&:to_s) : DEFAULT_IGNORE
350
+ end
351
+
352
+ def warn_share(ctx)
353
+ settings = ctx.settings
354
+ return WARN_AT unless settings.key?("warn_at")
355
+
356
+ share = settings["warn_at"]
357
+ share.is_a?(Numeric) && share.positive? ? share : nil
358
+ end
359
+
360
+ # Write one meter's cap into the session's state file (the other kept).
361
+ def save_meter(ctx, session, meter, data)
362
+ path = state_path(ctx, session)
363
+ FileUtils.mkdir_p(File.dirname(path))
364
+ saved = begin
365
+ JSON.parse(File.read(path))
366
+ rescue Errno::ENOENT, JSON::ParserError
367
+ {}
368
+ end
369
+ saved = {} unless saved.is_a?(Hash)
370
+ saved[meter] = data.transform_values { |value| whole(value) }
371
+ Samagotchi::AtomicFile.write(path, "#{JSON.generate(saved)}\n")
372
+ # Resolved again at the next use, from the file now holding the meter.
373
+ @mutex.synchronize { @resolved = nil }
374
+ end
375
+
376
+ # 800.0 as 800 (a whole cap is saved as an Integer), 2.5 as it is.
377
+ def whole(value) = value.is_a?(Float) && value.finite? && value == value.to_i ? value.to_i : value
378
+
379
+ def state_path(ctx, session) = File.join(ctx.data_dir, "sessions", "#{session.gsub(/[^A-Za-z0-9_.-]/, "_")}.json")
380
+
381
+ def count(value) = self.class.count(value)
382
+
383
+ def first_warning?(meter, key)
384
+ @mutex.synchronize do
385
+ next false if @warned[meter] == key
386
+
387
+ @warned[meter] = key
388
+ true
389
+ end
390
+ end
391
+
392
+ # Whether +key+ is new for the once-only notice kept in +ivar+ (and keep it).
393
+ def first_time?(ivar, key)
394
+ @mutex.synchronize do
395
+ next false if instance_variable_get(ivar) == key
396
+
397
+ instance_variable_set(ivar, key)
398
+ true
399
+ end
400
+ end
401
+ end
@@ -2680,7 +2680,8 @@ module Samagotchi
2680
2680
  # returns [] itself: the || keeps a nil builder safe).
2681
2681
  prompt_files: -> { @prompt_builder&.prompt_files || [] },
2682
2682
  frontend: -> { interface },
2683
- cost: -> { @metrics.cost }
2683
+ cost: -> { @metrics.cost },
2684
+ usage: -> { @metrics.usage }
2684
2685
  )
2685
2686
  end
2686
2687
 
@@ -52,15 +52,21 @@ module Samagotchi
52
52
 
53
53
  # Load hooks from a config hash and return a Registry with registered plugins.
54
54
  #
55
- # @param config_hash [Hash, nil] the hooks section from config.yml
55
+ # @param config_hash [Hash, nil] the parsed config.yml
56
56
  # @param env [Hash] environment variables (default: ENV)
57
57
  # @param failures [Guardrails::LoadFailures, nil] collects hooks that
58
58
  # failed to load
59
59
  # @return [Samagotchi::Hooks::Registry] the registry with all plugins registered
60
60
  def load(config_hash, env: ENV, failures: nil)
61
- return Hooks::Registry.new unless config_hash&.key?("hooks")
61
+ hooks_config = config_hash["hooks"] if config_hash.is_a?(Hash)
62
+ # An empty `hooks:` key is nil in YAML: no hooks, like no key.
63
+ return Hooks::Registry.new if hooks_config.nil?
64
+
65
+ unless hooks_config.is_a?(Hash)
66
+ Log.warn(:hooks, "hooks_section_invalid", echo: "[samagotchi:hooks] config.yml hooks: must be a mapping of event name to hook entries; ignored")
67
+ return Hooks::Registry.new
68
+ end
62
69
 
63
- hooks_config = config_hash["hooks"]
64
70
  hooks_dir = hooks_dir(env, data: config_hash)
65
71
 
66
72
  registry = Hooks::Registry.new
@@ -92,13 +98,20 @@ module Samagotchi
92
98
 
93
99
  # Parse hook definitions from the config hooks section.
94
100
  # Returns an array of { event_type:, path:, on_error: } hashes.
101
+ # An event key that holds something other than a list warns and is
102
+ # skipped; an empty one (nil) is skipped silently.
95
103
  def self.parse_definitions(hooks_config)
96
104
  definitions = []
97
105
  hooks_config.each do |event_type, configs|
98
- next unless event_type.to_s != "hooks_dir" && configs.is_a?(Array)
106
+ next if event_type.to_s == "hooks_dir" || configs.nil?
107
+
108
+ unless configs.is_a?(Array)
109
+ Log.warn(:hooks, "hooks_event_invalid", echo: "[samagotchi:hooks] config.yml hooks.#{event_type}: must be a list of hook entries; ignored", event: event_type.to_s)
110
+ next
111
+ end
99
112
 
100
- configs.each do |cfg|
101
- next unless cfg.is_a?(Hash) && cfg["path"]
113
+ configs.each_with_index do |cfg, index|
114
+ next unless valid_entry?(event_type, cfg, index)
102
115
 
103
116
  definitions << {
104
117
  event_type: event_type.to_s,
@@ -112,6 +125,21 @@ module Samagotchi
112
125
  definitions
113
126
  end
114
127
 
128
+ # A hook entry is a mapping with a path:; anything else warns, naming
129
+ # the entry (hooks.EVENT[N], from 0), and is skipped alone.
130
+ def self.valid_entry?(event_type, cfg, index)
131
+ where = "hooks.#{event_type}[#{index}]"
132
+ unless cfg.is_a?(Hash)
133
+ Log.warn(:hooks, "hooks_entry_invalid", echo: "[samagotchi:hooks] config.yml #{where}: must be a mapping with a path:; ignored", event: event_type.to_s, index: index)
134
+ return false
135
+ end
136
+ return true if cfg["path"].is_a?(String) && !cfg["path"].strip.empty?
137
+
138
+ Log.warn(:hooks, "hooks_entry_no_path", echo: "[samagotchi:hooks] config.yml #{where}: has no path:; ignored", event: event_type.to_s, index: index)
139
+ false
140
+ end
141
+ private_class_method :valid_entry?
142
+
115
143
  # Load a plugin from the hooks directory.
116
144
  # Returns an instance of the plugin class. Instances are cached per
117
145
  # [path, settings] across Engines: two entries with different
@@ -40,7 +40,8 @@ module Samagotchi
40
40
  "ctx.prompt_files" => "0.60.0", "chi.annotation" => "0.66.0", "chi.toolkit" => "0.66.0",
41
41
  "ctx.use_toolkit" => "0.66.0", "ctx.toolkit" => "0.66.0", "ctx.declared_tools" => "0.66.0",
42
42
  "chi.tool_source" => "0.67.0", "chi.peer_source" => "0.67.0", "ctx.http" => "0.80.0",
43
- "ctx.cost" => "0.83.0", "ctx.sessions.children" => "0.34.0", "ctx.load_tools" => "0.99.0"
43
+ "ctx.cost" => "0.83.0", "ctx.sessions.children" => "0.34.0", "ctx.load_tools" => "0.99.0",
44
+ "ctx.usage" => "0.100.0"
44
45
  }.freeze
45
46
 
46
47
  # @param bundle [String]
@@ -4,6 +4,7 @@ require "fileutils"
4
4
  require_relative "../log"
5
5
  require_relative "../paths"
6
6
  require_relative "../session_metrics/cost"
7
+ require_relative "../session_metrics/usage"
7
8
  require_relative "../guardrails/context"
8
9
  require_relative "../hook_question"
9
10
  require_relative "../idle_client"
@@ -55,11 +56,12 @@ module Samagotchi
55
56
  # ctx.http call without cancel: uses, nil for none: an init task's own,
56
57
  # else the running turn's for work done for it (the turn's thread, or a
57
58
  # plugin handler: +handler+) until the turn has ended.
58
- # +cost+ returns the session's spend now (SessionMetrics::Cost).
59
+ # +cost+ returns the session's spend now (SessionMetrics::Cost),
60
+ # +usage+ its work now (SessionMetrics::Usage).
59
61
  Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
60
62
  :ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
61
63
  :steer, :stop_turn, :stop_generation, :frontend, :toolkit, :declared_tools, :use_toolkit, :load_tools,
62
- :cancel_controller, :cost, keyword_init: true)
64
+ :cancel_controller, :cost, :usage, keyword_init: true)
63
65
 
64
66
  # ctx.ask_model failed: the model couldn't be reached, timed out, or
65
67
  # sent nothing usable. The message says why, for the user.
@@ -167,6 +169,12 @@ module Samagotchi
167
169
  # (docs/plugins.md); zero and known with no session
168
170
  def cost = @host.cost&.call || SessionMetrics::Cost.zero
169
171
 
172
+ # @return [SessionMetrics::Usage] the session's work so far, read now:
173
+ # #turns, #tool_calls, #tool_calls_by_tool, #steps, #active_ms
174
+ # (#active_minutes) and #tool_ms_by_tool (docs/plugins.md); zero
175
+ # with no session
176
+ def usage = @host.usage&.call || SessionMetrics::Usage.zero
177
+
170
178
  # @return [String, nil] the name of the session's toolkit ("full" for
171
179
  # one from before toolkits; nil with no session)
172
180
  def toolkit = @host.toolkit&.call
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class SessionMetrics
5
+ # The session's work so far (SessionMetrics#usage, what a bundle's
6
+ # ctx.usage reads), across its turns and workers: the same numbers
7
+ # /stats and `chi sessions stats` show.
8
+ # +turns+: finished turns plus the running one. +tool_calls+ and
9
+ # +tool_calls_by_tool+ (canonical names: an alias counts under the tool
10
+ # it resolved to, a call_tool call under the inner tool): every call,
11
+ # running ones included. +steps+: the loop rounds that dispatched tools
12
+ # (the snapshot's iterations_total). +active_ms+: work time, the
13
+ # generations' time plus the finished tool calls' time (approval waits
14
+ # left out; ask_user_question's, a wait on the user, not counted),
15
+ # never a question's wait between them; +tool_ms_by_tool+ is the tool
16
+ # part by tool (the same names), so a reader can leave a waiting tool's
17
+ # time (task_wait, a blocking delegate) out.
18
+ # Bundles use its methods only, never this constant's name.
19
+ Usage = Data.define(:turns, :tool_calls, :tool_calls_by_tool, :steps, :active_ms, :tool_ms_by_tool) do
20
+ def self.zero = new(turns: 0, tool_calls: 0, tool_calls_by_tool: {}, steps: 0, active_ms: 0, tool_ms_by_tool: {})
21
+
22
+ # Data freezes itself, not its hashes: these are copied and frozen.
23
+ def initialize(tool_calls_by_tool:, tool_ms_by_tool:, **)
24
+ super(tool_calls_by_tool: tool_calls_by_tool.dup.freeze, tool_ms_by_tool: tool_ms_by_tool.dup.freeze, **)
25
+ end
26
+
27
+ # @return [Float] active_ms in minutes
28
+ def active_minutes = active_ms / 60_000.0
29
+ end
30
+ end
31
+ end
@@ -14,6 +14,7 @@ require_relative "session"
14
14
  require_relative "session_metrics/totals"
15
15
  require_relative "session_metrics/model_segment"
16
16
  require_relative "session_metrics/cost"
17
+ require_relative "session_metrics/usage"
17
18
  require_relative "session_metrics/side_totals"
18
19
  require_relative "side_usage"
19
20
 
@@ -53,6 +54,10 @@ module Samagotchi
53
54
  SPEED_MIN_TOKENS = 8
54
55
  SPEED_MIN_MS = 100
55
56
 
57
+ # The tools whose time is a wait on the user, not work (#usage).
58
+ USER_WAIT_TOOLS = %w[ask_user_question].freeze
59
+ private_constant :USER_WAIT_TOOLS
60
+
56
61
  # The session's token totals as a saved analytics.json has them (zeros
57
62
  # where an older file lacks a count), with how full the context was.
58
63
  # A generation's decode speed (tokens per second) and where it came
@@ -377,6 +382,23 @@ module Samagotchi
377
382
  @mutex.synchronize { Cost.of(@totals.with_running(@turn), @side) }
378
383
  end
379
384
 
385
+ # The session's work now (ctx.usage): turns, tool calls (by tool), steps
386
+ # and active (work) time, earlier workers' loaded records and the
387
+ # running turn included. Safe from any thread.
388
+ # @return [Usage]
389
+ def usage
390
+ @mutex.synchronize do
391
+ turn = @turn
392
+ totals = @totals.with_running(turn)
393
+ calls, by_tool = tool_call_counts(turn)
394
+ tool_ms = @tool_tallies[:ms]
395
+ # Finished calls only: a running call's approval wait isn't known
396
+ # until it completes (waited_ms), so its time counts then.
397
+ Usage.new(turns: turn_count(turn), tool_calls: calls, tool_calls_by_tool: by_tool, steps: totals.iterations,
398
+ active_ms: (totals.gen_ms + tool_ms.values.sum).round, tool_ms_by_tool: tool_ms.transform_values(&:round))
399
+ end
400
+ end
401
+
380
402
  # Event sink. Safe to call from any thread; errors are isolated by the
381
403
  # caller (SessionObserver / TerminalUI) but we still guard internally.
382
404
  # @param event [Hash]
@@ -542,10 +564,10 @@ module Samagotchi
542
564
  def build_snapshot(records_mode)
543
565
  turn = @turn
544
566
  totals = @totals.with_running(turn)
545
- in_flight = turn ? turn.tool_calls_by_id.values : []
567
+ calls, by_tool = tool_call_counts(turn)
546
568
  {
547
569
  session_id: @session_id,
548
- turns: @turn_records.size + (turn ? 1 : 0),
570
+ turns: turn_count(turn),
549
571
  cancellations: totals.cancellations,
550
572
  tokens: totals.tokens_block,
551
573
  # The tokens, cost and speeds per model, in order (ModelSegment.rows:
@@ -557,8 +579,8 @@ module Samagotchi
557
579
  profile_source: @profile_source,
558
580
  served_model: @served_model,
559
581
  served_model_for: @served_model_for,
560
- tool_calls_total: @tool_records.size + in_flight.size,
561
- tool_calls_by_tool: tally_into(@tool_tallies[:by_tool].dup, in_flight),
582
+ tool_calls_total: calls,
583
+ tool_calls_by_tool: by_tool,
562
584
  # The calls the model made under an alias's name (bash run as
563
585
  # execute), by its own spelling; only when there are any.
564
586
  **(@tool_tallies[:aliased].empty? ? {} : { tool_calls_aliased: @tool_tallies[:aliased].dup }),
@@ -582,6 +604,17 @@ module Samagotchi
582
604
  }
583
605
  end
584
606
 
607
+ # The finished turns and the running one. Caller holds the mutex.
608
+ def turn_count(turn) = @turn_records.size + (turn ? 1 : 0)
609
+
610
+ # Every tool call (loaded records, this process's, the running ones)
611
+ # and the calls by tool, a copy. Caller holds the mutex.
612
+ # @return [Array(Integer, Hash)]
613
+ def tool_call_counts(turn)
614
+ in_flight = turn ? turn.tool_calls_by_id.values : []
615
+ [@tool_records.size + in_flight.size, tally_into(@tool_tallies[:by_tool].dup, in_flight)]
616
+ end
617
+
585
618
  # The model segments with the running turn in: the last one's when it
586
619
  # runs on that model or names none yet, else a new one. Caller holds
587
620
  # the mutex.
@@ -1199,7 +1232,8 @@ module Samagotchi
1199
1232
  @totals = Totals.zero
1200
1233
  @segments = []
1201
1234
  @last_model_ref = nil
1202
- @tool_tallies = { by_tool: {}, aliased: {}, errors: 0 }
1235
+ # ms: the finished calls' time by tool (#usage's active_ms).
1236
+ @tool_tallies = { by_tool: {}, aliased: {}, errors: 0, ms: {} }
1203
1237
  end
1204
1238
 
1205
1239
  def count_turn(record)
@@ -1219,6 +1253,15 @@ module Samagotchi
1219
1253
  tally_into(@tool_tallies[:by_tool], [record])
1220
1254
  called_as = record[:called_as].to_s
1221
1255
  @tool_tallies[:aliased][called_as] = @tool_tallies[:aliased].fetch(called_as, 0) + 1 unless called_as.empty?
1256
+ count_tool_ms(record)
1257
+ end
1258
+
1259
+ def count_tool_ms(record)
1260
+ name = record[:tool].to_s
1261
+ ms = record[:duration_ms]
1262
+ return if name.empty? || !ms.is_a?(Numeric) || USER_WAIT_TOOLS.include?(name)
1263
+
1264
+ @tool_tallies[:ms][name] = @tool_tallies[:ms].fetch(name, 0) + ms
1222
1265
  end
1223
1266
 
1224
1267
  def tally_into(counts, tools)