samagotchi 0.98.0 → 0.100.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -1
  3. data/README.md +2 -2
  4. data/docs/architecture.md +6 -3
  5. data/docs/bundle-authoring.md +1 -1
  6. data/docs/cli.md +1 -1
  7. data/docs/configuration.md +8 -0
  8. data/docs/guardrails.md +10 -2
  9. data/docs/hooks.md +9 -1
  10. data/docs/internals/anthropic.md +79 -8
  11. data/docs/internals/engine.md +13 -3
  12. data/docs/internals/llm-context-forget.md +2 -1
  13. data/docs/internals/prompt-caching.md +9 -0
  14. data/docs/memory.md +1 -1
  15. data/docs/plugins.md +83 -3
  16. data/docs/releasing.md +1 -1
  17. data/docs/sessions.md +10 -0
  18. data/docs/sub-agent.md +7 -2
  19. data/docs/testing.md +27 -0
  20. data/lib/samagotchi/bundles/core/manifest.yml +4 -3
  21. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  22. data/lib/samagotchi/bundles/work-cap/manifest.yml +13 -0
  23. data/lib/samagotchi/bundles/work-cap/plugin.rb +391 -0
  24. data/lib/samagotchi/engine.rb +67 -9
  25. data/lib/samagotchi/hooks/loader.rb +17 -4
  26. data/lib/samagotchi/kernel_loop.rb +15 -2
  27. data/lib/samagotchi/llm/anthropic_messages.rb +85 -4
  28. data/lib/samagotchi/llm/anthropic_wire.rb +90 -11
  29. data/lib/samagotchi/llm/chat_loop.rb +76 -7
  30. data/lib/samagotchi/llm/dialect.rb +8 -5
  31. data/lib/samagotchi/llm/refusal_memo.rb +6 -0
  32. data/lib/samagotchi/llm/turn_settings.rb +13 -5
  33. data/lib/samagotchi/llm_context_stale.rb +2 -2
  34. data/lib/samagotchi/message_parts.rb +2 -1
  35. data/lib/samagotchi/plugin/api.rb +2 -1
  36. data/lib/samagotchi/plugin/context.rb +31 -4
  37. data/lib/samagotchi/prompt.rb +3 -0
  38. data/lib/samagotchi/session_metrics/usage.rb +31 -0
  39. data/lib/samagotchi/session_metrics.rb +48 -5
  40. data/lib/samagotchi/testing/bundle.rb +2 -0
  41. data/lib/samagotchi/testing/host.rb +31 -1
  42. data/lib/samagotchi/testing/recorder.rb +18 -3
  43. data/lib/samagotchi/tool_loads.rb +62 -0
  44. data/lib/samagotchi/tool_response.rb +5 -1
  45. data/lib/samagotchi/tool_runner.rb +29 -7
  46. data/lib/samagotchi/tools/builtin_calls.rb +16 -0
  47. data/lib/samagotchi/tools/deferred_tools.rb +80 -0
  48. data/lib/samagotchi/tools/registry.rb +7 -2
  49. data/lib/samagotchi/tools/tool_index.rb +88 -6
  50. data/lib/samagotchi/version.rb +1 -1
  51. metadata +6 -1
data/docs/sub-agent.md CHANGED
@@ -47,7 +47,9 @@ chi answer ID --question QID --dismiss [--timeout S] [--format json]
47
47
 
48
48
  A child with a bundle setting of its own, such as a $ cap (the [cost-cap bundle](plugins.md#the-cost-cap-bundle)):
49
49
  `chi send --new --wait --format json --set cost-cap.cap=3 -m "task"`. At the cap the wait exits 3 with the cost-cap
50
- question; `chi answer ID --question QID --option Continue` (or `Stop`) answers it.
50
+ question; `chi answer ID --question QID --option Continue` (or `Stop`) answers it. A cap on the child's work across
51
+ its turns (the [work-cap bundle](plugins.md#the-work-cap-bundle)), the bound a parent that keeps answering the step
52
+ limit's Continue doesn't otherwise have: `--set work-cap.tool_calls=400` (or `work-cap.minutes=60`, active minutes).
51
53
 
52
54
  `chi answer` waits after answering, the same way `chi send --wait` does, so the
53
55
  next question comes back as exit 3 again. What it waits for is the answered
@@ -122,7 +124,10 @@ relayed to the parent's user the way approvals are.
122
124
 
123
125
  A session at its cost cap (the [cost-cap bundle](plugins.md#the-cost-cap-bundle)) waits the same way, as a
124
126
  question of kind `hook` with header "Cost cap" and options `Continue` and `Stop`: `--option Continue` raises the cap
125
- by another cap's worth and the turn goes on, `--option Stop` ends it `canceled` with `stopped_by: cost-cap`.
127
+ by another cap's worth and the turn goes on, `--option Stop` ends it `canceled` with `stopped_by: cost-cap`. A session at its work cap (the
128
+ [work-cap bundle](plugins.md#the-work-cap-bundle)) asks the same, header "Work cap", the question naming the
129
+ session's totals (`Work cap reached: 412 tool calls of 400 (6 turns, 47 min active, $1.20). Continue raises it to
130
+ 800.`); `stopped_by: work-cap` on Stop.
126
131
 
127
132
  ## Approvals
128
133
 
data/docs/testing.md CHANGED
@@ -219,3 +219,30 @@ each column; `--model GLOB`, `--since DATE` and `--min-steps N` filter, `--json`
219
219
  no session text (ids, model names, note names and digests, dates, numbers), and the suite runs it only on small
220
220
  synthetic sessions. It is how a note is judged on and off (`chi --mute model_notes_<name>`) for the same kind of
221
221
  task: see [memory.md](memory.md) ("Model notes").
222
+
223
+ ## Dependency check
224
+
225
+ `bundle exec rake deps:check` says what our dependencies did since the last check; `rake "deps:check[json]"` gives
226
+ the same as JSON. It reads only and always exits 0: it informs (a tech-debt audit round runs it first, and its NEW
227
+ lines go into that round's follow-ups), it doesn't gate, and CI doesn't run it. Three sections:
228
+
229
+ - **Engines:** each `hosts:` entry of your config.yml on a loopback or private address (an IP, `localhost`, a
230
+ single-label name or `.local`/`.lan`/`.internal`/`.home.arpa`; a public API is never probed) gets `/props`
231
+ (`build_info`: llama.cpp) and `/status` (`identity` and `chat_template`: Splash), 2 s each. A host that doesn't
232
+ answer prints "down, skipped"; one that is neither is left out. Each line carries the newest upstream release
233
+ (GitHub, no auth) with its URL, for a model to read the notes: llama.cpp's newest `b<N>` tag and how many builds
234
+ behind the host is, Splash's newest release.
235
+ - **Ruby gems:** `bundle outdated --only-explicit` (direct dependencies, with the Gemfile's requirement).
236
+ - **npm:** `npm outdated` (direct) and `npm audit`'s advisories by severity.
237
+
238
+ A section that can't be read (offline, a GitHub rate limit) says why and the others go on. What a run saw goes to
239
+ `$XDG_STATE_HOME/samagotchi/deps-check.json` (`~/.local/state/...`; `DEPS_CHECK_STATE` overrides it), outside the
240
+ repo since the engines are this machine's; the next run prints `NEW` on each line that changed since (the first run:
241
+ all), NEW lines first in each section.
242
+
243
+ **The Splash version** isn't in Splash's `/status`, so you record it: read `splash --version` on the engine host,
244
+ then `rake "deps:splash_version[1.3.2]"` (every Splash host) or `rake "deps:splash_version[host:port,1.3.2]"`. It is
245
+ kept with the build id `/status` shows (`identity.cache.build_id`); when that changes, the check asks you to confirm
246
+ the version and record it again. This step goes once a Splash release carries its version in `/status`.
247
+
248
+ The code is `script/deps/deps_check.rb`; its spec stubs every request and command (`spec/deps_check_spec.rb`).
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: core
3
- version: 0.3.4
3
+ version: 0.4.0
4
4
  scope: system
5
5
  description: "Recommended safety set"
6
6
  trust_level: reviewed
7
- # Its members' highest floor (cost-cap's)
8
- requires_chi: ">= 0.92.0"
7
+ # Its members' highest floor (work-cap's: ctx.usage, raised at the release)
8
+ requires_chi: ">= 0.100.0"
9
9
  includes:
10
10
  - loop-guard
11
11
  - check-in
@@ -13,3 +13,4 @@ includes:
13
13
  - slow-commands
14
14
  - agents-md
15
15
  - cost-cap
16
+ - work-cap
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.98.0
3
+ version: 0.100.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -0,0 +1,13 @@
1
+ ---
2
+ name: work-cap
3
+ version: 0.1.0
4
+ scope: system
5
+ description: "A per-session work cap (tool calls, active minutes) across turns: at a cap the turn pauses on Continue (another cap's worth) / Stop with the session's totals, a warning at 80% goes to the user only; /workcap shows or sets it"
6
+ trust_level: reviewed
7
+ plugin:
8
+ file: plugin.rb
9
+ sha256: sha256:d4de4b835d1657223517f3d6a5d8a9b089b7c8fad08ed077f72748369323bd65
10
+ # ask_user's effects: are in chi 0.92.0; ctx.usage is new in the next chi
11
+ requires_chi: ">= 0.100.0"
12
+ # chi --set work-cap.tool_calls=400 / work-cap.minutes=60: the session's own caps, a number or off
13
+ session_settings: {tool_calls: number, minutes: number}
@@ -0,0 +1,391 @@
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 (see State below), so a
16
+ # config change reaches a running session through /workcap or a new
17
+ # session; warn_at and ignore_tools are read at each check.
18
+ # tool_calls: 400 this session's own tool-call cap (session_settings:,
19
+ # chi --set work-cap.tool_calls=400); 0/off: none
20
+ # minutes: 60 this session's own cap on active minutes, same rules
21
+ # child: {tool_calls: 500, minutes: 90} delegates (ctx.delegate?) with
22
+ # no own value
23
+ # default: {} every other session (and a delegate without child:)
24
+ # warn_at: 0.8 the share of a cap that warns; 0/off: no warning
25
+ # ignore_tools: [task_wait, task_get, delegate, delegate_result]
26
+ # tools whose calls and time aren't counted (waiting:
27
+ # delegate blocks on its child by default)
28
+ # Each meter resolves on its own: its own value, else child:'s for a
29
+ # delegate, else default:'s. A value that doesn't read (tool_calls: many)
30
+ # warns the user once and the next one is used, never a silent "no cap".
31
+ #
32
+ # What counts: ctx.usage's tool calls and active time (generations plus
33
+ # finished tool calls, never a question's wait), less ignore_tools'.
34
+ #
35
+ # State: <data_dir>/sessions/<session id>.json, {"tool_calls": {"cap": 800,
36
+ # "step": 400}, "minutes": {"cap": null}}, written by /workcap and by
37
+ # Continue; a meter's "cap": null is off for the session, over the config;
38
+ # a meter missing from the file comes from the settings. Resolved once per
39
+ # session id (ctx.delegate? parses the session file) and kept until
40
+ # /workcap or Continue writes a new one.
41
+ require "json"
42
+ require "fileutils"
43
+
44
+ class Plugin
45
+ USAGE = "usage: /workcap [N | Nm | Nh | off] (N tool calls, Nm or Nh active time; /workcap alone shows the totals)"
46
+ OPTIONS = %w[Continue Stop].freeze
47
+ # For a parent agent's answer (guardrails.parent_answers): Continue
48
+ # spends more, Stop denies; under hard a parent may only Stop.
49
+ EFFECTS = { "Continue" => :spend, "Stop" => :deny }.freeze
50
+ WARN_AT = 0.8
51
+ METERS = %w[tool_calls minutes].freeze
52
+ DEFAULT_IGNORE = %w[task_wait task_get delegate delegate_result].freeze
53
+ # A cap as text: 3, 2.5, .5, 1e1, -1 (a negative is none).
54
+ NUMBER = /\A-?(?:\d+(?:\.\d*)?|\.\d+)(?:e[-+]?\d+)?\z/i
55
+ # Minutes as text: a NUMBER, or one with m/min (minutes) or h (hours).
56
+ DURATION = /\A(?<number>.+?)\s*(?<unit>m|min|mins|minutes|h|hours?)?\z/i
57
+
58
+ def initialize(_settings)
59
+ @mutex = Mutex.new
60
+ @resolved = nil # {session:, meters: {meter => {cap:, step:, key:}}, unreadable: {name => value}}
61
+ @warned = {} # meter => [session id, cap] the share warning was given for
62
+ @unread_warned = nil # the session id the "isn't a number" notice was given for
63
+ end
64
+
65
+ def register(chi)
66
+ chi.on(:before_generation) { |_event, ctx| check(ctx) }
67
+ chi.command("/workcap", "see or set this session's work cap: /workcap, /workcap 400, /workcap 90m, /workcap off",
68
+ anytime: true) do |args, ctx|
69
+ command(args, ctx)
70
+ end
71
+ end
72
+
73
+ # A cap (a positive Float) or nil (none, 0, off, false, or a value that
74
+ # isn't a number: .readable? tells them apart). Minutes take a unit:
75
+ # 90m, 1.5h.
76
+ def self.amount(value, meter)
77
+ number = number(value, meter)
78
+ number&.positive? ? number : nil
79
+ end
80
+
81
+ # +value+ as a finite Float, else nil. "5." gets its 0 first
82
+ # (.float_text: Ruby 3.3's Float wants one).
83
+ def self.number(value, meter)
84
+ number = case value
85
+ when Numeric then value.to_f
86
+ when String then text_number(value.strip, meter)
87
+ end
88
+ number if number&.finite?
89
+ end
90
+
91
+ def self.text_number(text, meter)
92
+ return Float(float_text(text)) if text.match?(NUMBER)
93
+ return nil unless meter == "minutes" && (match = DURATION.match(text)) && match[:unit]
94
+ return nil unless match[:number].match?(NUMBER)
95
+
96
+ Float(float_text(match[:number])) * (match[:unit].downcase.start_with?("h") ? 60 : 1)
97
+ end
98
+
99
+ # A NUMBER as Float reads it on every Ruby: a bare point ("5.", "5.e1")
100
+ # gets a 0 after it.
101
+ def self.float_text(text) = text.sub(/\.(?=e|\z)/i, ".0")
102
+
103
+ # Whether +value+ reads as a cap or as none: a number (text too), off,
104
+ # false or nil; not "many", true or a list.
105
+ def self.readable?(value, meter)
106
+ case value
107
+ when false, nil then true
108
+ when Numeric then value.finite?
109
+ when String then value.strip.casecmp?("off") || !number(value, meter).nil?
110
+ else false
111
+ end
112
+ end
113
+
114
+ # 400, 2.5: a whole number without its ".0".
115
+ def self.count(value)
116
+ rounded = value.round(1)
117
+ rounded == rounded.to_i ? rounded.to_i.to_s : rounded.to_s
118
+ end
119
+
120
+ private
121
+
122
+ def check(ctx)
123
+ resolved = resolve(ctx)
124
+ unreadable(ctx, resolved)
125
+ capped = resolved[:meters].select { |_meter, meter_cap| meter_cap[:cap] }
126
+ return if capped.empty?
127
+
128
+ usage = ctx.usage
129
+ used = used(ctx, usage)
130
+ reached = capped.select { |meter, meter_cap| used[meter] >= meter_cap[:cap] }
131
+ return at_cap(ctx, resolved, reached, usage, used) unless reached.empty?
132
+
133
+ share = warn_share(ctx)
134
+ return unless share
135
+
136
+ capped.each do |meter, meter_cap|
137
+ cap = meter_cap[:cap]
138
+ next unless used[meter] >= share * cap && first_warning?(meter, [resolved[:session], cap])
139
+
140
+ ctx.notify("#{count(used[meter])} of #{count(cap)} #{unit(meter)} (#{(used[meter] / cap * 100).floor}%)",
141
+ level: :warn)
142
+ end
143
+ end
144
+
145
+ # The counted work per meter: tool calls and active minutes, less the
146
+ # ignored tools' share.
147
+ def used(ctx, usage)
148
+ ignored = ignore_tools(ctx)
149
+ calls = usage.tool_calls - ignored.sum { |tool| usage.tool_calls_by_tool.fetch(tool, 0) }
150
+ active_ms = usage.active_ms - ignored.sum { |tool| usage.tool_ms_by_tool.fetch(tool, 0) }
151
+ { "tool_calls" => [calls, 0].max.to_f, "minutes" => [active_ms, 0].max / 60_000.0 }
152
+ end
153
+
154
+ # Once per session: the settings whose cap didn't read, and what is used.
155
+ def unreadable(ctx, resolved)
156
+ bad = resolved[:unreadable]
157
+ return if bad.empty? || !first_time?(:@unread_warned, resolved[:session])
158
+
159
+ named = bad.map { |key, value| "#{key}: #{value.inspect}" }.join(" and ")
160
+ used = resolved[:meters].values.filter_map do |meter_cap|
161
+ "#{meter_cap[:key]}: (#{count(meter_cap[:cap])})" if meter_cap[:cap]
162
+ end
163
+ using = used.empty? ? "no cap" : "using #{used.join(", ")}"
164
+ ctx.notify("work-cap: #{named} #{bad.size == 1 ? "isn't" : "aren't"} a number or off; #{using}", level: :warn)
165
+ end
166
+
167
+ def at_cap(ctx, resolved, reached, usage, used)
168
+ raised = reached.to_h do |meter, meter_cap|
169
+ [meter, raised_cap(meter_cap[:cap], meter_cap[:step] || meter_cap[:cap], used[meter])]
170
+ end
171
+ answer = ctx.ask_user(question: "Work cap reached: #{reached_text(reached, used)} (#{totals(ctx, usage, used, reached.keys)}). " \
172
+ "Continue raises #{raised_text(raised)}.",
173
+ header: "Work cap", options: OPTIONS, effects: EFFECTS)
174
+ if continue?(answer)
175
+ raised.each do |meter, cap|
176
+ save_meter(ctx, resolved[:session], meter, "cap" => cap, "step" => reached[meter][:step] || reached[meter][:cap])
177
+ end
178
+ ctx.notify("#{raised.size == 1 ? "cap" : "caps"} raised to " \
179
+ "#{raised.map { |meter, cap| "#{count(cap)} #{unit(meter)}" }.join(" and ")}")
180
+ else
181
+ stop(ctx, reached, usage, used)
182
+ end
183
+ end
184
+
185
+ # "412 tool calls of 400", and "61 active minutes of 60" for the other.
186
+ def reached_text(reached, used)
187
+ reached.map { |meter, meter_cap| "#{count(used[meter])} #{unit(meter)} of #{count(meter_cap[:cap])}" }.join(", ")
188
+ end
189
+
190
+ # "it to 800", or "them to 800 tool calls and 120 active minutes".
191
+ def raised_text(raised)
192
+ return "it to #{count(raised.values.first)}" if raised.size == 1
193
+
194
+ "them to #{raised.map { |meter, cap| "#{count(cap)} #{unit(meter)}" }.join(" and ")}"
195
+ end
196
+
197
+ # The session's totals a reader decides on, the reached meters left out
198
+ # (already named): turns, tool calls, active minutes and the spend.
199
+ def totals(ctx, usage, used, skip = [])
200
+ parts = ["#{usage.turns} #{usage.turns == 1 ? "turn" : "turns"}"]
201
+ parts << "#{count(used["tool_calls"])} tool calls" unless skip.include?("tool_calls")
202
+ parts << "#{active_text(used["minutes"])} active" unless skip.include?("minutes")
203
+ cost = ctx.cost
204
+ parts << format("$%.2f", cost.total) if cost.known? && cost.total.positive?
205
+ parts.join(", ")
206
+ end
207
+
208
+ # "47 min", or "<1 min" before a whole minute of work.
209
+ def active_text(minutes) = minutes < 1 ? "<1 min" : "#{count(minutes.floor)} min"
210
+
211
+ # Another step on the cap, as many steps as it takes to pass the work
212
+ # (a turn that overshot by more than a step asks once, not each step):
213
+ # Continue always leaves the cap past the work now.
214
+ def raised_cap(cap, step, used)
215
+ raised = cap + step
216
+ raised += step while raised <= used
217
+ raised.round(6)
218
+ end
219
+
220
+ def continue?(answer)
221
+ return false unless answer
222
+
223
+ Array(answer[:selected]).first == "Continue" || Array(answer[:selected_indices]).first&.zero?
224
+ end
225
+
226
+ def stop(ctx, reached, usage, used)
227
+ return unless ctx.stop_turn("work cap reached (#{reached_text(reached, used)})")
228
+
229
+ ctx.card(title: "work-cap stopped the turn",
230
+ body: "Used #{reached_text(reached, used)} (#{totals(ctx, usage, used, reached.keys)}).\n\n" \
231
+ "`/workcap N` raises the tool-call cap, `/workcap Nm` the active minutes, `/workcap off` removes both.",
232
+ level: :warn)
233
+ end
234
+
235
+ def command(args, ctx)
236
+ arg = args.to_s.strip.downcase
237
+ return show(ctx) if arg.empty?
238
+
239
+ session = ctx.session_id
240
+ return "work-cap: no session yet" unless session
241
+
242
+ if %w[off 0].include?(arg)
243
+ METERS.each { |meter| save_meter(ctx, session, meter, "cap" => nil) }
244
+ return "work-cap: no cap for this session"
245
+ end
246
+ meter = arg.match?(NUMBER) ? "tool_calls" : "minutes"
247
+ value = self.class.amount(arg, meter)
248
+ return USAGE unless value
249
+
250
+ save_meter(ctx, session, meter, "cap" => value, "step" => value)
251
+ "work-cap: #{unit(meter)} cap set to #{count(value)} (#{count(used(ctx, ctx.usage)[meter])} so far)"
252
+ rescue StandardError => e
253
+ ctx.log.warn(:workcap_command_failed, error: "#{e.class}: #{e.message}")
254
+ "work-cap: #{e.message}"
255
+ end
256
+
257
+ def show(ctx)
258
+ usage = ctx.usage
259
+ used = used(ctx, usage)
260
+ meters = resolve(ctx)[:meters]
261
+ return "work-cap: no cap (#{totals(ctx, usage, used)})" if meters.values.none? { |meter_cap| meter_cap[:cap] }
262
+
263
+ capped = meters.select { |_meter, meter_cap| meter_cap[:cap] }
264
+ caps = capped.map { |meter, meter_cap| "#{count(used[meter])} of #{count(meter_cap[:cap])} #{unit(meter)}" }
265
+ "work-cap: #{caps.join(", ")} (#{totals(ctx, usage, used, capped.keys)})"
266
+ end
267
+
268
+ def unit(meter) = meter == "tool_calls" ? "tool calls" : "active minutes"
269
+
270
+ # The session's caps now: per meter the state file's, else the session's
271
+ # own value (--set), else child:'s for a delegate, else default:'s.
272
+ # Cached per session id.
273
+ def resolve(ctx)
274
+ session = ctx.session_id
275
+ cached = @mutex.synchronize { @resolved }
276
+ return cached if cached && cached[:session] == session
277
+
278
+ saved = state(ctx, session)
279
+ configured = configured(ctx)
280
+ meters = METERS.to_h { |meter| [meter, saved[meter] || configured[:meters][meter]] }
281
+ resolved = { session: session, meters: meters, unreadable: configured[:unreadable] }
282
+ @mutex.synchronize { @resolved = resolved }
283
+ end
284
+
285
+ # The state file's meters ({meter => {cap:, step:}}), {} without one.
286
+ def state(ctx, session)
287
+ return {} unless session
288
+
289
+ saved = JSON.parse(File.read(state_path(ctx, session)))
290
+ return {} unless saved.is_a?(Hash)
291
+
292
+ METERS.each_with_object({}) do |meter, meters|
293
+ entry = saved[meter]
294
+ next unless entry.is_a?(Hash) && entry.key?("cap")
295
+
296
+ cap = self.class.amount(entry["cap"], meter)
297
+ meters[meter] = { cap: cap, step: self.class.amount(entry["step"], meter) || cap, key: "/workcap" }
298
+ end
299
+ rescue Errno::ENOENT, JSON::ParserError
300
+ {}
301
+ end
302
+
303
+ # Per meter the first of its own value, child:'s (a delegate's) and
304
+ # default:'s that is set and reads (.readable?); the ones set that don't
305
+ # read (and a child:/default: that isn't a mapping) are kept for the
306
+ # notice (#unreadable).
307
+ def configured(ctx)
308
+ settings = ctx.settings
309
+ unreadable = {}
310
+ groups = %w[child default].select { |group| settings.key?(group) }
311
+ groups.delete("child") if groups.include?("child") && !ctx.delegate?
312
+ groups = groups.select do |group|
313
+ next true if settings[group].is_a?(Hash)
314
+
315
+ unreadable[group] = settings[group] unless settings[group].nil?
316
+ false
317
+ end
318
+ meters = METERS.to_h do |meter|
319
+ sources = [[meter, settings, meter]] + groups.map { |group| ["#{group}.#{meter}", settings[group], meter] }
320
+ [meter, first_readable(sources, unreadable)]
321
+ end
322
+ { meters: meters, unreadable: unreadable }
323
+ end
324
+
325
+ def first_readable(sources, unreadable)
326
+ sources.each do |name, values, meter|
327
+ next unless values.key?(meter)
328
+
329
+ value = values[meter]
330
+ unless self.class.readable?(value, meter)
331
+ unreadable[name] = value
332
+ next
333
+ end
334
+ cap = self.class.amount(value, meter)
335
+ return { cap: cap, step: cap, key: name }
336
+ end
337
+ { cap: nil, step: nil, key: nil }
338
+ end
339
+
340
+ def ignore_tools(ctx)
341
+ settings = ctx.settings
342
+ settings.key?("ignore_tools") ? Array(settings["ignore_tools"]).map(&:to_s) : DEFAULT_IGNORE
343
+ end
344
+
345
+ def warn_share(ctx)
346
+ settings = ctx.settings
347
+ return WARN_AT unless settings.key?("warn_at")
348
+
349
+ share = settings["warn_at"]
350
+ share.is_a?(Numeric) && share.positive? ? share : nil
351
+ end
352
+
353
+ # Write one meter's cap into the session's state file (the other kept).
354
+ def save_meter(ctx, session, meter, data)
355
+ path = state_path(ctx, session)
356
+ FileUtils.mkdir_p(File.dirname(path))
357
+ saved = begin
358
+ JSON.parse(File.read(path))
359
+ rescue Errno::ENOENT, JSON::ParserError
360
+ {}
361
+ end
362
+ saved = {} unless saved.is_a?(Hash)
363
+ saved[meter] = data
364
+ Samagotchi::AtomicFile.write(path, "#{JSON.generate(saved)}\n")
365
+ # Resolved again at the next use, from the file now holding the meter.
366
+ @mutex.synchronize { @resolved = nil }
367
+ end
368
+
369
+ def state_path(ctx, session) = File.join(ctx.data_dir, "sessions", "#{session.gsub(/[^A-Za-z0-9_.-]/, "_")}.json")
370
+
371
+ def count(value) = self.class.count(value)
372
+
373
+ def first_warning?(meter, key)
374
+ @mutex.synchronize do
375
+ next false if @warned[meter] == key
376
+
377
+ @warned[meter] = key
378
+ true
379
+ end
380
+ end
381
+
382
+ # Whether +key+ is new for the once-only notice kept in +ivar+ (and keep it).
383
+ def first_time?(ivar, key)
384
+ @mutex.synchronize do
385
+ next false if instance_variable_get(ivar) == key
386
+
387
+ instance_variable_set(ivar, key)
388
+ true
389
+ end
390
+ end
391
+ end
@@ -39,6 +39,7 @@ require_relative "llm/openai_chat"
39
39
  require_relative "llm/turn_settings"
40
40
  require_relative "session"
41
41
  require_relative "toolkit_binding"
42
+ require_relative "tool_loads"
42
43
  require_relative "answer_tail"
43
44
  require_relative "archive_store"
44
45
  require_relative "session_observer"
@@ -277,6 +278,9 @@ module Samagotchi
277
278
  # The tools plugins publish without declaring (chi.tool_source), and
278
279
  # the search and call tools that reach them (Tools::ToolIndex).
279
280
  @tool_index = Tools::ToolIndex.new
281
+ # The tools plugins load (ctx.load_tools), staged until a turn's
282
+ # messages are built (#append_tool_loads).
283
+ @tool_loads = ToolLoads.new
280
284
  # The context sources the peer bundles declare as a peer agent's
281
285
  # answers (chi.peer_source); the worker's ContextAbsorber asks it.
282
286
  @peer_sources = PeerSources.new
@@ -2115,12 +2119,13 @@ module Samagotchi
2115
2119
  })
2116
2120
  end
2117
2121
 
2118
- return if turn.continue
2119
-
2120
- user_message = { role: "user", content: turn.prompt, turn_id: turn.id }
2121
- user_message[:images] = image_refs unless image_refs.empty?
2122
- turn.messages << user_message
2123
- session.last_prompt = turn.prompt
2122
+ unless turn.continue
2123
+ user_message = { role: "user", content: turn.prompt, turn_id: turn.id }
2124
+ user_message[:images] = image_refs unless image_refs.empty?
2125
+ turn.messages << user_message
2126
+ session.last_prompt = turn.prompt
2127
+ end
2128
+ append_tool_loads(turn)
2124
2129
  end
2125
2130
  private :turn_messages
2126
2131
 
@@ -2137,8 +2142,13 @@ module Samagotchi
2137
2142
  # through the kernel without its #run: tag those dumps with this
2138
2143
  # turn's model, not the last native one.
2139
2144
  explained = model.llm_context_explained(@model_settings.llm_context_session_args)
2145
+ model_backend = backend(model)
2146
+ # A host that loads tools runs a direct call of one the session
2147
+ # reaches but doesn't declare (ToolRunner, KernelLoop#dispatch).
2148
+ loads_tools = model_backend.respond_to?(:loads_tools?) && model_backend.loads_tools?(bare_for_backend)
2140
2149
  @kernel.turn_settings = turn.settings.with(model_name: bare_for_backend, window_setting: model.window_setting,
2141
- llm_context: explained&.resolved, price: model.price)
2150
+ llm_context: explained&.resolved, price: model.price,
2151
+ loads_tools: loads_tools)
2142
2152
  log_llm_context(explained)
2143
2153
 
2144
2154
  turn.limit = @no_interrupt ? IterationLimit.for(no_interrupt: true) : max_iterations || IterationLimit.for
@@ -2146,7 +2156,7 @@ module Samagotchi
2146
2156
  # end keeps it, so a Continue resumes this run.
2147
2157
  run_start = turn.messages.length
2148
2158
  reasoning_since = resumed_run_start(turn, run_start)
2149
- result = backend(model).complete(
2159
+ result = model_backend.complete(
2150
2160
  messages: turn.messages,
2151
2161
  max_iterations: turn.limit,
2152
2162
  on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller,
@@ -2555,6 +2565,52 @@ module Samagotchi
2555
2565
 
2556
2566
  public :tools, :toolkit_kits, :resolved_toolkit, :toolkit_explained, :toolkit_summary
2557
2567
 
2568
+ # A plugin's ctx.load_tools: stage the reachable tools +names+ names
2569
+ # (Tools::ToolIndex#deferred_name) that the session neither declares
2570
+ # nor loaded with an earlier tool change, for bundle +bundle+. A name
2571
+ # that names no tool at all is logged; a declared or removed one is
2572
+ # skipped quietly.
2573
+ # @return [Array<String>] the wire names staged
2574
+ def load_tools(names, bundle)
2575
+ added = ToolLoads.added(@session&.messages)
2576
+ wanted = names.filter_map do |name|
2577
+ wire = @tool_index.deferred_name(name)
2578
+ Log.info(:plugins, "load_tools_skipped", bundle: bundle, tool: name) unless wire || @tools.key?(name)
2579
+ wire
2580
+ end
2581
+ @tool_loads.stage(wanted - added, by: bundle)
2582
+ end
2583
+ public :load_tools
2584
+
2585
+ # The staged tool loads, appended to +turn+'s messages (after its user
2586
+ # message, or the trailing results and notes of a turn with none) as
2587
+ # one ToolLoads entry per bundle, when the turn's host takes tool
2588
+ # changes (ChatLoop#tool_changes?). Elsewhere they are dropped, logged.
2589
+ # A conversation that ends with a reply keeps them staged (a system
2590
+ # change must follow a user turn).
2591
+ def append_tool_loads(turn)
2592
+ return unless @tool_loads.any?
2593
+ return if turn.messages.last&.dig(:role).to_s == "model"
2594
+
2595
+ model = effective_model
2596
+ host = backend(model)
2597
+ staged = @tool_loads.take
2598
+ added = ToolLoads.added(turn.messages)
2599
+ unless host.respond_to?(:tool_changes?) && host.tool_changes?(bare_model_name(model.name))
2600
+ Log.info(:plugins, "tool_loads_not_sent", tools: staged.values.flatten.join(","), model: model.name.to_s)
2601
+ return
2602
+ end
2603
+
2604
+ staged.each do |bundle, names|
2605
+ names -= added
2606
+ next if names.empty?
2607
+
2608
+ turn.messages << ToolLoads.entry(names, by: bundle)
2609
+ hook_notify("tools loaded: #{names.join(", ")}", :info, bundle)
2610
+ end
2611
+ end
2612
+ private :append_tool_loads
2613
+
2558
2614
  # A plugin's ctx.use_toolkit (ToolkitBinding#use).
2559
2615
  # @return [Boolean] whether it was staged
2560
2616
  # @raise [ArgumentError] an unknown toolkit
@@ -2608,6 +2664,7 @@ module Samagotchi
2608
2664
  toolkit: -> { @toolkit_binding.name },
2609
2665
  declared_tools: -> { toolkit_explained.declared.map(&:name) },
2610
2666
  use_toolkit: ->(name) { use_toolkit(name) },
2667
+ load_tools: ->(names, bundle) { load_tools(names, bundle) },
2611
2668
  cancel_controller: ->(handler: false) { plugin_cancel_controller(handler: handler) },
2612
2669
  ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil|
2613
2670
  ask_side_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller,
@@ -2623,7 +2680,8 @@ module Samagotchi
2623
2680
  # returns [] itself: the || keeps a nil builder safe).
2624
2681
  prompt_files: -> { @prompt_builder&.prompt_files || [] },
2625
2682
  frontend: -> { interface },
2626
- cost: -> { @metrics.cost }
2683
+ cost: -> { @metrics.cost },
2684
+ usage: -> { @metrics.usage }
2627
2685
  )
2628
2686
  end
2629
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,10 +98,17 @@ 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
113
  configs.each do |cfg|
101
114
  next unless cfg.is_a?(Hash) && cfg["path"]