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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -1
- data/README.md +2 -2
- data/docs/architecture.md +6 -3
- data/docs/bundle-authoring.md +1 -1
- data/docs/cli.md +1 -1
- data/docs/configuration.md +8 -0
- data/docs/guardrails.md +10 -2
- data/docs/hooks.md +9 -1
- data/docs/internals/anthropic.md +79 -8
- data/docs/internals/engine.md +13 -3
- data/docs/internals/llm-context-forget.md +2 -1
- data/docs/internals/prompt-caching.md +9 -0
- data/docs/memory.md +1 -1
- data/docs/plugins.md +83 -3
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +10 -0
- data/docs/sub-agent.md +7 -2
- data/docs/testing.md +27 -0
- data/lib/samagotchi/bundles/core/manifest.yml +4 -3
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/bundles/work-cap/manifest.yml +13 -0
- data/lib/samagotchi/bundles/work-cap/plugin.rb +391 -0
- data/lib/samagotchi/engine.rb +67 -9
- data/lib/samagotchi/hooks/loader.rb +17 -4
- data/lib/samagotchi/kernel_loop.rb +15 -2
- data/lib/samagotchi/llm/anthropic_messages.rb +85 -4
- data/lib/samagotchi/llm/anthropic_wire.rb +90 -11
- data/lib/samagotchi/llm/chat_loop.rb +76 -7
- data/lib/samagotchi/llm/dialect.rb +8 -5
- data/lib/samagotchi/llm/refusal_memo.rb +6 -0
- data/lib/samagotchi/llm/turn_settings.rb +13 -5
- data/lib/samagotchi/llm_context_stale.rb +2 -2
- data/lib/samagotchi/message_parts.rb +2 -1
- data/lib/samagotchi/plugin/api.rb +2 -1
- data/lib/samagotchi/plugin/context.rb +31 -4
- data/lib/samagotchi/prompt.rb +3 -0
- data/lib/samagotchi/session_metrics/usage.rb +31 -0
- data/lib/samagotchi/session_metrics.rb +48 -5
- data/lib/samagotchi/testing/bundle.rb +2 -0
- data/lib/samagotchi/testing/host.rb +31 -1
- data/lib/samagotchi/testing/recorder.rb +18 -3
- data/lib/samagotchi/tool_loads.rb +62 -0
- data/lib/samagotchi/tool_response.rb +5 -1
- data/lib/samagotchi/tool_runner.rb +29 -7
- data/lib/samagotchi/tools/builtin_calls.rb +16 -0
- data/lib/samagotchi/tools/deferred_tools.rb +80 -0
- data/lib/samagotchi/tools/registry.rb +7 -2
- data/lib/samagotchi/tools/tool_index.rb +88 -6
- data/lib/samagotchi/version.rb +1 -1
- 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
|
+
version: 0.4.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Recommended safety set"
|
|
6
6
|
trust_level: reviewed
|
|
7
|
-
# Its members' highest floor (
|
|
8
|
-
requires_chi: ">= 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
|
|
@@ -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
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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"]
|