samagotchi 0.81.0 → 0.83.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 +45 -1
- data/README.md +2 -2
- data/docs/architecture.md +3 -0
- data/docs/cli.md +1 -1
- data/docs/configuration.md +54 -10
- data/docs/hooks.md +1 -1
- data/docs/memory.md +1 -1
- data/docs/plugins.md +58 -1
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +2 -1
- data/docs/sub-agent.md +23 -8
- data/lib/samagotchi/bundles/core/manifest.yml +4 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +11 -0
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +228 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +25 -8
- data/lib/samagotchi/cli/parent_wait.rb +9 -3
- data/lib/samagotchi/engine.rb +3 -2
- data/lib/samagotchi/guardrails/rm_targets.rb +4 -2
- data/lib/samagotchi/guardrails/shell_paths.rb +5 -3
- data/lib/samagotchi/idle_client.rb +2 -7
- data/lib/samagotchi/llm/http.rb +7 -2
- data/lib/samagotchi/parent_report.rb +7 -3
- data/lib/samagotchi/plugin/context.rb +8 -1
- data/lib/samagotchi/reply_wait.rb +66 -2
- data/lib/samagotchi/send_command.rb +47 -8
- data/lib/samagotchi/session_metrics/cost.rb +37 -0
- data/lib/samagotchi/session_metrics/side_totals.rb +16 -9
- data/lib/samagotchi/session_metrics.rb +12 -0
- data/lib/samagotchi/testing/boundary.rb +15 -0
- data/lib/samagotchi/testing/bundle.rb +35 -9
- data/lib/samagotchi/testing/host.rb +26 -1
- data/lib/samagotchi/testing/recorder.rb +4 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +45 -7
- data/lib/samagotchi/tools/delegate_wait.rb +40 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +3 -1
- data/lib/samagotchi/web/public/question_cards.js +10 -5
- metadata +4 -1
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The cost-cap bundle (docs/plugins.md, The cost-cap bundle): a per-session
|
|
4
|
+
# cap on what the session spends (ctx.cost: reported + estimated, side
|
|
5
|
+
# requests included). Before each generation it compares the spend with
|
|
6
|
+
# the cap: at warn_at of it the user (only) gets one notice, at the cap the
|
|
7
|
+
# turn pauses on a question, Continue (the cap goes up by a step) or Stop
|
|
8
|
+
# (the turn stops, with a card). With no one to ask it stops. The model
|
|
9
|
+
# never sees any of it: no tool, no steer, no prompt text.
|
|
10
|
+
#
|
|
11
|
+
# Settings (config.yml, bundles: cost-cap:), read at each use from
|
|
12
|
+
# ctx.settings, never at load:
|
|
13
|
+
# default: 5.00 USD cap for a session with none set; absent/0/off: none
|
|
14
|
+
# child: 2.00 for delegate children (ctx.delegate?); absent: default
|
|
15
|
+
# warn_at: 0.8 the share of the cap that warns; 0/off: no warning
|
|
16
|
+
#
|
|
17
|
+
# State: <data_dir>/sessions/<session id>.json, {"cap": 3.0, "step": 3.0},
|
|
18
|
+
# written by /cap and by Continue; "cap": null (or false) is off for the
|
|
19
|
+
# session, over the config. Without a file the cap comes from the settings.
|
|
20
|
+
# The cap is resolved once per session id (ctx.delegate? parses the session
|
|
21
|
+
# file) and kept until /cap or Continue writes a new one.
|
|
22
|
+
require "json"
|
|
23
|
+
require "fileutils"
|
|
24
|
+
|
|
25
|
+
class Plugin
|
|
26
|
+
USAGE = "usage: /cap [N | $N | off] (USD; /cap alone shows the spend)"
|
|
27
|
+
OPTIONS = %w[Continue Stop].freeze
|
|
28
|
+
WARN_AT = 0.8
|
|
29
|
+
|
|
30
|
+
def initialize(_settings)
|
|
31
|
+
@mutex = Mutex.new
|
|
32
|
+
@resolved = nil # {session:, cap:, step:}
|
|
33
|
+
@warned = nil # [session id, cap] the share warning was given for
|
|
34
|
+
@unknown_warned = nil # the session id the "can't be enforced" notice was given for
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def register(chi)
|
|
38
|
+
chi.on(:before_generation) { |_event, ctx| check(ctx) }
|
|
39
|
+
# Only the "can't be enforced" notice: a turn of one generation (or a
|
|
40
|
+
# session's last) has no next before_generation to give it.
|
|
41
|
+
chi.on(:after_generation) { |_event, ctx| check_known(ctx) }
|
|
42
|
+
chi.command("/cap", "see or set this session's cost cap: /cap, /cap 5, /cap off", anytime: true) do |args, ctx|
|
|
43
|
+
command(args, ctx)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The cap in USD (a positive Float) or nil (none, 0, off, false).
|
|
48
|
+
def self.amount(value)
|
|
49
|
+
number = case value
|
|
50
|
+
when Numeric then value.to_f
|
|
51
|
+
when String then value.strip.delete_prefix("$").then { |s| s.match?(/\A\d+(\.\d+)?\z/) ? s.to_f : nil }
|
|
52
|
+
end
|
|
53
|
+
number&.positive? ? number : nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Cents, or up to four decimals under $0.10, so a small cap (/cap 0.003)
|
|
57
|
+
# doesn't read "$0.00 spent of $0.00".
|
|
58
|
+
def self.money(value)
|
|
59
|
+
return format("$%.2f", value) if value.zero? || value.abs >= 0.1
|
|
60
|
+
|
|
61
|
+
format("$%.4f", value).sub(/(\.\d\d\d*?)0+\z/, '\1')
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def check(ctx)
|
|
67
|
+
resolved = resolve(ctx)
|
|
68
|
+
cap = resolved[:cap]
|
|
69
|
+
return unless cap
|
|
70
|
+
|
|
71
|
+
cost = ctx.cost
|
|
72
|
+
return unenforceable(ctx, resolved[:session], cap) unless cost.known?
|
|
73
|
+
|
|
74
|
+
total = cost.total
|
|
75
|
+
return at_cap(ctx, resolved, cost) if total >= cap
|
|
76
|
+
|
|
77
|
+
share = warn_share(ctx)
|
|
78
|
+
return unless share && total >= share * cap
|
|
79
|
+
return unless first_time?(:@warned, [resolved[:session], cap])
|
|
80
|
+
|
|
81
|
+
ctx.notify("#{money(total)} of #{money(cap)} spent (#{(total / cap * 100).floor}%)", level: :warn)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def check_known(ctx)
|
|
85
|
+
resolved = resolve(ctx)
|
|
86
|
+
return unless resolved[:cap]
|
|
87
|
+
|
|
88
|
+
unenforceable(ctx, resolved[:session], resolved[:cap]) unless ctx.cost.known?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def unenforceable(ctx, session, cap)
|
|
92
|
+
return unless first_time?(:@unknown_warned, session)
|
|
93
|
+
|
|
94
|
+
ctx.notify("the #{money(cap)} cap can't be enforced: #{ctx.model || "the model"} reports no cost and has no price",
|
|
95
|
+
level: :warn)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def at_cap(ctx, resolved, cost)
|
|
99
|
+
cap = resolved[:cap]
|
|
100
|
+
raised = raised_cap(cap, resolved[:step] || cap, cost.total)
|
|
101
|
+
answer = ctx.ask_user(question: "Cost cap reached: #{money(cost.total)} spent of #{money(cap)}. " \
|
|
102
|
+
"Continue raises it to #{money(raised)}.",
|
|
103
|
+
header: "Cost cap", options: OPTIONS)
|
|
104
|
+
if continue?(answer)
|
|
105
|
+
save(ctx, resolved[:session], "cap" => raised, "step" => resolved[:step] || cap)
|
|
106
|
+
ctx.notify("cap raised to #{money(raised)}")
|
|
107
|
+
else
|
|
108
|
+
stop(ctx, cap, cost)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Another step on the cap, as many steps as it takes to pass the spend
|
|
113
|
+
# (a turn that overshot by more than a step asks once, not each step).
|
|
114
|
+
def raised_cap(cap, step, total)
|
|
115
|
+
raised = cap + step
|
|
116
|
+
raised += step while raised <= total
|
|
117
|
+
raised.round(6)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def continue?(answer)
|
|
121
|
+
return false unless answer
|
|
122
|
+
|
|
123
|
+
Array(answer[:selected]).first == "Continue" || Array(answer[:selected_indices]).first&.zero?
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def stop(ctx, cap, cost)
|
|
127
|
+
return unless ctx.stop_turn("cost cap reached (#{money(cost.total)} of #{money(cap)})")
|
|
128
|
+
|
|
129
|
+
ctx.card(title: "cost-cap stopped the turn",
|
|
130
|
+
body: "Spent #{breakdown(cost)} of a #{money(cap)} cap.\n\n`/cap N` raises it, `/cap off` removes it.",
|
|
131
|
+
level: :warn)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def command(args, ctx)
|
|
135
|
+
arg = args.to_s.strip.downcase
|
|
136
|
+
return show(ctx) if arg.empty?
|
|
137
|
+
|
|
138
|
+
session = ctx.session_id
|
|
139
|
+
return "cost-cap: no session yet" unless session
|
|
140
|
+
|
|
141
|
+
if %w[off 0].include?(arg)
|
|
142
|
+
save(ctx, session, "cap" => nil)
|
|
143
|
+
return "cost-cap: no cap for this session"
|
|
144
|
+
end
|
|
145
|
+
value = arg.match?(/\A\$?\d+(\.\d+)?\z/) ? self.class.amount(arg) : nil
|
|
146
|
+
return USAGE unless value
|
|
147
|
+
|
|
148
|
+
save(ctx, session, "cap" => value, "step" => value)
|
|
149
|
+
"cost-cap: cap set to #{money(value)} (#{money(ctx.cost.total)} spent)"
|
|
150
|
+
rescue StandardError => e
|
|
151
|
+
"cost-cap: #{e.message}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def show(ctx)
|
|
155
|
+
cost = ctx.cost
|
|
156
|
+
cap = resolve(ctx)[:cap]
|
|
157
|
+
return "cost-cap: no cap (#{money(cost.total)} spent)" unless cap
|
|
158
|
+
|
|
159
|
+
line = "cost-cap: #{money(cost.total)} spent of #{money(cap)} (#{parts(cost)})"
|
|
160
|
+
cost.known? ? line : "#{line} (can't be enforced: no cost reported or priced)"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def parts(cost) = "reported #{money(cost.reported)}, estimated #{money(cost.estimated)}, side #{money(cost.side)}"
|
|
164
|
+
|
|
165
|
+
def breakdown(cost) = "#{money(cost.total)} (#{parts(cost)})"
|
|
166
|
+
|
|
167
|
+
# The session's cap and step now: the state file's, else child: for a
|
|
168
|
+
# delegate, else default:. Cached per session id.
|
|
169
|
+
def resolve(ctx)
|
|
170
|
+
session = ctx.session_id
|
|
171
|
+
cached = @mutex.synchronize { @resolved }
|
|
172
|
+
return cached if cached && cached[:session] == session
|
|
173
|
+
|
|
174
|
+
resolved = { session: session, **(state(ctx, session) || configured(ctx)) }
|
|
175
|
+
@mutex.synchronize { @resolved = resolved }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def state(ctx, session)
|
|
179
|
+
return nil unless session
|
|
180
|
+
|
|
181
|
+
saved = JSON.parse(File.read(state_path(ctx, session)))
|
|
182
|
+
return nil unless saved.is_a?(Hash) && saved.key?("cap")
|
|
183
|
+
|
|
184
|
+
cap = self.class.amount(saved["cap"])
|
|
185
|
+
{ cap: cap, step: self.class.amount(saved["step"]) || cap }
|
|
186
|
+
rescue Errno::ENOENT, JSON::ParserError
|
|
187
|
+
nil
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def configured(ctx)
|
|
191
|
+
settings = ctx.settings
|
|
192
|
+
return { cap: nil, step: nil } unless settings.key?("default") || settings.key?("child")
|
|
193
|
+
|
|
194
|
+
key = settings.key?("child") && ctx.delegate? ? "child" : "default"
|
|
195
|
+
cap = self.class.amount(settings[key])
|
|
196
|
+
{ cap: cap, step: cap }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def warn_share(ctx)
|
|
200
|
+
settings = ctx.settings
|
|
201
|
+
return WARN_AT unless settings.key?("warn_at")
|
|
202
|
+
|
|
203
|
+
share = settings["warn_at"]
|
|
204
|
+
share.is_a?(Numeric) && share.positive? ? share : nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def save(ctx, session, data)
|
|
208
|
+
path = state_path(ctx, session)
|
|
209
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
210
|
+
Samagotchi::AtomicFile.write(path, "#{JSON.generate(data)}\n")
|
|
211
|
+
cap = self.class.amount(data["cap"])
|
|
212
|
+
@mutex.synchronize { @resolved = { session: session, cap: cap, step: self.class.amount(data["step"]) || cap } }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def state_path(ctx, session) = File.join(ctx.data_dir, "sessions", "#{session.gsub(/[^A-Za-z0-9_.-]/, "_")}.json")
|
|
216
|
+
|
|
217
|
+
def money(value) = self.class.money(value)
|
|
218
|
+
|
|
219
|
+
# Whether +key+ is new for the once-only notice kept in +ivar+ (and keep it).
|
|
220
|
+
def first_time?(ivar, key)
|
|
221
|
+
@mutex.synchronize do
|
|
222
|
+
next false if instance_variable_get(ivar) == key
|
|
223
|
+
|
|
224
|
+
instance_variable_set(ivar, key)
|
|
225
|
+
true
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "securerandom"
|
|
4
5
|
require_relative "client_id"
|
|
5
6
|
require_relative "session"
|
|
6
7
|
require_relative "session_inbox"
|
|
@@ -129,7 +130,7 @@ module Samagotchi
|
|
|
129
130
|
# (DelegateWait.finish, its report variant), the cursor it moves the child to,
|
|
130
131
|
# and the cursor it was read from (a commit after the model moved it
|
|
131
132
|
# itself, with delegate_result, leaves it alone).
|
|
132
|
-
Report = Data.define(:child_id, :text, :before, :after, :rings) do
|
|
133
|
+
Report = Data.define(:child_id, :text, :before, :after, :rings, :claim_id) do
|
|
133
134
|
def line(mark: nil) = Steer::Line.new(text: text, source: ChildReports::SOURCE, mark: mark)
|
|
134
135
|
def origin = { client_id: "#{ClientId::CHILD_PREFIX}#{child_id[0, 8]}" }
|
|
135
136
|
end
|
|
@@ -142,6 +143,7 @@ module Samagotchi
|
|
|
142
143
|
@state_dir = state_dir
|
|
143
144
|
@session_dir = Session.session_dir(session_id, state_dir: state_dir)
|
|
144
145
|
@predecessor_dir = Session.valid_id?(predecessor) ? Session.session_dir(predecessor, state_dir: state_dir) : nil
|
|
146
|
+
Tools::DelegateCursors.clear_report_claims(@session_id, state_dir: @state_dir)
|
|
145
147
|
# child id → the Report this turn took for it (rings merged).
|
|
146
148
|
@taken = {}
|
|
147
149
|
end
|
|
@@ -177,12 +179,16 @@ module Samagotchi
|
|
|
177
179
|
end
|
|
178
180
|
|
|
179
181
|
# The turn that took the reports was kept: move each child's cursor on
|
|
180
|
-
#
|
|
181
|
-
# delegate_result), then delete the rings.
|
|
182
|
+
# and delete the rings.
|
|
182
183
|
def commit
|
|
183
184
|
@taken.each_value do |report|
|
|
184
185
|
Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
|
|
185
|
-
cursor
|
|
186
|
+
next cursor unless cursor.report_claim&.fetch("id", nil) == report.claim_id
|
|
187
|
+
|
|
188
|
+
before = report.before.with(report_claim: nil)
|
|
189
|
+
after = report.after.with(report_claim: nil)
|
|
190
|
+
current = cursor.with(report_claim: nil)
|
|
191
|
+
current == before ? after : current
|
|
186
192
|
end
|
|
187
193
|
delete_rings(report.rings)
|
|
188
194
|
rescue StandardError => e
|
|
@@ -193,6 +199,11 @@ module Samagotchi
|
|
|
193
199
|
|
|
194
200
|
# The turn was rolled back: its rings stay for the next one.
|
|
195
201
|
def release
|
|
202
|
+
@taken.each_value do |report|
|
|
203
|
+
Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
|
|
204
|
+
cursor.report_claim&.fetch("id", nil) == report.claim_id ? cursor.with(report_claim: nil) : cursor
|
|
205
|
+
end
|
|
206
|
+
end
|
|
196
207
|
@taken = {}
|
|
197
208
|
end
|
|
198
209
|
|
|
@@ -237,13 +248,19 @@ module Samagotchi
|
|
|
237
248
|
timeout: 0, turn: before.turn)
|
|
238
249
|
return nil unless reportable?(wait)
|
|
239
250
|
|
|
240
|
-
|
|
241
|
-
# a later wait on it would repeat its reply.
|
|
251
|
+
claim_id = before.report_claim&.fetch("id", nil) || SecureRandom.hex(16)
|
|
242
252
|
after = before.with(reply_file: wait.status == :done ? wait.file : before.reply_file,
|
|
243
|
-
turn: wait.status == :waiting_for_answer ? before.turn : nil)
|
|
253
|
+
turn: wait.status == :waiting_for_answer ? before.turn : nil, report_claim: nil)
|
|
244
254
|
.with_baseline(ReplyWait.baseline_of(wait.session))
|
|
255
|
+
claim = { "id" => claim_id, "file" => wait.file, "cursor" => after.to_json_hash }
|
|
256
|
+
return nil unless Tools::DelegateCursors.claim_report(@session_id, child_id, expected: before, claim: claim,
|
|
257
|
+
state_dir: @state_dir)
|
|
258
|
+
|
|
259
|
+
# A reported end is the follow-up turn's too (DelegateCursor#turn):
|
|
260
|
+
# a later wait on it would repeat its reply.
|
|
261
|
+
after = after.with(report_claim: claim)
|
|
245
262
|
text = Tools::DelegateWait.finish(wait, child_id, timeout: 0, report: true)
|
|
246
|
-
Report.new(child_id: child_id, text: text, before: before, after: after, rings: rings)
|
|
263
|
+
Report.new(child_id: child_id, text: text, before: before, after: after, rings: rings, claim_id: claim_id)
|
|
247
264
|
rescue ArgumentError
|
|
248
265
|
nil # the child is gone
|
|
249
266
|
end
|
|
@@ -62,19 +62,25 @@ module Samagotchi
|
|
|
62
62
|
# a --wait with no message is about (ReplyWait.own_turn)
|
|
63
63
|
# @param queued [Boolean] +turn+ is a queued message's (ReplyWait's
|
|
64
64
|
# queued:)
|
|
65
|
+
# @param ended_before [Boolean] a last_turn that ended before the
|
|
66
|
+
# baseline is the answer (ReplyWait's ended_before:) — the
|
|
67
|
+
# --wait-with-no-message's settled session
|
|
68
|
+
# @param report_turn_id [String, nil] the turn id to include in the
|
|
69
|
+
# report, even when ReplyWait uses the next-reply path
|
|
65
70
|
# @return [Integer] the exit status
|
|
66
71
|
def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: self.class::WORKER_GONE_AFTER, turn: nil,
|
|
67
|
-
queued: false)
|
|
72
|
+
queued: false, ended_before: false, report_turn_id: turn)
|
|
68
73
|
result = ReplyWait.call(id, state_dir: @state_dir, cursor: cursor, timeout: timeout, baseline: baseline,
|
|
69
74
|
owner_grace: owner_grace, poll_interval: self.class::POLL_INTERVAL, turn: turn,
|
|
70
|
-
queued: queued)
|
|
75
|
+
queued: queued, ended_before: ended_before)
|
|
76
|
+
result.turn_id = report_turn_id if report_turn_id && !report_turn_id.to_s.empty?
|
|
71
77
|
result.text = result.text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
|
|
72
78
|
@reported = true
|
|
73
79
|
ParentReport.report(result, session_id: id, stdout: @stdout, stderr: @stderr, command: command_name,
|
|
74
80
|
json: @json, timeout: timeout, extra: report_extra)
|
|
75
81
|
rescue Interrupt
|
|
76
82
|
if @json
|
|
77
|
-
@stdout.puts(ParentReport.json_line(ReplyWait::Result.new(status: :canceled), session_id: id,
|
|
83
|
+
@stdout.puts(ParentReport.json_line(ReplyWait::Result.new(status: :canceled, turn_id: report_turn_id), session_id: id,
|
|
78
84
|
extra: report_extra))
|
|
79
85
|
@stdout.flush
|
|
80
86
|
@reported = true
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -569,7 +569,7 @@ module Samagotchi
|
|
|
569
569
|
block = @metrics.add_side(usage)
|
|
570
570
|
@session_observer.notify(type: :side_usage, kind: usage.kind, side: block)
|
|
571
571
|
end
|
|
572
|
-
sid = @
|
|
572
|
+
sid = @metrics.session_id
|
|
573
573
|
return if sid.nil? || turn_running?
|
|
574
574
|
return unless File.exist?(Session.session_file(sid, state_dir: session_state_dir))
|
|
575
575
|
|
|
@@ -2581,7 +2581,8 @@ module Samagotchi
|
|
|
2581
2581
|
# build and after a rebuild that drops them (SystemPrompt#prompt_files
|
|
2582
2582
|
# returns [] itself: the || keeps a nil builder safe).
|
|
2583
2583
|
prompt_files: -> { @prompt_builder&.prompt_files || [] },
|
|
2584
|
-
frontend: -> { interface }
|
|
2584
|
+
frontend: -> { interface },
|
|
2585
|
+
cost: -> { @metrics.cost }
|
|
2585
2586
|
)
|
|
2586
2587
|
end
|
|
2587
2588
|
|
|
@@ -33,8 +33,10 @@ module Samagotchi
|
|
|
33
33
|
# @param session_root [String] the session's repo root (or cwd)
|
|
34
34
|
def outside_tmp?(command, cwd:, tmp_roots:, session_root:, home: Dir.home, env: ENV)
|
|
35
35
|
tokens = ShellLex.lex(command.to_s)
|
|
36
|
-
# rm inside $(…) or backticks: the lexer keeps no body.
|
|
37
|
-
|
|
36
|
+
# rm inside $(…) or backticks: the lexer keeps no body. A message
|
|
37
|
+
# heredoc's text is data, so leave it out of this fallback.
|
|
38
|
+
code = ShellLex.code_text(command.to_s)
|
|
39
|
+
return true if tokens.any? { |_, value| value.include?(ShellLex::SUBST) } && code.match?(/\brm\b/)
|
|
38
40
|
|
|
39
41
|
session = ProtectedPaths.real(session_root.to_s)
|
|
40
42
|
roots = tmp_roots.reject { |tmp| ShellPaths.within?(session, tmp) }
|
|
@@ -16,8 +16,9 @@ module Samagotchi
|
|
|
16
16
|
# A word it can't resolve (another $VAR, $(…), a glob, a relative path
|
|
17
17
|
# after cd $X) or one that reads as a script (sh -c '…', ruby -e '…':
|
|
18
18
|
# spaces, quotes, operators in it) falls back to a text match (+text+);
|
|
19
|
-
# so does the whole command when it holds a $(…) or backticks (the
|
|
20
|
-
#
|
|
19
|
+
# so does the whole command when it holds a $(…) or backticks (the lexer
|
|
20
|
+
# keeps no body), except for message heredoc bodies ShellLex.code_text
|
|
21
|
+
# recognizes as data.
|
|
21
22
|
module ShellPaths
|
|
22
23
|
# A word that is a script rather than a path.
|
|
23
24
|
SCRIPT = /[\s;&|<>()'"`]/
|
|
@@ -42,7 +43,8 @@ module Samagotchi
|
|
|
42
43
|
roots = dirs.compact.map { |dir| ProtectedPaths.real(dir.to_s.chomp("/")) }.reject(&:empty?).uniq
|
|
43
44
|
walk = Walk.new(cwd, home, env)
|
|
44
45
|
tokens = ShellLex.lex(command.to_s)
|
|
45
|
-
|
|
46
|
+
code = ShellLex.code_text(command.to_s)
|
|
47
|
+
return true if tokens.any? { |_, value| value.include?(ShellLex::SUBST) } && code.match?(text)
|
|
46
48
|
|
|
47
49
|
ShellLex.simple_commands(tokens).any? do |words|
|
|
48
50
|
walk.step(words).any? { |word, path| path ? names?(path, roots, tmp_roots) : word.match?(text) }
|
|
@@ -187,13 +187,8 @@ module Samagotchi
|
|
|
187
187
|
raise SummarizeError, "server returned no parseable assistant content (#{e.message})"
|
|
188
188
|
end
|
|
189
189
|
|
|
190
|
-
#
|
|
191
|
-
#
|
|
192
|
-
# host reused a cached prefix. A side request evicts a session's cached
|
|
193
|
-
# prompt only on a llama.cpp with no host-memory cache (--cache-ram 0)
|
|
194
|
-
# or an old single-slot build, or when pinned to the session's slot,
|
|
195
|
-
# which chi never does; paid hosts and current llama.cpp aren't
|
|
196
|
-
# affected (docs/internals/prompt-caching.md).
|
|
190
|
+
# Log reported request usage when cache counts are present; session
|
|
191
|
+
# side-usage counting is handled separately by #report_usage.
|
|
197
192
|
def log_usage(response, kind)
|
|
198
193
|
usage = response.usage
|
|
199
194
|
fields = usage&.cache_fields || {}
|
data/lib/samagotchi/llm/http.rb
CHANGED
|
@@ -390,7 +390,7 @@ module Samagotchi
|
|
|
390
390
|
end
|
|
391
391
|
Log.public_send(failure_level, :http, event, **log_base(current), status: e.is_a?(ProviderError) ? e.status : nil,
|
|
392
392
|
ms: elapsed_ms(current), attempts: current[:attempts], error: e.class.name,
|
|
393
|
-
msg: (e.respond_to?(:summary) ? e.summary : e.message)
|
|
393
|
+
msg: log_message(e.respond_to?(:summary) ? e.summary : e.message))
|
|
394
394
|
raise
|
|
395
395
|
end
|
|
396
396
|
|
|
@@ -410,13 +410,18 @@ module Samagotchi
|
|
|
410
410
|
def retry_after(error, attempts, delay, on_retry, cancel_controller, current)
|
|
411
411
|
Log.warn(:http, "retry", **log_base(current), attempt: attempts, max_retries: @retry_policy.max, delay_s: delay,
|
|
412
412
|
status: error.is_a?(ProviderError) ? error.status : nil, error: error.class.name,
|
|
413
|
-
msg: error.message
|
|
413
|
+
msg: log_message(error.message))
|
|
414
414
|
on_retry&.call(attempt: attempts, max_retries: @retry_policy.max, next_delay: delay,
|
|
415
415
|
error_class: error.class.name, error_message: error.message,
|
|
416
416
|
status: error.is_a?(ProviderError) ? error.status : nil)
|
|
417
417
|
wait(delay, cancel_controller)
|
|
418
418
|
end
|
|
419
419
|
|
|
420
|
+
def log_message(value)
|
|
421
|
+
value.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\uFFFD")
|
|
422
|
+
.gsub(/[[:cntrl:]\u2028\u2029]+/, " ").split.join(" ")[0, 300]
|
|
423
|
+
end
|
|
424
|
+
|
|
420
425
|
def check_status!(response)
|
|
421
426
|
status = response.code.to_i
|
|
422
427
|
return if status.between?(200, 299)
|
|
@@ -76,6 +76,7 @@ module Samagotchi
|
|
|
76
76
|
# @return [Hash] the --format json object
|
|
77
77
|
def json(result, session_id:, timeout: nil, extra: {})
|
|
78
78
|
report = { status: status(result), session_id: session_id }
|
|
79
|
+
report[:turn_id] = result.turn_id unless result.turn_id.to_s.empty?
|
|
79
80
|
case report[:status]
|
|
80
81
|
when "answered" then report[:text] = result.text.to_s
|
|
81
82
|
when "question"
|
|
@@ -122,7 +123,7 @@ module Samagotchi
|
|
|
122
123
|
text = if result.status == :waiting_for_answer
|
|
123
124
|
question_text(result.question, session_id: session_id)
|
|
124
125
|
else
|
|
125
|
-
"#{detail(result, session_id: session_id, timeout: timeout)}\n"
|
|
126
|
+
"#{detail(result, session_id: session_id, timeout: timeout, turn_id: result.turn_id)}\n"
|
|
126
127
|
end
|
|
127
128
|
stderr.print("#{command}: #{text}")
|
|
128
129
|
end
|
|
@@ -200,7 +201,7 @@ module Samagotchi
|
|
|
200
201
|
end
|
|
201
202
|
|
|
202
203
|
# What a wait that ended without an answer says, one line.
|
|
203
|
-
def detail(result, session_id:, timeout: nil)
|
|
204
|
+
def detail(result, session_id:, timeout: nil, turn_id: nil)
|
|
204
205
|
attach = "chi --attach #{session_id}"
|
|
205
206
|
case result.status
|
|
206
207
|
when :waiting_for_answer
|
|
@@ -217,7 +218,10 @@ module Samagotchi
|
|
|
217
218
|
when :error then "the worker failed: #{result.text}; #{attach} shows what happened"
|
|
218
219
|
when :worker_gone then "the worker is gone; #{attach} shows what happened"
|
|
219
220
|
when :stopped then "the session was stopped (chi sessions stop)"
|
|
220
|
-
else
|
|
221
|
+
else
|
|
222
|
+
wait_again = "chi send --wait --turn #{turn_id} #{session_id}" unless turn_id.to_s.empty?
|
|
223
|
+
hint = wait_again || attach
|
|
224
|
+
timeout ? "still running after #{format("%g", timeout)} s: #{hint}" : "still running: #{hint}"
|
|
221
225
|
end
|
|
222
226
|
end
|
|
223
227
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require_relative "../log"
|
|
5
5
|
require_relative "../paths"
|
|
6
|
+
require_relative "../session_metrics/cost"
|
|
6
7
|
require_relative "../guardrails/context"
|
|
7
8
|
require_relative "../idle_client"
|
|
8
9
|
require_relative "service"
|
|
@@ -51,10 +52,11 @@ module Samagotchi
|
|
|
51
52
|
# ctx.http call without cancel: uses, nil for none: an init task's own,
|
|
52
53
|
# else the running turn's for work done for it (the turn's thread, or a
|
|
53
54
|
# plugin handler: +handler+) until the turn has ended.
|
|
55
|
+
# +cost+ returns the session's spend now (SessionMetrics::Cost).
|
|
54
56
|
Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
|
|
55
57
|
:ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
|
|
56
58
|
:steer, :stop_turn, :stop_generation, :frontend, :toolkit, :declared_tools, :use_toolkit,
|
|
57
|
-
:cancel_controller, keyword_init: true)
|
|
59
|
+
:cancel_controller, :cost, keyword_init: true)
|
|
58
60
|
|
|
59
61
|
# ctx.ask_model failed: the model couldn't be reached, timed out, or
|
|
60
62
|
# sent nothing usable. The message says why, for the user.
|
|
@@ -140,6 +142,11 @@ module Samagotchi
|
|
|
140
142
|
# current_model_only names it
|
|
141
143
|
def model_key = @host.model_key&.call
|
|
142
144
|
|
|
145
|
+
# @return [SessionMetrics::Cost] the session's spend so far, read now:
|
|
146
|
+
# #total, #reported, #estimated, #side (Float USD) and #known?
|
|
147
|
+
# (docs/plugins.md); zero and known with no session
|
|
148
|
+
def cost = @host.cost&.call || SessionMetrics::Cost.zero
|
|
149
|
+
|
|
143
150
|
# @return [String, nil] the name of the session's toolkit ("full" for
|
|
144
151
|
# one from before toolkits; nil with no session)
|
|
145
152
|
def toolkit = @host.toolkit&.call
|
|
@@ -52,8 +52,10 @@ module Samagotchi
|
|
|
52
52
|
# @!attribute session [Session, nil] the session as the look that
|
|
53
53
|
# decided the outcome loaded it (a caller's next baseline); nil for
|
|
54
54
|
# :canceled and :timeout
|
|
55
|
+
# @!attribute turn_id [String, nil] the id of the turn this result is
|
|
56
|
+
# about, when known
|
|
55
57
|
Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, :error_kind, :retryable, :cancel_reason,
|
|
56
|
-
:stopped_by, :kept_steps, :session, keyword_init: true)
|
|
58
|
+
:stopped_by, :kept_steps, :session, :turn_id, keyword_init: true)
|
|
57
59
|
|
|
58
60
|
module_function
|
|
59
61
|
|
|
@@ -94,10 +96,18 @@ module Samagotchi
|
|
|
94
96
|
# @param queued [Boolean] the turn: input was sent as a queue (or with
|
|
95
97
|
# images), which a running turn never merges: no fallback, even when
|
|
96
98
|
# its file was taken before the first look
|
|
99
|
+
# @param ended_before [Boolean] a last_turn that ended before the
|
|
100
|
+
# baseline (chi send --wait with no message, on a session that went
|
|
101
|
+
# idle in the gap between waits) is the turn's end too: with the
|
|
102
|
+
# session idle and its last_turn still the baseline's, its end is
|
|
103
|
+
# the answer — its reply, or its no-reply outcome. Its reply file
|
|
104
|
+
# sorts at the cursor, not past it, and its end precedes the
|
|
105
|
+
# baseline, so without it the wait would time out "running" again.
|
|
106
|
+
# Not for a turn: wait (chi answer) whose own end it waits for.
|
|
97
107
|
# @return [Result]
|
|
98
108
|
# @raise [ArgumentError] no such session
|
|
99
109
|
def call(id, state_dir:, cursor:, timeout: nil, poll_interval: POLL_INTERVAL, cancelled: -> { false },
|
|
100
|
-
baseline: nil, owner_grace: nil, interject: nil, turn: nil, queued: false)
|
|
110
|
+
baseline: nil, owner_grace: nil, interject: nil, turn: nil, queued: false, ended_before: false)
|
|
101
111
|
deadline = timeout && (monotonic + timeout.to_f)
|
|
102
112
|
seen_running = false
|
|
103
113
|
gone_since = nil
|
|
@@ -159,6 +169,38 @@ module Samagotchi
|
|
|
159
169
|
|
|
160
170
|
return Result.new(status: :canceled) if cancelled.call
|
|
161
171
|
|
|
172
|
+
# The turn that ended before this wait (ended_before: chi send
|
|
173
|
+
# --wait with no message, on a session that went idle in the gap
|
|
174
|
+
# between waits): its last_turn still stands, the session is idle,
|
|
175
|
+
# so its end is the answer, not a wait that times out "running"
|
|
176
|
+
# again. Its reply file sorts at the cursor (the newest), not past
|
|
177
|
+
# it: it is the turn's when it is at least as new as the turn's
|
|
178
|
+
# end (the worker writes it after recording the ended_at), else the
|
|
179
|
+
# turn left no reply and an earlier turn's file is not its.
|
|
180
|
+
if ended_before && turn.nil? && session.status == Session::STATUS_IDLE &&
|
|
181
|
+
(ended = session.last_turn&.ended_at) && !ended.to_s.empty? &&
|
|
182
|
+
baseline && ended == baseline[:last_turn]
|
|
183
|
+
file = newest_reply(id, state_dir: state_dir)
|
|
184
|
+
path = File.join(reply_dir(id, state_dir: state_dir), file) if file
|
|
185
|
+
if file && reply_is_turns?(path, ended)
|
|
186
|
+
reply = Result.new(status: :done, text: File.read(path), file: file)
|
|
187
|
+
reply.session = settled(session, id, state_dir: state_dir)
|
|
188
|
+
return reply
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
return no_reply(session, baseline, fresh: true)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
if ended_before && turn && session.last_turn&.enqueued_id == turn &&
|
|
195
|
+
!(session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == turn) &&
|
|
196
|
+
(ended = session.last_turn&.ended_at) && !ended.to_s.empty? &&
|
|
197
|
+
baseline && ended == baseline[:last_turn]
|
|
198
|
+
# The turn-specific reply lookup above already found its file, if
|
|
199
|
+
# one exists. Without it, don't use the newest file as a fallback:
|
|
200
|
+
# it could belong to another turn.
|
|
201
|
+
return no_reply(session, baseline, fresh: true)
|
|
202
|
+
end
|
|
203
|
+
|
|
162
204
|
if turn
|
|
163
205
|
# Only the turn's own end: the session runs another turn when the
|
|
164
206
|
# wait starts, and that one's end is not the answer. A last_turn
|
|
@@ -257,6 +299,18 @@ module Samagotchi
|
|
|
257
299
|
session.last_turn&.enqueued_id
|
|
258
300
|
end
|
|
259
301
|
|
|
302
|
+
# Whether +turn+ is a turn the caller can wait for: running now, queued
|
|
303
|
+
# as its own turn, or the last one to have ended.
|
|
304
|
+
def known_turn?(session, session_dir:, turn:)
|
|
305
|
+
id = turn.to_s
|
|
306
|
+
return false if id.empty?
|
|
307
|
+
|
|
308
|
+
running = session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == id
|
|
309
|
+
queued = SessionInbox.waiting_input(session_dir, id) == :turn_end
|
|
310
|
+
ended = session.last_turn&.enqueued_id == id
|
|
311
|
+
running || queued || ended
|
|
312
|
+
end
|
|
313
|
+
|
|
260
314
|
# Whether the session's worker sidecar names TURN_IDS_FEATURE.
|
|
261
315
|
def turn_ids?(session_dir)
|
|
262
316
|
WorkerSidecar.read(session_dir)&.features&.include?(TURN_IDS_FEATURE) || false
|
|
@@ -390,6 +444,16 @@ module Samagotchi
|
|
|
390
444
|
cursor.nil? || file > cursor
|
|
391
445
|
end
|
|
392
446
|
|
|
447
|
+
# Whether the reply file at +path+ is the turn that ended at +ended_at+'s,
|
|
448
|
+
# not an earlier turn's: the worker records ended_at before writing the
|
|
449
|
+
# reply, so its mtime is at least the turn's end and earlier replies'
|
|
450
|
+
# mtimes precede it. Unlike the filename, mtime is independent of TZ.
|
|
451
|
+
def reply_is_turns?(path, ended_at)
|
|
452
|
+
File.mtime(path) >= Time.parse(ended_at.to_s)
|
|
453
|
+
rescue ArgumentError, SystemCallError
|
|
454
|
+
false
|
|
455
|
+
end
|
|
456
|
+
|
|
393
457
|
def monotonic
|
|
394
458
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
395
459
|
end
|